Compare commits

...

32 Commits

Author SHA1 Message Date
xingyue 9822e68c55 chore(dashboard): remove unused _parentRequired param from flattenSchema 2026-05-15 08:25:39 +08:00
xingyue f5977c46c6 feat(dashboard): workflow detail as separate page with fixed graph sidebar
- Workflow list is now a simple clickable list (no expand/collapse)
- Clicking a workflow navigates to dedicated detail page (#client/workflows/name)
- Detail page: fixed graph sidebar on left, scrollable role cards on right
- Back button returns to workflow list
- Route: added workflowName to hash routing
2026-05-14 21:05:00 +08:00
xingyue d037eca4ae feat(dashboard): recursive schema rendering with nested object, array, oneOf
- Nested object: expand properties with └─ indentation
- object[]: show type as 'object[]', expand items.properties
- string[]/number[]: show type directly, no expansion
- oneOf/discriminatedUnion: variant headers with ├/└ connectors
  - Auto-detect discriminator field (const values)
  - Skip discriminator in variant field list
- Recursive to arbitrary depth

Phase 1+2 of #258
2026-05-14 18:19:01 +08:00
xingyue b9d543a465 fix: move hooks before early returns to fix Rules of Hooks crash 2026-05-14 16:53:47 +08:00
xingyue c7b426ff5a feat(dashboard): redesign workflow detail layout
Left sidebar: compact workflow graph with clickable nodes for navigation.
Right panel: workflow overview card + per-role cards with meta schema tables.

Clicking a node in the graph scrolls to the corresponding role card.
All nodes are lit in static view (workflow definition, not a thread).
2026-05-14 16:47:29 +08:00
xiaomo d140801337 Merge pull request 'feat(dashboard): graph node click improvements' (#255) from feat/graph-interactions into main 2026-05-14 08:29:29 +00:00
xingyue 4563f1bb5e fix(dashboard): start node lights up when thread-start exists
Previously __start__ only lit when role records existed. Now it lights
up as soon as a thread-start record is present (i.e. the trigger prompt).
2026-05-14 16:24:30 +08:00
xingyue 59b7e89028 feat(dashboard): graph node click improvements
- Reduce feedback edge offset (140→80) for tighter layout
- Terminal nodes (start/end) now clickable when lit
- Unlit nodes have no cursor-pointer and ignore clicks
- Role nodes cycle through occurrences on repeated clicks
- Start node click scrolls to thread prompt
- End node click scrolls to bottom
- All records get data-record-index for scroll targeting

Ref: #247
2026-05-14 16:20:00 +08:00
xingyue 019d8c1ee9 fix: explicit handle IDs — forward edges use top/bottom, feedback uses sides
Prevent React Flow from auto-routing forward edges to side handles.
All handles now have explicit IDs and all edges specify sourceHandle/targetHandle.
2026-05-14 15:59:55 +08:00
xingyue 5e783e7a24 fix(dashboard): feedback edges connect from node sides via left/right handles (#247)
What: Feedback (back) edges now connect from the left/right side of nodes
instead of top/bottom, making the routing visually clearer.

Changes:
- role-node.tsx: add Left/Right handles for feedback edge connections
- use-layout.ts: set sourceHandle/targetHandle for feedback edges
- condition-edge.tsx + use-layout.ts: increase FEEDBACK_OFFSET_X to 140

Ref: #247
2026-05-14 15:52:24 +08:00
xingyue a450a88b16 fix(dashboard): increase feedback edge offset for clarity (#247) 2026-05-14 14:38:05 +08:00
xingyue 5b47317cef fix(dashboard): fix crash — t.state → data.state in role-node (#247) 2026-05-14 14:34:57 +08:00
xiaomo 3384c38d02 Merge pull request 'fix(dashboard): restore graph visual preferences (#247)' (#250) from fix/dashboard-graph-visual-247 into main 2026-05-14 03:43:32 +00:00
xingyue b370d96504 fix(dashboard): alternate feedback edges left/right (#247 Phase 2)
What: Feedback (back) edges now alternate between left and right sides
instead of all routing to the right.

Why: Multiple feedback edges targeting the same node (e.g. reviewer→coder
and tester→coder) were overlapping on the right side.

Changes:
- types.ts: add feedbackSide field to ConditionEdgeData
- use-layout.ts: track feedback count per target, alternate sides
- condition-edge.tsx: feedbackPath() accepts side param, mirrors path for left

Ref: #247, closes #249
2026-05-14 11:42:06 +08:00
xingyue 8cae114c7e fix(dashboard): unified solid edges, hide FALLBACK labels, conditional cursor (#247 Phase 1)
What: Restore graph visual preferences — all edges solid, FALLBACK labels hidden,
inactive nodes not clickable.

Why: Visual consistency and cleaner graph appearance per design preferences.

Changes:
- condition-edge.tsx: remove strokeDasharray, unify stroke color, hide FALLBACK labels
- role-node.tsx: cursor-pointer only on non-default state nodes

Ref: #247, closes #248
2026-05-14 11:39:51 +08:00
xiaomo c2c6fc5304 Merge pull request 'refactor: cursor-agent uses runtime.extract for workspace detection' (#246) from fix/cursor-agent-runtime-extract into main 2026-05-13 15:57:36 +00:00
xiaoju 94f725c50b refactor: cursor-agent uses runtime.extract for workspace detection
- Remove llmProvider and workspace from CursorAgentConfig (now just command/model/timeout)
- extractWorkspacePath uses runtime.extract + runtime.cas instead of standalone reactor
- TextProducerFn signature gains runtime parameter: (ctx, prompt, runtime)
- develop-entry.ts hardcodes cursor-agent path, no more env var dependency
- Drop @uncaged/workflow-reactor dep from workflow-agent-cursor
- Update tests for simplified config

小橘 <xiaoju@shazhou.work>
2026-05-13 15:51:43 +00:00
xiaomo 9b23e6f85a Merge pull request 'refactor(serve): remove tunnel + eliminate HTTP round-trip in gateway mode' (#245) from refactor/serve-remove-http-tunnel into main 2026-05-13 15:29:05 +00:00
xingyue 238a94f7a6 fix: restore original migration, rename pathAfterAgent → pathAfterClient
- wrangler.toml: keep first migration as AgentSocket (already applied),
  second migration handles the rename
- index.ts: pathAfterAgent → pathAfterClient
2026-05-13 23:28:20 +08:00
xingyue 236c771e4e refactor: rename serve→connect, agent→client across CLI/gateway/dashboard
- CLI: 'serve' command → 'connect', remove local-only HTTP mode
  (no WORKFLOW_GATEWAY_SECRET now errors instead of falling back)
- CLI: agentToken → clientToken, X-Agent-Token → X-Client-Token
- Gateway: AgentSocket DO → ClientSocket, AGENT_SOCKET → CLIENT_SOCKET
- Gateway: /api/agents/:agent/* → /api/clients/:client/*
- Gateway: agentToken → clientToken in EndpointRecord and register API
- Dashboard: all agent references → client throughout UI and API layer
- Added Durable Object migration for the class rename
2026-05-13 23:28:20 +08:00
xingyue 0ffd84cf7d refactor(serve): WS client calls app.fetch directly, no HTTP server in gateway mode
- WS client receives app.fetch function instead of localPort
- Gateway mode no longer starts a local HTTP server
- Local-only mode (no secret) still starts HTTP server as before
- Removes unnecessary HTTP round-trip for gateway requests
2026-05-13 23:28:20 +08:00
xiaomo e14643a50b Merge pull request 'chore: add output rules to develop roles — suppress verbose diffs' (#244) from chore/slim-role-output into main 2026-05-13 15:01:02 +00:00
xiaoju 76830c5e22 chore: add log-tag lint + fix biome errors + pre-push hook
- scripts/lint-log-tags.sh: static check for invalid Crockford Base32 log tags (I/L/O/U)
- fix two invalid log tags in ws-client.ts (6CJX2RLP→6CJX2R8P, T9W2KL5H→T9W2K35H)
- fix biome errors: unused import, exhaustive deps, cognitive complexity suppression
- add pre-push git hook running bun run check
- integrate lint-log-tags into bun run check pipeline

Refs #244
2026-05-13 14:59:20 +00:00
xingyue 90a388f5ab refactor(serve): remove tunnel/cloudflared, simplify to WS-only gateway
- Delete tunnel.ts (startTunnel/cloudflared), rename to gateway.ts
- Remove --no-tunnel, --tunnel-url flags
- ServeOptions: drop noTunnel, tunnelUrl fields
- Two modes: gateway (with WORKFLOW_GATEWAY_SECRET) or local-only
- WS reverse connection is the only gateway transport
2026-05-13 22:46:48 +08:00
xiaoju 82e40f0c21 feat: planner abort path — fail fast when workspace info is missing
- PlannerMeta is now a discriminated union: planned | aborted
- Moderator routes aborted planner → END (no coder invocation)
- System prompt requires absolute workspace path, instructs abort if missing
- extractRefs handles both variants
- Test: 'planner aborted → END'

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 14:20:23 +00:00
xiaoju 8d650326db chore: add output rules to all develop roles — suppress verbose diffs
Planner, coder, reviewer, and tester system prompts now explicitly
instruct the agent to keep responses short and avoid pasting diffs,
code blocks, or full build logs. This reduces CAS storage and token
waste when downstream roles read the thread.

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 13:52:04 +00:00
xingyue dd3eec7d35 docs: update CLAUDE.md — changesets + npmjs registry 2026-05-13 21:22:15 +08:00
xingyue 9276689cb6 chore: switch to npmjs registry, publish v0.4.5
- Remove Gitea npm registry, use npmjs.org
- changeset publish works natively with npmjs
- release script: build + test + changeset publish
- Remove custom release.sh, all via changesets
2026-05-13 21:20:18 +08:00
xingyue b4584cbaa6 chore: publish v0.4.3 — include src/ in published packages
bun runtime resolves the 'bun' exports condition to ./src/index.ts,
but src/ was not in the files array so consumers got ENOENT.
2026-05-13 21:11:17 +08:00
xingyue 1cf963a1fb chore: publish v0.4.2 — fix workspace deps, remove publish.sh
- workspace:* → workspace:^ (resolves to ^x.y.z instead of exact)
- Remove publish.sh, use changesets workflow
- changeset config: access public (Gitea compat)
- release script: build + test + changeset publish
2026-05-13 21:07:29 +08:00
xingyue ce5bc50210 chore: publish v0.4.1
小橘 <xiaoju@shazhou.work>
2026-05-13 20:59:59 +08:00
xiaomo 439e203113 Merge pull request 'feat: adopt @changesets/cli for synchronized version management' (#243) from feat/changesets-version-management into main 2026-05-13 12:57:41 +00:00
94 changed files with 1910 additions and 1159 deletions
+3 -9
View File
@@ -2,16 +2,10 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog", "changelog": "@changesets/cli/changelog",
"commit": false, "commit": false,
"fixed": [ "fixed": [["@uncaged/*"]],
[
"@uncaged/*"
]
],
"linked": [], "linked": [],
"access": "restricted", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"ignore": [ "ignore": ["@uncaged/workflow-dashboard"]
"@uncaged/workflow-dashboard"
]
} }
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# pre-push hook: typecheck + biome + lint-log-tags
set -euo pipefail
echo "🔍 pre-push: running checks..."
bun run check
echo "✅ pre-push: all checks passed"
+22 -35
View File
@@ -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:giteaauto 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>`
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$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"]
}, },
+15
View File
@@ -0,0 +1,15 @@
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,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+2 -7
View File
@@ -6,18 +6,13 @@
], ],
"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",
"link:consume": "./scripts/link-all.sh --consume",
"link:unlink": "./scripts/link-all.sh --unlink",
"publish:gitea": "./scripts/publish.sh patch",
"publish:gitea:dry": "./scripts/publish.sh --dry-run patch",
"changeset": "bunx changeset", "changeset": "bunx changeset",
"version": "bunx changeset version", "version": "bunx changeset version",
"release": "bunx changeset publish --no-git-tag" "release": "bun run build && bun test && npx changeset publish --no-git-tag"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.14", "@biomejs/biome": "^2.4.14",
+54
View File
@@ -1,5 +1,59 @@
# @uncaged/cli-workflow # @uncaged/cli-workflow
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
@@ -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" },
+11 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/cli-workflow", "name": "@uncaged/cli-workflow",
"version": "0.4.0", "version": "0.4.5",
"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"
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ 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 { createInitDispatcher } from "./commands/init/index.js"; import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchServe } from "./commands/serve/index.js"; import { dispatchConnect } from "./commands/connect/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> {
+3 -3
View File
@@ -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",
}, },
]), ]),
); );
@@ -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_GATEWAY_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_GATEWAY_SECRET is required");
return 1;
}
const clientToken = randomUUID();
const app = createApp(storageRoot, clientToken);
const log = createLogger({ sink: { kind: "stderr" } });
const stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
appFetch: app.fetch,
log,
});
printCliLine("connected to gateway via WebSocket");
// Register with gateway for discovery
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
`ws://${options.name}`,
options.gatewaySecret,
clientToken,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
`ws://${options.name}`,
options.gatewaySecret,
clientToken,
HEARTBEAT_INTERVAL_MS,
);
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
await new Promise(() => {});
return 0;
}
@@ -1,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;
};
@@ -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,17 @@ 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(new Request(localUrl, {
method: req.method, method: req.method,
headers: initHeaders, headers,
body: req.body === null ? undefined : req.body, 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 +97,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 +140,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) => {
@@ -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;
};
+2 -2
View File
@@ -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
@@ -1,5 +1,51 @@
# @uncaged/workflow-agent-cursor # @uncaged/workflow-agent-cursor
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
@@ -2,24 +2,11 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => { describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => { test("accepts valid config", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent", command: "/usr/local/bin/cursor-agent",
model: null, model: null,
timeout: 0, timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
}); });
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
}); });
@@ -29,8 +16,6 @@ describe("validateCursorAgentConfig", () => {
command: "cursor-agent", command: "cursor-agent",
model: null, model: null,
timeout: 0, 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,65 +23,22 @@ describe("validateCursorAgentConfig", () => {
} }
}); });
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
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", () => { test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({ const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent", command: "/usr/local/bin/cursor-agent",
model: null, model: null,
timeout: -1, timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}); });
expect(r.ok).toBe(false); 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", command: "/usr/local/bin/cursor-agent",
model: null, model: null,
timeout: 0, 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");
}); });
@@ -106,19 +48,6 @@ describe("createCursorAgent", () => {
command: "/usr/local/bin/cursor-agent", command: "/usr/local/bin/cursor-agent",
model: null, 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");
}); });
+10 -6
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-agent-cursor", "name": "@uncaged/workflow-agent-cursor",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -11,11 +12,11 @@
"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": {
@@ -24,5 +25,8 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "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;
+9 -20
View File
@@ -1,4 +1,4 @@
import type { AdapterFn } from "@uncaged/workflow-runtime"; import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util"; import { createLogger } from "@uncaged/workflow-util";
import { import {
buildThreadInput, buildThreadInput,
@@ -33,34 +33,23 @@ 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. */ /** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn { export function createCursorAgent(config: CursorAgentConfig) {
const modelFlag = resolveCursorModel(config.model); const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null; const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } }); const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt) => { return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config); const validated = validateCursorAgentConfig(config);
if (!validated.ok) { if (!validated.ok) {
throw new Error(validated.error); throw new Error(validated.error);
} }
let workspace: string; const workspace = await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
if (config.workspace !== null) { throw new Error(
workspace = config.workspace; "cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
} 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;
} }
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`); logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
@@ -1,12 +1,6 @@
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. */
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
}; };
@@ -8,12 +8,6 @@ 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");
} }
@@ -1,5 +1,37 @@
# @uncaged/workflow-agent-hermes # @uncaged/workflow-agent-hermes
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-agent-hermes", "name": "@uncaged/workflow-agent-hermes",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -11,8 +12,8 @@
"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": {
".": { ".": {
@@ -20,5 +21,8 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
},
"publishConfig": {
"access": "public"
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@ function throwHermesSpawnError(error: SpawnCliError): never {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn { export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout; const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt) => { return createTextAdapter(async (ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config); const validated = validateHermesAgentConfig(config);
if (!validated.ok) { if (!validated.ok) {
throw new Error(validated.error); throw new Error(validated.error);
+32
View File
@@ -1,5 +1,37 @@
# @uncaged/workflow-agent-llm # @uncaged/workflow-agent-llm
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-agent-llm", "name": "@uncaged/workflow-agent-llm",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -11,8 +12,8 @@
"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"
@@ -23,5 +24,8 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
},
"publishConfig": {
"access": "public"
} }
} }
@@ -93,7 +93,7 @@ export async function chatCompletionText(options: {
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */ /** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn { export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => { return createTextAdapter(async (ctx, prompt, _runtime) => {
const result = await chatCompletionText({ const result = await chatCompletionText({
provider, provider,
messages: [ messages: [
@@ -1,5 +1,43 @@
# @uncaged/workflow-agent-react # @uncaged/workflow-agent-react
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+8 -4
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-agent-react", "name": "@uncaged/workflow-agent-react",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -18,14 +19,17 @@
"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"
} }
} }
+34
View File
@@ -1,5 +1,39 @@
# @uncaged/workflow-cas # @uncaged/workflow-cas
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-cas", "name": "@uncaged/workflow-cas",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -17,12 +18,15 @@
} }
}, },
"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"
} }
} }
+28 -28
View File
@@ -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;
@@ -141,61 +141,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");
} }
+19 -13
View File
@@ -6,12 +6,13 @@ 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 +23,41 @@ 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,65 @@ 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((nodeId: string) => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); // Only allow clicks on lit (non-default) nodes
setHighlightedRole(roleName); if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null); // __start__: scroll to the first record (thread-start prompt)
highlightTimerRef.current = null; if (nodeId === "__start__") {
}, 1500); 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 +148,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 +268,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 +285,7 @@ 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,371 @@
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 { 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>
)}
{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,47 +1,36 @@
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 // Must match the FEEDBACK_OFFSET_X in use-layout.ts
const FEEDBACK_OFFSET_X = 100; const FEEDBACK_OFFSET_X = 80;
// Radius for feedback edge corners // Radius for feedback edge corners
const FEEDBACK_RADIUS = 16; const FEEDBACK_RADIUS = 16;
/** /**
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes. * Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
* The path goes: source right → arc → vertical up → arc → target right * The path goes: source → arc → vertical up → arc → target
*/ */
function feedbackPath( function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
sourceX: number, const d = side === "right" ? 1 : -1;
sourceY: number, const offsetX =
targetX: number, side === "right"
targetY: number, ? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
): string { : Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
const r = FEEDBACK_RADIUS; const r = FEEDBACK_RADIUS;
// Start from source right side, go right, then up, then left to target right side
const segments = [ const segments = [
`M ${sourceX} ${sourceY}`, `M ${sourceX} ${sourceY}`,
// Horizontal to the right `L ${offsetX - d * r} ${sourceY}`,
`L ${rightX - r} ${sourceY}`, `Q ${offsetX} ${sourceY} ${offsetX} ${sourceY - r}`,
// Arc turning upward `L ${offsetX} ${targetY + r}`,
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`, `Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
// Vertical upward
`L ${rightX} ${targetY + r}`,
// Arc turning left
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
// Horizontal left to target
`L ${targetX} ${targetY}`, `L ${targetX} ${targetY}`,
]; ];
return segments.join(" "); 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,
@@ -66,10 +55,13 @@ export function ConditionEdge(props: EdgeProps) {
let defaultLabelY: number; let defaultLabelY: number;
if (isFeedback) { if (isFeedback) {
// Custom feedback path routed to the right const side = edgeData?.feedbackSide ?? "right";
path = feedbackPath(sourceX, sourceY, targetX, targetY); path = feedbackPath(sourceX, sourceY, targetX, targetY, side);
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X; const offsetX =
defaultLabelX = rightX; side === "right"
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
defaultLabelX = offsetX;
defaultLabelY = (sourceY + targetY) / 2; defaultLabelY = (sourceY + targetY) / 2;
} else { } else {
const result = getSmoothStepPath({ const result = getSmoothStepPath({
@@ -87,9 +79,8 @@ export function ConditionEdge(props: EdgeProps) {
defaultLabelY = result[2]; defaultLabelY = result[2];
} }
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)"; const stroke = "var(--color-accent)";
const strokeDasharray = isFallback ? "5 4" : undefined; const label = isFallback ? "" : (edgeData?.condition ?? "");
const label = edgeData?.condition ?? "";
// Use pre-computed label position if available, otherwise fall back to default // Use pre-computed label position if available, otherwise fall back to default
const labelX = edgeData?.labelX ?? defaultLabelX; const labelX = edgeData?.labelX ?? defaultLabelX;
@@ -101,7 +92,7 @@ export function ConditionEdge(props: EdgeProps) {
id={id} id={id}
path={path} path={path}
markerEnd={markerEnd} markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }} style={{ stroke, strokeWidth: 1.5 }}
/> />
{label !== "" && ( {label !== "" && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
@@ -111,7 +102,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,11 @@ 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 +67,7 @@ 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,12 @@ 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} />
)} )}
{isStart ? "▶" : "■"} {isStart ? "▶" : "■"}
</div> </div>
@@ -23,6 +23,7 @@ export type ConditionEdgeData = {
isFallback: boolean; isFallback: boolean;
isFeedback: boolean; isFeedback: boolean;
isSelfLoop: boolean; isSelfLoop: boolean;
feedbackSide: "right" | "left" | null;
labelX: number | null; labelX: number | null;
labelY: number | null; labelY: number | null;
[key: string]: unknown; [key: string]: unknown;
@@ -1,7 +1,7 @@
import type { Edge, Node } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react";
import { useMemo } from "react"; import { useMemo } 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__";
@@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40;
// Vertical gap between nodes in the spine // Vertical gap between nodes in the spine
const LAYER_GAP = 80; const LAYER_GAP = 80;
// Horizontal offset for feedback (back) edges routed on the right side // Horizontal offset for feedback (back) edges routed on the right side
const FEEDBACK_OFFSET_X = 100; const FEEDBACK_OFFSET_X = 80;
type LayoutInput = { type LayoutInput = {
edges: readonly WorkflowGraphEdge[]; edges: readonly WorkflowGraphEdge[];
@@ -41,6 +41,7 @@ function edgeKey(e: WorkflowGraphEdge): string {
* Forward edges go from lower rank to higher rank; feedback edges go backwards. * Forward edges go from lower rank to higher rank; feedback edges go backwards.
* Self-loops are neither forward nor feedback — they're handled separately. * Self-loops are neither forward nor feedback — they're handled separately.
*/ */
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] { function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
// Collect all node IDs // Collect all node IDs
const ids = new Set<string>(); const ids = new Set<string>();
@@ -172,6 +173,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
// Build edges with label positions // Build edges with label positions
// For feedback edges (target rank < source rank), we'll compute label at midpoint // For feedback edges (target rank < source rank), we'll compute label at midpoint
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component. // of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
// Track feedback edge count per target node for alternating sides
const feedbackCountByTarget = new Map<string, number>();
const edges: Edge[] = input.edges.map((e) => { const edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK"; const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to; const isSelfLoop = e.from === e.to;
@@ -184,13 +187,20 @@ function computeLayout(input: LayoutInput): LayoutResult {
let labelX: number | null = null; let labelX: number | null = null;
let labelY: number | null = null; let labelY: number | null = null;
let feedbackSide: "right" | "left" | null = null;
if (sourcePos !== undefined && targetPos !== undefined) { if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback) { if (isFeedback) {
// Label on the right side of the feedback arc // Alternate feedback edges left/right per target node
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X; const count = feedbackCountByTarget.get(e.to) ?? 0;
feedbackCountByTarget.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; const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
labelX = rightX; labelX = offsetX;
labelY = midY; labelY = midY;
} else if (!isSelfLoop) { } else if (!isSelfLoop) {
// Forward edge: label between source bottom and target top // Forward edge: label between source bottom and target top
@@ -206,6 +216,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
id: edgeKey(e), id: edgeKey(e),
source: e.from, source: e.from,
target: e.to, target: e.to,
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
type: "condition", type: "condition",
data: { data: {
condition: e.condition, condition: e.condition,
@@ -213,8 +225,9 @@ function computeLayout(input: LayoutInput): LayoutResult {
isFallback, isFallback,
isFeedback, isFeedback,
isSelfLoop, isSelfLoop,
labelX, feedbackSide,
labelY, labelX,
labelY,
}, },
}; };
}); });
@@ -223,8 +236,5 @@ function computeLayout(input: LayoutInput): LayoutResult {
} }
export function useLayout(input: LayoutInput): LayoutResult { export function useLayout(input: LayoutInput): LayoutResult {
return useMemo( return useMemo(() => computeLayout(input), [input]);
() => computeLayout(input),
[input.edges, input.roles, input.nodeStates],
);
} }
@@ -32,16 +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);
} }
export function WorkflowGraph({ 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 onNodeClickHandler: OnNodeClick | undefined = const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined; onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
const styledEdges = useMemo( const styledEdges = useMemo(
() => () =>
@@ -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,33 @@ 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)", borderColor: "var(--color-border)", color: "var(--color-text)" }}
> >
<button <div className="flex items-center gap-2">
type="button" <span className="font-medium">{w.name}</span>
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)" }}
aria-expanded={isOpen}
>
<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,33 @@ 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({ view: "threads", client: route.client, threadId: id, workflowName: null }),
[navigate, route.agent], [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,
}; };
} }
+7 -7
View File
@@ -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 };
} }
+50
View File
@@ -1,5 +1,55 @@
# @uncaged/workflow-execute # @uncaged/workflow-execute
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+11 -7
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-execute", "name": "@uncaged/workflow-execute",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -17,12 +18,12 @@
"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": {
@@ -30,5 +31,8 @@
}, },
"devDependencies": { "devDependencies": {
"zod": "^4.0.0" "zod": "^4.0.0"
},
"publishConfig": {
"access": "public"
} }
} }
+16
View File
@@ -1,5 +1,21 @@
# @uncaged/workflow-gateway # @uncaged/workflow-gateway
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+5 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-gateway", "name": "@uncaged/workflow-gateway",
"version": "0.4.0", "version": "0.4.5",
"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"
} }
} }
@@ -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; GATEWAY_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,7 +32,7 @@ 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 {
@@ -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 {
+45 -45
View File
@@ -2,27 +2,27 @@ 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; GATEWAY_SECRET: string;
DASHBOARD_API_KEY: string; DASHBOARD_API_KEY: string;
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>; CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
}; };
}; };
type EndpointRecord = { type EndpointRecord = {
name: string; name: string;
url: string; url: string;
agentToken: string; clientToken: string;
registeredAt: number; registeredAt: number;
lastHeartbeat: number; lastHeartbeat: number;
}; };
@@ -43,7 +43,7 @@ function checkDashboardAuth(c: {
return key === c.env.DASHBOARD_API_KEY; return key === c.env.DASHBOARD_API_KEY;
} }
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 +52,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 +70,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 +81,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 +94,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 +113,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 +130,7 @@ async function fetchAgentWithRecordHeaders(
}); });
} }
async function fetchAgentWithDashboardHeaders( async function fetchClientWithDashboardHeaders(
targetUrl: string, targetUrl: string,
method: string, method: string,
headers: Headers, headers: Headers,
@@ -143,15 +143,15 @@ 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.GATEWAY_SECRET}` },
}), }),
@@ -171,7 +171,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,7 +184,7 @@ 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 (GATEWAY_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");
@@ -197,8 +197,8 @@ app.get("/ws/connect", async (c) => {
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);
}); });
@@ -210,9 +210,9 @@ 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);
@@ -227,7 +227,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,
}; };
@@ -261,7 +261,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 +277,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 +305,7 @@ 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.GATEWAY_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 +313,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);
} }
}); });
+5 -1
View File
@@ -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"]
[[migrations]]
tag = "rename-agent-to-client"
renamed_classes = [{ from = "AgentSocket", to = "ClientSocket" }]
# GATEWAY_SECRET is set via `wrangler secret put` # GATEWAY_SECRET is set via `wrangler secret put`
+24
View File
@@ -1,5 +1,29 @@
# @uncaged/workflow-protocol # @uncaged/workflow-protocol
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+5 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-protocol", "name": "@uncaged/workflow-protocol",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -24,5 +25,8 @@
"devDependencies": { "devDependencies": {
"zod": "^4.0.0", "zod": "^4.0.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
} }
} }
+30
View File
@@ -1,5 +1,35 @@
# @uncaged/workflow-reactor # @uncaged/workflow-reactor
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+6 -2
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-reactor", "name": "@uncaged/workflow-reactor",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -14,7 +15,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-protocol": "workspace:*" "@uncaged/workflow-protocol": "workspace:^"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4.0.0" "zod": "^4.0.0"
@@ -22,5 +23,8 @@
"devDependencies": { "devDependencies": {
"zod": "^4.0.0", "zod": "^4.0.0",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
} }
} }
+34
View File
@@ -1,5 +1,39 @@
# @uncaged/workflow-register # @uncaged/workflow-register
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-register", "name": "@uncaged/workflow-register",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -14,8 +15,8 @@
} }
}, },
"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",
@@ -27,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"
} }
} }
+34
View File
@@ -1,5 +1,39 @@
# @uncaged/workflow-runtime # @uncaged/workflow-runtime
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
- @uncaged/workflow-cas@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
- @uncaged/workflow-cas@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
## 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
## 0.4.0 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-runtime", "name": "@uncaged/workflow-runtime",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -11,8 +12,8 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-cas": "workspace:*", "@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:*" "@uncaged/workflow-protocol": "workspace:^"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4.0.0" "zod": "^4.0.0"
@@ -26,5 +27,8 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
},
"publishConfig": {
"access": "public"
} }
} }
@@ -0,0 +1,101 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);
@@ -1,5 +1,37 @@
# @uncaged/workflow-template-develop # @uncaged/workflow-template-develop
## 0.4.5
### Patch Changes
- @uncaged/workflow-register@0.4.5
- @uncaged/workflow-runtime@0.4.5
## 0.4.4
### Patch Changes
- @uncaged/workflow-register@0.4.4
- @uncaged/workflow-runtime@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-register@0.4.3
- @uncaged/workflow-runtime@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-register@0.4.2
- @uncaged/workflow-runtime@0.4.2
## 0.4.0 ## 0.4.0
### Minor Changes ### Minor Changes
@@ -9,7 +9,9 @@ import type { DevelopMeta } from "../src/roles.js";
const developModerator = tableToModerator(developTable); const developModerator = tableToModerator(developTable);
const DEFAULT_PHASES: PlannerMeta["phases"] = [ type PlannedMeta = Extract<PlannerMeta, { status: "planned" }>;
const DEFAULT_PHASES: PlannedMeta["phases"] = [
{ {
hash: "4KNMR2PX", hash: "4KNMR2PX",
title: "Do the work", title: "Do the work",
@@ -36,11 +38,11 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
}; };
} }
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> { function plannerStep(phases: PlannedMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
return { return {
role: "planner", role: "planner",
contentHash: "STUBHASHPLANNER001", contentHash: "STUBHASHPLANNER001",
meta: { phases }, meta: { status: "planned" as const, phases },
refs: phases.map((p) => p.hash), refs: phases.map((p) => p.hash),
timestamp: 1, timestamp: 1,
}; };
@@ -153,7 +155,7 @@ describe("developModerator", () => {
}); });
test("multiple planner phases → coder until all complete, then reviewer", () => { test("multiple planner phases → coder until all complete, then reviewer", () => {
const phases: PlannerMeta["phases"] = [ const phases: PlannedMeta["phases"] = [
{ hash: "AA000001", title: "first phase" }, { hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" }, { hash: "AA000002", title: "second phase" },
]; ];
@@ -167,7 +169,7 @@ describe("developModerator", () => {
}); });
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => { test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
const phases: PlannerMeta["phases"] = [ const phases: PlannedMeta["phases"] = [
{ hash: "BB000001", title: "setup branch" }, { hash: "BB000001", title: "setup branch" },
{ hash: "BB000002", title: "write tests" }, { hash: "BB000002", title: "write tests" },
{ hash: "BB000003", title: "verify" }, { hash: "BB000003", title: "verify" },
@@ -179,7 +181,7 @@ describe("developModerator", () => {
}); });
test("unrecognised completedPhase hash → coder retry when budget allows", () => { test("unrecognised completedPhase hash → coder retry when budget allows", () => {
const phases: PlannerMeta["phases"] = [ const phases: PlannedMeta["phases"] = [
{ hash: "CC000001", title: "first phase" }, { hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" }, { hash: "CC000002", title: "second phase" },
]; ];
@@ -187,7 +189,7 @@ describe("developModerator", () => {
}); });
test("incomplete phases → coder retry (supervisor controls termination)", () => { test("incomplete phases → coder retry (supervisor controls termination)", () => {
const phases: PlannerMeta["phases"] = [ const phases: PlannedMeta["phases"] = [
{ hash: "DD000001", title: "first phase" }, { hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" }, { hash: "DD000002", title: "second phase" },
]; ];
@@ -198,6 +200,17 @@ describe("developModerator", () => {
expect(developModerator(makeCtx(steps))).toBe("coder"); expect(developModerator(makeCtx(steps))).toBe("coder");
}); });
test("planner aborted → END", () => {
const abortedStep: RoleStep<DevelopMeta> = {
role: "planner",
contentHash: "STUBHASHABORT001",
meta: { status: "aborted", reason: "No workspace path provided" },
refs: [],
timestamp: 1,
};
expect(developModerator(makeCtx([abortedStep]))).toBe("__end__");
});
test("committer → END for any committer meta status", () => { test("committer → END for any committer meta status", () => {
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" }); const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
const recoverable = committerStep({ const recoverable = committerStep({
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-template-develop", "name": "@uncaged/workflow-template-develop",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -17,11 +18,14 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-register": "workspace:*", "@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@uncaged/workflow-protocol": "workspace:*" "@uncaged/workflow-protocol": "workspace:^"
},
"publishConfig": {
"access": "public"
} }
} }
@@ -30,6 +30,18 @@ function coderFinishedAllPlannedPhases(
// ── Conditions ───────────────────────────────────────────────────── // ── Conditions ─────────────────────────────────────────────────────
const plannerAborted: ModeratorCondition<DevelopMeta> = {
name: "plannerAborted",
description: "The planner aborted due to insufficient information",
check: (ctx) => {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return false;
}
return plannerStep.meta.status === "aborted";
},
};
const allPhasesComplete: ModeratorCondition<DevelopMeta> = { const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
name: "allPhasesComplete", name: "allPhasesComplete",
description: "All planned phases have been completed by the coder", description: "All planned phases have been completed by the coder",
@@ -38,7 +50,7 @@ const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
if (plannerStep === undefined) { if (plannerStep === undefined) {
return true; return true;
} }
const phases = plannerStep.meta.phases; const phases = plannerStep.meta.status === "planned" ? plannerStep.meta.phases : [];
if (!Array.isArray(phases)) { if (!Array.isArray(phases)) {
return true; return true;
} }
@@ -71,7 +83,10 @@ const testsPassed: ModeratorCondition<DevelopMeta> = {
const table: ModeratorTable<DevelopMeta> = { const table: ModeratorTable<DevelopMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }], [START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }], planner: [
{ condition: plannerAborted, role: END },
{ condition: "FALLBACK", role: "coder" },
],
coder: [ coder: [
{ condition: allPhasesComplete, role: "reviewer" }, { condition: allPhasesComplete, role: "reviewer" },
{ condition: "FALLBACK", role: "coder" }, { condition: "FALLBACK", role: "coder" },
@@ -25,7 +25,11 @@ The thread ID (26-char Crockford Base32) appears in the first message. If unsure
## Completing a phase ## Completing a phase
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`; Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.
## Output rules
Keep your final response **short** — a brief summary paragraph plus the structured meta output. Do NOT paste diffs, file contents, or code blocks in your response. The actual changes are already on disk; repeating them wastes tokens. Just say what you did and why.`;
export const coderRole: RoleDefinition<CoderMeta> = { export const coderRole: RoleDefinition<CoderMeta> = {
description: description:
@@ -6,16 +6,27 @@ export const phaseSchema = z.object({
title: z.string(), title: z.string(),
}); });
export const plannerMetaSchema = z.object({ export const plannerMetaSchema = z.discriminatedUnion("status", [
phases: z.array(phaseSchema), z.object({
}); status: z.literal("planned"),
phases: z.array(phaseSchema),
}),
z.object({
status: z.literal("aborted"),
reason: z.string().describe("Why the task cannot proceed"),
}),
]);
export type PlannerMeta = z.infer<typeof plannerMetaSchema>; export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide. Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Prerequisites — check FIRST
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
## Storing phase details — MANDATORY ## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier. For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
@@ -37,13 +48,20 @@ Fewer phases is always better. Each phase must justify its existence — if two
## Output format ## Output format
After storing all phases via the CLI, output compact JSON only: After storing all phases via the CLI, output compact JSON only:
{ "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] } { "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.`; If aborting:
{ "status": "aborted", "reason": "<what is missing>" }
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
## Output rules
Keep your final response **short** — just the JSON with phases. Do NOT paste code snippets, diffs, or implementation details in your response. Phase details are already stored in CAS; your response should only contain the compact phases JSON.`;
export const plannerRole: RoleDefinition<PlannerMeta> = { export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.", description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM, systemPrompt: PLANNER_SYSTEM,
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash), extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
}; };
@@ -32,7 +32,11 @@ const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correc
- **Approve** only if there are zero issues - **Approve** only if there are zero issues
- **Reject** with specific issues that must be fixed — every issue you find is blocking - **Reject** with specific issues that must be fixed — every issue you find is blocking
Be thorough. A false approve costs more than a false reject.`; Be thorough. A false approve costs more than a false reject.
## Output rules
Keep your final response **short**. Summarize findings in a few bullet points, then output the structured verdict. Do NOT paste the full diff or large code blocks in your response.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = { export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.", description: "Runs git diff checks and sets approved when the change is ready.",
@@ -14,7 +14,11 @@ export const testerMetaSchema = z.discriminatedUnion("status", [
export type TesterMeta = z.infer<typeof testerMetaSchema>; export type TesterMeta = z.infer<typeof testerMetaSchema>;
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`; const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.
## Output rules
Keep your final response **short**. Report pass/fail with a brief summary of failures (if any). Do NOT paste full test output or build logs — just the key error lines.`;
export const testerRole: RoleDefinition<TesterMeta> = { export const testerRole: RoleDefinition<TesterMeta> = {
description: "Runs test, build, and lint commands and reports pass or fail with details.", description: "Runs test, build, and lint commands and reports pass or fail with details.",
@@ -1,5 +1,37 @@
# @uncaged/workflow-template-solve-issue # @uncaged/workflow-template-solve-issue
## 0.4.5
### Patch Changes
- @uncaged/workflow-register@0.4.5
- @uncaged/workflow-runtime@0.4.5
## 0.4.4
### Patch Changes
- @uncaged/workflow-register@0.4.4
- @uncaged/workflow-runtime@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-register@0.4.3
- @uncaged/workflow-runtime@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-register@0.4.2
- @uncaged/workflow-runtime@0.4.2
## 0.4.0 ## 0.4.0
### Minor Changes ### Minor Changes
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-template-solve-issue", "name": "@uncaged/workflow-template-solve-issue",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -17,13 +18,16 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-register": "workspace:*", "@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@uncaged/workflow-cas": "workspace:*", "@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:*", "@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-protocol": "workspace:*" "@uncaged/workflow-protocol": "workspace:^"
},
"publishConfig": {
"access": "public"
} }
} }
+32
View File
@@ -1,5 +1,37 @@
# @uncaged/workflow-util-agent # @uncaged/workflow-util-agent
## 0.4.5
### Patch Changes
- @uncaged/workflow-cas@0.4.5
- @uncaged/workflow-runtime@0.4.5
## 0.4.4
### Patch Changes
- @uncaged/workflow-cas@0.4.4
- @uncaged/workflow-runtime@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-runtime@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-runtime@0.4.2
## 0.4.0 ## 0.4.0
### Minor Changes ### Minor Changes
+7 -3
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-util-agent", "name": "@uncaged/workflow-util-agent",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -18,8 +19,11 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-cas": "workspace:*", "@uncaged/workflow-cas": "workspace:^",
"zod": "^4.0.0" "zod": "^4.0.0"
},
"publishConfig": {
"access": "public"
} }
} }
@@ -7,6 +7,8 @@ import type {
} from "@uncaged/workflow-runtime"; } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4"; import type * as z from "zod/v4";
export type { WorkflowRuntime } from "@uncaged/workflow-runtime";
/** /**
* Result from a text-producing agent (CLI spawn, LLM call, etc.). * Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow. * `output` is the raw text; `childThread` links to a spawned sub-workflow.
@@ -23,6 +25,7 @@ export type TextAdapterResult = {
export type TextProducerFn = ( export type TextProducerFn = (
ctx: ThreadContext, ctx: ThreadContext,
prompt: string, prompt: string,
runtime: WorkflowRuntime,
) => Promise<string | TextAdapterResult>; ) => Promise<string | TextAdapterResult>;
/** /**
@@ -37,7 +40,7 @@ export type TextProducerFn = (
export function createTextAdapter(producer: TextProducerFn): AdapterFn { export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => { return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => { return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt); const result = await producer(ctx, prompt, runtime);
const output = typeof result === "string" ? result : result.output; const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread; const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []); const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
+30
View File
@@ -1,5 +1,35 @@
# @uncaged/workflow-util # @uncaged/workflow-util
## 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 ## 0.4.0
### Minor Changes ### Minor Changes
+6 -2
View File
@@ -1,7 +1,8 @@
{ {
"name": "@uncaged/workflow-util", "name": "@uncaged/workflow-util",
"version": "0.4.0", "version": "0.4.5",
"files": [ "files": [
"src",
"dist", "dist",
"package.json" "package.json"
], ],
@@ -14,9 +15,12 @@
} }
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-protocol": "workspace:*" "@uncaged/workflow-protocol": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
} }
} }
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Validate Crockford Base32 log tags in .log("TAG", ...) calls.
# Crockford Base32 excludes: I, L, O, U
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BAD=0
while IFS= read -r match; do
file="${match%%:*}"
rest="${match#*:}"
line="${rest%%:*}"
tag=$(echo "$rest" | grep -oP '\.log\(\s*"\K[A-Za-z0-9]+')
if echo "$tag" | grep -qiE '[ILOU]'; then
echo "${file}:${line} tag \"${tag}\" contains invalid Crockford Base32 char (I/L/O/U)"
BAD=1
fi
done < <(grep -rn '\.log("[A-Za-z0-9]\{8\}"' "$ROOT/packages/" --include='*.ts' \
| grep -v node_modules | grep -v '/dist/')
if [ "$BAD" -eq 0 ]; then
echo " ✅ All log tags are valid Crockford Base32"
fi
exit $BAD
-165
View File
@@ -1,165 +0,0 @@
#!/usr/bin/env bash
# publish.sh — Bump version, build, test, topologically publish @uncaged/* to Gitea npm
#
# Usage:
# ./scripts/publish.sh 0.4.0 # explicit version
# ./scripts/publish.sh patch # 0.3.1 → 0.3.2
# ./scripts/publish.sh minor # 0.3.1 → 0.4.0
# ./scripts/publish.sh major # 0.3.1 → 1.0.0
# ./scripts/publish.sh --dry-run patch # dry-run bun publish only (no git commit/push)
#
# Env (via `cfg` or export):
# GITEA_TOKEN — Gitea npm registry auth (see root .npmrc)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN="--dry-run"
shift
echo "🔍 Dry run — bun publish will not upload; git commit/push skipped"
echo
fi
# ─── Version ─────────────────────────────────────────────────────────────────
current_version() {
node -e "console.log(require('./packages/workflow-protocol/package.json').version)"
}
bump_version() {
local cur="$1" kind="$2"
IFS='.' read -r major minor patch <<< "$cur"
case "$kind" in
patch) echo "${major}.${minor}.$((patch + 1))" ;;
minor) echo "${major}.$((minor + 1)).0" ;;
major) echo "$((major + 1)).0.0" ;;
*) echo "$kind" ;;
esac
}
CURRENT=$(current_version)
VERSION=$(bump_version "$CURRENT" "${1:?Usage: publish.sh [--dry-run] <version|patch|minor|major>}")
echo "📦 Publish: $CURRENT$VERSION"
# ─── Bump version ─────────────────────────────────────────────────────────────
echo "🔢 Bumping versions..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
is_private=$(node -e "console.log(require('./$pkg').private || false)")
[[ "$is_private" == "true" ]] && continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
p.version = '$VERSION';
fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Topological publish order (workspace:* deps first) ───────────────────────
ORDERED=$(python3 -c "
import json, sys
from pathlib import Path
pkgs_dir = Path('$REPO_ROOT/packages')
name_to_dir = {}
for d in sorted(pkgs_dir.iterdir()):
pj = d / 'package.json'
if not pj.exists():
continue
data = json.loads(pj.read_text())
name = data.get('name', '')
if not name.startswith('@uncaged/') or data.get('private'):
continue
name_to_dir[name] = d.name
deps_graph = {}
for name, dirname in name_to_dir.items():
pj = pkgs_dir / dirname / 'package.json'
data = json.loads(pj.read_text())
local_deps = set()
for section in ('dependencies', 'devDependencies', 'peerDependencies'):
for dep, ver in data.get(section, {}).items():
if dep.startswith('@uncaged/') and dep in name_to_dir and ver.startswith('workspace:'):
local_deps.add(dep)
deps_graph[name] = local_deps
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
in_degree[n] = len(ds)
queue = sorted([n for n, deg in in_degree.items() if deg == 0])
result = []
while queue:
node = queue.pop(0)
result.append(node)
for n, ds in deps_graph.items():
if node in ds:
in_degree[n] -= 1
if in_degree[n] == 0:
queue.append(n)
queue.sort()
if len(result) != len(deps_graph):
missing = set(deps_graph) - set(result)
sys.stderr.write('publish: cyclic @uncaged/ workspace:* dependencies among: ' + ', '.join(sorted(missing)) + '\n')
sys.exit(1)
for name in result:
print(name_to_dir[name])
")
# ─── Build ────────────────────────────────────────────────────────────────────
echo "🔨 Building..."
bun run build
# ─── Self-test ────────────────────────────────────────────────────────────────
echo "🧪 Running tests..."
if ! bun test; then
echo "❌ Tests failed — aborting publish"
exit 1
fi
# ─── Publish (bun resolves workspace:* for publish) ──────────────────────────
echo "🚀 Publishing to $REGISTRY ..."
ok=0
fail=0
while IFS= read -r pkg; do
[[ -n "$pkg" ]] || continue
dir="$REPO_ROOT/packages/$pkg"
name=$(node -e "console.log(require('$dir/package.json').name)")
if ( cd "$dir" && bun publish --registry="$REGISTRY" ${DRY_RUN:+"$DRY_RUN"} ); then
echo "$name"
ok=$((ok + 1))
else
echo "⚠️ $name (publish failed or version may already exist)"
fail=$((fail + 1))
fi
done <<< "$ORDERED"
echo
echo "Published: $ok Skipped/Failed: $fail"
# ─── Commit ───────────────────────────────────────────────────────────────────
if [[ -n "$DRY_RUN" ]]; then
echo "⏭️ Skipping git commit/push (dry run). Revert bumps with: git checkout -- packages/*/package.json"
exit 0
fi
echo "📝 Committing..."
git add -A
git commit -m "chore: publish v${VERSION}
小橘 <xiaoju@shazhou.work>"
git push
echo "✅ v${VERSION} published"
+101
View File
@@ -0,0 +1,101 @@
/**
* greet workflow smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);