Compare commits

...

14 Commits

Author SHA1 Message Date
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

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

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

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

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

Testing: #211
2026-05-13 11:05:03 +08:00
25 changed files with 857 additions and 159 deletions
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
@@ -1,17 +1,14 @@
import { randomUUID } from "node:crypto";
import { hostname as osHostname } from "node:os";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { createLogger } from "@uncaged/workflow-util";
import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import {
registerWithGateway,
startHeartbeat,
startTunnel,
unregisterFromGateway,
} from "./tunnel.js";
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
import type { ServeOptions } from "./types.js";
import { startGatewayWsClient } from "./ws-client.js";
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
const HEARTBEAT_INTERVAL_MS = 60_000;
@@ -56,6 +53,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let hostname = "127.0.0.1";
let name = osHostname().split(".")[0].toLowerCase();
let noTunnel = false;
let tunnelUrl: string | null = null;
let gatewayUrl = DEFAULT_GATEWAY_URL;
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
const stringFlags: Record<string, (v: string) => void> = {
@@ -68,6 +66,9 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
"--gateway": (v) => {
gatewayUrl = v;
},
"--tunnel-url": (v) => {
tunnelUrl = v;
},
};
for (let i = 0; i < argv.length; i++) {
@@ -87,7 +88,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
}
}
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
@@ -107,47 +108,64 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
return 0;
}
// Start cloudflared quick tunnel
printCliLine("starting cloudflared quick tunnel...");
const tunnel = await startTunnel(options.port);
let resolvedTunnelUrl: string;
let stopWsClient: (() => void) | null = null;
if (!tunnel) {
printCliLine("failed to create tunnel — continuing without gateway registration");
await new Promise(() => {});
return 0;
if (options.tunnelUrl !== null) {
resolvedTunnelUrl = options.tunnelUrl;
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
} else {
if (options.gatewaySecret === "") {
printCliLine(
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
);
await new Promise(() => {});
return 0;
}
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
const log = createLogger({ sink: { kind: "stderr" } });
stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
localPort: options.port,
log,
});
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
}
printCliLine(`tunnel: ${tunnel.url}`);
// Register with gateway
if (options.gatewaySecret) {
if (agentToken === null) {
printCliLine("internal error: agent token missing");
await new Promise(() => {});
return 1;
}
const token = agentToken;
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
tunnel.url,
resolvedTunnelUrl,
options.gatewaySecret,
agentToken!,
token,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
// Start heartbeat
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
tunnel.url,
resolvedTunnelUrl,
options.gatewaySecret,
agentToken!,
token,
HEARTBEAT_INTERVAL_MS,
);
// Cleanup on exit
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient?.();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
tunnel.process.kill();
process.exit(0);
};
@@ -157,7 +175,6 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
}
// Keep process alive
await new Promise(() => {});
return 0;
}
@@ -3,6 +3,7 @@ export type ServeOptions = {
hostname: string;
name: string;
noTunnel: boolean;
tunnelUrl: string | null;
gatewayUrl: string;
gatewaySecret: string;
};
@@ -0,0 +1,165 @@
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
import type { LogFn } from "@uncaged/workflow-util";
export type GatewayWsClientParams = {
gatewayUrl: string;
name: string;
secret: string;
localPort: number;
log: LogFn;
};
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
const u = new URL(gatewayUrl);
if (u.protocol === "https:") {
u.protocol = "wss:";
} else if (u.protocol === "http:") {
u.protocol = "ws:";
}
u.pathname = "/ws/connect";
u.search = "";
u.searchParams.set("name", name);
u.searchParams.set("secret", secret);
return u.href;
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of h) {
out[k] = v;
}
return out;
}
async function handleGatewayMessage(
ws: WebSocket,
raw: string,
params: GatewayWsClientParams,
): Promise<void> {
const req = parseWsRequestJson(raw);
if (req === null) {
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
return;
}
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
const initHeaders = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
initHeaders.set(k, v);
}
let resp: Response;
try {
resp = await fetch(localUrl, {
method: req.method,
headers: initHeaders,
body: req.body === null ? undefined : req.body,
});
} catch (e) {
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
const errBody: WsResponse = {
id: req.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
};
ws.send(JSON.stringify(errBody));
return;
}
const bodyText = await resp.text();
const headerRecord = headersToRecord(resp.headers);
const out: WsResponse = {
id: req.id,
status: resp.status,
headers: headerRecord,
body: bodyText,
};
ws.send(JSON.stringify(out));
}
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let attempt = 0;
const clearReconnectTimer = (): void => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const scheduleReconnect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
attempt++;
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
reconnectTimer = setTimeout(connect, delayMs);
};
const connect = (): void => {
if (stopped) {
return;
}
clearReconnectTimer();
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
try {
socket = new WebSocket(wsUrl);
} catch (e) {
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
scheduleReconnect();
return;
}
const ws = socket;
ws.addEventListener("open", () => {
attempt = 0;
params.log("4PWN3V82", "gateway WebSocket connected");
});
ws.addEventListener("close", (ev) => {
socket = null;
params.log(
"8QTR6ZKC",
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
);
if (!stopped) {
scheduleReconnect();
}
});
ws.addEventListener("error", () => {
params.log("9BWS1M7F", "gateway WebSocket error");
});
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
return;
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
});
});
};
connect();
return (): void => {
stopped = true;
clearReconnectTimer();
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.close(1000, "shutdown");
}
socket = null;
};
}
+86 -16
View File
@@ -183,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -230,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -246,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### Bundle import restrictions
The bundle validator only allows these import specifiers:
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
- \`@uncaged/workflow-*\` packages
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"scripts": {
"test": "bun test"
@@ -26,53 +26,6 @@ function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
return null;
}
type GraphPanelProps = {
descriptor: WorkflowDescriptor;
workflowName: string | null;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: GraphPanelProps) {
const [open, setOpen] = useState(true);
const edgeCount = descriptor.graph.edges.length;
return (
<div
className="mb-4 rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
{open ? "▼" : "▶"} Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</button>
{open && (
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={onNodeClick}
/>
</div>
)}
</div>
);
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>();
const roleRecords = records.filter(
@@ -227,46 +180,85 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p>
)}
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<GraphPanel
descriptor={descriptor}
workflowName={workflowName}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
)}
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
)}
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
const isFirstForRole = firstIndexByRole.get(r.role) === i;
const flash = highlightedRole === r.role;
return (
<div
key={key}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return <RecordCard key={key} record={r} highlighted={false} />;
})}
<div ref={recordsEndRef} aria-hidden />
<div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
const isFirstForRole = firstIndexByRole.get(r.role) === i;
const flash = highlightedRole === r.role;
return (
<div
key={key}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return <RecordCard key={key} record={r} highlighted={false} />;
})}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+5 -2
View File
@@ -1,8 +1,11 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.1.0",
"private": true,
"version": "0.3.11",
"type": "module",
"exports": {
".": "./src/index.ts",
"./ws-protocol": "./src/ws-protocol.ts"
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
@@ -0,0 +1,162 @@
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
import { DurableObject } from "cloudflare:workers";
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
type AgentSocketEnv = {
GATEWAY_SECRET: string;
};
export const AGENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/agent-socket/status";
export const AGENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/agent-socket/proxy";
const PROXY_TIMEOUT_MS = 30_000;
type PendingEntry = {
resolve: (r: Response) => void;
timer: ReturnType<typeof setTimeout>;
};
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function wsResponseToHttp(wr: WsResponse): Response {
const headers = new Headers();
for (const [k, v] of Object.entries(wr.headers)) {
headers.set(k, v);
}
return new Response(wr.body, { status: wr.status, headers });
}
export class AgentSocket extends DurableObject<AgentSocketEnv> {
private readonly pending = new Map<string, PendingEntry>();
private requireAuth(request: Request): Response | null {
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
return jsonResponse(401, { error: "unauthorized" });
}
return null;
}
private handleStatusGet(request: Request): Response {
const denied = this.requireAuth(request);
if (denied !== null) {
return denied;
}
const sockets = this.ctx.getWebSockets();
const connected = sockets.length > 0;
return new Response(JSON.stringify({ connected, connectedCount: sockets.length }), {
headers: { "Content-Type": "application/json" },
});
}
private async handleProxyPost(request: Request): Promise<Response> {
const denied = this.requireAuth(request);
if (denied !== null) {
return denied;
}
const raw = await request.text();
const wsRequest = parseWsRequestJson(raw);
if (wsRequest === null) {
return jsonResponse(400, { error: "invalid proxy body" });
}
const sockets = this.ctx.getWebSockets();
const ws = sockets[0];
if (ws === undefined) {
return jsonResponse(503, { error: "no active websocket" });
}
return await new Promise<Response>((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(wsRequest.id);
resolve(jsonResponse(504, { error: "gateway timeout" }));
}, PROXY_TIMEOUT_MS);
this.pending.set(wsRequest.id, {
resolve: (r: Response) => {
clearTimeout(timer);
this.pending.delete(wsRequest.id);
resolve(r);
},
timer,
});
try {
ws.send(JSON.stringify(wsRequest));
} catch {
clearTimeout(timer);
this.pending.delete(wsRequest.id);
resolve(jsonResponse(502, { error: "websocket send failed" }));
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
return this.handleStatusGet(request);
}
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
return this.handleProxyPost(request);
}
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("expected WebSocket upgrade", { status: 426 });
}
for (const ws of this.ctx.getWebSockets()) {
ws.close(1000, "replaced by new connection");
}
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
const wr = parseWsResponseJson(text);
if (wr === null) {
return;
}
const entry = this.pending.get(wr.id);
if (entry === undefined) {
return;
}
clearTimeout(entry.timer);
this.pending.delete(wr.id);
entry.resolve(wsResponseToHttp(wr));
}
async webSocketClose(
_ws: WebSocket,
_code: number,
_reason: string,
_wasClean: boolean,
): Promise<void> {
this.rejectAllPending("agent websocket closed");
}
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
this.rejectAllPending("agent websocket error");
}
private rejectAllPending(message: string): void {
const entries = [...this.pending.values()];
this.pending.clear();
for (const entry of entries) {
clearTimeout(entry.timer);
entry.resolve(jsonResponse(502, { error: message }));
}
}
}
+203 -16
View File
@@ -1,11 +1,21 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import {
AGENT_SOCKET_INTERNAL_PROXY_PATH,
AGENT_SOCKET_INTERNAL_STATUS_PATH,
AgentSocket,
} from "./agent-socket.js";
import type { WsRequest } from "./ws-protocol.js";
export { AgentSocket };
type Env = {
Bindings: {
ENDPOINTS: KVNamespace;
GATEWAY_SECRET: string;
DASHBOARD_API_KEY: string;
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
};
};
@@ -33,9 +43,165 @@ function checkDashboardAuth(c: {
return key === c.env.DASHBOARD_API_KEY;
}
function isLocalAgentUrl(url: string): boolean {
try {
const u = new URL(url);
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
} catch {
return false;
}
}
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of raw) {
const lower = key.toLowerCase();
if (lower === "host" || lower === "authorization") {
continue;
}
if (
lower === "connection" ||
lower === "keep-alive" ||
lower === "proxy-connection" ||
lower === "transfer-encoding" ||
lower === "upgrade"
) {
continue;
}
out[key] = value;
}
if (agentToken !== "") {
out["X-Agent-Token"] = agentToken;
}
return out;
}
function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
const headers = new Headers(raw);
headers.delete("host");
headers.delete("Authorization");
if (token !== "") {
headers.set("X-Agent-Token", token);
}
return headers;
}
async function readBodyForWsProxy(method: string, req: Request): Promise<string | null> {
if (method === "GET" || method === "HEAD") {
return null;
}
const buf = await req.arrayBuffer();
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
}
async function fetchThroughAgentSocket(
bindings: Env["Bindings"],
agent: string,
gateSecret: string,
wsRequest: WsRequest,
): Promise<Response> {
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
return stub.fetch(
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
method: "POST",
headers: {
Authorization: `Bearer ${gateSecret}`,
"Content-Type": "application/json",
},
body: JSON.stringify(wsRequest),
}),
);
}
async function fetchAgentWithRecordHeaders(
targetUrl: string,
method: string,
forwardRecord: Record<string, string>,
bodyStr: string | null,
): Promise<Response> {
const headers = new Headers();
for (const [k, v] of Object.entries(forwardRecord)) {
headers.set(k, v);
}
return fetch(targetUrl, {
method,
headers,
body: method !== "GET" && method !== "HEAD" ? (bodyStr ?? undefined) : undefined,
});
}
async function fetchAgentWithDashboardHeaders(
targetUrl: string,
method: string,
headers: Headers,
rawBody: BodyInit | null | undefined,
): Promise<Response> {
return fetch(targetUrl, {
method,
headers,
body: method !== "GET" && method !== "HEAD" ? rawBody : undefined,
});
}
async function fetchAgentSocketStatus(
env: Env["Bindings"],
name: string,
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
try {
const id = env.AGENT_SOCKET.idFromName(name);
const stub = env.AGENT_SOCKET.get(id);
const resp = await stub.fetch(
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
method: "GET",
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
}),
);
if (!resp.ok) {
return { ok: false };
}
const body = (await resp.json()) as { connected: boolean };
return { ok: true, connected: body.connected };
} catch {
return { ok: false };
}
}
function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean | null): string {
if (doConnected === true) {
return "online";
}
if (doConnected === false) {
if (isLocalAgentUrl(record.url)) {
return "offline";
}
const age = Date.now() - record.lastHeartbeat;
return age < TTL_SECONDS * 1000 ? "online" : "offline";
}
const age = Date.now() - record.lastHeartbeat;
return age < TTL_SECONDS * 1000 ? "online" : "offline";
}
// ── Health ──────────────────────────────────────────────────────────
app.get("/healthz", (c) => c.json({ ok: true }));
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
app.get("/ws/connect", async (c) => {
const secret = c.req.query("secret");
const name = c.req.query("name");
if (name === undefined || name === "") {
return c.json({ error: "name required" }, 400);
}
if (secret !== c.env.GATEWAY_SECRET) {
return c.json({ error: "unauthorized" }, 401);
}
if (c.req.header("Upgrade") !== "websocket") {
return c.text("expected WebSocket upgrade", 426);
}
const id = c.env.AGENT_SOCKET.idFromName(name);
const stub = c.env.AGENT_SOCKET.get(id);
return stub.fetch(c.req.raw);
});
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
const gateway = new Hono<Env>();
@@ -95,11 +261,12 @@ gateway.get("/endpoints", async (c) => {
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;
const doStatus = await fetchAgentSocketStatus(c.env, record.name);
const doConnected = doStatus.ok ? doStatus.connected : null;
endpoints.push({
name: record.name,
url: record.url,
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
status: endpointStatusFromKvAndDo(record, doConnected),
lastHeartbeat: record.lastHeartbeat,
});
}
@@ -110,7 +277,7 @@ gateway.get("/endpoints", async (c) => {
app.route("/api/gateway", gateway);
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent 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");
@@ -120,26 +287,45 @@ app.all("/api/agents/:agent/*", async (c) => {
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 proxyPath = `/api${pathAfterAgent}${url.search}`;
const method = c.req.method;
const token = record.agentToken ?? "";
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
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);
const doStatus = await fetchAgentSocketStatus(c.env, agent);
if (doStatus.ok && doStatus.connected) {
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
const wsRequest: WsRequest = {
id: crypto.randomUUID(),
method,
path: proxyPath,
headers: forwardRecord,
body: bodyStr,
};
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
if (proxyResp.status !== 503) {
return new Response(proxyResp.body, {
status: proxyResp.status,
headers: proxyResp.headers,
});
}
try {
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
});
} catch (err) {
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
}
}
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
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
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
@@ -149,4 +335,5 @@ app.all("/api/agents/:agent/*", async (c) => {
}
});
// biome-ignore lint/style/noDefaultExport: Cloudflare Workers entry expects default export
export default app;
@@ -0,0 +1,93 @@
/** Wire format for HTTP-over-WebSocket proxy between gateway Durable Object and local serve. */
export type WsRequest = {
id: string;
method: string;
path: string;
headers: Record<string, string>;
body: string | null;
};
export type WsResponse = {
id: string;
status: number;
headers: Record<string, string>;
body: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/** Parse and validate a JSON payload as {@link WsRequest}. */
export function parseWsRequestJson(raw: string): WsRequest | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
const id = parsed.id;
const method = parsed.method;
const path = parsed.path;
const headers = parsed.headers;
const body = parsed.body;
if (!isNonEmptyString(id) || !isNonEmptyString(method) || !isNonEmptyString(path)) {
return null;
}
if (!isRecord(headers)) {
return null;
}
const headerRecord: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v !== "string") {
return null;
}
headerRecord[k] = v;
}
if (body !== null && typeof body !== "string") {
return null;
}
return { id, method, path, headers: headerRecord, body: body === null ? null : body };
}
/** Parse and validate a JSON payload as {@link WsResponse}. */
export function parseWsResponseJson(raw: string): WsResponse | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (!isRecord(parsed)) {
return null;
}
const id = parsed.id;
const status = parsed.status;
const headers = parsed.headers;
const respBody = parsed.body;
if (!isNonEmptyString(id) || typeof status !== "number" || !Number.isFinite(status)) {
return null;
}
if (!isRecord(headers)) {
return null;
}
const headerRecord: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (typeof v !== "string") {
return null;
}
headerRecord[k] = v;
}
if (typeof respBody !== "string") {
return null;
}
return { id, status: Math.trunc(status), headers: headerRecord, body: respBody };
}
+7
View File
@@ -6,4 +6,11 @@ compatibility_date = "2025-04-01"
binding = "ENDPOINTS"
id = "88b118d1cfab4c049f9c1684848811a3"
[durable_objects]
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
[[migrations]]
tag = "add-agent-socket"
new_sqlite_classes = ["AgentSocket"]
# GATEWAY_SECRET is set via `wrangler secret put`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util",
"version": "0.3.5",
"version": "0.3.11",
"type": "module",
"exports": {
".": {