Compare commits

..

142 Commits

Author SHA1 Message Date
xiaoju 942ff4b1a4 fix(cli): race condition in thread rm + flaky test (#265)
Two fixes:
1. cmdThreadRemove: always call both removeThreadEntry and
   removeThreadHistoryEntries regardless of resolved source,
   preventing race where thread moves from active to history
   between resolve and delete.
2. Test: add waitUntilRunningFileAbsent before thread show/rm,
   matching the pattern used by adjacent test cases.

Verified 5x consecutive runs with 0 failures.

Closes #265
2026-05-14 13:17:04 +00:00
xiaomo 71ccf8d03c Merge pull request 'chore(util-agent): remove dead createTextAdapter / TextProducerFn' (#263) from chore/252-remove-text-adapter into main 2026-05-14 12:58:22 +00:00
xingyue 510b49287a Merge pull request 'feat(dashboard): redesign workflow detail layout' (#257) from feat/workflow-detail-layout into main 2026-05-14 12:58:04 +00:00
xiaoju bb6b309efa chore(util-agent): remove dead createTextAdapter / TextProducerFn
All adapters migrated to createAgentAdapter in #261. No consumers remain.

Refs #252
2026-05-14 12:23:01 +00:00
xiaomo 56db22a908 Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main 2026-05-14 12:19:37 +00:00
xiaoju 2a1b7b0aeb refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter
- LLM: AgentFn<{prompt}> + createAgentAdapter, chatCompletionText unchanged
- Hermes: AgentFn<{prompt}> + createAgentAdapter, config validation in extract
- Cursor: AgentFn<{prompt, workspace}> + createAgentAdapter, workspace
  extraction moved to extract fn, AgentFn itself only receives resolved options

All public API signatures preserved. createTextAdapter/TextProducerFn retained.

Closes #261, Phase 2 of #252
2026-05-14 10:22:37 +00: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
xiaomo 07f52594d1 Merge pull request 'feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge' (#256) from feat/252-agent-fn into main 2026-05-14 08:53:30 +00: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
xiaoju 4582274ba4 feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge
Add AgentFn<Opt = void> as the formal agent boundary type:
- Input: ThreadContext (fixed), Output: string (fixed)
- Opt: agent-specific structured options (e.g. { workspace } for Cursor)

Add createAgentAdapter<Opt>(agent, extract) → AdapterFn bridge in
workflow-util-agent, plus createSimpleAgentAdapter for Opt = void.

Also fixes: workflow-cas composite flag + cursor tsconfig reference.

Refs #252
2026-05-14 08:41:22 +00: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
xingyue 522afdd4bd feat: adopt changesets + fix exports, bump to 0.4.0
- Install @changesets/cli with fixed mode (all @uncaged/* packages sync version)
- Fix package exports: add bun condition, point import to dist/
- Bump all packages to 0.4.0 via changeset version
- Auto-generated CHANGELOG.md for each package
- Ignore workflow-dashboard (private)
- Add npm scripts: changeset, version, release
- publish.sh: support workspace:^ prefix matching

Closes #241, Closes #242
2026-05-13 20:56:21 +08:00
xingyue ca644dabaa chore: bump all packages to 0.4.0, fix exports for publish
- All @uncaged/* packages → 0.4.0
- Internal deps: workspace:* → workspace:^ (resolves to ^0.4.0 on publish)
- Fix exports: add 'bun' condition for local dev (src), 'import' for consumers (dist)
- Remove stale 'main: src/index.ts' from 6 packages
- Fix publish.sh topo sort to match workspace:^ prefix

星月 <xingyue@shazhou.work>
2026-05-13 20:46:00 +08:00
xiaomo 9d9c00df98 Merge pull request 'chore: remove link-all.sh' (#240) from chore/remove-link-all into main 2026-05-13 10:16:22 +00:00
xiaoju a1c5dc3e92 chore: remove link-all.sh
Local symlink workflow replaced by Gitea npm registry publish flow.

Signed-off-by: 小橘 <xiaoju@shazhou.work>
2026-05-13 09:56:07 +00:00
xiaoju c85980f604 Merge pull request 'chore: merge publish-all.sh into publish.sh' (#238) from chore/merge-publish-scripts into main 2026-05-13 09:52:43 +00:00
xingyue eff5fb332a chore: merge publish.sh and publish-all.sh (#237)
- Topological sort from publish-all.sh replaces hardcoded order
- bun publish directly (no manual workspace:* replacement/restoration)
- bun run build + bun test (not npm run)
- --dry-run support (skips git commit/push)
- Delete publish-all.sh
- Update package.json scripts

Closes #237

Signed-off-by: 星月 <xingyue@shazhou.work>
2026-05-13 17:51:49 +08:00
xingyue 658a4a24ef chore: merge publish-all.sh into publish.sh
- Use bun pm pack for workspace:* resolution (no manual replace/restore)
- Topological sort replaces hardcoded PUBLISH_ORDER
- Registry unified to uncaged org
- Delete scripts/publish-all.sh
- Add --dry-run flag support

Closes #237
2026-05-13 17:44:06 +08:00
xingyue aabfd90a87 Merge pull request 'fix: auto-discover publishable packages + pre-publish test gate' (#236) from fix/auto-discover-publish into main 2026-05-13 09:42:55 +00:00
xingyue 0207f93303 fix: npm test → bun test per review 2026-05-13 17:42:11 +08:00
xingyue e1423f196b refactor: delegate publish to publish-all.sh, remove duplicated discovery+topo logic
- Remove inline auto-discover + Kahn's topo sort from publish.sh (was duplicating publish-all.sh)
- Remove inline publish loop + smoke test (publish-all.sh handles both)
- publish.sh now: bump version → replace workspace:* → build → test → call publish-all.sh → restore → commit
- Net: -97 lines, single source of truth for package discovery and publish order
2026-05-13 17:32:08 +08:00
xingyue ae6954a02f fix(publish): auto-discover packages + pre-publish test gate
What: Replace hardcoded PUBLISH_ORDER with auto-discovery of all
non-private packages, sorted by topological dependency order (Kahn's).
Add a test gate (npm test) after build, before publish.

Why: The manual list was missing workflow-gateway and workflow-agent-react,
causing them to never get published. Any future package additions would
have the same problem.

Changes:
- scripts/publish.sh: Replace static PUBLISH_ORDER array with node script
  that reads all packages/*/package.json, filters out private, and
  topologically sorts by @uncaged/* internal dependencies
- scripts/publish.sh: Add npm test step between build and publish,
  aborting on failure
2026-05-13 17:22:50 +08:00
xingyue aede8f7613 chore: publish v0.3.21
小橘 <xiaoju@shazhou.work>
2026-05-13 17:10:39 +08:00
xiaomo 6d1e0498ba Merge pull request 'refactor(dashboard): replace ELK with custom spine layout' (#235) from refactor/dashboard-custom-spine-layout into main 2026-05-13 09:03:34 +00:00
xingyue 6cce5e2593 chore: publish v0.3.20
小橘 <xiaoju@shazhou.work>
2026-05-13 17:00:43 +08:00
xingyue d3a7ed9062 chore: publish v0.3.19
小橘 <xiaoju@shazhou.work>
2026-05-13 16:56:55 +08:00
xingyue e7f733c393 refactor(dashboard): replace ELK with custom spine layout
What: Replace ELK layout engine with a hand-written spine layout that
topologically sorts nodes into a vertical main path with feedback edges
routed to the right side.

Why: ELK's layered algorithm spreads the graph too wide when handling
feedback (back) edges, causing fitView to shrink nodes until text is
unreadable. Our workflow graphs are predominantly linear pipelines with
feedback loops — a custom layout handles this topology much better.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite from async ELK to synchronous spine layout — topo-sort extracts
  main path, nodes stack vertically, feedback edges get right-side routing
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  add custom SVG path for feedback edges (right-side arc with Q curves),
  use typed isFeedback/isSelfLoop fields from ConditionEdgeData
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  rename elkLabelX/Y to labelX/Y, add isFeedback and isSelfLoop fields
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  remove ReactFlowProvider/useReactFlow/useEffect fitView workaround
  (no longer needed — layout is synchronous), simplify component
- packages/workflow-dashboard/package.json: remove elkjs and dagre deps
2026-05-13 16:54:04 +08:00
xingyue d4bb4a9324 Merge pull request 'fix(cli): point bin to dist/cli.js instead of src/cli.ts' (#234) from fix/cli-bin-path into main 2026-05-13 08:43:41 +00:00
xingyue e4900b6fd6 fix(cli): keep bin pointing to src/cli.ts, add src to files
The actual issue was that 'files' only included dist/, so src/ was
excluded from the published package. bun can run .ts natively — no
need to point bin at compiled dist/cli.js.

Fix: add 'src' to files array so it ships with the package.
2026-05-13 16:43:07 +08:00
xiaomo 39540d9ae8 Merge pull request 'fix(dashboard): address ELK layout review feedback' (#233) from fix/dashboard-elk-review-feedback into main 2026-05-13 08:40:32 +00:00
xingyue 10899364d4 fix(cli): point bin to dist/cli.js instead of src/cli.ts
The bin entry pointed to src/cli.ts but only dist/ is published,
causing 'Cannot find module cli-dispatch.js' on global install.
2026-05-13 16:38:54 +08:00
xingyue dc5fdd7358 fix(dashboard): address ELK layout review feedback
What: Fix three non-blocking issues from PR #232 review.

Why: Code quality — unhandled promise rejection risk, type safety,
and project convention compliance.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  add elkLabelX/elkLabelY fields to ConditionEdgeData type (number | null,
  not optional — per project no-optional-properties rule)
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  remove 'as ConditionEdgeData' type assertion (now unnecessary),
  add .catch() to computeLayout promise
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  remove redundant inline type extension, use ConditionEdgeData directly

Ref: PR #232 review comments
2026-05-13 16:37:07 +08:00
xiaoju bb1293f6b9 fix: add exports field to 6 packages for proper type resolution
Packages without exports.types pointed main/types to src/ which
doesn't exist in published tarballs. Now all packages have:
  exports."." = { types: dist/index.d.ts, import: src/index.ts }

Bump to 0.3.18.
2026-05-13 08:29:36 +00:00
xiaomo 55b3b61498 Merge pull request 'feat(dashboard): switch graph layout from Dagre to ELK' (#232) from feat/dashboard-elk-layout into main 2026-05-13 08:28:24 +00:00
xingyue 484ed520cd feat(dashboard): switch graph layout from Dagre to ELK
What: Replace Dagre layout engine with ELK (Eclipse Layout Kernel) for
workflow graph visualization in the dashboard.

Why: Dagre lacks support for edge label placement and orthogonal edge
routing, causing condition labels to overlap with nodes. ELK provides
proper label positioning, better edge routing, and more compact layouts.

Changes:
- packages/workflow-dashboard/package.json: add elkjs dependency
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite layout from Dagre to async ELK with layered algorithm,
  orthogonal routing, reduced spacing for compactness
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  use ELK-computed label positions, show all labels including FALLBACK,
  switch to getSmoothStepPath for all edges
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  wrap in ReactFlowProvider, add fitView on async layout change,
  key-based remount for layout stability
- packages/workflow-dashboard/src/components/workflow-list.tsx:
  left-right layout (info left, graph right), fix toggleExpanded
  React 18 batching bug, increase graph container height
2026-05-13 16:26:03 +08:00
xiaoju 497f03c747 chore: bump all packages to 0.3.17 2026-05-13 08:04:32 +00:00
xiaoju cfe4543d39 refactor!: remove deprecated Agent types, introduce Adapter-first API
BREAKING CHANGES:
- Remove AgentFn, AgentFnResult, AgentBinding from workflow-protocol
- Remove wrapAgentAsAdapter from workflow-util-agent
- workflowAsAgent → workflowAdapter (old name kept as deprecated re-export)

New APIs:
- createTextAdapter(producer) — bridges text-producing functions to AdapterFn
- TextProducerFn, TextAdapterResult types
- workflowAdapter() — direct AdapterFn for child workflow delegation

All agent packages (cursor, hermes, llm) now return AdapterFn directly,
no wrapping needed. Bundle entries simplified accordingly.

小橘 🍊(NEKO Team)
2026-05-13 08:03:27 +00:00
xiaoju 399b967c59 refactor: reduce cognitive complexity in dispatch.ts and shell-exec.ts
- Extract helpers from promptSecret/onData (32→~4)
- Extract sub-functions from collectInteractiveSetup (36→~8)
- Extract classifyExecError from shell-exec handler (17→~3)
- Replace all non-null assertions with safe .at() access

0 biome errors, 0 warnings.
2026-05-13 07:37:47 +00:00
xiaoju 061926b86a chore: fix all biome lint errors
- Auto-fix string concatenation → template literals
- Remove unused imports
- Prefix unused function with underscore
- Format fixes across multiple files
2026-05-13 07:26:11 +00:00
xiaoju acb0ebed97 chore: add @types/node for node:* module declarations 2026-05-13 07:21:43 +00:00
xiaoju d5d7be6100 chore: add files field to all packages, bump to 0.3.16
Excludes tsconfig.json and source files from published packages.
Fixes TypeScript errors when consuming packages via bun.
2026-05-13 07:19:49 +00:00
xiaoju 1566a43395 chore: bump all packages to 0.3.15 2026-05-13 07:04:12 +00:00
xiaoju afbde4573a chore: add bunfig.toml to gitignore (contains registry token) 2026-05-13 06:55:16 +00:00
xiaoju 63e447fc3d chore: unify npm registry to uncaged org
publish-all.sh now targets the same org as .npmrc.

小橘 🍊
2026-05-13 06:49:30 +00:00
xiaoju 34fcbf29cb chore: bump workflow-util and workflow-util-agent to 0.3.14
小橘 🍊
2026-05-13 06:12:29 +00:00
xiaoju 256799fcfd chore: bump workflow-util and workflow-util-agent to 0.3.12
小橘 🍊
2026-05-13 06:04:53 +00:00
xiaoju 21cf3db111 feat(util): extract requireEnv/optionalEnv to workflow-util
- requireEnv(name, message) — throws with custom error message
- optionalEnv(name, fallback?) — returns fallback or null
- Update develop and solve-issue bundle entries to use shared helpers
- Remove inline requireEnv/optionalEnv and wrapAgentAsAdapter usage
- Add tests for both functions

小橘 🍊
2026-05-13 06:02:17 +00:00
xiaomo ed38543db4 Merge pull request 'docs(skill): add authoring pitfalls to skill author topic' (#231) from fix/skill-author-pitfalls into main 2026-05-13 03:59:50 +00:00
xiaomo 78771fbebc Merge pull request 'fix(publish-all): regenerate lockfile before pack' (#230) from fix/publish-lockfile-regen into main 2026-05-13 03:59:42 +00:00
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

Knowledge from smoke test discoveries — these are the most common
mistakes when writing workflow bundles.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
xiaoju 6d4bf108bb fix(publish-all): regenerate lockfile before pack
After bumping versions, bun pm pack reads the old bun.lock and resolves
workspace:* to stale versions. Now deletes bun.lock and runs bun install
before the pack loop to ensure correct resolution.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:52:10 +00:00
xingyue 5b7c9b844b fix(engine): abort signal races gen.next() to fix flaky kill test (#209)
Root cause: executeThread awaited gen.next() without racing against
the abort signal. When a workflow bundle awaited a long setTimeout
between yields, the engine could not respond to kill until the
Promise resolved — causing the kill test to flake when the thread
completed before kill arrived.

Fix: Promise.race gen.next() with an abort listener so kill takes
effect immediately, even mid-yield. Also move the bundle's delay
to after the first yield (between planner and coder) to ensure the
thread is killable while running.

Closes #209
2026-05-13 11:31:40 +08:00
xiaoju f0d1bb9ae8 chore: bump all to 0.3.11
小橘 🍊
2026-05-13 03:28:33 +00:00
xiaoju 04cfd33f99 chore: bump all to 0.3.10 (regenerate lockfile)
小橘 🍊
2026-05-13 03:27:05 +00:00
xiaoju a8c00f169b chore: bump all packages to 0.3.9 (fix workspace:* dep resolution)
小橘 🍊
2026-05-13 03:25:50 +00:00
xiaoju c4d34530e8 chore: bump cli-workflow 0.3.8 (fix gateway dep resolution)
小橘 🍊
2026-05-13 03:23:09 +00:00
xiaoju 90a410c00a chore: bump cli-workflow to 0.3.7 (fix gateway dep version)
小橘 🍊
2026-05-13 03:21:34 +00:00
xiaoju 6276ca5a4a chore: publish workflow-gateway (remove private flag)
小橘 🍊
2026-05-13 03:20:33 +00:00
xiaoju 8e63f99eb6 chore: bump all public packages to 0.3.6
小橘 🍊
2026-05-13 03:18:25 +00:00
xiaomo 9ca70bbb69 Merge pull request 'feat: minimal tool set for workflow-agent-react (#222 Phase 3)' (#229) from feat/222-tools-smoke-test-phase3 into main 2026-05-13 03:16:37 +00:00
xiaomo ed1f38c7da Merge pull request 'refactor(dashboard): side-by-side graph + cards layout' (#215) from refactor/thread-detail-side-by-side-layout into main 2026-05-13 03:06:35 +00:00
xiaomo 1664d68b50 Merge pull request 'feat: WS request proxy — Phase 2 (#210)' (#214) from feat/210-ws-gateway-phase2 into main 2026-05-13 03:06:29 +00:00
xingyue 1871ef31b4 refactor(dashboard): replace vertical layout with side-by-side graph+cards
Change thread-detail from vertical (graph on top, cards below) to a
side-by-side layout:
- Left panel (280px, sticky): workflow graph, always visible
- Right panel (flex-1, scrollable): record cards
- Remove collapsible GraphPanel wrapper
- Graph acts as navigation (click node → scroll to card)

Refs: workflow thread 06F1NX4C9ET6HPXJAH7CWWF8MR
2026-05-13 11:05:03 +08:00
xingyue ec3c97b200 feat: WS request proxy — Gateway proxies HTTP via WebSocket (#210 Phase 2)
- Add ws-protocol.ts with WsRequest/WsResponse types + parsers
- AgentSocket DO: proxy POST handler, pending request map, 30s timeout
- /api/agents/:agent/* routes through DO WS when connected, falls back to HTTP
- ws-client handles incoming WsRequest, fetches local serve, returns WsResponse
- startGatewayWsClient accepts localPort for request handling

Testing: #213
2026-05-13 11:05:03 +08:00
xingyue 18e3dc7603 feat: WebSocket reverse-connection gateway Phase 1 (#210)
- Add AgentSocket Durable Object (holds one WS per agent name)
- Add /ws/connect route with GATEWAY_SECRET auth
- Add ws-client.ts with auto-reconnect (exponential backoff 1s-30s)
- serve defaults to WS mode (no cloudflared needed)
- Keep --tunnel-url and --no-tunnel as fallback options
- Endpoints list merges KV heartbeat + DO WebSocket status

Testing: #211
2026-05-13 11:05:03 +08:00
xiaoju fc229cac79 test: add tool handler unit tests (#222) 2026-05-13 02:57:47 +00:00
xiaoju ec555b43d1 feat: add minimal tool set (read/write/patch/shell) to workflow-agent-react (#222) 2026-05-13 02:57:47 +00:00
xiaomo c8de86d7c9 Merge pull request 'feat: workflow-agent-react + wrapAgentAsAdapter shared + childThread support (#222 Phase 2)' (#226) from feat/222-react-adapter-phase2 into main 2026-05-13 02:51:07 +00:00
xiaoju bd110b76e1 chore: remove accidental self-referencing symlinks
小橘 🍊
2026-05-13 02:44:24 +00:00
xiaoju dc10ccceaa test: add react adapter unit tests (#222)
小橘 🍊
2026-05-13 02:40:22 +00:00
xiaoju c040a90a8f feat: add @uncaged/workflow-agent-react package (#222) 2026-05-13 02:38:38 +00:00
xiaoju ec4599a230 refactor: extract wrapAgentAsAdapter to util-agent, support childThread in RoleFn (#222) 2026-05-13 02:37:32 +00:00
xiaomo 1f4bd3f431 Merge pull request 'feat(protocol): AdapterFn replaces AgentBinding in createWorkflow (#222 Phase 1)' (#224) from feat/222-adapter-fn-phase1 into main 2026-05-13 02:30:29 +00:00
xiaoju bebf4aad45 feat(protocol): add AdapterFn/RoleFn/AdapterBinding, refactor createWorkflow to use AdapterBinding (#222)
- Add RoleFn<T>, AdapterFn, AdapterBinding types to workflow-protocol
- Mark AgentFn, AgentFnResult, AgentBinding as @deprecated
- Refactor createWorkflow to accept AdapterBinding instead of AgentBinding
- Adapter returns typed meta directly — no more extract call in workflow loop
- Add buildThreadInput (ThreadContext-based), keep buildAgentPrompt as deprecated wrapper
- Update template bundle-entries to wrap AgentFn as AdapterFn
- Update solve-issue tests to use AdapterFn directly
2026-05-13 02:27:36 +00:00
xiaoju 11ba185fef docs: RFC v3 — react adapter as thin wrapper over reactor
小橘 🍊
2026-05-13 02:19:12 +00:00
xiaoju 730340d123 docs: RFC v2 — AdapterFn replaces AgentFn, schema-aware resolve
小橘 🍊
2026-05-13 02:15:21 +00:00
xiaoju c848216396 docs: RFC for workflow-agent-react package
小橘 🍊
2026-05-13 01:55:14 +00:00
xingyue 2698e0a6cb fix(setup): add GLM international endpoint (api.z.ai) 2026-05-13 09:52:07 +08:00
xingyue 47f2b1a128 fix(setup): address code review issues (#221)
- Fix resolve variable shadowing in promptSecret (rename to fulfill)
- Fix readline leak on invalid choice (close before returning err)
- Remove Anthropic/Gemini from presets (not OpenAI-compatible)
- Fix GLM URL: api.z.ai → open.bigmodel.cn
- Restore terminal raw mode before process.exit on Ctrl+C
- Add debug logging to fetchAvailableModels failures
- Add comment explaining DashScope-specific model filter patterns
- Move PresetProvider and CmdSetupSuccess types to types.ts per convention
2026-05-13 09:43:57 +08:00
xingyue 0c02cb7574 chore: publish v0.3.5
小橘 <xiaoju@shazhou.work>
2026-05-13 09:34:41 +08:00
xingyue 320810ec25 fix(cli-workflow): workspace path accepts relative/absolute paths with retry
- cmdInitWorkspace now resolves full paths via resolve() instead of
  requiring a single segment name
- mkdir uses recursive: true for nested paths (e.g. ./a/b/workflows)
- Setup interactive prompt retries on existing directory instead of exiting
- Update tests: nested paths are now valid, add accepts-nested-path test
2026-05-13 09:30:41 +08:00
xingyue 91f585c534 feat(cli-workflow): numbered model selection in setup
- Show available models with numbered labels in multi-column layout
- User can pick by number or type model name directly
- Print selected model with arrow confirmation
2026-05-13 09:25:00 +08:00
xingyue 299ff126d9 feat(cli-workflow): preset provider selection in setup
- Add providers.yaml with 18 preset providers (international + China + local)
- Add preset-providers.ts to load and cache YAML presets
- Refactor interactive setup to show numbered provider list
- Only prompt for manual name/URL when choosing Custom
- YAML-driven: add new providers without code changes
2026-05-13 09:19:30 +08:00
xingyue 931eb81458 fix(setup): default workspace to ./workflows when left empty
Enter = use default ./workflows. Type 'skip' to skip.
2026-05-12 22:32:01 +08:00
xingyue c604d1f600 fix(setup): simplify model prompt — just ask for model name
Provider is already known from the first step, so prompt simply
asks 'Default model:' and auto-prepends provider/ prefix.
2026-05-12 22:28:35 +08:00
xingyue 20bcc65f61 fix(setup): auto-prefix provider on model input
Users can now type bare model names (e.g. 'qwen-plus') or paste
model IDs with vendor prefixes (e.g. 'MiniMax/MiniMax-M2.7') —
the provider prefix is normalised automatically.
2026-05-12 22:27:07 +08:00
xingyue f5612ef1b5 fix(setup): filter non-chat models and display in multi-column layout
Filter out speech/embed/image/video/audio/tts/asr/ocr/rerank models
from the /models listing. Display remaining models in a responsive
multi-column grid that adapts to terminal width.
2026-05-12 22:09:13 +08:00
xingyue a92deeaf3f fix(setup): mask each character when pasting API key
Raw mode receives pasted text as a single chunk. Iterate
per-character so every char gets a '*'. Also fix backspace
to erase the visual '*' from the terminal.
2026-05-12 22:03:48 +08:00
xingyue 1e936cf04a fix: improve setup interactive UX
1. Mask API key input with * characters (raw mode)
2. Fetch and list available models from provider /models endpoint
3. Workspace prompt: fill path directly (default skip), not y/n
4. Add .gitkeep to workflows/ in init workspace scaffold
2026-05-12 21:44:21 +08:00
xingyue ea16057803 fix: improve setup interactive prompts with context and examples
- Add intro line explaining what's being configured
- Provider: explain it's a label for the LLM service
- Base URL: explain it's OpenAI-compatible, show examples
- API key: clarify it's for this provider
- Default model: show format with dynamic provider name
- Workspace prompt: clearer wording
2026-05-12 20:52:42 +08:00
xingyue 4493fd8979 chore: publish v0.3.4
小橘 <xiaoju@shazhou.work>
2026-05-12 20:35:02 +08:00
xiaomo cc1ee8d5e3 Merge pull request 'chore: address #219 review comments' (#220) from fix/219-review-followup into main 2026-05-12 12:29:44 +00:00
xingyue 0ad5c85f5a chore: address #219 review comments
- Add comment explaining biome.json scripts/bundle.ts exclusion
- Add note about readline API key visibility limitation
2026-05-12 20:27:27 +08:00
xiaomo d02d410dcd Merge pull request 'feat: setup command + workspace build scripts (#216)' (#219) from feat/216-setup-and-build-scripts into main 2026-05-12 12:24:43 +00:00
xingyue cdf3c95622 feat: add setup command for provider/model config (#216 Phase 2)
New command: uncaged-workflow setup

CLI mode (agent-friendly):
  uncaged-workflow setup \
    --provider <name> --base-url <url> --api-key <key> \
    --default-model <provider/model> [--init-workspace <name>]

Interactive mode: prompts for each value when no flags given.

- Reads/writes workflow.yaml config section
- Idempotent: updates provider without losing workflows
- Sets maxDepth=3, supervisorInterval=3 on fresh config
- Optional --init-workspace creates workspace in cwd

Testing: #218
Ref: #216
2026-05-12 20:18:23 +08:00
xingyue a7fea10383 feat: init workspace generates bundle script (#216 Phase 1)
- Add scripts.bundle to generated package.json
- Generate scripts/bundle.ts that scans workflows/*-entry.ts,
  uses Bun.build() to produce dist/*.esm.js + dist/*.d.ts
- External: @uncaged/workflow-* packages (per bundle contract)
- Add tests for new scaffold files

Testing: #217
Ref: #216
2026-05-12 20:09:41 +08:00
xingyue 3846dc12a9 chore: bump all public packages to 0.3.3 2026-05-12 12:58:17 +08:00
xingyue c5fd84432f fix(agent): defer config validation to call time
Bundle top-level code runs during `workflow add` (descriptor extraction),
but agent config env vars (e.g. WORKFLOW_HERMES_COMMAND) are only available
at `workflow run` time. Deferring validation prevents premature throws.
2026-05-12 12:58:06 +08:00
xingyue 4c4dabb7a3 chore: bump all public packages to 0.3.2 2026-05-12 12:55:21 +08:00
xingyue 1b62cec0a2 feat(agent): require absolute path for command in hermes/cursor agent configs
BREAKING: HermesAgentConfig.command and CursorAgentConfig.command are now
required string fields (absolute path to CLI binary). Validation rejects
non-absolute paths at construction time.

- Eliminates PATH resolution ambiguity in spawned worker processes
- spawnCli: explicit env: process.env for clarity
- bundle-entry: WORKFLOW_CURSOR_COMMAND is now required
- Updated tests for both agents
2026-05-12 12:52:48 +08:00
xingyue ecc348f182 feat(agent): add command config to hermes/cursor agents + explicit env inheritance
- HermesAgentConfig.command: override hermes CLI path (default: "hermes")
- CursorAgentConfig.command: override cursor-agent CLI path (default: "cursor-agent")
- spawnCli: explicit env: process.env for clarity and future extensibility
- bundle-entry: read WORKFLOW_CURSOR_COMMAND from env
2026-05-12 12:49:28 +08:00
xingyue 41209f1ef8 docs: add end-to-end development flow to CLAUDE.md 2026-05-12 11:38:03 +08:00
xingyue 58a4aefcc4 refactor(publish): auto topological sort instead of hardcoded order
Kahn algorithm reads workspace:* deps from all package.json files
and publishes leaf-first. No manual maintenance when adding packages.
2026-05-12 11:36:29 +08:00
xingyue bbb79f821e feat(init): generate bunfig.toml with Gitea scoped registry + fix versions
- init workspace now generates bunfig.toml pointing @uncaged scope to Gitea
- Fix template versions: "*" → "^0.3.1" (packages are now published)
2026-05-12 11:31:41 +08:00
xingyue 05fbd4f5b5 feat(publish): add Gitea npm registry publish script + docs
- scripts/publish-all.sh: bun pm pack (resolves workspace:*) + npm publish
- All 14 public @uncaged/* packages published to git.shazhou.work
- CLAUDE.md: document Gitea registry, bunfig.toml scoped registry, publish workflow
- bun link docs demoted to alternative for un-published local changes
2026-05-12 11:30:52 +08:00
xingyue 7e7331eb2d chore: warn against bun install after link --consume 2026-05-12 11:10:04 +08:00
xingyue 0fbbf37548 chore(cli): add bun run link scripts + fix init template versions
- Add link/link:consume/link:unlink scripts to root package.json
- Document cross-repo bun link workflow in CLAUDE.md
- Fix hardcoded @uncaged/workflow-runtime "^0.1.0" → "*" in
  init workspace and init template scaffolds (actual version is 0.3.x)
2026-05-12 11:03:12 +08:00
xingyue 2af39463de scripts: link-all.sh support register/consume/unlink modes 2026-05-12 10:59:12 +08:00
xingyue 5f2458238f scripts: add link-all.sh for local @uncaged/* package linking 2026-05-12 10:56:31 +08:00
xingyue aadec0b96c feat: WorkflowList expandable cards with static graph (#198)
- Workflow cards click to expand/collapse
- Lazy-load descriptor on first expand
- Static WorkflowGraph (all nodes default state, no highlighting)
- Show description, version count, hash
- Fix WorkflowSummary type to match actual API response
2026-05-12 10:53:49 +08:00
xiaomo 1c68ce6217 Merge pull request 'feat: Phase 3 — agent observability for Merkle call stack' (#203) from feat/197-agent-observability into main 2026-05-12 02:34:47 +00:00
xingyue 7265603b55 feat: click graph node to scroll and highlight RecordCard (#198 Phase 3) 2026-05-12 10:34:06 +08:00
xingyue b1e66fa7a4 fix: use async/await instead of .then() in getWorkflowDescriptor 2026-05-12 10:29:50 +08:00
xiaomo 81a7a8c7c1 Merge pull request 'feat: Dashboard workflow graph visualization (React Flow)' (#204) from feat/198-dashboard-workflow-graph into main 2026-05-12 02:28:40 +00:00
xingyue 9cb7d68abe feat: Dashboard workflow graph visualization with React Flow (#198)
Phase 1: API + static graph rendering

Backend:
- GET /workflows/:name now returns descriptor (with graph) from bundle YAML
- Graceful fallback to null if YAML missing/invalid

Frontend:
- New workflow-graph/ component module (7 files)
- React Flow + dagre auto-layout (TB direction)
- Custom nodes: RoleNode (rounded rect) + TerminalNode (circle for START/END)
- Custom edges: dashed for FALLBACK, solid with label for conditions
- Self-loop edges supported (e.g. coder → coder)
- Node states: default/completed/active with color-coded borders
- Active node pulse animation
- Collapsible graph panel (300px) above thread records
- Dark theme using existing CSS variables

Integration:
- ThreadDetail extracts workflow name → fetches descriptor → computes node states → renders graph
- Node states derived from ThreadRecord[] (completed/active/default)
2026-05-12 10:27:07 +08:00
157 changed files with 6307 additions and 1259 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@uncaged/*"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@uncaged/workflow-dashboard"]
}
+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"
+1
View File
@@ -5,3 +5,4 @@ bun.lock
tsconfig.tsbuildinfo
.npmrc
bunfig.toml
+46 -1
View File
@@ -30,6 +30,7 @@ workflow/
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
workflow-agent-llm/ # @uncaged/workflow-agent-llm
workflow-agent-react/ # @uncaged/workflow-agent-react
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
workflow-template-develop/ # @uncaged/workflow-template-develop
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
@@ -40,7 +41,7 @@ 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
@@ -245,6 +246,50 @@ bun run format # biome format --write
bun test # run tests
```
### Version Management & Publishing
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
```bash
# 1. After making changes, add a changeset describing the change
bun changeset
# 2. Before release, bump all package versions + generate CHANGELOGs
bun version
# 3. Build, test, and publish to npmjs
bun release
```
- `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`
### Consuming @uncaged/* Packages
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
### End-to-end: Monorepo → Registry → Workspace → Bundle
```
workflow/ (monorepo) — engine, runtime, templates, agents
│ bun release — build + test + changeset publish
npmjs.org — @uncaged/* scoped packages (public)
│ bun install
my-workflows/ (workspace) — normal package.json
│ bun run build:develop — bun build → single .esm.js
uncaged-workflow workflow add — register bundle locally
uncaged-workflow run — execute workflow
```
1. **Monorepo changes**`bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
2. **Workspace**`bun install` fetches latest from npmjs
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
4. **Register & Run**`uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
## Commit Convention
```
+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": {
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
-2
View File
@@ -1,2 +0,0 @@
[test]
pathIgnorePatterns = ["dist/**"]
+16
View File
@@ -0,0 +1,16 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+191
View File
@@ -0,0 +1,191 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/workflow-protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `workflow-util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
+7 -2
View File
@@ -6,13 +6,18 @@
],
"scripts": {
"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",
"format": "biome format --write .",
"test": "bun run --filter '*' test"
"test": "bun run --filter '*' test",
"changeset": "bunx changeset",
"version": "bunx changeset version",
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@changesets/cli": "^2.31.0",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
}
+72
View File
@@ -0,0 +1,72 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-cas@0.4.0
- @uncaged/workflow-execute@0.4.0
- @uncaged/workflow-gateway@0.4.0
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-register@0.4.0
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util@0.4.0
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
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 {
return serializeMerkleNode(createContentMerkleNode(raw));
}
function buildApp(storageRoot: string) {
const app = createApp(storageRoot);
const app = createApp(storageRoot, null);
return {
fetch: (path: string, init?: RequestInit) =>
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 () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
app.get("/test-error", () => {
throw new Error("boom");
});
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
describe("serve security", () => {
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(
new Request("http://localhost/healthz", {
headers: { Origin: "http://localhost:5173" },
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
test("setup --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
expect(u).toContain("Thread execution:");
expect(u).toContain("Content-addressable storage:");
expect(u).toContain("Development:");
expect(u).toContain("Configuration:");
expect(u).toContain("setup [--provider <name>]");
expect(u).toContain("Shortcuts:");
expect(u).toContain("Reference:");
expect(u).toContain("skill [topic]");
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### setup");
expect(doc).toContain("### Top-level shortcuts");
});
@@ -38,8 +38,16 @@ describe("init workspace", () => {
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
workspaces: string[];
scripts: { bundle: string };
};
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
expect(bundleSrc).toContain("Bun.build");
expect(bundleSrc).toContain("-entry.ts");
expect(bundleSrc).toContain("distDir");
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
type: string;
@@ -83,7 +91,7 @@ describe("init workspace", () => {
"RoleDefinition",
"WorkflowDefinition",
"ModeratorTable",
"AgentFn",
"AdapterFn",
"ExtractFn",
"RoleMeta",
]) {
@@ -117,9 +125,6 @@ describe("init workspace", () => {
});
test("errors on invalid workspace name", async () => {
const slash = await cmdInitWorkspace(parent, "a/b");
expect(slash.ok).toBe(false);
const dots = await cmdInitWorkspace(parent, "..");
expect(dots.ok).toBe(false);
@@ -127,6 +132,14 @@ describe("init workspace", () => {
expect(empty.ok).toBe(false);
});
test("accepts nested path as workspace name", async () => {
const nested = await cmdInitWorkspace(parent, "a/b");
expect(nested.ok).toBe(true);
if (nested.ok) {
expect(nested.value.rootPath).toContain("a/b");
}
});
test("usage lists init subcommands", () => {
const u = formatCliUsage();
expect(u).toContain("init workspace <name>");
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readWorkflowRegistry } from "@uncaged/workflow-register";
import { runCli } from "../src/cli-dispatch.js";
import { cmdSetup } from "../src/commands/setup/index.js";
describe("setup command (CLI mode)", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
const r = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok) {
return;
}
expect(reg.value.config).not.toBeNull();
if (reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test123",
});
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
expect(reg.value.config.maxDepth).toBe(3);
expect(reg.value.config.supervisorInterval).toBe(3);
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
expect(raw).toContain("dashscope");
expect(raw).toContain("qwen-plus");
});
test("idempotent: second run updates apiKey and preserves workflows", async () => {
const initialYaml = `config:
maxDepth: 7
supervisorInterval: 2
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-old
models:
default: dashscope/qwen-plus
workflows:
keep-me:
hash: "0000000000000"
timestamp: 1
history: []
`;
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
const r2 = await cmdSetup(storageRoot, {
provider: "dashscope",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-newkey",
defaultModel: "dashscope/qwen-plus",
initWorkspaceName: null,
});
expect(r2.ok).toBe(true);
if (!r2.ok) {
return;
}
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
expect(reg.value.config.maxDepth).toBe(7);
expect(reg.value.config.supervisorInterval).toBe(2);
expect(reg.value.workflows["keep-me"]).toBeDefined();
if (reg.value.workflows["keep-me"] === undefined) {
return;
}
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
});
test("runCli setup dispatches with flags and exits 0", async () => {
const code = await runCli(storageRoot, [
"setup",
"--provider",
"openai",
"--base-url",
"https://api.openai.com/v1",
"--api-key",
"sk-test",
"--default-model",
"openai/gpt-4o",
]);
expect(code).toBe(0);
const reg = await readWorkflowRegistry(storageRoot);
expect(reg.ok).toBe(true);
if (!reg.ok || reg.value.config === null) {
return;
}
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
});
});
@@ -70,10 +70,10 @@ const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 600));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
@@ -180,6 +180,9 @@ describe("cli thread commands", () => {
}
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
const shown = await cmdThreadShow(storageRoot, threadId);
expect(shown.ok).toBe(true);
if (!shown.ok) {
+16 -7
View File
@@ -1,21 +1,30 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.1",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
+4 -2
View File
@@ -4,7 +4,8 @@ import { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/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 { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
@@ -66,10 +67,11 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
setup: dispatchSetup,
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
serve: dispatchServe,
connect: dispatchConnect,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
+13
View File
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
const SETUP_USAGE_COMMANDS = [
{
name: "",
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
description:
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
},
] as const;
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
description: e.description,
})),
},
{
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
];
}
+6 -4
View File
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
};
export function formatUsageCommandLines(
@@ -38,9 +39,10 @@ export function formatCliUsage(
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
prefix: `${group.name}${namePart}${args}`,
description: cmd.description,
};
});
@@ -57,12 +59,12 @@ export function formatCliUsage(
);
lines.push("");
lines.push("Server:");
lines.push("Gateway:");
lines.push(
...formatUsageCommandLines([
{
prefix: "serve [--port N] [--host ADDR]",
description: "Start HTTP API server (default: 127.0.0.1:7860)",
prefix: "connect [--name NAME] [--gateway URL]",
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
export function createApp(storageRoot: string, agentToken: string | null): Hono {
export function createApp(storageRoot: string, clientToken: string | null): Hono {
const app = new Hono();
app.onError((_err, c) => {
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
await next();
});
// ── Agent token auth (skip healthz) ───────────────────────────────
if (agentToken !== null) {
// ── Client token auth (skip healthz) ───────────────────────────────
if (clientToken !== null) {
app.use("/api/*", async (c, next) => {
const token = c.req.header("X-Agent-Token");
if (token !== agentToken) {
const token = c.req.header("X-Client-Token");
if (token !== clientToken) {
return c.json({ error: "unauthorized" }, 401);
}
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";
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(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
clientToken: string,
): Promise<boolean> {
try {
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
});
if (!resp.ok) {
const body = await resp.text();
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
export function startHeartbeat(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
clientToken: string,
intervalMs: number,
): ReturnType<typeof setInterval> {
return setInterval(() => {
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
}, intervalMs);
}
@@ -0,0 +1,2 @@
export { dispatchConnect } from "./connect.js";
export type { ConnectOptions } from "./types.js";
@@ -1,9 +1,14 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
validateWorkflowDescriptor,
} from "@uncaged/workflow-register";
import { Hono } from "hono";
import { parse as parseYaml } from "yaml";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
let descriptor: WorkflowDescriptor | null = null;
try {
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
const yamlText = await readFile(yamlPath, "utf8");
const parsed: unknown = parseYaml(yamlText);
const validated = validateWorkflowDescriptor(parsed);
descriptor = validated.ok ? validated.value : null;
} catch {
descriptor = null;
}
return c.json({ name, ...entry, descriptor });
});
app.get("/:name/history", async (c) => {
@@ -0,0 +1,5 @@
export type ConnectOptions = {
name: string;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -0,0 +1,162 @@
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
import type { LogFn } from "@uncaged/workflow-util";
export type GatewayWsClientParams = {
gatewayUrl: string;
name: string;
secret: string;
appFetch: (request: Request) => Response | Promise<Response>;
log: LogFn;
};
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
const u = new URL(gatewayUrl);
if (u.protocol === "https:") {
u.protocol = "wss:";
} else if (u.protocol === "http:") {
u.protocol = "ws:";
}
u.pathname = "/ws/connect";
u.search = "";
u.searchParams.set("name", name);
u.searchParams.set("secret", secret);
return u.href;
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of h) {
out[k] = v;
}
return out;
}
async function handleGatewayMessage(
ws: WebSocket,
raw: string,
params: GatewayWsClientParams,
): Promise<void> {
const req = parseWsRequestJson(raw);
if (req === null) {
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
return;
}
const localUrl = `http://localhost${req.path}`;
const headers = new Headers(req.headers);
let resp: Response;
try {
resp = await params.appFetch(new Request(localUrl, {
method: req.method,
headers,
body: req.body === null ? undefined : req.body,
}));
} catch (e) {
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
const errBody: WsResponse = {
id: req.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
};
ws.send(JSON.stringify(errBody));
return;
}
const bodyText = await resp.text();
const headerRecord = headersToRecord(resp.headers);
const out: WsResponse = {
id: req.id,
status: resp.status,
headers: headerRecord,
body: bodyText,
};
ws.send(JSON.stringify(out));
}
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let attempt = 0;
const clearReconnectTimer = (): void => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
attempt++;
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delayMs);
};
const connect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
try {
socket = new WebSocket(wsUrl);
} catch (e) {
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
scheduleReconnect();
return;
}
const ws = socket;
ws.addEventListener("open", () => {
attempt = 0;
params.log("4PWN3V82", "gateway WebSocket connected");
});
ws.addEventListener("close", (ev) => {
socket = null;
params.log(
"8QTR6ZKC",
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
);
if (!stopped) {
scheduleReconnect();
}
});
ws.addEventListener("error", () => {
params.log("9BWS1M7F", "gateway WebSocket error");
});
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
return;
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
});
});
};
connect();
return (): void => {
stopped = true;
clearReconnectTimer();
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.close(1000, "shutdown");
}
socket = null;
};
}
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -1,11 +1,10 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
private: true,
type: "module",
workspaces: ["templates/*", "workflows"],
scripts: {
bundle: "bun run scripts/bundle.ts",
},
},
null,
2,
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
private: true,
type: "module",
dependencies: {
"@uncaged/workflow-runtime": "^0.1.0",
"@uncaged/workflow-runtime": "^0.3.1",
zod: "^4.0.0",
},
},
@@ -42,7 +44,9 @@ function biomeJson(): string {
{
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
files: {
includes: ["**", "!**/node_modules", "!**/dist"],
// Exclude generated bundle script — it uses Bun globals and console that
// conflict with the workspace's Biome rules (noConsole, etc.).
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
},
formatter: {
indentWidth: 2,
@@ -86,7 +90,7 @@ function agentsMd(): string {
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -96,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
@@ -107,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -157,6 +161,12 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
`;
}
function bunfigToml(): string {
return `[install.scopes]
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
`;
}
function readmeMd(workspaceName: string): string {
return `# ${workspaceName}
@@ -184,32 +194,137 @@ uncaged-workflow init workspace ${workspaceName}
`;
}
function bundleTs(): string {
return [
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
'import { join } from "node:path";',
"",
'const rootDir = join(import.meta.dir, "..");',
'const workflowsDir = join(rootDir, "workflows");',
'const distDir = join(rootDir, "dist");',
"",
"type JsonDeps = {",
" dependencies: Record<string, string> | null;",
" devDependencies: Record<string, string> | null;",
"};",
"",
"function isEntryFile(name: string): boolean {",
' return name.endsWith("-entry.ts");',
"}",
"",
"function entryStem(name: string): string {",
' return name.slice(0, -".ts".length);',
"}",
"",
"async function uncagedWorkflowExternals(): Promise<string[]> {",
" const names = new Set<string>();",
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
" for (const pkgPath of paths) {",
" let raw: string;",
" try {",
' raw = await readFile(pkgPath, "utf8");',
" } catch {",
" continue;",
" }",
" const parsed = JSON.parse(raw) as JsonDeps;",
" const blocks = [parsed.dependencies, parsed.devDependencies];",
" for (const block of blocks) {",
" if (block == null) {",
" continue;",
" }",
" for (const key of Object.keys(block)) {",
' if (key.startsWith("@uncaged/workflow")) {',
" names.add(key);",
" }",
" }",
" }",
" }",
" if (names.size === 0) {",
' names.add("@uncaged/workflow-runtime");',
' names.add("@uncaged/workflow-protocol");',
" }",
" return [...names];",
"}",
"",
"async function main(): Promise<void> {",
" await mkdir(distDir, { recursive: true });",
" let files: string[];",
" try {",
" files = await readdir(workflowsDir);",
" } catch {",
' console.error("bundle: missing workflows/ directory");',
" process.exitCode = 1;",
" return;",
" }",
" const entries = files.filter(isEntryFile);",
" if (entries.length === 0) {",
' console.warn("bundle: no *-entry.ts files under workflows/");',
" return;",
" }",
" const external = await uncagedWorkflowExternals();",
" for (const file of entries) {",
" const stem = entryStem(file);",
" const entryPath = join(workflowsDir, file);",
" const result = await Bun.build({",
" entrypoints: [entryPath],",
" outdir: distDir,",
' format: "esm",',
' target: "node",',
" splitting: false,",
' naming: { entry: "[name].esm.js" },',
" external,",
" });",
" if (!result.success) {",
" for (const log of result.logs) {",
" console.error(log);",
" }",
` throw new Error(\`bundle failed for \${file}\`);`,
" }",
" const dts =",
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
" }",
"}",
"",
"await main();",
"",
].join("\n");
}
export async function cmdInitWorkspace(
parentDir: string,
workspaceName: string,
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
const validated = validateWorkspaceSegment(workspaceName);
if (!validated.ok) {
return validated;
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
const resolved = resolve(parentDir, workspaceName);
const rootPath = resolved;
const dirName = basename(resolved);
if (dirName === "" || dirName === "." || dirName === "..") {
return err(`invalid workspace path: ${workspaceName}`);
}
const rootPath = join(parentDir, workspaceName);
if (await pathExists(rootPath)) {
return err(`directory already exists: ${rootPath}`);
}
await mkdir(rootPath, { recursive: false });
await mkdir(join(rootPath, "templates"), { recursive: false });
await mkdir(join(rootPath, "workflows"), { recursive: false });
await mkdir(rootPath, { recursive: true });
await mkdir(join(rootPath, "templates"), { recursive: true });
await mkdir(join(rootPath, "workflows"), { recursive: true });
await mkdir(join(rootPath, "scripts"), { recursive: true });
await Promise.all([
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
]);
return ok({ rootPath });
@@ -1,3 +0,0 @@
export { createApp } from "./app.js";
export { dispatchServe, startServer } from "./serve.js";
export type { ServeOptions } from "./types.js";
@@ -1,163 +0,0 @@
import { randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import {
registerWithGateway,
startHeartbeat,
startTunnel,
unregisterFromGateway,
} from "./tunnel.js";
import type { ServeOptions } from "./types.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 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;
},
};
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, 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;
}
// Start cloudflared quick tunnel
printCliLine("starting cloudflared quick tunnel...");
const tunnel = await startTunnel(options.port);
if (!tunnel) {
printCliLine("failed to create tunnel — continuing without gateway registration");
await new Promise(() => {});
return 0;
}
printCliLine(`tunnel: ${tunnel.url}`);
// Register with gateway
if (options.gatewaySecret) {
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
tunnel.url,
options.gatewaySecret,
agentToken!,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
// Start heartbeat
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
tunnel.url,
options.gatewaySecret,
agentToken!,
HEARTBEAT_INTERVAL_MS,
);
// Cleanup on exit
const cleanup = async () => {
clearInterval(heartbeatTimer);
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
tunnel.process.kill();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
} else {
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
}
// Keep process alive
await new Promise(() => {});
return 0;
}
@@ -1,8 +0,0 @@
export type ServeOptions = {
port: number;
hostname: string;
name: string;
noTunnel: boolean;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -0,0 +1,451 @@
import { existsSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
import { loadPresetProviders } from "./preset-providers.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
type OpenAiModelEntry = {
id: string;
};
type OpenAiModelsResponse = {
data: OpenAiModelEntry[];
};
function usageSetup(): string {
return [
"uncaged-workflow setup — configure workflow.yaml providers and default model",
"",
"Non-interactive (agent mode):",
" uncaged-workflow setup \\",
" --provider <name> \\",
" --base-url <url> \\",
" --api-key <key> \\",
" --default-model <provider/model> \\",
" [--init-workspace <name>]",
"",
"Interactive: run with no flags (prompts for each value).",
"",
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
].join("\n");
}
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
return err(`${flag} requires a value`);
}
return ok(next);
}
type ParsedSetup = SetupCliArgs | "interactive" | "help";
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
"--provider": "provider",
"--base-url": "baseUrl",
"--api-key": "apiKey",
"--default-model": "defaultModel",
"--init-workspace": "initWorkspaceName",
};
function emptyFlagState(): Record<SetupFlagField, string | null> {
return {
provider: null,
baseUrl: null,
apiKey: null,
defaultModel: null,
initWorkspaceName: null,
};
}
function finalizeParsedSetup(
state: Record<SetupFlagField, string | null>,
): Result<ParsedSetup, string> {
const hasAnyFlag =
state.provider !== null ||
state.baseUrl !== null ||
state.apiKey !== null ||
state.defaultModel !== null ||
state.initWorkspaceName !== null;
if (!hasAnyFlag) {
return ok("interactive");
}
if (state.provider === null) {
return err(
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
);
}
const missing: string[] = [];
if (state.baseUrl === null) {
missing.push("--base-url");
}
if (state.apiKey === null) {
missing.push("--api-key");
}
if (state.defaultModel === null) {
missing.push("--default-model");
}
if (missing.length > 0) {
return err(`missing required flag(s): ${missing.join(", ")}`);
}
const b = state.baseUrl;
const k = state.apiKey;
const m = state.defaultModel;
if (b === null || k === null || m === null) {
return err("internal: missing required flags after validation");
}
return ok({
provider: state.provider,
baseUrl: b,
apiKey: k,
defaultModel: m,
initWorkspaceName: state.initWorkspaceName,
});
}
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
const state = emptyFlagState();
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === undefined) {
break;
}
if (tok === "--help" || tok === "-h") {
return ok("help");
}
const field = SETUP_FLAG_TO_FIELD[tok];
if (field === undefined) {
return err(`unknown argument: ${tok}`);
}
const v = requireNext(argv, i, tok);
if (!v.ok) {
return v;
}
state[field] = v.value;
i++;
}
return finalizeParsedSetup(state);
}
async function promptLine(
rl: { question: (q: string) => Promise<string> },
label: string,
): Promise<string> {
const raw = await rl.question(label);
return raw.trim();
}
type SecretInputState = {
buf: string;
rawWasSet: boolean;
onData: (chunk: string) => void;
fulfill: (value: string) => void;
};
function isLineTerminator(c: string): boolean {
return c === "\n" || c === "\r" || c === "\u0004";
}
function handleLineTerminator(state: SecretInputState): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(state.rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", state.onData);
process.stdout.write("\n");
state.fulfill(state.buf.trim());
}
function handleBackspace(state: SecretInputState): void {
if (state.buf.length > 0) {
state.buf = state.buf.slice(0, -1);
process.stdout.write("\b \b");
}
}
function handleInterrupt(rawWasSet: boolean): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
function isBackspace(c: string): boolean {
return c === "\u007F" || c === "\b";
}
/** Process a single character in secret input. Returns "done" to stop reading. */
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
if (isLineTerminator(c)) {
handleLineTerminator(state);
return "done";
}
if (isBackspace(c)) {
handleBackspace(state);
return "skip";
}
if (c === "\u0003") {
handleInterrupt(state.rawWasSet);
}
state.buf += c;
process.stdout.write("*");
return "append";
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((fulfill) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (processSecretChar(c, state) === "done") return;
}
};
state.onData = onData;
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
return [];
}
const body = (await res.json()) as OpenAiModelsResponse;
if (!Array.isArray(body.data)) {
return [];
}
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
// wordart, wanx, wan2, paraformer) but harmless for other providers.
const NON_CHAT_RE =
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
return body.data
.map((m) => m.id)
.filter((id) => !NON_CHAT_RE.test(id))
.sort();
} catch (e) {
setupDispatchLog(
"V8NQ4JT6",
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
);
return [];
}
}
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
function printProviderMenu(presets: readonly PresetProvider[]): void {
const numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets.at(i);
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
}
async function selectProvider(
rl: { question: (q: string) => Promise<string> },
presets: readonly PresetProvider[],
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
return err(`invalid choice: ${choice}`);
}
if (choiceNum <= presets.length) {
const selected = presets.at(choiceNum - 1);
if (!selected) return err(`invalid choice: ${choice}`);
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
}
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") return err("provider name must not be empty");
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") return err("base URL must not be empty");
return ok({ provider, baseUrl });
}
function printModelList(models: string[]): void {
const cols = process.stdout.columns || 80;
const nw = String(models.length).length;
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2;
const numCols = Math.max(1, Math.floor(cols / cellWidth));
for (let i = 0; i < models.length; i += numCols) {
const cells: string[] = [];
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
const num = String(j + 1).padStart(nw);
const model = models.at(j) ?? "";
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
}
async function selectModel(
rl: { question: (q: string) => Promise<string> },
models: string[],
): Promise<Result<string, string>> {
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
printModelList(models);
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
if (modelInput === "") return err("default model must not be empty");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
return ok(models.at(modelNum - 1) ?? modelInput);
}
return ok(modelInput);
}
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") return err("default model must not be empty");
return ok(modelInput);
}
async function selectWorkspace(rl: {
question: (q: string) => Promise<string>;
}): Promise<string | null> {
while (true) {
const wsPath = await promptLine(
rl,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") return null;
const candidate = wsPath === "" ? "./workflows" : wsPath;
const resolved = resolvePath(process.cwd(), candidate);
if (existsSync(resolved)) {
printCliWarn(`directory already exists: ${resolved}`);
printCliLine("Please enter a different path, or type 'skip' to skip.");
continue;
}
return candidate;
}
}
function stripProviderPrefix(model: string): string {
if (model.includes("/")) {
return model.split("/").pop() ?? model;
}
return model;
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
printCliLine("Configure the LLM provider that workflow agents will use.\n");
const presets = loadPresetProviders();
printProviderMenu(presets);
const providerResult = await selectProvider(rl, presets);
if (!providerResult.ok) {
rl.close();
return providerResult;
}
const { provider, baseUrl } = providerResult.value;
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") return err("API key must not be empty");
const rl2 = createInterface({ input, output });
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
const modelResult = await selectModel(rl2, models);
if (!modelResult.ok) {
rl2.close();
return modelResult;
}
const bare = stripProviderPrefix(modelResult.value);
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
const initWorkspaceName = await selectWorkspace(rl2);
rl2.close();
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
}
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseSetupArgv(argv);
if (!parsed.ok) {
printCliError(`${parsed.error}\n\n${usageSetup()}`);
return 1;
}
if (parsed.value === "help") {
printCliLine(usageSetup());
return 0;
}
let args: SetupCliArgs;
if (parsed.value === "interactive") {
const collected = await collectInteractiveSetup();
if (!collected.ok) {
printCliError(collected.error);
return 1;
}
args = collected.value;
} else {
args = parsed.value;
}
const result = await cmdSetup(storageRoot, args);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printSetupSummary(result.value);
return 0;
}
@@ -0,0 +1,4 @@
export { dispatchSetup } from "./dispatch.js";
export { loadPresetProviders } from "./preset-providers.js";
export { cmdSetup, printSetupSummary } from "./setup.js";
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import type { PresetProvider } from "./types.js";
type RawPresetEntry = {
name: unknown;
label: unknown;
baseUrl: unknown;
};
function isRawEntry(v: unknown): v is RawPresetEntry {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
}
let cached: ReadonlyArray<PresetProvider> | null = null;
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
if (cached !== null) return cached;
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
if (!Array.isArray(parsed)) {
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
}
const result: PresetProvider[] = [];
for (const entry of parsed) {
if (!isRawEntry(entry)) {
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
}
result.push({
name: entry.name as string,
label: entry.label as string,
baseUrl: entry.baseUrl as string,
});
}
cached = result;
return result;
}
@@ -0,0 +1,73 @@
# Preset LLM providers for `uncaged-workflow setup`.
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
# Add new providers here — no code changes required.
# ── International ──────────────────────────────────────────
- name: openai
label: OpenAI
baseUrl: https://api.openai.com/v1
- name: xai
label: xAI
baseUrl: https://api.x.ai/v1
- name: openrouter
label: OpenRouter
baseUrl: https://openrouter.ai/api/v1
- name: venice
label: Venice
baseUrl: https://api.venice.ai/api/v1
# ── China ──────────────────────────────────────────────────
- name: dashscope
label: DashScope (Alibaba)
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
- name: deepseek
label: DeepSeek
baseUrl: https://api.deepseek.com/v1
- name: siliconflow
label: SiliconFlow
baseUrl: https://api.siliconflow.cn/v1
- name: volcengine
label: Volcengine (ByteDance)
baseUrl: https://ark.cn-beijing.volces.com/api/v3
- name: kimi
label: Kimi (Moonshot)
baseUrl: https://api.moonshot.cn/v1
- name: glm
label: GLM (Zhipu AI)
baseUrl: https://open.bigmodel.cn/api/paas/v4
- name: glm-intl
label: GLM (Zhipu AI Intl)
baseUrl: https://api.z.ai/api/paas/v4
- name: stepfun
label: StepFun
baseUrl: https://api.stepfun.com/v1
- name: minimax
label: MiniMax
baseUrl: https://api.minimax.io/v1
- name: tencent
label: Tencent TokenHub
baseUrl: https://tokenhub.tencentmaas.com/v1
- name: xiaomi
label: Xiaomi MiMo
baseUrl: https://api.xiaomimimo.com/v1
# ── Local ──────────────────────────────────────────────────
- name: ollama
label: Ollama (local)
baseUrl: http://localhost:11434/v1
@@ -0,0 +1,103 @@
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
import {
readWorkflowRegistry,
splitProviderModelRef,
workflowRegistryPath,
writeWorkflowRegistry,
} from "@uncaged/workflow-register";
import { createLogger } from "@uncaged/workflow-util";
import { printCliLine } from "../../cli-output.js";
import { cmdInitWorkspace } from "../init/index.js";
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
): Result<WorkflowConfig, string> {
const modelSplit = splitProviderModelRef(input.defaultModel);
if (!modelSplit.ok) {
return err(modelSplit.error);
}
if (modelSplit.value.providerName !== input.provider) {
return err(
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
);
}
const maxDepth = prev === null ? 3 : prev.maxDepth;
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
const providers = {
...(prev === null ? {} : prev.providers),
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
};
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
return ok({
maxDepth,
supervisorInterval,
providers,
models,
});
}
export async function cmdSetup(
storageRoot: string,
input: SetupCliArgs,
): Promise<Result<CmdSetupSuccess, string>> {
const readResult = await readWorkflowRegistry(storageRoot);
if (!readResult.ok) {
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
return err(readResult.error.message);
}
const current = readResult.value;
const merged = mergeWorkflowConfig(current.config, input);
if (!merged.ok) {
return merged;
}
const nextConfig = merged.value;
const nextRegistry = {
config: nextConfig,
workflows: current.workflows,
};
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
if (!written.ok) {
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
return err(written.error.message);
}
const registryPath = workflowRegistryPath(storageRoot);
let initWorkspaceRootPath: string | null = null;
if (input.initWorkspaceName !== null) {
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
if (!initResult.ok) {
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
return err(initResult.error);
}
initWorkspaceRootPath = initResult.value.rootPath;
}
return ok({
registryPath,
provider: input.provider,
defaultModel: input.defaultModel,
maxDepth: nextConfig.maxDepth,
supervisorInterval: nextConfig.supervisorInterval,
initWorkspaceRootPath,
});
}
export function printSetupSummary(result: CmdSetupSuccess): void {
printCliLine(`wrote registry: ${result.registryPath}`);
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
printCliLine(`config.models.default = "${result.defaultModel}"`);
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
if (result.initWorkspaceRootPath !== null) {
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
}
}
@@ -0,0 +1,23 @@
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
export type SetupCliArgs = {
provider: string;
baseUrl: string;
apiKey: string;
defaultModel: string;
initWorkspaceName: string | null;
};
export type PresetProvider = {
name: string;
label: string;
baseUrl: string;
};
export type CmdSetupSuccess = {
registryPath: string;
provider: string;
defaultModel: string;
maxDepth: number;
supervisorInterval: number;
initWorkspaceRootPath: string | null;
};
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
return err(`thread not found: ${threadId}`);
}
if (resolved.source === "active") {
await removeThreadEntry(resolved.bundleDir, threadId);
} else {
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
// Always clear both stores: between resolve and delete the worker may finish and
// move the thread from threads.json into history; branching only on resolved.source
// would skip history removal and leave a dangling row.
await removeThreadEntry(resolved.bundleDir, threadId);
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
if (!hist.ok) {
return hist;
}
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
+90 -19
View File
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
const commandSections: string[] = [];
for (const group of groups) {
const rows = group.commands.map((cmd) => {
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
});
commandSections.push(
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
@@ -85,11 +86,11 @@ ${commandSections.join("\n\n")}
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
### serve
### connect
| 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
@@ -182,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -229,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -245,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### Bundle import restrictions
The bundle validator only allows these import specifiers:
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
- \`@uncaged/workflow-*\` packages
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
@@ -0,0 +1,62 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-reactor@0.4.0
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
- @uncaged/workflow-util@0.4.0
@@ -1,104 +1,65 @@
import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const baseConfig = {
command: "/usr/local/bin/cursor-agent",
model: null as string | null,
timeout: 0,
workspace: null as string | null,
};
describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
...baseConfig,
});
expect(r.ok).toBe(true);
});
test("accepts valid config with null workspace and llmProvider", () => {
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
...baseConfig,
command: "cursor-agent",
});
expect(r.ok).toBe(true);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects empty workspace string", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
...baseConfig,
timeout: -1,
});
expect(r.ok).toBe(false);
});
test("rejects non-absolute workspace when set", () => {
const r = validateCursorAgentConfig({
...baseConfig,
workspace: "relative/path",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("llmProvider");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AgentFn with explicit workspace", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
...baseConfig,
});
expect(typeof agent).toBe("function");
});
test("returns an AgentFn with null workspace and llmProvider", () => {
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
...baseConfig,
timeout: -1,
});
expect(typeof agent).toBe("function");
});
test("throws on invalid config at construction", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}),
).toThrow();
});
test("throws when null workspace without llmProvider", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
}),
).toThrow();
});
});
+21 -7
View File
@@ -1,18 +1,32 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.1",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,5 +1,5 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
const EXTRACT_SYSTEM_FN = (_toolName: 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 {
function buildExtractionInput(ctx: ThreadContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
lines.push("");
lines.push(
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
);
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
ctx: ThreadContext,
runtime: WorkflowRuntime,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
llm: createLlmFn(provider),
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 input = buildExtractionInput(ctx);
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
const result = await runtime.extract(workspaceSchema, contentHash);
const workspace = result.meta.workspace.trim();
if (!result.ok) {
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
+47 -33
View File
@@ -1,6 +1,11 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger, type LogFn } from "@uncaged/workflow-util";
import {
buildThreadInput,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
@@ -28,37 +33,18 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const extracted = await extractWorkspacePath(ctx, 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;
}
type CursorAgentOpt = { prompt: string; workspace: string };
function createCursorAgentFn(
config: CursorAgentConfig,
modelFlag: string,
timeoutMs: number | null,
logger: LogFn,
): AgentFn<CursorAgentOpt> {
return async (ctx, { prompt, workspace }) => {
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const fullPrompt = await buildAgentPrompt(ctx);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"-p",
fullPrompt,
@@ -71,7 +57,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
"--trust",
"--force",
];
const run = await spawnCli("cursor-agent", args, {
const run = await spawnCli(config.command, args, {
cwd: workspace,
timeoutMs,
});
@@ -81,3 +67,31 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
return run.value;
};
}
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createAgentAdapter(
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace =
config.workspace !== null
? config.workspace
: await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
return { prompt, workspace };
},
);
}
+6 -5
View File
@@ -1,10 +1,11 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
/** Absolute path to the cursor-agent CLI binary. */
command: string;
model: string | null;
timeout: number;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
/**
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
* from the thread via runtime extraction.
*/
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
};
@@ -1,16 +1,18 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
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 (!isAbsolute(config.command)) {
return err("command must be an absolute path to the cursor-agent CLI binary");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
if (config.workspace !== null && !isAbsolute(config.workspace)) {
return err("workspace must be an absolute filesystem path when set");
}
return ok(undefined);
}
+5 -1
View File
@@ -6,5 +6,9 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
"references": [
{ "path": "../workflow-cas" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" }
]
}
@@ -0,0 +1,45 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
describe("validateHermesAgentConfig", () => {
test("accepts valid config", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateHermesAgentConfig({
command: "hermes",
model: null,
timeout: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("absolute path");
}
});
test("rejects negative timeout", () => {
const r = validateHermesAgentConfig({
command: "/usr/local/bin/hermes",
model: null,
timeout: -5,
});
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AgentFn", () => {
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
timeout: null,
timeout: -5,
});
expect(typeof agent).toBe("function");
});
+18 -4
View File
@@ -1,14 +1,28 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.1",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
+25 -12
View File
@@ -1,11 +1,18 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -24,17 +31,12 @@ function throwHermesSpawnError(error: SpawnCliError): never {
throw new Error("hermes: unknown spawn error");
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
const timeoutMs = config.timeout;
return async (ctx) => {
const fullPrompt = await buildAgentPrompt(ctx);
return async (ctx, { prompt }) => {
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"chat",
"-q",
@@ -47,7 +49,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
if (config.model !== null) {
args.push("--model", config.model);
}
const run = await spawnCli("hermes", args, {
const run = await spawnCli(config.command, args, {
cwd: null,
timeoutMs,
});
@@ -57,3 +59,14 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
});
}
@@ -1,4 +1,6 @@
export type HermesAgentConfig = {
/** Absolute path to the hermes CLI binary. */
command: string;
model: string | null;
timeout: number | null;
};
@@ -1,8 +1,13 @@
import { isAbsolute } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { HermesAgentConfig } from "./types.js";
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the hermes CLI binary");
}
if (config.timeout !== null && config.timeout < 0) {
return err("timeout must be null or a non-negative number (milliseconds)");
}
+45
View File
@@ -0,0 +1,45 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-runtime@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -1,9 +1,16 @@
import { describe, expect, test } from "bun:test";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import {
type CasStore,
type ExtractFn,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import * as z from "zod";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
function makeCtx(userContent: string): AgentContext {
function makeCtx(userContent: string): ThreadContext {
return {
start: {
role: START,
@@ -16,14 +23,34 @@ function makeCtx(userContent: string): AgentContext {
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
};
}
const testSchema = z.object({ summary: z.string() });
function makeRuntime(): WorkflowRuntime {
let stored = "";
const cas: CasStore = {
put: async (content: string) => {
stored = content;
return "HASH001";
},
get: async () => stored,
delete: async () => {},
list: async () => [],
};
const extract: ExtractFn = async (_schema, _contentHash) => ({
meta: { summary: "extracted" },
contentPayload: stored,
refs: [],
});
return { cas, extract };
}
describe("createLlmAdapter", () => {
const originalFetch = globalThis.fetch;
test("posts system + user (start.content) and returns assistant text", async () => {
test("posts system + user (start.content) and returns typed meta with childThread: null", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
@@ -34,11 +61,13 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"));
const roleFn = adapter("system instructions", testSchema);
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
globalThis.fetch = originalFetch;
expect(out).toBe("model reply");
expect(result.meta).toEqual({ summary: "extracted" });
expect(result.childThread).toBeNull();
});
test("throws on non-ok fetch response", async () => {
@@ -52,8 +81,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
@@ -62,8 +92,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
+21 -3
View File
@@ -1,13 +1,31 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.1",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,11 +1,12 @@
import {
type AgentContext,
type AdapterFn,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -97,13 +98,14 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: AgentContext) => {
type LlmAgentOpt = { prompt: string };
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
return async (ctx, { prompt }) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "system", content: prompt },
{ role: "user", content: ctx.start.content },
],
});
@@ -113,3 +115,10 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn {
return result.value;
};
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
prompt,
}));
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }]
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
}
@@ -0,0 +1,52 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-reactor@0.4.0
- @uncaged/workflow-util-agent@0.4.0
@@ -0,0 +1,211 @@
import { describe, expect, test } from "bun:test";
import { ok, START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
import * as z from "zod/v4";
import { createReactAdapter } from "../src/create-react-adapter.js";
import type { ReactAdapterConfig } from "../src/types.js";
// ── Helpers ─────────────────────────────────────────────────────────
function makeThread(prompt: string): ThreadContext {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: prompt,
meta: {},
timestamp: Date.now(),
parentState: null,
},
steps: [],
};
}
const STUB_RUNTIME: WorkflowRuntime = {
cas: {
put: async (_content: string) => "STUBHASH",
get: async (_hash: string) => null,
delete: async (_hash: string) => {},
list: async () => [],
},
extract: async (_schema, _contentHash) => ({
meta: {},
contentPayload: "",
refs: [],
}),
};
const TEST_SCHEMA = z
.object({
summary: z.string(),
score: z.number(),
})
.meta({ title: "resolve", description: "Submit the final result." });
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
const message: Record<string, unknown> = { role: "assistant" };
if (content !== null) {
message.content = content;
}
if (toolCalls !== null) {
message.tool_calls = toolCalls;
}
return JSON.stringify({ choices: [{ message }] });
}
function makeToolCallResponse(name: string, args: Record<string, unknown>, id: string): string {
return makeChatResponse(null, [
{
id,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
]);
}
// ── Tests ───────────────────────────────────────────────────────────
describe("createReactAdapter", () => {
test("direct resolve: LLM immediately calls resolve tool with valid args", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeToolCallResponse("resolve", { summary: "done", score: 42 }, "call_1"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "done", score: 42 });
expect(result.childThread).toBeNull();
});
test("tool call then resolve: LLM calls user tool first, then resolves", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
return ok(makeToolCallResponse("search", { query: "test" }, "call_1"));
}
return ok(makeToolCallResponse("resolve", { summary: "found it", score: 99 }, "call_2"));
};
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search for information",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
};
const toolResults: string[] = [];
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async (name, args) => {
toolResults.push(`${name}:${args}`);
return "search result: found the answer";
},
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "found it", score: 99 });
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toContain("search:");
});
test("plain JSON response accepted", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeChatResponse(JSON.stringify({ summary: "plain", score: 7 }), null));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "plain", score: 7 });
});
test("schema validation failure + retry: invalid args then valid args", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
// Invalid: score should be number, not string
return ok(
makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"),
);
}
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "fixed", score: 10 });
expect(callCount).toBe(2);
});
test("max rounds exceeded: throws error", async () => {
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search",
parameters: { type: "object", properties: {}, required: [] },
},
};
const llm: LlmFn = async (_input) => {
// Always call search, never resolve
return ok(makeToolCallResponse("search", {}, "call_n"));
};
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async () => "still searching...",
maxRounds: 3,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
await expect(roleFn(makeThread("test task"), STUB_RUNTIME)).rejects.toThrow(
"max_react_rounds_exceeded",
);
});
});
@@ -0,0 +1,121 @@
import { afterAll, describe, expect, test } from "bun:test";
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { patchFileTool, readFileTool, shellExecTool, writeFileTool } from "../src/tools/index.js";
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
mkdirSync(TMP_DIR, { recursive: true });
const tmpFile = (name: string) => join(TMP_DIR, name);
const cleanupFiles: string[] = [];
afterAll(() => {
for (const f of cleanupFiles) {
try {
unlinkSync(f);
} catch {
/* ignore */
}
}
try {
unlinkSync(TMP_DIR);
} catch {
/* ignore */
}
});
describe("read_file", () => {
test("reads file with line numbers", async () => {
const p = tmpFile("read-test.txt");
cleanupFiles.push(p);
const content = "line1\nline2\nline3\n";
require("node:fs").writeFileSync(p, content);
const result = await readFileTool.handler(
JSON.stringify({ path: p, offset: null, limit: null }),
);
expect(result).toContain("1|line1");
expect(result).toContain("2|line2");
expect(result).toContain("3|line3");
});
test("reads with offset and limit", async () => {
const p = tmpFile("read-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "a\nb\nc\nd\ne\n");
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: 2, limit: 2 }));
expect(result).toBe("2|b\n3|c");
});
test("returns error for missing file", async () => {
const result = await readFileTool.handler(
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
);
expect(result).toContain("Error:");
});
});
describe("write_file", () => {
test("writes file and creates dirs", async () => {
const p = tmpFile("sub/write-test.txt");
cleanupFiles.push(p);
const result = await writeFileTool.handler(JSON.stringify({ path: p, content: "hello world" }));
expect(result).toContain("11 bytes");
expect(readFileSync(p, "utf-8")).toBe("hello world");
});
});
describe("patch_file", () => {
test("patches file content", async () => {
const p = tmpFile("patch-test.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo bar baz");
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }),
);
expect(result).toContain("Successfully");
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
});
test("errors on not found", async () => {
const p = tmpFile("patch-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo");
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
);
expect(result).toContain("not found");
});
test("errors on non-unique match", async () => {
const p = tmpFile("patch-test3.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "aaa bbb aaa");
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }),
);
expect(result).toContain("not unique");
});
});
describe("shell_exec", () => {
test("runs echo", async () => {
const result = await shellExecTool.handler(
JSON.stringify({ command: "echo hello", timeout: null }),
);
expect(result.trim()).toBe("hello");
});
test("handles timeout", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "sleep 10", timeout: 1 }));
expect(result).toContain("timed out");
});
});
@@ -0,0 +1,35 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,69 @@
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
import { createThreadReactor } from "@uncaged/workflow-reactor";
import { buildThreadInput } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
import type { ReactAdapterConfig } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
const { $schema: _drop, ...rest } = json;
return rest;
}
function readToolName(parametersSchema: Record<string, unknown>): string {
const title = parametersSchema.title;
if (typeof title === "string" && title.trim().length > 0) {
return title.trim();
}
return "resolve";
}
function readToolDescription(parametersSchema: Record<string, unknown>): string {
const d = parametersSchema.description;
if (typeof d === "string" && d.trim().length > 0) {
return d.trim();
}
return "Submit the final structured result.";
}
export function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: config.llm,
staticTools: config.tools,
structuredToolFromSchema: (s) => {
const rawJsonSchema = z.toJSONSchema(s) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const name = readToolName(parameters);
return {
name,
tool: {
type: "function" as const,
function: {
name,
description: readToolDescription(parameters),
parameters,
},
},
};
},
systemPromptForStructuredTool: (_name) => prompt,
toolHandler: async (call, _thread) => {
return config.toolHandler(call.function.name, call.function.arguments);
},
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const input = await buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return { meta: result.value, childThread: null };
};
};
}
@@ -0,0 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,16 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
import { patchFileTool } from "./patch-file.js";
import { readFileTool } from "./read-file.js";
import { shellExecTool } from "./shell-exec.js";
import type { ToolEntry } from "./types.js";
import { writeFileTool } from "./write-file.js";
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
export const defaultTools: readonly ToolDefinition[] = ALL_TOOLS.map((t) => t.definition);
export async function defaultToolHandler(name: string, args: string): Promise<string> {
const entry = ALL_TOOLS.find((t) => t.definition.function.name === name);
if (!entry) return `Unknown tool: ${name}`;
return entry.handler(args);
}
@@ -0,0 +1,6 @@
export { defaultToolHandler, defaultTools } from "./defaults.js";
export { patchFileTool } from "./patch-file.js";
export { readFileTool } from "./read-file.js";
export { shellExecTool } from "./shell-exec.js";
export type { ToolEntry, ToolHandler } from "./types.js";
export { writeFileTool } from "./write-file.js";
@@ -0,0 +1,43 @@
import { readFile, writeFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const patchFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "patch_file",
description: "Find and replace a string in a file (first occurrence only).",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
old_string: { type: "string", description: "Text to find" },
new_string: { type: "string", description: "Replacement text" },
},
required: ["path", "old_string", "new_string"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; old_string: string; new_string: string };
const content = await readFile(parsed.path, "utf-8");
const firstIdx = content.indexOf(parsed.old_string);
if (firstIdx === -1) {
return `Error: old_string not found in ${parsed.path}`;
}
const secondIdx = content.indexOf(parsed.old_string, firstIdx + 1);
if (secondIdx !== -1) {
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
}
const updated =
content.slice(0, firstIdx) +
parsed.new_string +
content.slice(firstIdx + parsed.old_string.length);
await writeFile(parsed.path, updated);
return `Successfully patched ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,43 @@
import { readFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const readFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "read_file",
description: "Read a text file and return lines with line numbers.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file to read" },
offset: {
type: ["number", "null"],
description: "Start line number (1-indexed, default: 1)",
},
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
},
required: ["path"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as {
path: string;
offset: number | null;
limit: number | null;
};
const content = await readFile(parsed.path, "utf-8");
const allLines = content.split("\n");
const offset = parsed.offset ?? 1;
const start = Math.max(0, offset - 1);
const end =
parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
const lines = allLines.slice(start, end);
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,58 @@
import { execSync } from "node:child_process";
import type { ToolEntry } from "./types.js";
const MAX_OUTPUT = 10000;
function truncate(text: string): string {
return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n...(truncated)` : text;
}
function classifyExecError(err: unknown): string {
if (
err &&
typeof err === "object" &&
"status" in err &&
(err as { status: unknown }).status === null
) {
return "Error: command timed out";
}
if (err && typeof err === "object" && "stderr" in err) {
const e = err as { stderr: string; stdout: string; status: number };
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
return truncate(combined) || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
export const shellExecTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "shell_exec",
description: "Execute a shell command and return stdout + stderr.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to run" },
timeout: { type: ["number", "null"], description: "Timeout in seconds (default: 30)" },
},
required: ["command"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { command: string; timeout: number | null };
const timeoutMs = (parsed.timeout ?? 30) * 1000;
const output = execSync(parsed.command, {
encoding: "utf-8",
timeout: timeoutMs,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: MAX_OUTPUT * 2,
});
return truncate(output);
} catch (err: unknown) {
return classifyExecError(err);
}
},
};
@@ -0,0 +1,8 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
export type ToolHandler = (args: string) => Promise<string>;
export type ToolEntry = {
definition: ToolDefinition;
handler: ToolHandler;
};
@@ -0,0 +1,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ToolEntry } from "./types.js";
export const writeFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "write_file",
description: "Write content to a file, creating parent directories as needed.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to write" },
content: { type: "string", description: "File content" },
},
required: ["path", "content"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; content: string };
await mkdir(dirname(parsed.path), { recursive: true });
const buf = Buffer.from(parsed.content, "utf-8");
await writeFile(parsed.path, buf);
return `Successfully wrote ${buf.length} bytes to ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,10 @@
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
export type ReactToolHandler = (name: string, args: string) => Promise<string>;
export type ReactAdapterConfig = {
llm: LlmFn;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-util-agent" }
]
}
+47
View File
@@ -0,0 +1,47 @@
# @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
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
- @uncaged/workflow-util@0.4.0
+13 -4
View File
@@ -1,23 +1,32 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.1",
"version": "0.4.5",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"scripts": {
"test": "bun test"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"xxhashjs": "^0.2.2",
"yaml": "^2.7.1"
},
"devDependencies": {
"@types/bun": "latest"
},
"publishConfig": {
"access": "public"
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"composite": true
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
+5
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"files": [
"dist",
"package.json"
],
"private": true,
"type": "module",
"scripts": {
@@ -9,6 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
"@xyflow/react": "^12.10.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
+73 -26
View File
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
return {};
}
function agentBase(agent: string): string {
function clientBase(client: string): string {
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";
}
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
// ── Endpoint types ──────────────────────────────────────────────────
export type AgentEndpoint = {
export type ClientEndpoint = {
name: string;
url: string;
status: string;
@@ -66,8 +66,13 @@ export type AgentEndpoint = {
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
hash: string | null;
timestamp: number | null;
};
export type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
export type ThreadSummary = {
@@ -104,51 +109,93 @@ export type WorkflowResultRecord = {
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowRoleDescriptor = {
description: string;
schema: Record<string, unknown>;
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
export type WorkflowDetail = {
name: string;
hash: string;
timestamp: number;
history: readonly WorkflowHistoryEntry[];
descriptor: WorkflowDescriptor | null;
};
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
export function listClients(): Promise<ClientEndpoint[]> {
const url = GATEWAY_URL || "";
return fetchJson(url, "/api/gateway/endpoints");
}
// ── Agent-scoped endpoints ──────────────────────────────────────────
// ── Client-scoped endpoints ──────────────────────────────────────────
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(agentBase(agent), "/workflows");
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(clientBase(client), "/workflows");
}
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
}
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads/running");
export async function getWorkflowDescriptor(
client: string,
name: string,
): Promise<WorkflowDescriptor | null> {
const res = await getWorkflowDetail(client, name);
return res.descriptor;
}
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(agentBase(agent), `/threads/${id}`);
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(clientBase(client), "/threads");
}
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(clientBase(client), "/threads/running");
}
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(clientBase(client), `/threads/${id}`);
}
export function runThread(
agent: string,
client: string,
workflow: string,
prompt: 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 }> {
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
}
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
}
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
}
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
return fetchJson(agentBase(agent), "/healthz");
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
return fetchJson(clientBase(client), "/healthz");
}
+13 -13
View File
@@ -11,7 +11,7 @@ import { useHashRoute } from "./use-hash-route.ts";
export function App() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute();
const [showRun, setShowRun] = useState(false);
if (!authed) {
@@ -22,36 +22,36 @@ export function App() {
<div className="flex h-screen">
<Sidebar
view={view}
agent={agent}
client={client}
onViewChange={setView}
onAgentChange={setAgent}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
/>
<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">
{!agent && (
{!client && (
<div className="flex items-center justify-center h-full">
<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>
</div>
)}
{agent && view === "threads" && threadId === null && (
<ThreadList agent={agent} onSelect={setThreadId} />
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{agent && view === "threads" && threadId !== null && (
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{agent && view === "workflows" && <WorkflowList agent={agent} />}
{client && view === "workflows" && <WorkflowList client={client} />}
</div>
</main>
{showRun && agent && (
{showRun && client && (
<RunDialog
agent={agent}
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
@@ -70,7 +70,6 @@ export function LoginPage({ onLogin }: Props) {
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
autoFocus
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
agent: "#3b82f6",
client: "#3b82f6",
extractor: "#f59e0b",
};
@@ -56,11 +56,11 @@ function StartCard({ record }: { record: ThreadStartRecord }) {
);
}
function RoleMessage({ record }: { record: RoleRecord }) {
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role);
return (
<div
className="p-3 rounded-lg border text-sm"
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
@@ -114,12 +114,17 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
);
}
export function RecordCard({ record }: { record: ThreadRecord }) {
type RecordCardProps = {
record: ThreadRecord;
highlighted: boolean;
};
export function RecordCard({ record, highlighted }: RecordCardProps) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} />;
return <RoleMessage record={record} highlighted={highlighted} />;
case "workflow-result":
return <ResultCard record={record} />;
}
@@ -3,13 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
client: string;
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ agent, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(agent), [agent]);
export function RunDialog({ client, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -21,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
setSubmitting(true);
setError(null);
try {
const result = await runThread(agent, workflow, prompt);
const result = await runThread(client, workflow, prompt);
onCreated(result.threadId);
} catch (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"
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">
<div>
<label
@@ -1,27 +1,27 @@
import { useEffect } from "react";
import type { AgentEndpoint } from "../api.ts";
import { listAgents } from "../api.ts";
import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
view: "threads" | "workflows";
agent: string | null;
client: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onAgentChange: (a: string | null) => void;
onClientChange: (a: string | null) => void;
onLogout: () => void;
};
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
const { status, data } = useFetch(() => listAgents(), []);
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
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(() => {
if (agent === null && agents.length > 0) {
onAgentChange(agents[0].name);
if (client === null && clients.length > 0) {
onClientChange(clients[0].name);
}
}, [agent, agents, onAgentChange]);
}, [client, clients, onClientChange]);
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
@@ -42,33 +42,33 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
</p>
</div>
{/* Agent selector */}
{/* Client selector */}
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<label
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
htmlFor="agent-select"
htmlFor="client-select"
>
Agent
Client
</label>
<select
id="agent-select"
id="client-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={agent ?? ""}
onChange={(e) => onAgentChange(e.target.value || null)}
value={client ?? ""}
onChange={(e) => onClientChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : agents.length === 0 ? (
<option value="">No agents online</option>
) : clients.length === 0 ? (
<option value="">No clients online</option>
) : (
agents.map((a) => (
clients.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getAgentHealth } from "../api.ts";
import { getClientHealth } from "../api.ts";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
type Props = {
agent: string | null;
client: string | null;
onRun: () => void;
};
@@ -18,17 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
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 wasConnectedRef = useRef(false);
const checkHealth = useCallback(async () => {
if (!agent) {
if (!client) {
setStatus("disconnected");
return;
}
try {
await getAgentHealth(agent);
await getClientHealth(client);
wasConnectedRef.current = true;
setStatus("connected");
} catch {
@@ -38,7 +38,7 @@ export function StatusBar({ agent, onRun }: Props) {
setStatus("disconnected");
}
}
}, [agent]);
}, [client]);
useEffect(() => {
wasConnectedRef.current = false;
@@ -57,17 +57,17 @@ export function StatusBar({ agent, onRun }: Props) {
>
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>
{agent ? `Agent: ${agent}` : "No agent selected"}
{client ? `Client: ${client}` : "No client selected"}
</span>
<button
type="button"
onClick={onRun}
disabled={!agent}
disabled={!client}
className="px-3 py-1 rounded text-xs font-medium"
style={{
background: agent ? "var(--color-accent)" : "var(--color-border)",
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: agent ? 1 : 0.5,
opacity: client ? 1 : 0.5,
}}
>
Run Thread
@@ -1,20 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getThread,
getWorkflowDescriptor,
killThread,
pauseThread,
resumeThread,
type ThreadRecord,
type WorkflowDescriptor,
} from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
client: string;
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
}
return null;
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>();
const roleRecords = records.filter(
(r): r is Extract<ThreadRecord, { type: "role" }> => r.type === "role",
);
const hasResult = records.some((r) => r.type === "workflow-result");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
const isLast = i === roleRecords.length - 1;
states.set(role, !hasResult && isLast ? "active" : "completed");
}
const hasStart = records.some((r) => r.type === "thread-start");
if (hasStart) {
states.set("__start__", "completed");
}
if (hasResult) {
states.set("__end__", "completed");
for (const [k, v] of states) {
if (v === "active") states.set(k, "completed");
}
}
return states;
}
export function ThreadDetail({ client, threadId, onBack }: Props) {
const sse = useSSE(client, threadId);
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const liveActive = sse.connected && !sse.completed;
const records = liveActive
@@ -23,6 +69,76 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
? data.records
: ([] as typeof sse.records);
const workflowName = useMemo(() => extractWorkflowName(records), [records]);
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
() =>
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
[client, workflowName],
);
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
const indicesByRole = useMemo(() => {
const m = new Map<string, number[]>();
for (let i = 0; i < records.length; i++) {
const r = records[i];
if (r.type === "role") {
const list = m.get(r.role) ?? [];
list.push(i);
m.set(r.role, list);
}
}
return m;
}, [records]);
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const handleGraphNodeClick = useCallback((nodeId: string) => {
// Only allow clicks on lit (non-default) nodes
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
}, [nodeStates, indicesByRole]);
useEffect(() => {
return () => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -32,7 +148,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(agent, threadId);
await fn(client, threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -95,20 +211,87 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p>
)}
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => (
<RecordCard key={`${threadId}-${i}`} record={r} />
))}
<div ref={recordsEndRef} aria-hidden />
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
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
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
)}
<div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
const roleIndices = indicesByRole.get(r.role);
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
const flash = highlightedRole === r.role;
return (
<div
key={key}
data-record-index={i}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return <div key={key} data-record-index={i}><RecordCard record={r} highlighted={false} /></div>;
})}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -2,12 +2,12 @@ import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
client: string;
onSelect: (id: string) => void;
};
export function ThreadList({ agent, onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
export function ThreadList({ client, onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
@@ -0,0 +1,117 @@
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
const FEEDBACK_OFFSET_X = 80;
// Radius for feedback edge corners
const FEEDBACK_RADIUS = 16;
/**
* Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
* The path goes: source → arc → vertical up → arc → target
*/
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
const d = side === "right" ? 1 : -1;
const offsetX =
side === "right"
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
const r = FEEDBACK_RADIUS;
const segments = [
`M ${sourceX} ${sourceY}`,
`L ${offsetX - d * r} ${sourceY}`,
`Q ${offsetX} ${sourceY} ${offsetX} ${sourceY - r}`,
`L ${offsetX} ${targetY + r}`,
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
`L ${targetX} ${targetY}`,
];
return segments.join(" ");
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: edge routing logic is inherently branchy
export function ConditionEdge(props: EdgeProps) {
const {
id,
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
} = props;
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const isFeedback = edgeData?.isFeedback ?? false;
let path: string;
let defaultLabelX: number;
let defaultLabelY: number;
if (isFeedback) {
const side = edgeData?.feedbackSide ?? "right";
path = feedbackPath(sourceX, sourceY, targetX, targetY, side);
const offsetX =
side === "right"
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
defaultLabelX = offsetX;
defaultLabelY = (sourceY + targetY) / 2;
} else {
const result = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
path = result[0];
defaultLabelX = result[1];
defaultLabelY = result[2];
}
const stroke = "var(--color-accent)";
const label = isFallback ? "" : (edgeData?.condition ?? "");
// Use pre-computed label position if available, otherwise fall back to default
const labelX = edgeData?.labelX ?? defaultLabelX;
const labelY = edgeData?.labelY ?? defaultLabelY;
return (
<>
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5 }}
/>
{label !== "" && (
<EdgeLabelRenderer>
<div
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
whiteSpace: "nowrap",
zIndex: 10,
}}
title={edgeData?.conditionDescription ?? undefined}
>
{label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}
@@ -0,0 +1,2 @@
export type { NodeState } from "./types.ts";
export { WorkflowGraph } from "./workflow-graph.tsx";
@@ -0,0 +1,73 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function stateIcon(state: RoleNodeData["state"]): string | null {
if (state === "completed") return "✓";
if (state === "active") return "●";
return null;
}
export function RoleNode(props: NodeProps) {
const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 180,
height: 60,
background: "var(--color-surface)",
borderColor: borderColor(data.state),
color: "var(--color-text)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
boxSizing: "border-box",
}}
title={data.description}
>
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Left} id="left-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Right} id="right-in" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Left} id="left-out" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Right} id="right-out" style={handleStyle} isConnectable={false} />
<div className="flex items-center gap-1.5 font-mono">
{icon !== null && (
<span
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
<span className="truncate">{data.label}</span>
</div>
{data.description !== "" && (
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{data.description}
</div>
)}
<Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
</div>
);
}
@@ -0,0 +1,58 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
case "active":
return "var(--color-accent)";
default:
return "var(--color-border)";
}
}
function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "var(--color-success)";
if (state === "active") return "var(--color-accent)";
return "var(--color-surface)";
}
export function TerminalNode(props: NodeProps) {
const data = props.data as TerminalNodeData;
const isStart = data.kind === "start";
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
} as const;
return (
<div
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
style={{
width: 40,
height: 40,
background: bgColor(data.state),
borderColor: borderColor(data.state),
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
}}
title={isStart ? "Start" : "End"}
>
{isStart ? (
<Handle
type="source"
position={Position.Bottom}
id="bottom-out"
style={handleStyle}
isConnectable={false}
/>
) : (
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
)}
{isStart ? "▶" : "■"}
</div>
);
}
@@ -0,0 +1,34 @@
import type { WorkflowGraphEdge } from "../../api.ts";
export type NodeState = "default" | "completed" | "active";
export type TerminalKind = "start" | "end";
export type RoleNodeData = {
label: string;
description: string;
state: NodeState;
[key: string]: unknown;
};
export type TerminalNodeData = {
kind: TerminalKind;
state: NodeState;
[key: string]: unknown;
};
export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
isFeedback: boolean;
isSelfLoop: boolean;
feedbackSide: "right" | "left" | null;
labelX: number | null;
labelY: number | null;
[key: string]: unknown;
};
export type GraphInput = {
edges: readonly WorkflowGraphEdge[];
};
@@ -0,0 +1,240 @@
import type { Edge, Node } from "@xyflow/react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
import type { NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
const START_ID = "__start__";
const END_ID = "__end__";
const ROLE_NODE_WIDTH = 180;
const ROLE_NODE_HEIGHT = 60;
const TERMINAL_NODE_SIZE = 40;
// Vertical gap between nodes in the spine
const LAYER_GAP = 80;
// Horizontal offset for feedback (back) edges routed on the right side
const FEEDBACK_OFFSET_X = 80;
type LayoutInput = {
edges: readonly WorkflowGraphEdge[];
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
};
type LayoutResult = {
nodes: Node[];
edges: Edge[];
};
function nodeSize(id: string): { width: number; height: number } {
if (id === START_ID || id === END_ID) {
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
}
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
/**
* Extract the linear spine from the graph using topological ordering.
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
* 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[] {
// Collect all node IDs
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
// or FALLBACK if no other option.
const forwardAdj = new Map<string, string[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const existing = forwardAdj.get(e.from) ?? [];
existing.push(e.to);
forwardAdj.set(e.from, existing);
}
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
const visited = new Set<string>();
const spine: string[] = [];
// Build a set of "primary" next targets per node (non-FALLBACK first)
const primaryNext = new Map<string, string>();
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const list = edgesByFrom.get(e.from) ?? [];
list.push(e);
edgesByFrom.set(e.from, list);
}
// For each node, the "primary" next is the first non-FALLBACK target,
// or the FALLBACK target if all edges are FALLBACK
for (const [from, edgeList] of edgesByFrom) {
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
}
// Walk the spine from __start__
let current: string | null = START_ID;
while (current !== null && !visited.has(current)) {
visited.add(current);
spine.push(current);
const next = primaryNext.get(current);
if (next !== undefined && next !== "" && !visited.has(next)) {
current = next;
} else {
current = null;
}
}
// Add any remaining nodes not on the main path (shouldn't normally happen)
for (const id of ids) {
if (!visited.has(id)) {
spine.push(id);
}
}
return spine;
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
roles: Record<string, { description: string }>,
state: NodeState,
): Node<RoleNodeData> {
const description = roles[id]?.description ?? "";
return {
id,
type: "role",
position: pos,
data: { label: id, description, state },
draggable: false,
};
}
function buildTerminalNode(
id: string,
pos: { x: number; y: number },
state: NodeState,
): Node<TerminalNodeData> {
return {
id,
type: "terminal",
position: pos,
data: { kind: id === START_ID ? "start" : "end", state },
draggable: false,
selectable: false,
};
}
function computeLayout(input: LayoutInput): LayoutResult {
const spine = extractSpine(input.edges);
const rank = new Map<string, number>();
for (let i = 0; i < spine.length; i++) {
rank.set(spine[i], i);
}
// Position nodes along a vertical spine, centered horizontally
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
let y = 0;
for (const id of spine) {
const size = nodeSize(id);
// Center-align all nodes on the spine
const x = centerX - size.width / 2;
nodePositions.set(id, { x, y, w: size.width, h: size.height });
y += size.height + LAYER_GAP;
}
// Build nodes
const nodes: Node[] = [];
for (const id of spine) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
}
}
// Build edges with label positions
// 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.
// Track feedback edge count per target node for alternating sides
const feedbackCountByTarget = new Map<string, number>();
const edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to;
const sourceRank = rank.get(e.from) ?? 0;
const targetRank = rank.get(e.to) ?? 0;
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
const sourcePos = nodePositions.get(e.from);
const targetPos = nodePositions.get(e.to);
let labelX: number | null = null;
let labelY: number | null = null;
let feedbackSide: "right" | "left" | null = null;
if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback) {
// Alternate feedback edges left/right per target node
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;
labelX = offsetX;
labelY = midY;
} else if (!isSelfLoop) {
// Forward edge: label between source bottom and target top
const midX = centerX;
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
labelX = midX;
labelY = midY;
}
// Self-loop: let ReactFlow default handle it
}
return {
id: edgeKey(e),
source: e.from,
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",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
isFeedback,
isSelfLoop,
feedbackSide,
labelX,
labelY,
},
};
});
return { nodes, edges };
}
export function useLayout(input: LayoutInput): LayoutResult {
return useMemo(() => computeLayout(input), [input]);
}
@@ -0,0 +1,81 @@
import {
Background,
type EdgeTypes,
MarkerType,
type Node,
type NodeTypes,
type OnNodeClick,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
import { TerminalNode } from "./terminal-node.tsx";
import type { NodeState } from "./types.ts";
import { useLayout } from "./use-layout.ts";
type Props = {
graph: WorkflowGraphData;
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
const nodeTypes: NodeTypes = {
role: RoleNode,
terminal: TerminalNode,
};
const edgeTypes: EdgeTypes = {
condition: ConditionEdge,
};
function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
if (node.type !== "role" && node.type !== "terminal") return;
onNodeClick(node.id);
}
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
...e,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
color: "var(--color-text)",
},
})),
[layout.edges],
);
return (
<ReactFlow
nodes={layout.nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.3}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode="dark"
style={{ background: "var(--color-bg)" }}
>
<Background color="var(--color-border)" gap={20} size={1} />
</ReactFlow>
);
}
@@ -1,12 +1,441 @@
import { listWorkflows } from "../api.ts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail, listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
agent: string;
client: string;
};
export function WorkflowList({ agent }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
type DetailCacheEntry =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ok"; detail: WorkflowDetail };
function versionCount(detail: WorkflowDetail): number {
return detail.history.length + 1;
}
type SchemaRow = {
key: string;
name: string;
type: string;
description: string;
depth: number;
prefix: string;
isVariantHeader: boolean;
};
function resolveType(prop: Record<string, unknown>): string {
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined) {
const itemType = String(items.type ?? "unknown");
return `${itemType}[]`;
}
return "array";
}
return String(prop.type ?? "unknown");
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
parentRequired: Set<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];
// Try to find a distinguishing literal (e.g. status: "approved")
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; // skip discriminator field
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
rows.push(...subRows);
}
}
return rows;
}
// Handle regular object with properties
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,
});
// Recurse into nested object
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}-`, required));
}
// Recurse into array of objects
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}-`, new Set()));
}
}
// Recurse into oneOf
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
}
return rows;
}
function RoleCard({
roleName,
role,
}: {
roleName: string;
role: WorkflowRoleDescriptor;
}) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
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>
);
}
function ExpandedWorkflowBody({
cacheEntry,
staticNodeStates,
}: {
cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>;
}) {
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null;
const descriptor = detail?.descriptor ?? null;
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
// All roles are "completed" (static view, all nodes lit) — must be before early returns
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]);
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 edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = detail !== null ? versionCount(detail) : 0;
const hasGraph = descriptor !== null && edgeCount > 0;
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 className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
{/* Left: graph sidebar */}
{hasGraph && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
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: workflow info + role cards */}
<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)" }}
>
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
{detail.name}
</h3>
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</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>
{vc} version{vc !== 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>
);
}
export function WorkflowList({ client }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
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 clients
useEffect(() => {
setExpanded(new Set());
setDetailsByName(new Map());
}, [client]);
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(client, 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;
});
}
})();
},
[client],
);
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")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
@@ -21,26 +450,58 @@ export function WorkflowList({ agent }: Props) {
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
{workflows.map((w) => {
const isOpen = expanded.has(w.name);
return (
<div
key={w.name}
className="rounded-lg border overflow-hidden"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{w.currentHash}
</code>
</div>
))}
<button
type="button"
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>
)}
</div>
+30
View File
@@ -19,3 +19,33 @@ body {
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
}
50% {
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
}
}
.wf-node-pulse {
animation: wf-node-pulse 1.6s ease-in-out infinite;
}
@keyframes wf-record-card-highlight {
0% {
border-color: var(--color-accent);
}
35% {
border-color: var(--color-accent);
}
100% {
border-color: var(--color-border);
}
}
.wf-record-card-highlight {
animation: wf-record-card-highlight 1.5s ease-out forwards;
}
@@ -4,35 +4,35 @@ type View = "threads" | "workflows";
type HashRoute = {
view: View;
agent: string | null;
client: string | null;
threadId: string | null;
};
function parseHash(hash: string): HashRoute {
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("/");
// Check if first part is a known view
if (parts[0] === "threads" || parts[0] === "workflows") {
return {
view: parts[0] as View,
agent: null,
client: null,
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
};
}
// First part is agent name
const agent = parts[0] || null;
// First part is client name
const client = parts[0] || null;
const viewPart = parts[1] ?? "threads";
const view: View = viewPart === "workflows" ? "workflows" : "threads";
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
return { view, agent, threadId };
return { view, client, threadId };
}
function buildHash(route: HashRoute): string {
const prefix = route.agent ? `${route.agent}/` : "";
const prefix = route.client ? `${route.client}/` : "";
if (route.view === "workflows") {
return `#${prefix}workflows`;
}
@@ -44,10 +44,10 @@ function buildHash(route: HashRoute): string {
export function useHashRoute(): {
view: View;
agent: string | null;
client: string | null;
threadId: string | null;
setView: (v: View) => void;
setAgent: (a: string | null) => void;
setClient: (a: string | null) => void;
setThreadId: (id: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
@@ -67,26 +67,26 @@ export function useHashRoute(): {
}, []);
const setView = useCallback(
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
[navigate, route.agent],
(v: View) => navigate({ view: v, client: route.client, threadId: null }),
[navigate, route.client],
);
const setAgent = useCallback(
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
const setClient = useCallback(
(a: string | null) => navigate({ view: route.view, client: a, threadId: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
[navigate, route.agent],
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }),
[navigate, route.client],
);
return {
view: route.view,
agent: route.agent,
client: route.client,
threadId: route.threadId,
setView,
setAgent,
setClient,
setThreadId,
};
}

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