Compare commits

..

48 Commits

Author SHA1 Message Date
xiaoju ff3e19fd22 docs: add comments explaining summarizer constants
小橘 <xiaoju@shazhou.work>
2026-05-11 10:28:54 +00:00
xiaoju b509d1715e refactor: extract shared CAS reactor pattern into cas-reactor.ts
Deduplicate CAS_GET_TOOL_DEFINITION, isRecord, toolHandler, and
structuredToolFromSchema between summarizer.ts and extract-fn.ts.

Both now use createCasReactor(provider, cas, opts) and only provide
their own systemPrompt.

小橘 <xiaoju@shazhou.work>
2026-05-11 10:25:31 +00:00
xiaoju b93f6e736f feat: generate LLM summary in __end__ node via ReAct loop
Instead of hardcoding 'completed: moderator returned END', the engine now
calls a summarizer (ReAct loop with cas_get tool) to produce a meaningful
summary of the workflow outcome before writing the __end__ node.

- New summarizer.ts following supervisor.ts pattern
- Uses extract scene LLM provider (falls back to raw completion.summary on failure)
- Tracks step contentHashes for summarizer context
- Schema: z.object({ summary: z.string() })

Refs #187, #188

小橘 <xiaoju@shazhou.work>
2026-05-11 10:11:31 +00:00
xiaoju ec13c19505 Merge pull request 'refactor: replace maxRounds with supervisor check interval' (#186) from refactor/185-remove-max-rounds into main 2026-05-11 09:01:24 +00:00
xiaoju 203b86e827 refactor: remove dead hasRoundsRemaining condition, use FALLBACK
The condition always returned true, making the subsequent FALLBACK → END
unreachable. Simplified to FALLBACK → coder directly.

Refs #185
2026-05-11 08:59:14 +00:00
xiaoju 90de1c7025 test: fix develop moderator tests for supervisor-controlled termination
hasRoundsRemaining is now always true — supervisor controls when to
stop, not a round counter. Tests updated to expect coder retry
instead of END on exhaustion.

Refs #185
2026-05-11 08:55:49 +00:00
xiaoju 2b587612d5 refactor: replace maxRounds with supervisor check interval
Removes maxRounds as a hard stop limit from the entire stack. The supervisor
(already configured via workflow.yaml supervisorInterval) is now the sole
termination authority.

Changes across 27 files in 11 packages:
- workflow-protocol: StartStep.meta is now empty, StartNodePayload drops maxRounds
- workflow-cas: isStartPayload no longer checks maxRounds
- workflow-execute: engine, worker, fork-thread all stripped of maxRounds plumbing
- cli-workflow: --max-rounds flag removed from CLI and HTTP API
- workflow-runtime: build-context and create-workflow no longer reference maxRounds
- workflow-dashboard: UI no longer sends maxRounds
- workflow-template-develop/solve-issue: moderator no longer checks rounds remaining
- All tests updated

Fixes #185
2026-05-11 08:51:35 +00:00
xingyue 2342a6e3bd fix: login.tsx use new gateway endpoint path 2026-05-11 16:46:38 +08:00
xingyue 0021596ff0 Merge pull request 'refactor: simplify ExtractFn to (schema, contentHash)' (#184) from refactor/180-simplify-extract-fn into main 2026-05-11 08:03:48 +00:00
xiaomo 56ec8cd401 Merge pull request 'refactor: reorganize gateway routes under /api/ prefix' (#183) from feat/177-gateway-route-reorg into main 2026-05-11 08:01:02 +00:00
xiaoju fe87efd79d fix: cursor agent workspace from config instead of type assertion
Address review feedback: remove unsafe `as unknown as` cast on
currentRole. CursorAgentConfig now takes workspace directly instead
of using ExtractFn to infer it from thread context.

Refs #180
2026-05-11 08:00:51 +00:00
xingyue b783027406 fix: remove stray lockfiles + refactor gateway auth with Hono group
- Remove root and workflow-gateway pnpm-lock.yaml (workspace mode)
- Replace startsWith auth skip with Hono route group
- Gateway management routes use GATEWAY_SECRET (per-route)
- /api/gateway/endpoints + /api/agents/* use dashboard auth
- No more global /api/* middleware with path-based exceptions
2026-05-11 15:59:42 +08:00
xiaoju 904ee1eb83 refactor: workflow-as-agent outputs readable summary instead of raw hash
Extract can now work with the readable content directly, without
needing a special extractPrompt for DAG traversal.

Closes #182
2026-05-11 07:55:49 +00:00
xiaoju 1742ced6df refactor: simplify ExtractFn to (schema, contentHash)
- Remove extractPrompt from RoleDefinition
- Remove ExtractContext type
- ExtractFn now takes (schema, contentHash) instead of (schema, prompt, ExtractContext)
- createExtract reads CAS content by hash, keeps ReAct loop with cas_get
- Coder schema uses .describe() for phase hash hint
- All role definitions, CLI templates, and skill output updated

Refs #180, closes #174, closes #181
2026-05-11 07:54:09 +00:00
xingyue 93145cf08c refactor: reorganize gateway routes under /api/ prefix (closes #178, closes #179)
- Gateway management: /api/gateway/register, /api/gateway/endpoints
- Agent proxy: /api/agents/:agent/*
- /healthz stays at root (CF/k8s convention)
- Skip dashboard auth for gateway register routes
- Update CLI serve tunnel registration paths
- Update dashboard API client paths

Ref: #177
2026-05-11 15:48:13 +08:00
xiaoju da6bcb10d6 feat(workflow): add declarative ModeratorTable type and migrate templates
Migrate workflow-template-develop and workflow-template-solve-issue
moderators to use the declarative ModeratorTable/tableToModerator
pattern. Update workflow-runtime re-exports and workflow-execute
engine to use renamed types.

Fixes #172
2026-05-11 06:25:39 +00:00
xiaoju 6fc97fc8c8 feat(workflow-protocol): add declarative moderator table types and tableToModerator
Add ModeratorCondition, FALLBACK, ModeratorTransition, ModeratorTable types
and tableToModerator converter function. Export from workflow-protocol and
re-export from workflow-runtime for backward compat.

Refs #172
2026-05-11 06:22:24 +00:00
xiaoju 93d9821f64 docs: update CLI skill with serve command, thread status, defaults, env vars
小橘 <xiaoju@shazhou.work>
2026-05-10 01:57:42 +00:00
xiaoju 29367cbe31 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:44:40 +00:00
xiaoju ec397aecd3 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:42:18 +00:00
xiaoju 2e9d939f8e fix: thread detail API returns correct status instead of source
小橘 <xiaoju@shazhou.work>
2026-05-10 01:39:09 +00:00
xiaoju 064a24f093 fix: no-ctl threads should be failed, not active
小橘 <xiaoju@shazhou.work>
2026-05-10 01:36:14 +00:00
xiaoju fede623a82 dashboard: remove 'All agents' dropdown option, auto-select first agent
小橘 <xiaoju@shazhou.work>
2026-05-09 13:26:11 +00:00
xiaoju 2a52b930b9 chore: raise default maxRounds from 5 to 10 (CLI, matches API default)
小橘 <xiaoju@shazhou.work>
2026-05-09 13:17:57 +00:00
xiaoju bf2f790e6e fix: detect crashed threads even when .running marker is already gone
Check worker PID liveness as final fallback — if worker is dead
and thread has no __end__ node, it crashed.

小橘 <xiaoju@shazhou.work>
2026-05-09 12:52:39 +00:00
xiaoju 08a79b77db fix: SSE sends 'done' event for non-running threads, frontend stops reconnecting
- routes-live: emit 'done' event before closing SSE for non-running threads
- use-sse: handle 'done' event — set completed, disconnect, stop reconnect
- Prevents 'Live' badge flash on failed/completed threads

小橘 <xiaoju@shazhou.work>
2026-05-09 12:49:20 +00:00
xiaoju 22a6200b69 fix: close SSE stream for non-running threads, fix Live badge
- routes-live: check .running marker before keeping SSE open;
  if thread is not running, emit existing records and close
- thread-detail: only show Live badge when connected AND not completed

小橘 <xiaoju@shazhou.work>
2026-05-09 12:45:58 +00:00
xiaoju 7e7f6aa6d6 fix: detect crashed threads by checking worker PID liveness
When .running marker exists but no __end__ in CAS chain,
check if the worker process is actually alive. Dead PID
means the worker crashed without cleanup → status 'failed'.

Fixes #170

小橘 <xiaoju@shazhou.work>
2026-05-09 12:38:18 +00:00
xiaoju d6fe3f844c fix: detect crashed threads as failed instead of stuck running
- resolveThreadListStatus() checks CAS chain for __end__ node
- Stale .running markers no longer cause false 'running' status
- Distinguish 'failed' (returnCode != 0) from 'completed'
- Worker signal handlers (SIGINT/SIGTERM) clean up .running files
- listRunningThreads filters out terminated threads with stale markers

Fixes #170

小橘 <xiaoju@shazhou.work>
2026-05-09 12:28:33 +00:00
xiaoju d0803019b5 feat: ephemeral agent token for serve ↔ gateway auth
- serve generates random UUID on startup
- registration sends agentToken to gateway, stored in KV
- gateway injects X-Agent-Token header when proxying to agent
- serve rejects /api/* requests without valid token
- healthz remains unauthenticated
- tunnel URL is now protected — direct access returns 401

小橘 <xiaoju@shazhou.work>
2026-05-09 12:05:10 +00:00
xiaoju f16e7641fd chore: add .env.production for dashboard gateway URL
小橘 <xiaoju@shazhou.work>
2026-05-09 11:58:51 +00:00
xiaoju 3b41625001 feat: dashboard API key authentication
- Gateway: DASHBOARD_API_KEY middleware on /endpoints and /api/* routes
- Dashboard: login page with key validation, stored in localStorage
- SSE: key passed as ?key= query param (EventSource can't set headers)
- Sidebar: logout button to clear key

Refs #169
小橘 <xiaoju@shazhou.work>
2026-05-09 11:56:25 +00:00
xiaoju c602d2284b fix(dashboard): pass content as children to ReactMarkdown
Self-closing <ReactMarkdown /> renders nothing — need children.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:58:33 +00:00
xiaoju d96e10b0fc feat(dashboard): structured record rendering with markdown support (#169)
- API returns structured fields for thread-start (workflow, prompt, status)
  and workflow-result (returnCode, content, timestamp)
- New RecordCard component renders by type:
  - StartCard: workflow name badge + prompt blockquote
  - RoleMessage: role-colored badges (preparer/agent/extractor) + markdown
  - ResultCard: success/fail status badge + summary
- Added react-markdown + shiki for markdown rendering with syntax highlighting
- Replaces generic <pre> blocks with proper structured rendering

Refs #169
小橘 <xiaoju@shazhou.work>
2026-05-09 10:41:13 +00:00
xiaoju 8e36d3e1f5 fix: use getContentMerklePayload to extract prompt text
Was showing raw YAML of the CAS node instead of the payload string.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:34:43 +00:00
xiaoju bbe4fe0ed1 fix: include prompt text in thread-start record
Read prompt from StartNode refs[0] CAS blob and display it.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:32:59 +00:00
xiaoju e105c5cac1 fix: show workflow name instead of bundle hash in thread-start record
小橘 <xiaoju@shazhou.work>
2026-05-09 10:31:08 +00:00
xiaoju 578776fccf fix: add standard fields to thread-start record
小橘 <xiaoju@shazhou.work>
2026-05-09 10:27:03 +00:00
xiaoju cb756a999a fix: normalize workflow-result records to match ThreadRecord shape
Both REST and SSE endpoints now return workflow-result with standard
fields (role, content, timestamp) instead of non-standard (summary).
Fixes 'Invalid Date' and empty content in dashboard.

小橘 <xiaoju@shazhou.work>
2026-05-09 10:24:48 +00:00
xiaoju e0577ceefe fix: add /api/healthz alias for gateway proxy health check
Gateway proxies /api/neko/healthz → /api/healthz on the agent,
but healthz was only on /healthz. Dashboard status bar showed
permanent Offline.

小橘 🍊(NEKO Team)
2026-05-09 10:05:46 +00:00
xiaoju 024dd8c1e8 Merge pull request 'feat: auto-tunnel + CF Worker gateway + dashboard multi-agent' (#168) from feat/164-cf-worker-gateway into main 2026-05-09 10:02:36 +00:00
xiaoju 9e98119145 feat: dashboard multi-agent support + CF Pages deploy
Phase C of #164:
- Dashboard fetches agents from gateway /endpoints
- Sidebar shows agent selector with online/offline status
- All API calls routed through gateway /api/:agent/*
- Hash routing: #agent/threads/id format
- SSE live streaming via gateway proxy
- VITE_GATEWAY_URL env var for gateway configuration
- Deployed to CF Pages: workflow-dashboard-54r.pages.dev
- Custom domain: workflow.shazhou.work (pending SSL)

Ref: #164, closes #167

小橘 🍊(NEKO Team)
2026-05-09 10:01:27 +00:00
xiaoju fd8943f131 feat: serve auto-tunnel + gateway registration
Phase B of #164:
- serve --name <agent> starts cloudflared quick tunnel automatically
- Registers with CF Worker gateway, heartbeat every 60s
- Graceful unregister on SIGINT/SIGTERM
- --no-tunnel flag for local dev
- Default name from hostname

Ref: #164, closes #166

小橘 🍊(NEKO Team)
2026-05-09 09:53:08 +00:00
xiaoju f7253d5948 feat: CF Worker API gateway with KV endpoint registry
Phase A of #164:
- Hono-based CF Worker at workflow-gateway.shazhou.workers.dev
- POST /register — agent registration with shared secret
- DELETE /register/:name — unregister
- GET /endpoints — list online agents
- GET /api/:agent/* — proxy to agent tunnel URL
- KV-backed with TTL auto-expiry

Ref: #164, closes #165

小橘 🍊(NEKO Team)
2026-05-09 09:48:49 +00:00
xiaoju 1c5636c270 Merge pull request 'fix: content node refs field + backward compat' (#163) from fix/161-162-cas-content-refs into main 2026-05-09 09:10:09 +00:00
xiaoju ca0403c8ab fix: content node refs field + thread head update
Fixes #161

Fixes #162

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 08:53:02 +00:00
xiaoju aa25f55f63 fix: add workflow-protocol and workflow-util to bundle validator allowlist
小橘 <xiaoju@shazhou.work>
2026-05-09 08:36:39 +00:00
xiaoju e29d1bf345 feat: Phase 5 — CLI + Dashboard CAS adaptation, cleanup .data.jsonl
- Align REST API contracts for Dashboard (threads list, detail, SSE)
- Add content resolution from CAS in thread show + API responses
- Rename dataWatcher → threadsJsonWatcher in SSE routes
- Update docs (CLAUDE.md, architecture.md, skill.ts) to reflect CAS storage
- Zero .data.jsonl code paths in production code
- All 166 tests pass, bun run check clean

Refs #155, closes #160

小橘 <xiaoju@shazhou.work>
2026-05-09 08:16:04 +00:00
103 changed files with 5254 additions and 721 deletions
+1
View File
@@ -4,3 +4,4 @@ bun.lock
*.tgz
tsconfig.tsbuildinfo
.npmrc
+1 -1
View File
@@ -10,7 +10,7 @@ This monorepo implements a workflow engine that executes single-file ESM bundles
|---------|-----------|
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
+1 -1
View File
@@ -8,7 +8,7 @@ A workflow engine that executes single-file ESM bundles. Each workflow is a self
|---------|-------------|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
+12 -13
View File
@@ -189,11 +189,15 @@ type WorkflowFn = (
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
├── bundles/
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
│ └── history/
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
├── logs/ # One folder per bundle hash
│ └── C9NMV6V2TQT81/
│ ├── 01KQXKW…YG.data.jsonl # Thread state
│ └── 01KQXKW…YG.info.jsonl # Debug log
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
│ └── 01KQXKW…YG.info.jsonl # Debug log
└── workflow.yaml # Registry
```
@@ -207,18 +211,13 @@ type WorkflowFn = (
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
### Thread JSONL
### Thread storage (CAS + index)
**`.data.jsonl`** — Line 1: start record; following lines: role steps with CAS-backed content.
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
```jsonc
// Start record
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
"timestamp": 1714963200000 }
// Role output (engine persists contentHash + refs; body in ~/.uncaged/workflow/cas/)
{ "role": "planner", "contentHash": "…", "meta": { "phases": [...] }, "refs": ["…"], "timestamp": ... }
```
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
@@ -45,7 +45,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
@@ -100,7 +100,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
@@ -135,7 +135,7 @@ describe("gc cli and garbageCollectCas", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
+51
View File
@@ -0,0 +1,51 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-cas':
specifier: workspace:*
version: link:../workflow-cas
'@uncaged/workflow-execute':
specifier: workspace:*
version: link:../workflow-execute
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
'@uncaged/workflow-register':
specifier: workspace:*
version: link:../workflow-register
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
'@uncaged/workflow-util':
specifier: workspace:*
version: link:../workflow-util
hono:
specifier: ^4.12.18
version: 4.12.18
yaml:
specifier: ^2.8.4
version: 2.8.4
packages:
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
hono@4.12.18: {}
yaml@2.8.4: {}
@@ -50,7 +50,6 @@ const greeterMetaSchema = z.object({
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
extractPrompt: "Extract the assistant's greeting as message.",
schema: greeterMetaSchema,
extractRefs: null,
};
@@ -93,18 +93,18 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
## 2. 核心概念
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`extractPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string): Hono {
export function createApp(storageRoot: string, agentToken: string | null): Hono {
const app = new Hono();
app.onError((_err, c) => {
@@ -37,7 +37,19 @@ export function createApp(storageRoot: string): Hono {
await next();
});
// ── Agent token auth (skip healthz) ───────────────────────────────
if (agentToken !== null) {
app.use("/api/*", async (c, next) => {
const token = c.req.header("X-Agent-Token");
if (token !== agentToken) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
});
}
app.get("/healthz", (c) => c.json({ ok: true }));
app.get("/api/healthz", (c) => c.json({ ok: true }));
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
app.route("/api/threads", createThreadRoutes(storageRoot));
@@ -1,4 +1,4 @@
import { statSync, watch } from "node:fs";
import { existsSync, statSync, watch } from "node:fs";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import {
@@ -118,7 +118,12 @@ async function emitRecordsForHead(params: {
params.eventId.n++;
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({ type: "workflow-result", ...wf }),
data: JSON.stringify({
type: "workflow-result",
returnCode: wf.returnCode,
content: wf.summary,
timestamp: null,
}),
id: String(params.eventId.n),
});
return true;
@@ -136,6 +141,7 @@ async function emitRecordsForHead(params: {
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({
type: "role",
role: fr.payload.role,
contentHash: fr.payload.content,
content,
@@ -306,10 +312,22 @@ export function createLiveRoutes(storageRoot: string): Hono {
return;
}
// If thread is not actively running, emit all records and close — don't keep SSE open
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
if (!existsSync(runningPath)) {
eventId.n++;
await stream.writeSSE({
event: "done",
data: JSON.stringify({ reason: "not-running" }),
id: String(eventId.n),
});
return;
}
const controller = new AbortController();
let completed = false;
const dataWatcher = watch(threadsJsonPath, async () => {
const threadsJsonWatcher = watch(threadsJsonPath, async () => {
if (completed) {
return;
}
@@ -334,7 +352,7 @@ export function createLiveRoutes(storageRoot: string): Hono {
stream.onAbort(() => {
completed = true;
dataWatcher.close();
threadsJsonWatcher.close();
infoWatcher?.close();
});
@@ -347,7 +365,7 @@ export function createLiveRoutes(storageRoot: string): Hono {
stream.onAbort(() => resolve());
});
dataWatcher.close();
threadsJsonWatcher.close();
infoWatcher?.close();
});
});
@@ -1,24 +1,119 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
import { pathExists } from "../../fs-utils.js";
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadListStatus,
resolveThreadRecord,
} from "../../thread-scan.js";
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
import { cmdRun } from "../thread/run.js";
async function readStartInfo(
cas: ReturnType<typeof createCasStore>,
startHash: string,
): Promise<{ name: string | null; prompt: string | null }> {
const raw = await cas.get(startHash);
if (raw === null) return { name: null, prompt: null };
const parsed = parseCasThreadNode(raw);
if (parsed === null || parsed.kind !== "start") return { name: null, prompt: null };
const name = parsed.node.payload.name;
const promptHash = parsed.node.refs[0] ?? null;
let prompt: string | null = null;
if (promptHash !== null) {
prompt = await getContentMerklePayload(cas, promptHash);
}
return { name, prompt };
}
async function buildThreadDetailRecords(
storageRoot: string,
resolved: ResolvedThreadRecord,
runningMarkerPresent: boolean,
statusRow: HistoricalThreadRow,
): Promise<unknown[]> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
const records: unknown[] = [
{
type: "thread-start",
workflow: workflowName ?? "unknown",
prompt: prompt ?? null,
threadId: resolved.threadId,
status,
timestamp: null,
},
];
for (const fr of chronological) {
if (fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
if (fr.payload.role === END) {
const returnCode = fr.payload.meta.returnCode;
const summary = fr.payload.meta.summary;
if (typeof returnCode === "number" && typeof summary === "string") {
records.push({
type: "workflow-result",
returnCode,
content: summary,
timestamp: fr.payload.timestamp,
});
}
continue;
}
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
const content =
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`;
records.push({
type: "role",
role: fr.payload.role,
contentHash: fr.payload.content,
content,
meta: fr.payload.meta,
timestamp: fr.payload.timestamp,
});
}
return records;
}
export function createThreadRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const nameFilter = c.req.query("workflow") ?? null;
const rows = await listHistoricalThreads(storageRoot, nameFilter);
return c.json({ threads: rows });
const threads = await Promise.all(
rows.map(async (r) => {
const runningPath = join(storageRoot, "logs", r.hash, `${r.threadId}.running`);
const runningMarkerPresent = await pathExists(runningPath);
const status = await resolveThreadListStatus(storageRoot, r, runningMarkerPresent);
return {
threadId: r.threadId,
workflow: r.workflowName,
hash: r.hash,
startedAt: new Date(r.activityTs).toISOString(),
status,
};
}),
);
return c.json({ threads });
});
app.get("/running", async (c) => {
@@ -32,42 +127,22 @@ export function createThreadRoutes(storageRoot: string): Hono {
if (resolved === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const records: unknown[] = [
{
type: "thread-start",
threadId: resolved.threadId,
bundleHash: resolved.bundleHash,
head: resolved.head,
start: resolved.start,
source: resolved.source,
},
];
for (const fr of chronological) {
if (fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
if (fr.payload.role === END) {
const returnCode = fr.payload.meta.returnCode;
const summary = fr.payload.meta.summary;
if (typeof returnCode === "number" && typeof summary === "string") {
records.push({ type: "workflow-result", returnCode, summary });
}
continue;
}
records.push({
role: fr.payload.role,
contentHash: fr.payload.content,
meta: fr.payload.meta,
timestamp: fr.payload.timestamp,
});
}
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
const runningMarkerPresent = await pathExists(runningPath);
const statusRow = {
threadId: resolved.threadId,
hash: resolved.bundleHash,
workflowName: null,
source: resolved.source,
activityTs: 0,
head: resolved.head,
};
const records = await buildThreadDetailRecords(
storageRoot,
resolved,
runningMarkerPresent,
statusRow,
);
return c.json({ threadId, records });
});
@@ -81,13 +156,12 @@ export function createThreadRoutes(storageRoot: string): Hono {
const name = body.workflow;
const prompt = body.prompt;
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
const result = await cmdRun(storageRoot, name, prompt);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
+107 -13
View File
@@ -1,12 +1,27 @@
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";
export function startServer(storageRoot: string, options: ServeOptions): void {
const app = createApp(storageRoot);
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,
@@ -28,30 +43,51 @@ function parsePortValue(value: string | undefined): Result<number, string> {
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;
}
if (!portResult.ok) return portResult;
port = portResult.value;
i++;
} else if (arg === "--host") {
const next = argv[i + 1];
if (next === undefined) {
return err("--host requires a value");
}
hostname = next;
} 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 });
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
@@ -61,7 +97,65 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
return 1;
}
startServer(storageRoot, parsed.value);
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(() => {});
@@ -0,0 +1,88 @@
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,
secret: string,
agentToken: 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 }),
});
if (!resp.ok) {
const body = await resp.text();
printCliLine(`gateway registration failed: ${resp.status} ${body}`);
return false;
}
return true;
} catch (e) {
printCliLine(`gateway registration error: ${e}`);
return false;
}
}
export async function unregisterFromGateway(
gatewayUrl: string,
name: string,
secret: string,
): Promise<void> {
try {
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${secret}` },
});
} catch {
// Best effort — process is exiting
}
}
export function startHeartbeat(
gatewayUrl: string,
name: string,
tunnelUrl: string,
secret: string,
agentToken: string,
intervalMs: number,
): ReturnType<typeof setInterval> {
return setInterval(() => {
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
}, intervalMs);
}
@@ -1,4 +1,8 @@
export type ServeOptions = {
port: number;
hostname: string;
name: string;
noTunnel: boolean;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
return 1;
}
const result = await cmdRun(
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.maxRounds,
);
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
if (!result.ok) {
printCliError(result.error);
return 1;
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
args: "<name> [--prompt <text>]",
description: "Start a new thread executing a workflow",
},
list: {
@@ -10,7 +10,6 @@ export async function cmdRun(
storageRoot: string,
name: string,
prompt: string,
maxRounds: number,
): Promise<Result<{ threadId: string }, string>> {
const nameOk = validateCliWorkflowName(name);
if (!nameOk.ok) {
@@ -41,7 +40,7 @@ export async function cmdRun(
threadId,
workflowName: name,
prompt,
options: { maxRounds, depth: 0 },
options: { depth: 0 },
},
{ awaitResponseLine: false },
);
@@ -1,4 +1,4 @@
import { createCasStore } from "@uncaged/workflow-cas";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { END } from "@uncaged/workflow-runtime";
@@ -19,15 +19,20 @@ export async function cmdThreadShow(
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
const chronological = [...frames].reverse();
const steps: Array<{ role: string; hash: string; timestamp: number }> = [];
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
for (const fr of chronological) {
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
continue;
}
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
steps.push({
role: fr.payload.role,
hash: fr.hash,
timestamp: fr.payload.timestamp,
content:
payloadText !== null
? payloadText
: `(content not in CAS; contentHash=${fr.payload.content})`,
});
}
+6 -23
View File
@@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
export type ParsedRunArgv = {
name: string;
prompt: string;
maxRounds: number;
};
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
function parseFlagAt(
argv: string[],
index: number,
): Result<{ kind: "prompt"; value: string }, string> | null {
const flag = argv[index];
if (flag === "--prompt") {
const value = argv[index + 1];
@@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
}
return ok({ kind: "prompt", value });
}
if (flag === "--max-rounds") {
const value = argv[index + 1];
if (value === undefined) {
return err("missing value for --max-rounds");
}
const n = Number(value);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
return err("--max-rounds must be a non-negative integer");
}
return ok({ kind: "max-rounds", value: n });
}
return null;
}
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
let name: string | undefined;
let prompt = "";
let maxRounds = 5;
let i = 0;
const first = argv[0];
@@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
}
const flag = parsed.value;
if (flag.kind === "prompt") {
prompt = flag.value;
i += 2;
continue;
}
maxRounds = flag.value;
prompt = flag.value;
i += 2;
}
@@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
return err("run requires <name>");
}
return ok({ name, prompt, maxRounds });
return ok({ name, prompt });
}
+20 -4
View File
@@ -70,8 +70,8 @@ function formatSkillCli(): string {
|---------|-------------|
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
| **Thread** | A single execution of a workflow, identified by a ULID. CAS state chain; \`threads.json\` for active; \`history/*.jsonl\` when done; \`.info.jsonl\` for debug logs. |
| **CAS** | Global content-addressable blob store (\`cas/\`), keyed by hash. |
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
## Commands
@@ -85,6 +85,12 @@ ${commandSections.join("\n\n")}
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
### serve
| Command | Args | Description |
|---------|------|-------------|
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
## Typical Workflow
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
@@ -92,6 +98,15 @@ ${commandSections.join("\n\n")}
3. \`uncaged-workflow live --latest\` — attach and watch output
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
## Thread Status
| Status | Meaning |
|--------|---------|
| \`running\` | Worker process is alive (\`.running\` marker + live PID) |
| \`active\` | In \`threads.json\` but not currently running (paused or waiting) |
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
## Exit Codes
| Code | Meaning |
@@ -103,7 +118,9 @@ ${commandSections.join("\n\n")}
| Variable | Description |
|----------|-------------|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
| \`WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Same as above (takes priority) |
| \`WORKFLOW_LLM_API_KEY\` | API key for LLM calls during workflow execution |
`;
}
@@ -200,7 +217,6 @@ Each role has:
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
+115 -6
View File
@@ -5,10 +5,13 @@ import {
readThreadsIndex,
type ThreadHistoryEntry,
type ThreadIndex,
walkStateFramesNewestFirst,
} from "@uncaged/workflow-execute";
import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
import { readWorkerCtl } from "./worker-spawn.js";
async function readWorkflowNameFromStartHash(
storageRoot: string,
@@ -94,6 +97,12 @@ export type HistoricalThreadRow = {
threadId: string;
hash: string;
workflowName: string | null;
/** Active entry from `threads.json` vs completed line from `history/*.jsonl`. */
source: "active" | "history";
/** `updatedAt` for active threads; `completedAt` for history (ms since epoch). */
activityTs: number;
/** Current CAS head (`threads.json` / history row). */
head: string;
};
export type ResolvedThreadRecord = {
@@ -168,6 +177,95 @@ export async function resolveThreadRecord(
return null;
}
export type ThreadHeadTerminal =
| { kind: "non-terminal" }
| { kind: "terminal"; returnCode: number };
/** True when the newest frame at `headHash` is `__end__` (workflow finished in CAS). */
export async function readThreadTerminalFromHead(
storageRoot: string,
headHash: string,
): Promise<ThreadHeadTerminal> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, headHash);
const newest = frames[0];
if (newest === undefined) {
return { kind: "non-terminal" };
}
if (newest.payload.role !== END) {
return { kind: "non-terminal" };
}
const rc = newest.payload.meta.returnCode;
if (typeof rc !== "number") {
return { kind: "terminal", returnCode: 1 };
}
return { kind: "terminal", returnCode: rc };
}
export type ThreadListStatus = "running" | "active" | "completed" | "failed";
/** Combines `.running` marker with CAS head: stale markers do not imply `running`. */
export async function resolveThreadListStatus(
storageRoot: string,
row: HistoricalThreadRow,
runningMarkerPresent: boolean,
): Promise<ThreadListStatus> {
const terminal = await readThreadTerminalFromHead(storageRoot, row.head);
if (terminal.kind === "terminal") {
return terminal.returnCode !== 0 ? "failed" : "completed";
}
if (row.source === "history") {
return "completed";
}
if (runningMarkerPresent) {
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
if (ctlResult.ok) {
try {
process.kill(ctlResult.value.pid, 0);
return "running";
} catch {
// Worker PID is dead but .running marker remains — crashed thread
return "failed";
}
}
return "running";
}
// No .running marker + no __end__ + source "active" → check if worker is dead (crashed)
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
if (!ctlResult.ok) {
// No ctl file means worker never registered or was already cleaned up — dead thread
return "failed";
}
try {
process.kill(ctlResult.value.pid, 0);
} catch {
// Worker PID is dead, thread never finished — crashed
return "failed";
}
return "active";
}
async function appendRunningThreadRowIfLive(
storageRoot: string,
hash: string,
threadId: string,
out: RunningThreadRow[],
): Promise<void> {
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved !== null && resolved.bundleHash !== hash) {
return;
}
if (resolved !== null) {
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
if (terminal.kind === "terminal") {
return;
}
}
const workflowName =
resolved !== null ? await readWorkflowNameFromStartHash(storageRoot, resolved.start) : null;
out.push({ threadId, hash, workflowName });
}
/** Threads currently executing — identified via `<threadId>.running` markers. */
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadRow[]> {
const logsRoot = join(storageRoot, "logs");
@@ -192,10 +290,7 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
continue;
}
const threadId = fileName.slice(0, -".running".length);
const resolved = await resolveThreadRecord(storageRoot, threadId);
const workflowName =
resolved !== null ? await readWorkflowNameFromStartHash(storageRoot, resolved.start) : null;
out.push({ threadId, hash, workflowName });
await appendRunningThreadRowIfLive(storageRoot, hash, threadId, out);
}
}
@@ -243,7 +338,14 @@ export async function listHistoricalThreads(
if (workflowNameFilter !== null && workflowName !== workflowNameFilter) {
continue;
}
out.push({ threadId, hash: bundleHash, workflowName });
out.push({
threadId,
hash: bundleHash,
workflowName,
source: "active",
activityTs: entry.updatedAt,
head: entry.head,
});
}
const histDir = join(bundleDir, "history");
@@ -271,7 +373,14 @@ export async function listHistoricalThreads(
if (workflowNameFilter !== null && workflowName !== workflowNameFilter) {
continue;
}
out.push({ threadId: e.threadId, hash: bundleHash, workflowName });
out.push({
threadId: e.threadId,
hash: bundleHash,
workflowName,
source: "history",
activityTs: e.completedAt,
head: e.head,
});
}
}
}
+20 -1
View File
@@ -6,6 +6,7 @@ import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
import { readThreadTerminalFromHead, resolveThreadRecord } from "./thread-scan.js";
export type WorkerCtl = {
pid: number;
@@ -269,7 +270,25 @@ export async function resolveRunningHashForThread(
if (!(await pathExists(logsRoot))) {
return err(`thread not running (no logs dir): ${threadId}`);
}
const hashes = await readdir(logsRoot);
const resolved = await resolveThreadRecord(storageRoot, threadId);
if (resolved !== null) {
const runningPath = join(logsRoot, resolved.bundleHash, `${threadId}.running`);
if (!(await pathExists(runningPath))) {
return err(`thread not running: ${threadId}`);
}
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
if (terminal.kind === "terminal") {
return err(`thread not running: ${threadId}`);
}
return ok(resolved.bundleHash);
}
let hashes: string[];
try {
hashes = await readdir(logsRoot);
} catch {
return err(`thread not running: ${threadId}`);
}
for (const hash of hashes) {
const runningPath = join(logsRoot, hash, `${threadId}.running`);
if (await pathExists(runningPath)) {
@@ -1,24 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
_schema: z.ZodType<T>,
_prompt: string,
_ctx: ExtractContext,
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
meta: { workspace: "/tmp" } as unknown as T,
contentPayload: "",
refs: [],
});
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
extract: testExtract,
workspace: "/tmp/test-project",
});
expect(r.ok).toBe(true);
});
@@ -27,11 +15,11 @@ describe("validateCursorAgentConfig", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
extract: null as unknown as ExtractFn,
workspace: "",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("extract");
expect(r.error).toContain("workspace");
}
});
@@ -39,7 +27,7 @@ describe("validateCursorAgentConfig", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: -1,
extract: testExtract,
workspace: "/tmp/test-project",
});
expect(r.ok).toBe(false);
});
@@ -50,7 +38,7 @@ describe("createCursorAgent", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
extract: testExtract,
workspace: "/tmp/test-project",
});
expect(typeof agent).toBe("function");
});
@@ -60,7 +48,7 @@ describe("createCursorAgent", () => {
createCursorAgent({
model: null,
timeout: -1,
extract: testExtract,
workspace: "/tmp/test-project",
}),
).toThrow();
});
+28
View File
@@ -0,0 +1,28 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
'@uncaged/workflow-util-agent':
specifier: workspace:*
version: link:../workflow-util-agent
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
zod@4.4.3: {}
+2 -18
View File
@@ -1,6 +1,5 @@
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
@@ -8,12 +7,6 @@ import { validateCursorAgentConfig } from "./validate-config.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
const cursorWorkspaceSchema = z.object({
workspace: z
.string()
.describe("Absolute path to the project/repository directory the agent should work in"),
});
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
@@ -44,16 +37,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const timeoutMs = config.timeout > 0 ? config.timeout : null;
return async (ctx) => {
const extractCtx: ExtractContext = {
...ctx,
agentContent: "",
};
const extracted = await config.extract(
cursorWorkspaceSchema,
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
extractCtx,
);
const { workspace } = extracted.meta;
const workspace = config.workspace;
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
+1 -3
View File
@@ -1,7 +1,5 @@
import type { ExtractFn } from "@uncaged/workflow-runtime";
export type CursorAgentConfig = {
model: string | null;
timeout: number;
extract: ExtractFn;
workspace: string;
};
@@ -3,8 +3,8 @@ import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (typeof config.extract !== "function") {
return err("extract must be a function");
if (typeof config.workspace !== "string" || config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path)");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
+16
View File
@@ -0,0 +1,16 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
'@uncaged/workflow-util-agent':
specifier: workspace:*
version: link:../workflow-util-agent
@@ -8,7 +8,7 @@ function makeCtx(userContent: string): AgentContext {
start: {
role: START,
content: userContent,
meta: { maxRounds: 10 },
meta: {},
timestamp: 1,
},
depth: 0,
+13
View File
@@ -0,0 +1,13 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
+75
View File
@@ -0,0 +1,75 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
'@uncaged/workflow-util':
specifier: workspace:*
version: link:../workflow-util
xxhashjs:
specifier: ^0.2.2
version: 0.2.2
yaml:
specifier: ^2.7.1
version: 2.8.4
devDependencies:
'@types/bun':
specifier: latest
version: 1.3.13
packages:
'@types/bun@1.3.13':
resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==}
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
bun-types@1.3.13:
resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==}
cuint@0.2.2:
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
xxhashjs@0.2.2:
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@types/bun@1.3.13':
dependencies:
bun-types: 1.3.13
'@types/node@25.6.2':
dependencies:
undici-types: 7.19.2
bun-types@1.3.13:
dependencies:
'@types/node': 25.6.2
cuint@0.2.2: {}
undici-types@7.19.2: {}
xxhashjs@0.2.2:
dependencies:
cuint: 0.2.2
yaml@2.8.4: {}
+42 -14
View File
@@ -1,8 +1,41 @@
import { parse, stringify } from "yaml";
import type { CasStore, MerkleNode, StepMerklePayload, ThreadMerklePayload } from "./types.js";
import type {
CasStore,
MerkleNode,
MerkleNodeType,
StepMerklePayload,
ThreadMerklePayload,
} from "./types.js";
function requireStringHashArray(value: unknown, notArrayMessage: string): string[] {
if (!Array.isArray(value)) {
throw new Error(notArrayMessage);
}
const out: string[] = [];
for (const c of value) {
if (typeof c !== "string") {
throw new Error("merkle: hash entry must be a string");
}
out.push(c);
}
return out;
}
function edgeListRaw(rec: Record<string, unknown>, type: MerkleNodeType): unknown {
if (type === "content") {
return rec.refs !== undefined ? rec.refs : rec.children;
}
return rec.children;
}
export function serializeMerkleNode(node: MerkleNode): string {
if (node.type === "content") {
return stringify(
{ type: node.type, payload: node.payload, refs: node.children },
{ indent: 2 },
);
}
return stringify(
{ type: node.type, payload: node.payload, children: node.children },
{ indent: 2 },
@@ -17,23 +50,18 @@ export function parseMerkleNode(yamlText: string): MerkleNode {
const rec = raw as Record<string, unknown>;
const type = rec.type;
const payload = rec.payload;
const children = rec.children;
if (type !== "content" && type !== "step" && type !== "thread") {
throw new Error("merkle: invalid or missing type");
}
if (typeof payload !== "string" && (payload === null || typeof payload !== "object")) {
throw new Error("merkle: payload must be a string or object");
}
if (!Array.isArray(children)) {
throw new Error("merkle: children must be an array");
}
const childHashes: string[] = [];
for (const c of children) {
if (typeof c !== "string") {
throw new Error("merkle: child hash must be a string");
}
childHashes.push(c);
}
const notArrayMsg =
type === "content"
? "merkle: content node requires refs or children array"
: "merkle: children must be an array";
const childHashes = requireStringHashArray(edgeListRaw(rec, type), notArrayMsg);
return {
type,
payload: typeof payload === "string" ? payload : (payload as Record<string, unknown>),
@@ -85,8 +113,8 @@ export async function putContentMerkleNode(store: CasStore, content: string): Pr
/**
* Loads a CAS blob and returns the payload string for a `content` node.
*
* Accepts both the legacy `{type:content, payload, children}` Merkle layout
* and the RFC v3 `{type:content, payload, refs}` content node layout.
* Accepts both the legacy `{ type:content, payload, children }` Merkle layout
* and the RFC-aligned `{ type:content, payload, refs }` content node layout.
*/
export async function getContentMerklePayload(
store: CasStore,
-1
View File
@@ -21,7 +21,6 @@ function isStartPayload(value: unknown): value is StartNodePayload {
return (
typeof value.name === "string" &&
typeof value.hash === "string" &&
typeof value.maxRounds === "number" &&
typeof value.depth === "number"
);
}
+4 -1
View File
@@ -9,7 +9,10 @@ function refsFromBlob(content: string): string[] {
return [];
}
const rec = raw as Record<string, unknown>;
const refs = rec.refs;
let refs = rec.refs;
if (!Array.isArray(refs) && Array.isArray(rec.children)) {
refs = rec.children;
}
if (!Array.isArray(refs)) {
return [];
}
@@ -0,0 +1 @@
VITE_GATEWAY_URL=https://workflow-gateway.shazhou.workers.dev
+3 -1
View File
@@ -10,7 +10,9 @@
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
File diff suppressed because it is too large Load Diff
+100 -30
View File
@@ -1,9 +1,43 @@
const BASE = "/api";
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || "";
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
export function getApiKey(): string | null {
try {
return localStorage.getItem("workflow-api-key");
} catch {
return null;
}
}
export function setApiKey(key: string): void {
localStorage.setItem("workflow-api-key", key);
}
export function clearApiKey(): void {
localStorage.removeItem("workflow-api-key");
}
export function hasApiKey(): boolean {
return getApiKey() !== null && getApiKey() !== "";
}
function authHeaders(): Record<string, string> {
const key = getApiKey();
if (key) return { Authorization: `Bearer ${key}` };
return {};
}
function agentBase(agent: string): string {
if (GATEWAY_URL) {
return `${GATEWAY_URL}/api/agents/${agent}`;
}
// Local dev: proxy via vite, no agent prefix
return "/api";
}
async function postJson<T>(base: string, path: string, body: unknown): Promise<T> {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
});
if (!res.ok) {
@@ -13,14 +47,23 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
return res.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
async function fetchJson<T>(base: string, path: string): Promise<T> {
const res = await fetch(`${base}${path}`, { headers: authHeaders() });
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
// ── Endpoint types ──────────────────────────────────────────────────
export type AgentEndpoint = {
name: string;
url: string;
status: string;
lastHeartbeat: number;
};
export type WorkflowSummary = {
name: string;
currentHash: string;
@@ -35,50 +78,77 @@ export type ThreadSummary = {
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
export type ThreadStartRecord = {
type: "thread-start";
workflow: string;
prompt: string | null;
threadId: string;
status: string;
timestamp: null;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
export type RoleRecord = {
type: "role";
role: string;
content: string;
timestamp: number | null;
meta: Record<string, unknown>;
};
export type WorkflowResultRecord = {
type: "workflow-result";
returnCode: number;
content: string;
timestamp: number | null;
};
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
const url = GATEWAY_URL || "";
return fetchJson(url, "/api/gateway/endpoints");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
// ── Agent-scoped endpoints ──────────────────────────────────────────
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(agentBase(agent), "/workflows");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads/running");
}
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(agentBase(agent), `/threads/${id}`);
}
export function runThread(
agent: string,
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
return postJson(agentBase(agent), "/threads", { workflow, prompt });
}
export function killThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/kill`, {});
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
}
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/pause`, {});
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
}
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/resume`, {});
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
return fetchJson(agentBase(agent), "/healthz");
}
+34 -8
View File
@@ -1,4 +1,6 @@
import { useState } from "react";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
@@ -8,24 +10,48 @@ import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
export function App() {
const { view, threadId, setView, setThreadId } = useHashRoute();
const [authed, setAuthed] = useState(hasApiKey());
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
const [showRun, setShowRun] = useState(false);
if (!authed) {
return <LoginPage onLogin={() => setAuthed(true)} />;
}
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} />
<Sidebar
view={view}
agent={agent}
onViewChange={setView}
onAgentChange={setAgent}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && threadId === null && <ThreadList onSelect={setThreadId} />}
{view === "threads" && threadId !== null && (
<ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
{!agent && (
<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.
</p>
</div>
)}
{view === "workflows" && <WorkflowList />}
{agent && view === "threads" && threadId === null && (
<ThreadList agent={agent} onSelect={setThreadId} />
)}
{agent && view === "threads" && threadId !== null && (
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{agent && view === "workflows" && <WorkflowList agent={agent} />}
</div>
</main>
{showRun && (
{showRun && agent && (
<RunDialog
agent={agent}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
@@ -0,0 +1,96 @@
import { useState } from "react";
import { setApiKey } from "../api.ts";
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!key.trim()) return;
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
headers: { Authorization: `Bearer ${key.trim()}` },
});
if (res.status === 401) {
setError("Invalid API key");
setLoading(false);
return;
}
if (!res.ok) {
setError(`Server error: ${res.status}`);
setLoading(false);
return;
}
} catch (err) {
setError(`Connection failed: ${err instanceof Error ? err.message : String(err)}`);
setLoading(false);
return;
}
setApiKey(key.trim());
onLogin();
}
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
autoFocus
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
type BundledLanguage,
type BundledTheme,
createHighlighter,
type HighlighterGeneric,
} from "shiki";
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
const LANGS: BundledLanguage[] = [
"typescript",
"javascript",
"json",
"yaml",
"bash",
"python",
"markdown",
];
function getHighlighter(): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
if (highlighterPromise === null) {
highlighterPromise = createHighlighter({
themes: ["github-dark"],
langs: LANGS,
});
}
return highlighterPromise;
}
function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) {
const [html, setHtml] = useState<string | null>(null);
const code = String(children).replace(/\n$/, "");
const lang = className?.replace("language-", "") ?? "text";
useEffect(() => {
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
try {
const result = hl.codeToHtml(code, { lang, theme: "github-dark" });
setHtml(result);
} catch {
setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [code, lang]);
if (html !== null) {
return (
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<code>{code}</code>
</pre>
);
}
export function Markdown({ content }: { content: string }) {
return (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
components={{
code({ className, children, ...props }) {
const isInline = !className;
if (isInline) {
return (
<code
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
{...props}
>
{children}
</code>
);
}
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-1.5 leading-relaxed">{children}</p>;
},
ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
},
ol({ children }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
},
h1({ children }) {
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
{children}
</blockquote>
);
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,126 @@
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { Markdown } from "./markdown.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
agent: "#3b82f6",
extractor: "#f59e0b",
};
function roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
}
function formatTime(ts: number | null): string | null {
if (ts === null) return null;
return new Date(ts).toLocaleTimeString();
}
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<div
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
{record.status}
</span>
</div>
{record.prompt !== null && (
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</div>
);
}
function RoleMessage({ record }: { record: RoleRecord }) {
const color = roleColor(record.role);
return (
<div
className="p-3 rounded-lg border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
>
{record.role}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<div
className="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{success ? "✅" : "❌"}</span>
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
exit {record.returnCode}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
);
}
export function RecordCard({ record }: { record: ThreadRecord }) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} />;
case "workflow-result":
return <ResultCard record={record} />;
}
}
@@ -3,15 +3,15 @@ import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(), []);
export function RunDialog({ agent, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(agent), [agent]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -21,7 +21,7 @@ export function RunDialog({ onClose, onCreated }: Props) {
setSubmitting(true);
setError(null);
try {
const result = await runThread(workflow, prompt, maxRounds);
const result = await runThread(agent, workflow, prompt);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -38,7 +38,7 @@ export function RunDialog({ 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</h3>
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
@@ -90,29 +90,6 @@ export function RunDialog({ onClose, onCreated }: Props) {
placeholder="Enter the task prompt..."
/>
</div>
<div>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
@@ -1,10 +1,29 @@
import { useEffect } from "react";
import type { AgentEndpoint } from "../api.ts";
import { listAgents } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
view: "threads" | "workflows";
agent: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onAgentChange: (a: string | null) => void;
onLogout: () => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
const { status, data } = useFetch(() => listAgents(), []);
const agents: AgentEndpoint[] = status === "ok" ? data : [];
// Auto-select first agent when none is selected
useEffect(() => {
if (agent === null && agents.length > 0) {
onAgentChange(agents[0].name);
}
}, [agent, agents, onAgentChange]);
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
@@ -22,8 +41,45 @@ export function Sidebar({ view, onViewChange }: Props) {
Dashboard
</p>
</div>
{/* Agent 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"
>
Agent
</label>
<select
id="agent-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)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : agents.length === 0 ? (
<option value="">No agents online</option>
) : (
agents.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</select>
</div>
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
{viewItems.map((item) => (
<button
type="button"
key={item.key}
@@ -38,6 +94,17 @@ export function Sidebar({ view, onViewChange }: Props) {
</button>
))}
</nav>
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
🚪 Logout
</button>
</div>
</aside>
);
}
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getHealth } from "../api.ts";
import { getAgentHealth } from "../api.ts";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
type Props = {
agent: string | null;
onRun: () => void;
};
@@ -17,13 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
return { text: "● Offline", color: "var(--color-error)" };
}
export function StatusBar({ onRun }: Props) {
export function StatusBar({ agent, onRun }: Props) {
const [status, setStatus] = useState<HealthStatus>("disconnected");
const wasConnectedRef = useRef(false);
const checkHealth = useCallback(async () => {
if (!agent) {
setStatus("disconnected");
return;
}
try {
await getHealth();
await getAgentHealth(agent);
wasConnectedRef.current = true;
setStatus("connected");
} catch {
@@ -33,9 +38,11 @@ export function StatusBar({ onRun }: Props) {
setStatus("disconnected");
}
}
}, []);
}, [agent]);
useEffect(() => {
wasConnectedRef.current = false;
setStatus("disconnected");
checkHealth();
const interval = setInterval(checkHealth, 10_000);
return () => clearInterval(interval);
@@ -49,12 +56,19 @@ export function StatusBar({ onRun }: Props) {
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<span style={{ color: "var(--color-text-muted)" }}>
{agent ? `Agent: ${agent}` : "No agent selected"}
</span>
<button
type="button"
onClick={onRun}
disabled={!agent}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
style={{
background: agent ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: agent ? 1 : 0.5,
}}
>
Run Thread
</button>
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
type Props = {
agent: string;
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const sse = useSSE(threadId);
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
@@ -30,7 +32,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(threadId);
await fn(agent, threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -78,7 +80,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && (
{sse.connected && !sse.completed && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
@@ -101,39 +103,8 @@ export function ThreadDetail({ threadId, onBack }: Props) {
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r) => (
<div
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
{records.map((r, i) => (
<RecordCard key={`${threadId}-${i}`} record={r} />
))}
<div ref={recordsEndRef} aria-hidden />
</div>
@@ -2,11 +2,12 @@ import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
export function ThreadList({ agent, onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
@@ -1,8 +1,12 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
type Props = {
agent: string;
};
export function WorkflowList({ agent }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
@@ -4,37 +4,50 @@ type View = "threads" | "workflows";
type HashRoute = {
view: View;
agent: string | null;
threadId: string | null;
};
function parseHash(hash: string): HashRoute {
const raw = hash.replace(/^#\/?/, "");
if (raw.startsWith("threads/")) {
const id = raw.slice("threads/".length);
if (id.length > 0) {
return { view: "threads", threadId: id };
}
// Format: #agent/threads/id or #agent/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,
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
};
}
if (raw === "workflows") {
return { view: "workflows", threadId: null };
}
return { view: "threads", threadId: null };
// First part is agent name
const agent = 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 };
}
function buildHash(route: HashRoute): string {
const prefix = route.agent ? `${route.agent}/` : "";
if (route.view === "workflows") {
return "#workflows";
return `#${prefix}workflows`;
}
if (route.threadId !== null) {
return `#threads/${route.threadId}`;
return `#${prefix}threads/${route.threadId}`;
}
return "#threads";
return `#${prefix}threads`;
}
export function useHashRoute(): {
view: View;
agent: string | null;
threadId: string | null;
setView: (v: View) => void;
setAgent: (a: string | null) => void;
setThreadId: (id: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
@@ -53,12 +66,27 @@ export function useHashRoute(): {
setRoute(next);
}, []);
const setView = useCallback((v: View) => navigate({ view: v, threadId: null }), [navigate]);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", threadId: id }),
[navigate],
const setView = useCallback(
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
[navigate, route.agent],
);
return { view: route.view, threadId: route.threadId, setView, setThreadId };
const setAgent = useCallback(
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
[navigate, route.agent],
);
return {
view: route.view,
agent: route.agent,
threadId: route.threadId,
setView,
setAgent,
setThreadId,
};
}
+26 -4
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import type { ThreadRecord } from "./api.ts";
import { getApiKey } from "./api.ts";
export type UseSSEReturn = {
records: ThreadRecord[];
@@ -56,7 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
ctx.cleanupEs();
}
export function useSSE(threadId: string | null): UseSSEReturn {
function sseUrl(agent: string, threadId: string): string {
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
const key = getApiKey();
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
if (gatewayUrl) {
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
}
return `/api/threads/${encodeURIComponent(threadId)}/live`;
}
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
const [records, setRecords] = useState<ThreadRecord[]>([]);
const [connected, setConnected] = useState(false);
const [completed, setCompleted] = useState(false);
@@ -65,7 +76,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
if (threadId === null) {
if (threadId === null || agent === null) {
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
@@ -75,6 +86,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
const tid = threadId;
const agentName = agent;
completedRef.current = false;
reconnectAttemptsRef.current = 0;
@@ -113,7 +125,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
cleanupEs();
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
const url = sseUrl(agentName, tid);
es = new EventSource(url);
es.onopen = () => {
@@ -136,6 +148,16 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}),
);
es.addEventListener("done", () => {
if (cancelled) {
return;
}
completedRef.current = true;
setCompleted(true);
setConnected(false);
cleanupEs();
});
es.onerror = () => {
if (cancelled || completedRef.current) {
return;
@@ -155,7 +177,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
cleanupEs();
};
}, [threadId]);
}, [agent, threadId]);
return { records, connected, completed };
}
@@ -34,7 +34,6 @@ function noLogger(): (tag: string, content: string) => void {
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
return {
maxRounds: 5,
depth: 0,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
@@ -107,7 +106,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "hello", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -127,7 +126,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
expect(startNode.type).toBe("start");
expect((startNode.payload as Record<string, unknown>).name).toBe("demo");
expect((startNode.payload as Record<string, unknown>).hash).toBe(bundleHash);
expect((startNode.payload as Record<string, unknown>).maxRounds).toBe(5);
const refs = startNode.refs as string[];
expect(refs.length).toBe(1);
@@ -164,7 +162,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
const opts = makeOptions({
storageRoot,
maxRounds: 5,
awaitAfterEachYield: async () => {
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
const parsed = JSON.parse(text) as Record<string, { head: string }>;
@@ -228,7 +225,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "p", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -279,7 +276,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
wf,
"demo",
{ prompt: "p", steps: [] },
makeOptions({ storageRoot, maxRounds: 5 }),
makeOptions({ storageRoot }),
io,
noLogger(),
);
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { type ExtractContext, START } from "@uncaged/workflow-runtime";
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
import * as z from "zod/v4";
import { createExtract } from "../src/extract/extract-fn.js";
@@ -45,21 +44,9 @@ describe("createExtract — ExtractResult shape", () => {
);
const schema = z.object({ confidence: z.number() });
const ctx: ExtractContext = {
threadId: "01THREADTESTAAAAAAAAAAAAAA",
depth: 0,
start: {
role: START,
content: "task text",
meta: { maxRounds: 10 },
timestamp: 100,
},
steps: [],
currentRole: { name: "analyst", systemPrompt: "be precise" },
agentContent: "model says hello",
};
const contentHash = await putContentNodeWithRefs(cas, "model says hello", []);
const out = await extract(schema, "extract fields", ctx);
const out = await extract(schema, contentHash);
expect(out.meta).toEqual({ confidence: 0.9 });
expect(out.contentPayload).toBe("model says hello");
@@ -45,7 +45,6 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
{
name: "demo",
hash: bundleHash,
maxRounds: 5,
depth: 0,
},
promptHash,
+51
View File
@@ -0,0 +1,51 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-cas':
specifier: workspace:*
version: link:../workflow-cas
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
'@uncaged/workflow-reactor':
specifier: workspace:*
version: link:../workflow-reactor
'@uncaged/workflow-register':
specifier: workspace:*
version: link:../workflow-register
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
'@uncaged/workflow-util':
specifier: workspace:*
version: link:../workflow-util
yaml:
specifier: ^2.7.1
version: 2.8.4
devDependencies:
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
yaml@2.8.4: {}
zod@4.4.3: {}
@@ -0,0 +1,79 @@
import type { CasStore } from "@uncaged/workflow-cas";
import type { ThreadReactorFn } from "@uncaged/workflow-reactor";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { LlmProvider } from "@uncaged/workflow-runtime";
import { extractFunctionToolFromZodSchema } from "./extract/index.js";
export type CasReactorThread = {
cas: CasStore;
};
const CAS_GET_TOOL_DEFINITION = {
type: "function" as const,
function: {
name: "cas_get",
description:
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and refs or children fields (content nodes use refs).",
parameters: {
type: "object",
properties: {
hash: { type: "string", description: "The CAS hash to retrieve" },
},
required: ["hash"],
},
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export type CasReactorOpts = {
maxRounds: number;
systemPromptForStructuredTool: (structuredToolName: string) => string;
};
export function createCasReactor(
provider: LlmProvider,
cas: CasStore,
opts: CasReactorOpts,
): ThreadReactorFn<CasReactorThread> {
return createThreadReactor<CasReactorThread>({
llm: createLlmFn(provider),
maxRounds: opts.maxRounds,
staticTools: [CAS_GET_TOOL_DEFINITION],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: opts.systemPromptForStructuredTool,
toolHandler: async (call, _thread) => {
if (call.function.name !== "cas_get") {
return `Unknown tool: ${call.function.name}`;
}
let hash: string;
try {
const ta = JSON.parse(call.function.arguments) as unknown;
if (!isRecord(ta) || typeof ta.hash !== "string") {
return 'cas_get requires {"hash": "<cas-hash>"}.';
}
hash = ta.hash;
} catch {
return "cas_get arguments were not valid JSON.";
}
const blob = await cas.get(hash);
return blob === null ? "null" : blob;
},
});
}
+28 -38
View File
@@ -26,6 +26,7 @@ import { END, START } from "@uncaged/workflow-runtime";
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
import { createExtract } from "../extract/index.js";
import { createSummarizer, type SummarizeFn } from "./summarizer.js";
import { runSupervisor } from "./supervisor.js";
import {
appendThreadHistoryEntry,
@@ -53,6 +54,7 @@ async function resolveEngineRegistryRuntime(
Result<
{
extract: ReturnType<typeof createExtract>;
summarize: SummarizeFn;
workflowConfig: WorkflowConfig;
},
string
@@ -76,7 +78,11 @@ async function resolveEngineRegistryRuntime(
apiKey: ex.apiKey,
model: ex.model,
};
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
return ok({
extract: createExtract(llmProvider, { cas }),
summarize: createSummarizer(llmProvider, cas),
workflowConfig: cfg,
});
}
async function appendStateForStep(params: {
@@ -211,17 +217,17 @@ async function maybeSupervisorHaltsThread(params: {
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
return null;
}
if (sup.value !== "stop") {
if (sup.value !== "kill") {
return null;
}
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
params.logger("M4QX8VHN", `thread ${params.threadId} killed by supervisor`);
return finalizeThread({
cas: params.cas,
bundleDir: params.bundleDir,
threadId: params.threadId,
startHash: params.startHash,
chain: params.chain,
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
completion: { returnCode: 1, summary: "killed: supervisor detected pathological behavior" },
});
}
@@ -250,6 +256,7 @@ async function driveWorkflowGenerator(params: {
bundleDir: string;
startHash: string;
chain: ChainState;
summarize: SummarizeFn;
}): Promise<WorkflowResult> {
const {
fn,
@@ -262,6 +269,7 @@ async function driveWorkflowGenerator(params: {
cas,
bundleDir,
startHash,
summarize,
} = params;
let chain: ChainState = params.chain;
const gen = fn(thread, runtime);
@@ -270,6 +278,10 @@ async function driveWorkflowGenerator(params: {
role: s.role,
summary: JSON.stringify(s.meta),
}));
const summarizerSteps: { role: string; contentHash: string }[] = thread.steps.map((s) => ({
role: s.role,
contentHash: s.contentHash,
}));
while (true) {
if (executeOptions.signal.aborted) {
@@ -284,32 +296,24 @@ async function driveWorkflowGenerator(params: {
});
}
if (written >= executeOptions.maxRounds) {
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
return await finalizeThread({
cas,
bundleDir,
threadId,
startHash,
chain,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
},
});
}
const iterResult = await gen.next();
if (iterResult.done) {
logger("F3HN8QKP", `thread ${threadId} generator finished`);
const rawCompletion = iterResult.value;
const llmSummary = await summarize({
prompt: thread.start.content,
recentSteps: summarizerSteps,
fallback: rawCompletion.summary,
logger,
});
return await finalizeThread({
cas,
bundleDir,
threadId,
startHash,
chain,
completion: iterResult.value,
completion: { ...rawCompletion, summary: llmSummary },
});
}
@@ -335,6 +339,7 @@ async function driveWorkflowGenerator(params: {
role: step.role,
summary: JSON.stringify(step.meta),
});
summarizerSteps.push({ role: step.role, contentHash: step.contentHash });
await Promise.race([
executeOptions.awaitAfterEachYield(),
@@ -383,7 +388,7 @@ async function driveWorkflowGenerator(params: {
* Persistence layout (RFC v3 — CAS-based thread storage):
* - Thread chain is written as immutable CAS blobs: a single {@link StartNode}
* plus one {@link StateNode} per role step (including a final `__end__`
* state on completion / abort / `maxRounds`).
* state on completion / abort).
* - The active thread head is published in `<bundleDir>/threads.json`; on
* completion it is removed and a record is appended to
* `<bundleDir>/history/{YYYY-MM-DD}.jsonl`.
@@ -433,7 +438,6 @@ export async function executeThread(
{
name: workflowName,
hash: io.hash,
maxRounds: options.maxRounds,
depth: options.depth,
},
promptHash,
@@ -475,21 +479,6 @@ export async function executeThread(
const nowMs = Date.now();
if (options.maxRounds <= 0) {
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
return await finalizeThread({
cas: io.cas,
bundleDir,
threadId: io.threadId,
startHash,
chain,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
},
});
}
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
if (!registryRuntime.ok) {
throw new Error(registryRuntime.error);
@@ -501,7 +490,7 @@ export async function executeThread(
start: {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
meta: {},
timestamp: nowMs,
},
steps: input.steps.map((out, i) => ({
@@ -530,5 +519,6 @@ export async function executeThread(
bundleDir,
startHash,
chain,
summarize: registryRuntime.value.summarize,
});
}
@@ -104,9 +104,7 @@ async function readPromptText(cas: CasStore, promptHash: string): Promise<Result
async function readStartWorkflowIdentity(params: {
cas: CasStore;
startHash: string;
}): Promise<
Result<{ workflowName: string; maxRounds: number; depth: number; prompt: string }, string>
> {
}): Promise<Result<{ workflowName: string; depth: number; prompt: string }, string>> {
const yamlText = await params.cas.get(params.startHash);
if (yamlText === null) {
return err(`start node missing in CAS: ${params.startHash}`);
@@ -127,7 +125,6 @@ async function readStartWorkflowIdentity(params: {
const p = parsed.node.payload;
return ok({
workflowName: p.name,
maxRounds: p.maxRounds,
depth: p.depth,
prompt: prompt.value,
});
@@ -317,7 +314,7 @@ export async function prepareCasFork(params: {
hash: params.bundleHash,
sourceThreadId: params.sourceThreadId,
prompt: id.value.prompt,
runOptions: { maxRounds: id.value.maxRounds, depth: id.value.depth },
runOptions: { depth: id.value.depth },
steps,
stepTimestamps,
forkContinuation: cont.value,
@@ -0,0 +1,56 @@
import type { CasStore } from "@uncaged/workflow-cas";
import type { LlmProvider } from "@uncaged/workflow-runtime";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
import { createCasReactor } from "../cas-reactor.js";
/** Max ReAct rounds: 3 cas_get reads + 1 structured output = 4 rounds is sufficient. */
const SUMMARIZER_MAX_REACT_ROUNDS = 4;
/** Only pass the last N steps; each step is just a role+contentHash reference (~60 chars), not full content. */
const SUMMARIZER_RECENT_STEP_LIMIT = 20;
const summarySchema = z.object({ summary: z.string() }).meta({
title: "workflow_summary",
description: "A concise summary of the completed workflow's results and outcome.",
});
function buildSummarizerInput(args: {
prompt: string;
recentSteps: readonly { role: string; contentHash: string }[];
}): string {
const recent = args.recentSteps.slice(-SUMMARIZER_RECENT_STEP_LIMIT);
const stepsBlock = recent
.map((s, i) => `${i + 1}. [${s.role}] contentHash: ${s.contentHash}`)
.join("\n");
return `Original task:\n${args.prompt}\n\nCompleted steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}\n\nUse cas_get to read step content if needed. Summarize the workflow outcome concisely.`;
}
export type SummarizeFn = (args: {
prompt: string;
recentSteps: readonly { role: string; contentHash: string }[];
fallback: string;
logger: LogFn;
}) => Promise<string>;
export function createSummarizer(provider: LlmProvider, cas: CasStore): SummarizeFn {
const reactor = createCasReactor(provider, cas, {
maxRounds: SUMMARIZER_MAX_REACT_ROUNDS,
systemPromptForStructuredTool: (structuredToolName) =>
`You summarize completed workflow threads. You have access to cas_get to read step content by hash. After reviewing the steps, call the ${structuredToolName} tool with a concise summary of the workflow outcome and results. Or reply with only a JSON object such as {"summary":"..."}.`,
});
return async (args) => {
const result = await reactor({
thread: { cas },
input: buildSummarizerInput(args),
schema: summarySchema,
});
if (!result.ok) {
args.logger("P2WX7KNR", `summarizer failed: ${result.error}`);
return args.fallback;
}
args.logger("Q5MT3VBF", "summarizer produced workflow summary");
return result.value.summary;
};
}
@@ -12,12 +12,12 @@ const SUPERVISOR_MAX_REACT_ROUNDS = 4;
const supervisorDecisionSchema = z
.object({
decision: z.enum(["continue", "stop"]),
decision: z.enum(["continue", "kill"]),
})
.meta({
title: "supervisor_decision",
description:
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
'Workflow supervisor decision. "continue" when the thread is making progress or following its normal role sequence; "kill" only when the thread is stuck in an infinite loop, producing no meaningful progress, or has gone off the rails. Normal workflow completion is handled by the moderator — the supervisor should NOT kill a thread just because it looks done.',
});
type SupervisorThreadContext = Record<string, never>;
@@ -63,7 +63,7 @@ export async function runSupervisor(
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
`You supervise a multi-step workflow. Your job is to detect pathological situations — NOT to decide when the workflow is "done" (that is the moderator's job). Reply with "continue" when the thread is making progress or following its normal role sequence. Reply with "kill" ONLY when the thread is stuck in an infinite loop, producing repetitive/meaningless output, or has clearly gone off the rails. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"continue"}.`,
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
});
@@ -2,7 +2,7 @@ import type { CasStore } from "@uncaged/workflow-cas";
import type { RoleOutput } from "@uncaged/workflow-runtime";
import type { Result } from "@uncaged/workflow-util";
export type SupervisorDecision = "continue" | "stop";
export type SupervisorDecision = "continue" | "kill";
export type ExecuteThreadIo = {
threadId: string;
@@ -39,7 +39,6 @@ export type PrefilledDiskStep = {
};
export type ExecuteThreadOptions = {
maxRounds: number;
/** Passed to the bundle thread context as `ThreadContext.depth`. */
depth: number;
signal: AbortSignal;
@@ -68,7 +67,7 @@ export type CasForkPlan = {
hash: string;
sourceThreadId: string;
prompt: string;
runOptions: { maxRounds: number; depth: number };
runOptions: { depth: number };
steps: RoleOutput[];
stepTimestamps: number[];
forkContinuation: ForkContinuationOptions;
+21 -7
View File
@@ -1,3 +1,4 @@
import { unlinkSync } from "node:fs";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
@@ -31,7 +32,7 @@ type RunCommand = {
threadId: string;
workflowName: string;
prompt: string;
options: { maxRounds: number; depth: number };
options: { depth: number };
steps: RoleOutput[];
/** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */
stepTimestamps: number[] | null;
@@ -184,10 +185,6 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
return null;
}
const optRec = options as Record<string, unknown>;
const maxRounds = optRec.maxRounds;
if (typeof maxRounds !== "number") {
return null;
}
const depthRaw = optRec.depth;
const depth =
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
@@ -209,7 +206,7 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
threadId,
workflowName,
prompt,
options: { maxRounds, depth },
options: { depth },
steps: parsedSteps.steps,
stepTimestamps: parsedSteps.stepTimestamps,
forkSourceThreadId,
@@ -382,6 +379,23 @@ async function main(): Promise<void> {
let activeThreads = 0;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
function cleanupAllRunningMarkersSync(): void {
for (const threadId of threads.keys()) {
try {
unlinkSync(join(storageRoot, "logs", hash, `${threadId}.running`));
} catch {
// ignore missing file or other fs errors
}
}
}
for (const sig of ["SIGINT", "SIGTERM"] as const) {
process.on(sig, () => {
cleanupAllRunningMarkersSync();
process.exit(sig === "SIGINT" ? 130 : 143);
});
}
const cas = createCasStore(getGlobalCasDir(storageRoot));
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
@@ -498,8 +512,8 @@ async function main(): Promise<void> {
const message = e instanceof Error ? e.message : String(e);
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
} finally {
threads.delete(threadId);
await unlink(runningPath).catch(() => {});
threads.delete(threadId);
bumpDone();
socket?.end();
}
@@ -1,13 +1,8 @@
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type {
ExtractContext,
ExtractFn,
ExtractResult,
LlmProvider,
} from "@uncaged/workflow-runtime";
import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
import { createCasReactor } from "../cas-reactor.js";
export type ExtractDeps = {
cas: CasStore;
@@ -15,65 +10,6 @@ export type ExtractDeps = {
const MAX_REACT_ROUNDS = 10;
const CAS_GET_TOOL_DEFINITION = {
type: "function" as const,
function: {
name: "cas_get",
description:
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
parameters: {
type: "object",
properties: {
hash: { type: "string", description: "The CAS hash to retrieve" },
},
required: ["hash"],
},
},
};
export type ExtractThreadContext = {
cas: CasStore;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
ctx: ExtractContext,
prompt: string,
deps: ExtractDeps,
): Promise<string> {
const lines: string[] = [];
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
lines.push("");
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(deps.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
lines.push(`### ${step.role}`);
lines.push(body);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
return lines.join("\n");
}
/**
* Create an ExtractFn backed by an LLM provider.
*
@@ -82,52 +18,21 @@ export async function buildExtractUserContent(
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
*/
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
const llm = createLlmFn(provider);
const reactor = createThreadReactor<ExtractThreadContext>({
llm,
const reactor = createCasReactor(provider, deps.cas, {
maxRounds: MAX_REACT_ROUNDS,
staticTools: [CAS_GET_TOOL_DEFINITION],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
toolHandler: async (call, thread) => {
if (call.function.name !== "cas_get") {
return `Unexpected tool routed to handler: ${call.function.name}`;
}
let hash: string;
try {
const ta = JSON.parse(call.function.arguments) as unknown;
if (!isRecord(ta) || typeof ta.hash !== "string") {
return 'cas_get requires a JSON object with a string "hash" field.';
}
hash = ta.hash;
} catch {
return 'cas_get arguments were not valid JSON. Provide {"hash": "<cas-hash>"}.';
}
const blob = await thread.cas.get(hash);
return blob === null ? "null" : blob;
},
`You extract structured metadata from content. The content is from a CAS node. Use cas_get to read referenced nodes if needed. When ready, call the ${structuredToolName} tool with JSON matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
});
return async <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
contentHash: string,
): Promise<ExtractResult<T>> => {
const text = await buildExtractUserContent(ctx, prompt, deps);
const payload = await getContentMerklePayload(deps.cas, contentHash);
if (payload === null) {
throw new Error(`extract: missing CAS content node for hash ${contentHash}`);
}
const text = `${payload}\n\nExtract structured metadata according to the schema.`;
const result = await reactor({
thread: { cas: deps.cas },
input: text,
@@ -138,7 +43,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
}
return {
meta: result.value,
contentPayload: ctx.agentContent,
contentPayload: payload,
refs: [],
};
};
@@ -1,8 +1,4 @@
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
} from "./extract-fn.js";
export { createExtract } from "./extract-fn.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
-2
View File
@@ -37,9 +37,7 @@ export { EMPTY_CHAIN_STATE } from "./engine/types.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
@@ -95,7 +95,6 @@ export function workflowAsAgent(
workflowName,
input,
{
maxRounds: ctx.start.meta.maxRounds,
depth: nextDepth,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
@@ -108,7 +107,7 @@ export function workflowAsAgent(
io,
logger,
);
return result.rootHash;
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return `ERROR: ${message}`;
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"hono": "^4.7.11"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260425.1",
"wrangler": "^4.20.0"
}
}
+888
View File
@@ -0,0 +1,888 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.11
version: 4.12.18
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20260425.1
version: 4.20260511.1
wrangler:
specifier: ^4.20.0
version: 4.90.0(@cloudflare/workers-types@4.20260511.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260507.1':
resolution: {integrity: sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260507.1':
resolution: {integrity: sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260507.1':
resolution: {integrity: sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260507.1':
resolution: {integrity: sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260507.1':
resolution: {integrity: sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260511.1':
resolution: {integrity: sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260507.1:
resolution: {integrity: sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260507.1:
resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.90.0:
resolution: {integrity: sha512-bmNIykl59TfCUn5xQgU7IWylSsPx3LQaPLMSAq2VQHt89CBrcj9qXQ0eYfjBCWA5XTBVgten391evt7xxtXwcA==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260507.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260507.1
'@cloudflare/workerd-darwin-64@1.20260507.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260507.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260507.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260507.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260507.1':
optional: true
'@cloudflare/workers-types@4.20260511.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.18: {}
kleur@4.1.5: {}
miniflare@4.20260507.1:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260507.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.8.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.0
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260507.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260507.1
'@cloudflare/workerd-darwin-arm64': 1.20260507.1
'@cloudflare/workerd-linux-64': 1.20260507.1
'@cloudflare/workerd-linux-arm64': 1.20260507.1
'@cloudflare/workerd-windows-64': 1.20260507.1
wrangler@4.90.0(@cloudflare/workers-types@4.20260511.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260507.1
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260507.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260511.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
+152
View File
@@ -0,0 +1,152 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
type Env = {
Bindings: {
ENDPOINTS: KVNamespace;
GATEWAY_SECRET: string;
DASHBOARD_API_KEY: string;
};
};
type EndpointRecord = {
name: string;
url: string;
agentToken: string;
registeredAt: number;
lastHeartbeat: number;
};
const TTL_SECONDS = 300; // 5 min — offline if no heartbeat
const app = new Hono<Env>();
app.use("*", cors());
function checkDashboardAuth(c: {
req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined };
env: Env["Bindings"];
}): boolean {
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
const query = c.req.query("key");
const key = bearer ?? query;
return key === c.env.DASHBOARD_API_KEY;
}
// ── Health ──────────────────────────────────────────────────────────
app.get("/healthz", (c) => c.json({ ok: true }));
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
const gateway = new Hono<Env>();
gateway.post("/register", async (c) => {
const body = await c.req.json<{
name?: string;
url?: string;
secret?: string;
agentToken?: string;
}>();
const { name, url, secret, agentToken } = body;
if (!name || !url) {
return c.json({ error: "name and url required" }, 400);
}
if (secret !== c.env.GATEWAY_SECRET) {
return c.json({ error: "unauthorized" }, 401);
}
const existing = await c.env.ENDPOINTS.get<EndpointRecord>(name, "json");
const now = Date.now();
const record: EndpointRecord = {
name,
url: url.replace(/\/+$/, ""), // strip trailing slash
agentToken: agentToken ?? existing?.agentToken ?? "",
registeredAt: existing?.registeredAt ?? now,
lastHeartbeat: now,
};
await c.env.ENDPOINTS.put(name, JSON.stringify(record), {
expirationTtl: TTL_SECONDS,
});
const status = existing ? 200 : 201;
return c.json({ registered: name }, status);
});
gateway.delete("/register/:name", async (c) => {
const auth = c.req.header("Authorization");
if (auth !== `Bearer ${c.env.GATEWAY_SECRET}`) {
return c.json({ error: "unauthorized" }, 401);
}
const name = c.req.param("name");
await c.env.ENDPOINTS.delete(name);
return c.json({ unregistered: name });
});
// endpoints requires dashboard auth
gateway.get("/endpoints", async (c) => {
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
const list = await c.env.ENDPOINTS.list();
const endpoints: Array<{ name: string; url: string; status: string; lastHeartbeat: number }> = [];
for (const key of list.keys) {
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
if (record) {
const age = Date.now() - record.lastHeartbeat;
endpoints.push({
name: record.name,
url: record.url,
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
lastHeartbeat: record.lastHeartbeat,
});
}
}
return c.json(endpoints);
});
app.route("/api/gateway", gateway);
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
app.all("/api/agents/:agent/*", async (c) => {
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
const agent = c.req.param("agent");
const record = await c.env.ENDPOINTS.get<EndpointRecord>(agent, "json");
if (!record) {
return c.json({ error: "agent not found" }, 404);
}
// Build target URL: strip /api/:agent prefix, forward the rest
const url = new URL(c.req.url);
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
headers.delete("Authorization"); // don't forward dashboard key to agent
if (record.agentToken) {
headers.set("X-Agent-Token", record.agentToken);
}
try {
const resp = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
});
// Stream response back
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
});
} catch (err) {
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
}
});
export default app;
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
name = "workflow-gateway"
main = "src/index.ts"
compatibility_date = "2025-04-01"
[[kv_namespaces]]
binding = "ENDPOINTS"
id = "88b118d1cfab4c049f9c1684848811a3"
# GATEWAY_SECRET is set via `wrangler secret put`
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "../src/moderator-table.js";
import type { ModeratorContext, ModeratorTable, StartStep } from "../src/types.js";
import { END, START } from "../src/types.js";
type TestMeta = {
planner: { plan: string };
coder: { code: string };
reviewer: { approved: boolean };
};
function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta> {
const steps = roles.map((role, i) => ({
role,
meta: {} as TestMeta[typeof role],
contentHash: `hash-${i}`,
refs: [],
timestamp: Date.now() + i,
}));
return {
threadId: "test-thread",
depth: 0,
start: {
role: START,
content: "test",
meta: {},
timestamp: Date.now(),
} as StartStep,
steps,
};
}
describe("tableToModerator", () => {
test("START -> role A (FALLBACK) returns A on first call", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition true wins over FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "always",
description: "always true",
check: () => true,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition false falls through to FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("coder");
});
test("no matching transitions returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe(END);
});
test("multi-step: A -> FALLBACK END returns END after A", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: END }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx(["planner"]))).toBe(END);
});
test("role not in table returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// coder has empty transitions array
expect(mod(makeCtx(["planner", "coder"]))).toBe(END);
});
test("condition receives ctx", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "has-steps",
description: "checks ctx.steps",
check: (ctx) => ctx.steps.length > 0,
},
role: "coder",
},
{ condition: "FALLBACK", role: "planner" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// No steps -> condition false -> FALLBACK -> planner
expect(mod(makeCtx([]))).toBe("planner");
});
});
+32
View File
@@ -0,0 +1,32 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
typescript:
specifier: ^5.8.3
version: 5.9.3
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
typescript@5.9.3: {}
zod@4.4.3: {}
@@ -3,7 +3,6 @@
export type StartNodePayload = {
name: string;
hash: string;
maxRounds: number;
depth: number;
};
+8 -1
View File
@@ -14,12 +14,15 @@ export type {
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
ProviderConfig,
ResolvedModel,
Result,
@@ -47,3 +50,7 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── Moderator Table ────────────────────────────────────────────────
export { tableToModerator } from "./moderator-table.js";
@@ -0,0 +1,22 @@
import type { Moderator, ModeratorTable, RoleMeta } from "./types.js";
import { END, START } from "./types.js";
export function tableToModerator<M extends RoleMeta>(table: ModeratorTable<M>): Moderator<M> {
return (ctx) => {
const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null;
const currentRole: string = lastStep ? lastStep.role : START;
const transitions = (table as Record<string, (typeof table)[string]>)[currentRole];
if (!transitions) {
return END;
}
for (const transition of transitions) {
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
return transition.role;
}
}
return END;
};
}
+24 -8
View File
@@ -46,7 +46,7 @@ export type RoleOutput = {
export type StartStep = {
role: typeof START;
content: string;
meta: { maxRounds: number };
meta: Record<string, never>;
timestamp: number;
};
@@ -76,10 +76,6 @@ export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> &
};
};
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
};
// ── Workflow Completion ────────────────────────────────────────────
export type WorkflowCompletion = {
@@ -128,8 +124,7 @@ export type ExtractResult<T extends Record<string, unknown>> = {
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
contentHash: string,
) => Promise<ExtractResult<T>>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
@@ -154,7 +149,6 @@ export type WorkflowFn = (
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
@@ -169,6 +163,28 @@ export type WorkflowDefinition<M extends RoleMeta> = {
moderator: Moderator<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
export type ModeratorCondition<M extends RoleMeta> = {
name: string;
description: string;
check: (ctx: ModeratorContext<M>) => boolean;
};
export type FALLBACK = "FALLBACK";
export type ModeratorTransition<M extends RoleMeta> = {
condition: ModeratorCondition<M> | FALLBACK;
role: (keyof M & string) | typeof END;
};
export type ModeratorTable<M extends RoleMeta> = Record<
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
>;
// ── Advance Outcome ────────────────────────────────────────────────
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
+36
View File
@@ -0,0 +1,36 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
devDependencies:
typescript:
specifier: ^5.8.3
version: 5.9.3
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
typescript@5.9.3: {}
zod@4.4.3: {}
+59
View File
@@ -0,0 +1,59 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
'@uncaged/workflow-util':
specifier: workspace:*
version: link:../workflow-util
devDependencies:
acorn:
specifier: ^8.14.1
version: 8.16.0
typescript:
specifier: ^5.8.3
version: 5.9.3
yaml:
specifier: ^2.7.1
version: 2.8.4
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
acorn@8.16.0: {}
typescript@5.9.3: {}
yaml@2.8.4: {}
zod@4.4.3: {}
@@ -40,7 +40,9 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (
spec === "@uncaged/workflow" ||
spec === "@uncaged/workflow-runtime" ||
spec === "@uncaged/workflow-cas"
spec === "@uncaged/workflow-protocol" ||
spec === "@uncaged/workflow-cas" ||
spec === "@uncaged/workflow-util"
) {
return true;
}
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHAAAAAAAAAAA";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
{ name: "demo", hash: bundleHash, depth: 2 },
promptHash,
);
@@ -59,7 +59,6 @@ describe("buildThreadContext", () => {
expect(ctx.depth).toBe(2);
expect(ctx.start.role).toBe(START);
expect(ctx.start.content).toBe("hello-task");
expect(ctx.start.meta.maxRounds).toBe(99);
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
expect(ctx.steps[0]?.refs).toEqual([art]);
expect(ctx.steps[1]?.refs).toEqual([]);
@@ -72,7 +71,7 @@ describe("buildThreadContext", () => {
const promptHash = await cas.put("only-prompt");
const startHash = await putStartNode(
cas,
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
promptHash,
);
@@ -80,7 +79,6 @@ describe("buildThreadContext", () => {
expect(ctx.steps).toEqual([]);
expect(ctx.start.content).toBe("only-prompt");
expect(ctx.depth).toBe(1);
expect(ctx.start.meta.maxRounds).toBe(3);
});
test("omits __end__ states from steps", async () => {
@@ -89,7 +87,7 @@ describe("buildThreadContext", () => {
const bundleHash = "BHCCCCCCCCCCC";
const startHash = await putStartNode(
cas,
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
{ name: "demo", hash: bundleHash, depth: 0 },
promptHash,
);
+29
View File
@@ -0,0 +1,29 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-cas':
specifier: workspace:*
version: link:../workflow-cas
'@uncaged/workflow-protocol':
specifier: workspace:*
version: link:../workflow-protocol
devDependencies:
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
zod@4.4.3: {}
@@ -57,7 +57,7 @@ async function threadFromStartHead<M extends RoleMeta>(
start: {
role: START,
content: prompt,
meta: { maxRounds: p.maxRounds },
meta: {},
timestamp: 0,
},
steps: [],
@@ -116,7 +116,7 @@ async function threadFromStateHead<M extends RoleMeta>(
start: {
role: START,
content: prompt,
meta: { maxRounds: sp.maxRounds },
meta: {},
timestamp: firstTs,
},
steps,
@@ -7,7 +7,6 @@ import {
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
@@ -89,15 +88,11 @@ async function advanceOneRound<M extends RoleMeta>(
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const agentContentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
const extracted = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
agentContentHash,
);
const refsFromMeta = resolveExtractedRefs(
@@ -106,11 +101,10 @@ async function advanceOneRound<M extends RoleMeta>(
);
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
const contentHash = await putContentNodeWithRefs(
runtime.cas,
extracted.contentPayload,
artifactRefs,
);
const contentHash =
artifactRefs.length === 0
? agentContentHash
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
const step = {
@@ -151,17 +145,9 @@ export function createWorkflow<M extends RoleMeta>(
if (thread.start.role !== START) {
throw new Error(`workflow loop expected start role to be ${START}`);
}
const maxRounds = thread.start.meta.maxRounds;
let currentThread = thread as ModeratorContext<M>;
while (true) {
if (currentThread.steps.length >= maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, {
thread: currentThread,
runtime,
+5 -2
View File
@@ -6,12 +6,15 @@ export type {
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
Result,
RoleDefinition,
RoleMeta,
@@ -28,4 +31,4 @@ export type {
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
export { END, START, tableToModerator } from "./types.js";
+7 -2
View File
@@ -8,12 +8,17 @@ export type {
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
ProviderConfig,
ResolvedModel,
Result,
RoleDefinition,
RoleMeta,
@@ -31,4 +36,4 @@ export type {
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
@@ -13,23 +13,20 @@ const DEFAULT_PHASES: PlannerMeta["phases"] = [
},
];
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
function makeStart(): ModeratorContext<DevelopMeta>["start"] {
return {
role: START,
content: "Implement the feature",
meta: { maxRounds },
meta: {},
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<DevelopMeta>["steps"],
): ModeratorContext<DevelopMeta> {
function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContext<DevelopMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
start: makeStart(),
steps,
};
}
@@ -90,20 +87,18 @@ function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
describe("developModerator", () => {
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
expect(developModerator(makeCtx(20, []))).toBe("planner");
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
expect(developModerator(makeCtx([]))).toBe("planner");
expect(developModerator(makeCtx([plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"tester",
);
expect(
developModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
),
developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])),
).toBe("committer");
expect(
developModerator(
makeCtx(20, [
makeCtx([
plannerStep(),
coderStep(),
reviewerStep(true),
@@ -120,16 +115,16 @@ describe("developModerator", () => {
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("reviewer rejects → END when max rounds exhausted", () => {
test("reviewer rejects → coder retry (supervisor controls termination)", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(4, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("tester failed → coder retry when budget allows", () => {
@@ -139,17 +134,17 @@ describe("developModerator", () => {
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("tester failed → END when max rounds exhausted", () => {
test("tester failed → coder retry (supervisor controls termination)", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(5, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
@@ -157,13 +152,11 @@ describe("developModerator", () => {
{ hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
"coder",
);
expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder");
expect(
developModerator(
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
),
).toBe("reviewer");
});
@@ -175,7 +168,7 @@ describe("developModerator", () => {
{ hash: "BB000003", title: "verify" },
{ hash: "BB000004", title: "polish" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe(
"reviewer",
);
});
@@ -185,12 +178,10 @@ describe("developModerator", () => {
{ hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
"coder",
);
expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder");
});
test("incomplete phases → END when max rounds exhausted", () => {
test("incomplete phases → coder retry (supervisor controls termination)", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" },
@@ -199,7 +190,7 @@ describe("developModerator", () => {
plannerStep(phases),
coderStep("DD000001"),
];
expect(developModerator(makeCtx(3, steps))).toBe(END);
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("committer → END for any committer meta status", () => {
@@ -220,9 +211,9 @@ describe("developModerator", () => {
reviewerStep(true),
testerStep(true),
];
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
expect(developModerator(makeCtx([...base, committed]))).toBe(END);
expect(developModerator(makeCtx([...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END);
});
});
@@ -0,0 +1,44 @@
/**
* develop bundle entry 🍊
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { createExtract } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`missing required env var: ${name}`);
}
return value;
}
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const provider = {
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
};
const agent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const extract = createExtract(provider);
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
export const descriptor = buildDevelopDescriptor();
export const run = wf.run;
+28
View File
@@ -0,0 +1,28 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-register':
specifier: workspace:*
version: link:../workflow-register
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
zod:
specifier: ^4.0.0
version: 4.4.3
packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
zod@4.4.3: {}
@@ -1,8 +1,15 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import {
END,
type ModeratorCondition,
type ModeratorTable,
START,
tableToModerator,
} from "@uncaged/workflow-runtime";
import type { DevelopMeta } from "./roles.js";
// ── Helpers ────────────────────────────────────────────────────────
function coderFinishedAllPlannedPhases(
phases: ReadonlyArray<{ hash: string }>,
coderCompletedPhases: ReadonlyArray<string>,
@@ -22,68 +29,63 @@ function coderFinishedAllPlannedPhases(
return false;
}
function nextAfterCoder(
ctx: ModeratorContext<DevelopMeta>,
maxRounds: number,
): (keyof DevelopMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return "reviewer";
}
const phases = plannerStep.meta.phases;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
if (allDone) {
return "reviewer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
// ── Conditions ─────────────────────────────────────────────────────
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "planner";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") {
return "coder";
}
if (last.role === "coder") {
return nextAfterCoder(ctx, maxRounds);
}
if (last.role === "reviewer") {
if (last.meta.status === "approved") {
return "tester";
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
name: "allPhasesComplete",
description: "All planned phases have been completed by the coder",
check: (ctx) => {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return true;
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
const phases = plannerStep.meta.phases;
if (!Array.isArray(phases)) {
return true;
}
return END;
}
if (last.role === "tester") {
if (last.meta.status === "passed") {
return "committer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "committer") {
return END;
}
return END;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
return coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
},
};
const reviewApproved: ModeratorCondition<DevelopMeta> = {
name: "reviewApproved",
description: "The last reviewer approved the changes",
check: (ctx) => {
const last = ctx.steps[ctx.steps.length - 1];
return last.role === "reviewer" && last.meta.status === "approved";
},
};
const testsPassed: ModeratorCondition<DevelopMeta> = {
name: "testsPassed",
description: "The last tester reported tests passed",
check: (ctx) => {
const last = ctx.steps[ctx.steps.length - 1];
return last.role === "tester" && last.meta.status === "passed";
},
};
// ── Transition Table ───────────────────────────────────────────────
const table: ModeratorTable<DevelopMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [
{ condition: allPhasesComplete, role: "reviewer" },
{ condition: "FALLBACK", role: "coder" },
],
reviewer: [
{ condition: reviewApproved, role: "tester" },
{ condition: "FALLBACK", role: "coder" },
],
tester: [
{ condition: testsPassed, role: "committer" },
{ condition: "FALLBACK", role: "coder" },
],
committer: [{ condition: "FALLBACK", role: END }],
};
export const developModerator = tableToModerator(table);
@@ -2,7 +2,11 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const coderMetaSchema = z.object({
completedPhase: z.string(),
completedPhase: z
.string()
.describe(
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
),
filesChanged: z.array(z.string()),
summary: z.string(),
});
@@ -27,8 +31,6 @@ export const coderRole: RoleDefinition<CoderMeta> = {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM,
extractPrompt:
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase],
};
@@ -28,8 +28,6 @@ Do not attempt to fix failures yourself.`;
export const committerRole: RoleDefinition<CommitterMeta> = {
description: "Creates a branch and commits changes.",
systemPrompt: COMMITTER_SYSTEM,
extractPrompt:
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
schema: committerMetaSchema,
extractRefs: null,
};
@@ -44,8 +44,6 @@ Order phases so earlier steps unblock later ones. Cover root cause, edge cases,
export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
extractPrompt:
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash),
};
@@ -37,8 +37,6 @@ Be thorough. A false approve costs more than a false reject.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: REVIEWER_SYSTEM,
extractPrompt:
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
schema: reviewerMetaSchema,
extractRefs: null,
};
@@ -19,8 +19,6 @@ const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, an
export const testerRole: RoleDefinition<TesterMeta> = {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: TESTER_SYSTEM,
extractPrompt:
"Extract the verification result: passed with summary details, or failed with details of what broke.",
schema: testerMetaSchema,
extractRefs: null,
};
@@ -98,23 +98,22 @@ function installMockToolCallCompletions(
};
}
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
meta: { maxRounds },
meta: {},
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<SolveIssueMeta>["steps"],
): ModeratorContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
start: makeStart(),
steps,
};
}
@@ -182,7 +181,7 @@ function makeThread(prompt: string) {
start: {
role: START,
content: prompt,
meta: { maxRounds: 20 },
meta: {},
timestamp: Date.now(),
},
steps: [],
@@ -191,12 +190,12 @@ function makeThread(prompt: string) {
describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({
@@ -211,7 +210,7 @@ describe("solveIssueModerator", () => {
test("submitter failed → END", () => {
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "failed", error: "gh not authenticated" }),
@@ -225,7 +224,7 @@ describe("solveIssueModerator", () => {
// routed to END, since the moderator is a closed switch over known roles.
expect(
solveIssueModerator(
makeCtx(20, [
makeCtx([
preparerStep(),
developerStep(),
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
+35
View File
@@ -0,0 +1,35 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@uncaged/workflow-register':
specifier: workspace:*
version: link:../workflow-register
'@uncaged/workflow-runtime':
specifier: workspace:*
version: link:../workflow-runtime
zod:
specifier: ^4.0.0
version: 4.4.3
devDependencies:
'@uncaged/workflow-cas':
specifier: workspace:*
version: link:../workflow-cas
'@uncaged/workflow-execute':
specifier: workspace:*
version: link:../workflow-execute
packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
zod@4.4.3: {}
@@ -16,21 +16,10 @@ The actual implementation (planning → coding → reviewing → testing → com
Pass through the task and let the child workflow do the work.`;
const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary.
Procedure:
1. cas_get(<rootHash>) the root node lists all child step hashes (planner, coder, reviewer, tester, committer).
2. Find the committer step. cas_get its hash to read the committer's meta extract branch and commitSha from there.
3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps.
4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them).
Return: { branch, commitSha, filesChanged, summary }.`;
export const developerRole: RoleDefinition<DeveloperMeta> = {
description:
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
systemPrompt: DEVELOPER_SYSTEM,
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
schema: developerMetaSchema,
extractRefs: () => [],
};
@@ -1,26 +1,12 @@
import type { Moderator } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
import type { SolveIssueMeta } from "./roles.js";
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
if (ctx.steps.length === 0) {
return "preparer";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "preparer") {
return "developer";
}
if (last.role === "developer") {
return "submitter";
}
if (last.role === "submitter") {
return END;
}
return END;
const table: ModeratorTable<SolveIssueMeta> = {
[START]: [{ condition: "FALLBACK", role: "preparer" }],
preparer: [{ condition: "FALLBACK", role: "developer" }],
developer: [{ condition: "FALLBACK", role: "submitter" }],
submitter: [{ condition: "FALLBACK", role: END }],
};
export const solveIssueModerator = tableToModerator(table);
@@ -44,8 +44,6 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
description:
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: PREPARER_SYSTEM,
extractPrompt:
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema,
extractRefs: null,
};
@@ -31,13 +31,9 @@ Read the thread for context:
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry surface the error so the moderator can decide.`;
const SUBMITTER_EXTRACT_PROMPT =
"Extract the submission result. status='submitted' with prUrl on success, or status='failed' with a short error message on failure.";
export const submitterRole: RoleDefinition<SubmitterMeta> = {
description: "Pushes the developer's branch to the remote and opens a pull request.",
systemPrompt: SUBMITTER_SYSTEM,
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
schema: submitterMetaSchema,
extractRefs: null,
};
@@ -7,7 +7,7 @@ function startTask(content: string): AgentContext["start"] {
return {
role: START,
content,
meta: { maxRounds: 5 },
meta: {},
timestamp: 1,
};
}

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