From f7253d594815d1d49e00197b3ade1b4b9deed088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 9 May 2026 09:48:49 +0000 Subject: [PATCH 1/3] feat: CF Worker API gateway with KV endpoint registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/workflow-gateway/package.json | 17 ++++ packages/workflow-gateway/src/index.ts | 125 ++++++++++++++++++++++++ packages/workflow-gateway/tsconfig.json | 12 +++ packages/workflow-gateway/wrangler.toml | 9 ++ 4 files changed, 163 insertions(+) create mode 100644 packages/workflow-gateway/package.json create mode 100644 packages/workflow-gateway/src/index.ts create mode 100644 packages/workflow-gateway/tsconfig.json create mode 100644 packages/workflow-gateway/wrangler.toml diff --git a/packages/workflow-gateway/package.json b/packages/workflow-gateway/package.json new file mode 100644 index 0000000..a5defe6 --- /dev/null +++ b/packages/workflow-gateway/package.json @@ -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" + } +} diff --git a/packages/workflow-gateway/src/index.ts b/packages/workflow-gateway/src/index.ts new file mode 100644 index 0000000..e1942bc --- /dev/null +++ b/packages/workflow-gateway/src/index.ts @@ -0,0 +1,125 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +type Env = { + Bindings: { + ENDPOINTS: KVNamespace; + GATEWAY_SECRET: string; + }; +}; + +type EndpointRecord = { + name: string; + url: string; + registeredAt: number; + lastHeartbeat: number; +}; + +const TTL_SECONDS = 300; // 5 min — offline if no heartbeat + +const app = new Hono(); + +app.use("*", cors()); + +// ── Health ────────────────────────────────────────────────────────── +app.get("/healthz", (c) => c.json({ ok: true })); + +// ── Register / heartbeat ──────────────────────────────────────────── +app.post("/register", async (c) => { + const body = await c.req.json<{ name?: string; url?: string; secret?: string }>(); + const { name, url, secret } = 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(name, "json"); + const now = Date.now(); + + const record: EndpointRecord = { + name, + url: url.replace(/\/+$/, ""), // strip trailing slash + 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); +}); + +// ── Unregister ────────────────────────────────────────────────────── +app.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 }); +}); + +// ── List endpoints ────────────────────────────────────────────────── +app.get("/endpoints", async (c) => { + 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(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); +}); + +// ── API proxy: /api/:agent/* → agent's tunnel URL ─────────────────── +app.all("/api/:agent/*", async (c) => { + const agent = c.req.param("agent"); + const record = await c.env.ENDPOINTS.get(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/${agent}`, ""); + const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`; + + // Forward headers (skip host) + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + + 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; diff --git a/packages/workflow-gateway/tsconfig.json b/packages/workflow-gateway/tsconfig.json new file mode 100644 index 0000000..4dc8382 --- /dev/null +++ b/packages/workflow-gateway/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/workflow-gateway/wrangler.toml b/packages/workflow-gateway/wrangler.toml new file mode 100644 index 0000000..9688a1d --- /dev/null +++ b/packages/workflow-gateway/wrangler.toml @@ -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` -- 2.43.0 From fd8943f1316e07c69b75285c5aae826182ae5bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 9 May 2026 09:53:08 +0000 Subject: [PATCH 2/3] feat: serve auto-tunnel + gateway registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of #164: - serve --name 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) --- .../cli-workflow/src/commands/serve/serve.ts | 108 ++++++++++++++++-- .../cli-workflow/src/commands/serve/tunnel.ts | 86 ++++++++++++++ .../cli-workflow/src/commands/serve/types.ts | 4 + 3 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 packages/cli-workflow/src/commands/serve/tunnel.ts diff --git a/packages/cli-workflow/src/commands/serve/serve.ts b/packages/cli-workflow/src/commands/serve/serve.ts index 1d894fd..214aab0 100644 --- a/packages/cli-workflow/src/commands/serve/serve.ts +++ b/packages/cli-workflow/src/commands/serve/serve.ts @@ -1,10 +1,20 @@ +import { hostname as osHostname } from "node:os"; import { err, ok, type Result } from "@uncaged/workflow-protocol"; import { serve } from "bun"; import { printCliLine } from "../../cli-output.js"; import { createApp } from "./app.js"; +import { + registerWithGateway, + startHeartbeat, + startTunnel, + unregisterFromGateway, +} from "./tunnel.js"; import type { ServeOptions } from "./types.js"; +const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev"; +const HEARTBEAT_INTERVAL_MS = 60_000; + export function startServer(storageRoot: string, options: ServeOptions): void { const app = createApp(storageRoot); @@ -28,30 +38,51 @@ function parsePortValue(value: string | undefined): Result { return ok(parsed); } +function requireNextArg(argv: string[], i: number, flag: string): Result { + const next = argv[i + 1]; + if (next === undefined) { + return err(`${flag} requires a value`); + } + return ok(next); +} + function parseServeArgv(argv: string[]): Result { 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 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 { @@ -61,7 +92,62 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis return 1; } - startServer(storageRoot, parsed.value); + const options = parsed.value; + startServer(storageRoot, options); + + 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, + ); + if (registered) { + printCliLine(`registered with gateway as "${options.name}"`); + } + + // Start heartbeat + const heartbeatTimer = startHeartbeat( + options.gatewayUrl, + options.name, + tunnel.url, + options.gatewaySecret, + 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(() => {}); diff --git a/packages/cli-workflow/src/commands/serve/tunnel.ts b/packages/cli-workflow/src/commands/serve/tunnel.ts new file mode 100644 index 0000000..dd253df --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/tunnel.ts @@ -0,0 +1,86 @@ +import { printCliLine } from "../../cli-output.js"; + +type TunnelHandle = { + process: ReturnType; + url: string; +}; + +export async function startTunnel(port: number): Promise { + 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, +): Promise { + try { + const resp = await fetch(`${gatewayUrl}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, url: tunnelUrl, secret }), + }); + 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 { + try { + await fetch(`${gatewayUrl}/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, + intervalMs: number, +): ReturnType { + return setInterval(() => { + registerWithGateway(gatewayUrl, name, tunnelUrl, secret).catch(() => {}); + }, intervalMs); +} diff --git a/packages/cli-workflow/src/commands/serve/types.ts b/packages/cli-workflow/src/commands/serve/types.ts index ad8ef10..541269c 100644 --- a/packages/cli-workflow/src/commands/serve/types.ts +++ b/packages/cli-workflow/src/commands/serve/types.ts @@ -1,4 +1,8 @@ export type ServeOptions = { port: number; hostname: string; + name: string; + noTunnel: boolean; + gatewayUrl: string; + gatewaySecret: string; }; -- 2.43.0 From 9e98119145fa0600575f79cfa13e2915706b30ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 9 May 2026 10:01:27 +0000 Subject: [PATCH 3/3] feat: dashboard multi-agent support + CF Pages deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/workflow-dashboard/src/api.ts | 71 +++++++++++++------ packages/workflow-dashboard/src/app.tsx | 26 ++++--- .../src/components/run-dialog.tsx | 9 +-- .../src/components/sidebar.tsx | 65 ++++++++++++++++- .../src/components/status-bar.tsx | 26 +++++-- .../src/components/thread-detail.tsx | 9 +-- .../src/components/thread-list.tsx | 5 +- .../src/components/workflow-list.tsx | 8 ++- .../workflow-dashboard/src/use-hash-route.ts | 57 ++++++++++----- packages/workflow-dashboard/src/use-sse.ts | 18 +++-- 10 files changed, 221 insertions(+), 73 deletions(-) diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts index 4afe307..4310989 100644 --- a/packages/workflow-dashboard/src/api.ts +++ b/packages/workflow-dashboard/src/api.ts @@ -1,7 +1,15 @@ -const BASE = "/api"; +const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || ""; -async function postJson(path: string, body: unknown): Promise { - const res = await fetch(`${BASE}${path}`, { +function agentBase(agent: string): string { + if (GATEWAY_URL) { + return `${GATEWAY_URL}/api/${agent}`; + } + // Local dev: proxy via vite, no agent prefix + return "/api"; +} + +async function postJson(base: string, path: string, body: unknown): Promise { + const res = await fetch(`${base}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -13,14 +21,23 @@ async function postJson(path: string, body: unknown): Promise { return res.json() as Promise; } -async function fetchJson(path: string): Promise { - const res = await fetch(`${BASE}${path}`); +async function fetchJson(base: string, path: string): Promise { + const res = await fetch(`${base}${path}`); if (!res.ok) { throw new Error(`API ${res.status}: ${path}`); } return res.json() as Promise; } +// ── Endpoint types ────────────────────────────────────────────────── + +export type AgentEndpoint = { + name: string; + url: string; + status: string; + lastHeartbeat: number; +}; + export type WorkflowSummary = { name: string; currentHash: string; @@ -43,42 +60,52 @@ export type ThreadRecord = { [key: string]: unknown; }; -export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> { - return fetchJson("/workflows"); +// ── Gateway endpoints ─────────────────────────────────────────────── + +export function listAgents(): Promise { + const url = GATEWAY_URL || ""; + return fetchJson(url, "/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, maxRounds }); } -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"); } diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index 5ee4013..77ad746 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -8,24 +8,34 @@ import { WorkflowList } from "./components/workflow-list.tsx"; import { useHashRoute } from "./use-hash-route.ts"; export function App() { - const { view, threadId, setView, setThreadId } = useHashRoute(); + const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute(); const [showRun, setShowRun] = useState(false); return (
- +
- setShowRun(true)} /> + setShowRun(true)} />
- {view === "threads" && threadId === null && } - {view === "threads" && threadId !== null && ( - setThreadId(null)} /> + {!agent && ( +
+

+ Select an agent from the sidebar to get started. +

+
)} - {view === "workflows" && } + {agent && view === "threads" && threadId === null && ( + + )} + {agent && view === "threads" && threadId !== null && ( + setThreadId(null)} /> + )} + {agent && view === "workflows" && }
- {showRun && ( + {showRun && agent && ( setShowRun(false)} onCreated={(id) => { setShowRun(false); diff --git a/packages/workflow-dashboard/src/components/run-dialog.tsx b/packages/workflow-dashboard/src/components/run-dialog.tsx index 84a79d5..9a53dc3 100644 --- a/packages/workflow-dashboard/src/components/run-dialog.tsx +++ b/packages/workflow-dashboard/src/components/run-dialog.tsx @@ -3,12 +3,13 @@ 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); @@ -21,7 +22,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, maxRounds); onCreated(result.threadId); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -38,7 +39,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)" }} > -

Run Thread

+

Run Thread on {agent}

+ + {/* Agent selector */} +
+ + {expanded && ( +
+ {agents.length === 0 && ( +

+ {status === "loading" ? "Loading..." : "No agents online"} +

+ )} + {agents.map((a) => ( + + ))} +
+ )} +
+ + {/* View navigation */}