Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 942ff4b1a4 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 90a388f5ab |
@@ -2,16 +2,10 @@
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
[
|
||||
"@uncaged/*"
|
||||
]
|
||||
],
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@uncaged/workflow-dashboard"
|
||||
]
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
|
||||
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
|
||||
import {
|
||||
buildDevelopDescriptor,
|
||||
developWorkflowDefinition,
|
||||
} from "./packages/workflow-template-develop/src/index.js";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
import { createApp } from "../src/commands/connect/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
const app = createApp(storageRoot, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
@@ -180,6 +180,9 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||
expect(shown.ok).toBe(true);
|
||||
if (!shown.ok) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
@@ -71,7 +71,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -59,12 +59,12 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
||||
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
||||
if (agentToken !== null) {
|
||||
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||
if (clientToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Agent-Token");
|
||||
if (token !== agentToken) {
|
||||
const token = c.req.header("X-Client-Token");
|
||||
if (token !== clientToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||
import type { ConnectOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return { ok: false, error: `${flag} requires a value` };
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ name, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseConnectArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_GATEWAY_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const clientToken = randomUUID();
|
||||
const app = createApp(storageRoot, clientToken);
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
const stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
appFetch: app.fetch,
|
||||
log,
|
||||
});
|
||||
|
||||
printCliLine("connected to gateway via WebSocket");
|
||||
|
||||
// Register with gateway for discovery
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
+6
-40
@@ -1,51 +1,17 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
type TunnelHandle = {
|
||||
process: ReturnType<typeof Bun.spawn>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// cloudflared prints the URL to stderr
|
||||
const reader = proc.stderr.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const deadline = Date.now() + 30_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match) {
|
||||
// Release the reader so stderr keeps flowing without backpressure
|
||||
reader.releaseLock();
|
||||
return { process: proc, url: match[0] };
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
proc.kill();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
||||
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
clientToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
||||
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchConnect } from "./connect.js";
|
||||
export type { ConnectOptions } from "./types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConnectOptions = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
+7
-10
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
localPort: number;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
@@ -44,20 +44,17 @@ async function handleGatewayMessage(
|
||||
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);
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(localUrl, {
|
||||
resp = await params.appFetch(new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers: initHeaders,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
});
|
||||
}));
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -1,180 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
export function startServer(
|
||||
storageRoot: string,
|
||||
options: ServeOptions,
|
||||
agentToken: string | null,
|
||||
): void {
|
||||
const app = createApp(storageRoot, agentToken);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: options.port,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
|
||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
|
||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
if (value === undefined) {
|
||||
return err("--port requires a value");
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||
return err(`invalid port: ${value}`);
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let noTunnel = false;
|
||||
let tunnelUrl: string | null = null;
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--host": (v) => {
|
||||
hostname = v;
|
||||
},
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
"--tunnel-url": (v) => {
|
||||
tunnelUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port" || arg === "-p") {
|
||||
const portResult = parsePortValue(argv[i + 1]);
|
||||
if (!portResult.ok) return portResult;
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} else if (arg === "--no-tunnel") {
|
||||
noTunnel = true;
|
||||
} else if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
const agentToken = options.noTunnel ? null : randomUUID();
|
||||
startServer(storageRoot, options, agentToken);
|
||||
|
||||
if (options.noTunnel) {
|
||||
printCliLine("tunnel disabled (--no-tunnel)");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
let resolvedTunnelUrl: string;
|
||||
let stopWsClient: (() => void) | null = null;
|
||||
|
||||
if (options.tunnelUrl !== null) {
|
||||
resolvedTunnelUrl = options.tunnelUrl;
|
||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
||||
} else {
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine(
|
||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
||||
);
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
localPort: options.port,
|
||||
log,
|
||||
});
|
||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
||||
}
|
||||
|
||||
if (options.gatewaySecret) {
|
||||
if (agentToken === null) {
|
||||
printCliLine("internal error: agent token missing");
|
||||
await new Promise(() => {});
|
||||
return 1;
|
||||
}
|
||||
const token = agentToken;
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
token,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
token,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient?.();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
} else {
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
tunnelUrl: string | null;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
if (resolved.source === "active") {
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
} else {
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
// Always clear both stores: between resolve and delete the worker may finish and
|
||||
// move the thread from threads.json into history; branching only on resolved.source
|
||||
// would skip history removal and leave a dangling row.
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
|
||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||
|
||||
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
### connect
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config with explicit workspace", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
const baseConfig = {
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null as string | null,
|
||||
timeout: 0,
|
||||
workspace: null as string | null,
|
||||
};
|
||||
|
||||
test("accepts valid config with null workspace and llmProvider", () => {
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
...baseConfig,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -38,87 +27,38 @@ describe("validateCursorAgentConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects empty workspace string", () => {
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
llmProvider: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-absolute workspace when set", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
workspace: "relative/path",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("llmProvider");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AdapterFn with explicit workspace", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
...baseConfig,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
|
||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||
});
|
||||
|
||||
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
|
||||
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
|
||||
|
||||
function buildExtractionInput(ctx: AgentContext): string {
|
||||
function buildExtractionInput(ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function extractWorkspacePath(
|
||||
ctx: AgentContext,
|
||||
provider: LlmProvider,
|
||||
ctx: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
logger: LogFn,
|
||||
): Promise<string | null> {
|
||||
const reactor = createThreadReactor<null>({
|
||||
llm: createLlmFn(provider),
|
||||
maxRounds: 2,
|
||||
staticTools: [],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const jsonSchema = z.toJSONSchema(schema);
|
||||
return {
|
||||
name: "set_workspace",
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "set_workspace",
|
||||
description: "Set the extracted workspace path",
|
||||
parameters: jsonSchema as Record<string, unknown>,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
|
||||
toolHandler: async () => "unknown tool",
|
||||
});
|
||||
const input = buildExtractionInput(ctx);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||
|
||||
const result = await reactor({
|
||||
thread: null,
|
||||
input: buildExtractionInput(ctx),
|
||||
schema: workspaceSchema,
|
||||
});
|
||||
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||
const workspace = result.meta.workspace.trim();
|
||||
|
||||
if (!result.ok) {
|
||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspace = result.value.workspace.trim();
|
||||
if (!workspace.startsWith("/")) {
|
||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -33,36 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
let workspace: string;
|
||||
|
||||
if (config.workspace !== null) {
|
||||
workspace = config.workspace;
|
||||
} else {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
||||
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
|
||||
if (extracted === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
||||
);
|
||||
}
|
||||
workspace = extracted;
|
||||
}
|
||||
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||
|
||||
function createCursorAgentFn(
|
||||
config: CursorAgentConfig,
|
||||
modelFlag: string,
|
||||
timeoutMs: number | null,
|
||||
logger: LogFn,
|
||||
): AgentFn<CursorAgentOpt> {
|
||||
return async (ctx, { prompt, workspace }) => {
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
@@ -86,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createAgentAdapter(
|
||||
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const workspace =
|
||||
config.workspace !== null
|
||||
? config.workspace
|
||||
: await extractWorkspacePath(ctx, runtime, logger);
|
||||
if (workspace === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||
);
|
||||
}
|
||||
return { prompt, workspace };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
|
||||
/**
|
||||
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||
* from the thread via runtime extraction.
|
||||
*/
|
||||
workspace: string | null;
|
||||
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
|
||||
llmProvider: LlmProvider | null;
|
||||
};
|
||||
|
||||
@@ -8,14 +8,11 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
if (config.workspace !== null && config.workspace.length === 0) {
|
||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
||||
}
|
||||
if (config.workspace === null && config.llmProvider === null) {
|
||||
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
}
|
||||
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||
return err("workspace must be an absolute filesystem path when set");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
return async (ctx, { prompt }) => {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
return { prompt };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
||||
import {
|
||||
type AdapterFn,
|
||||
type AgentFn,
|
||||
err,
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
type LlmAgentOpt = { prompt: string };
|
||||
|
||||
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
|
||||
return async (ctx, { prompt }) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
|
||||
prompt,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
||||
|
||||
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function agentBase(agent: string): string {
|
||||
function clientBase(client: string): string {
|
||||
if (GATEWAY_URL) {
|
||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
||||
return `${GATEWAY_URL}/api/clients/${client}`;
|
||||
}
|
||||
// Local dev: proxy via vite, no agent prefix
|
||||
// Local dev: proxy via vite, no client prefix
|
||||
return "/api";
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
|
||||
|
||||
// ── Endpoint types ──────────────────────────────────────────────────
|
||||
|
||||
export type AgentEndpoint = {
|
||||
export type ClientEndpoint = {
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
@@ -141,61 +141,61 @@ export type WorkflowDetail = {
|
||||
|
||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||
|
||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
||||
export function listClients(): Promise<ClientEndpoint[]> {
|
||||
const url = GATEWAY_URL || "";
|
||||
return fetchJson(url, "/api/gateway/endpoints");
|
||||
}
|
||||
|
||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
||||
// ── Client-scoped endpoints ──────────────────────────────────────────
|
||||
|
||||
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/workflows");
|
||||
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/workflows");
|
||||
}
|
||||
|
||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
||||
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function getWorkflowDescriptor(
|
||||
agent: string,
|
||||
client: string,
|
||||
name: string,
|
||||
): Promise<WorkflowDescriptor | null> {
|
||||
const res = await getWorkflowDetail(agent, name);
|
||||
const res = await getWorkflowDetail(client, name);
|
||||
return res.descriptor;
|
||||
}
|
||||
|
||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads");
|
||||
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/threads");
|
||||
}
|
||||
|
||||
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads/running");
|
||||
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(clientBase(client), "/threads/running");
|
||||
}
|
||||
|
||||
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(agentBase(agent), `/threads/${id}`);
|
||||
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||
return fetchJson(clientBase(client), `/threads/${id}`);
|
||||
}
|
||||
|
||||
export function runThread(
|
||||
agent: string,
|
||||
client: string,
|
||||
workflow: string,
|
||||
prompt: string,
|
||||
): Promise<{ threadId: string }> {
|
||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
||||
return postJson(clientBase(client), "/threads", { workflow, prompt });
|
||||
}
|
||||
|
||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
|
||||
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
|
||||
}
|
||||
|
||||
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
|
||||
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
|
||||
}
|
||||
|
||||
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
|
||||
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
|
||||
}
|
||||
|
||||
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
|
||||
return fetchJson(agentBase(agent), "/healthz");
|
||||
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
|
||||
return fetchJson(clientBase(client), "/healthz");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
|
||||
const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
if (!authed) {
|
||||
@@ -22,36 +22,36 @@ export function App() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
view={view}
|
||||
agent={agent}
|
||||
client={client}
|
||||
onViewChange={setView}
|
||||
onAgentChange={setAgent}
|
||||
onClientChange={setClient}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
||||
<StatusBar client={client} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{!agent && (
|
||||
{!client && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p style={{ color: "var(--color-text-muted)" }}>
|
||||
Select an agent from the sidebar to get started.
|
||||
Select an client from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agent && view === "threads" && threadId === null && (
|
||||
<ThreadList agent={agent} onSelect={setThreadId} />
|
||||
{client && view === "threads" && threadId === null && (
|
||||
<ThreadList client={client} onSelect={setThreadId} />
|
||||
)}
|
||||
{agent && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
{client && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{agent && view === "workflows" && <WorkflowList agent={agent} />}
|
||||
{client && view === "workflows" && <WorkflowList client={client} />}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && agent && (
|
||||
{showRun && client && (
|
||||
<RunDialog
|
||||
agent={agent}
|
||||
client={client}
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
preparer: "#8b5cf6",
|
||||
agent: "#3b82f6",
|
||||
client: "#3b82f6",
|
||||
extractor: "#f59e0b",
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
||||
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -21,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(agent, workflow, prompt);
|
||||
const result = await runThread(client, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -38,7 +38,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
className="w-full max-w-lg p-6 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AgentEndpoint } from "../api.ts";
|
||||
import { listAgents } from "../api.ts";
|
||||
import type { ClientEndpoint } from "../api.ts";
|
||||
import { listClients } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
onViewChange: (v: "threads" | "workflows") => void;
|
||||
onAgentChange: (a: string | null) => void;
|
||||
onClientChange: (a: string | null) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listAgents(), []);
|
||||
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listClients(), []);
|
||||
|
||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
||||
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
||||
|
||||
// Auto-select first agent when none is selected
|
||||
// Auto-select first client when none is selected
|
||||
useEffect(() => {
|
||||
if (agent === null && agents.length > 0) {
|
||||
onAgentChange(agents[0].name);
|
||||
if (client === null && clients.length > 0) {
|
||||
onClientChange(clients[0].name);
|
||||
}
|
||||
}, [agent, agents, onAgentChange]);
|
||||
}, [client, clients, onClientChange]);
|
||||
|
||||
const viewItems = [
|
||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||
@@ -42,33 +42,33 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
{/* Client selector */}
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="agent-select"
|
||||
htmlFor="client-select"
|
||||
>
|
||||
Agent
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
id="agent-select"
|
||||
id="client-select"
|
||||
className="w-full rounded px-2 py-1.5 text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
value={agent ?? ""}
|
||||
onChange={(e) => onAgentChange(e.target.value || null)}
|
||||
value={client ?? ""}
|
||||
onChange={(e) => onClientChange(e.target.value || null)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<option value="">Loading…</option>
|
||||
) : agents.length === 0 ? (
|
||||
<option value="">No agents online</option>
|
||||
) : clients.length === 0 ? (
|
||||
<option value="">No clients online</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
clients.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||
</option>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getAgentHealth } from "../api.ts";
|
||||
import { getClientHealth } from "../api.ts";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
type Props = {
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
@@ -18,17 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
}
|
||||
|
||||
export function StatusBar({ agent, onRun }: Props) {
|
||||
export function StatusBar({ client, onRun }: Props) {
|
||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
if (!agent) {
|
||||
if (!client) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getAgentHealth(agent);
|
||||
await getClientHealth(client);
|
||||
wasConnectedRef.current = true;
|
||||
setStatus("connected");
|
||||
} catch {
|
||||
@@ -38,7 +38,7 @@ export function StatusBar({ agent, onRun }: Props) {
|
||||
setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
}, [agent]);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
wasConnectedRef.current = false;
|
||||
@@ -57,17 +57,17 @@ export function StatusBar({ agent, onRun }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{agent ? `Agent: ${agent}` : "No agent selected"}
|
||||
{client ? `Client: ${client}` : "No client selected"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={!agent}
|
||||
disabled={!client}
|
||||
className="px-3 py-1 rounded text-xs font-medium"
|
||||
style={{
|
||||
background: agent ? "var(--color-accent)" : "var(--color-border)",
|
||||
background: client ? "var(--color-accent)" : "var(--color-border)",
|
||||
color: "#fff",
|
||||
opacity: agent ? 1 : 0.5,
|
||||
opacity: client ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
▶ Run Thread
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RecordCard } from "./record-card.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
||||
}
|
||||
|
||||
if (roleRecords.length > 0) {
|
||||
const hasStart = records.some((r) => r.type === "thread-start");
|
||||
if (hasStart) {
|
||||
states.set("__start__", "completed");
|
||||
}
|
||||
if (hasResult) {
|
||||
@@ -52,9 +53,9 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
const sse = useSSE(agent, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -72,35 +73,65 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
|
||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
||||
() =>
|
||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
|
||||
[agent, workflowName],
|
||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
|
||||
[client, workflowName],
|
||||
);
|
||||
|
||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||
|
||||
const firstIndexByRole = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
const indicesByRole = useMemo(() => {
|
||||
const m = new Map<string, number[]>();
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i];
|
||||
if (r.type === "role" && !m.has(r.role)) {
|
||||
m.set(r.role, i);
|
||||
if (r.type === "role") {
|
||||
const list = m.get(r.role) ?? [];
|
||||
list.push(i);
|
||||
m.set(r.role, list);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [records]);
|
||||
|
||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
||||
const el = firstCardByRoleRef.current.get(roleName);
|
||||
if (el == null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(roleName);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const handleGraphNodeClick = useCallback((nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
if (nodeId === "__start__") {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
// __end__: scroll to bottom
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Role nodes: cycle through occurrences
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el !== null) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
}, [nodeStates, indicesByRole]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -117,7 +148,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
setActionStatus(`${action}ing...`);
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(agent, threadId);
|
||||
await fn(client, threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
@@ -237,11 +268,13 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const roleIndices = indicesByRole.get(r.role);
|
||||
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-record-index={i}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
@@ -252,7 +285,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
return <div key={key} data-record-index={i}><RecordCard record={r} highlighted={false} /></div>;
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ThreadList({ agent, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
|
||||
export function ThreadList({ client, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||
|
||||
@@ -2,30 +2,28 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "
|
||||
import type { ConditionEdgeData } from "./types.ts";
|
||||
|
||||
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
const FEEDBACK_OFFSET_X = 80;
|
||||
// Radius for feedback edge corners
|
||||
const FEEDBACK_RADIUS = 16;
|
||||
|
||||
/**
|
||||
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
|
||||
* The path goes: source right → arc → vertical up → arc → target right
|
||||
* Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
|
||||
* The path goes: source → arc → vertical up → arc → target
|
||||
*/
|
||||
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
|
||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
|
||||
const d = side === "right" ? 1 : -1;
|
||||
const offsetX =
|
||||
side === "right"
|
||||
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||
const r = FEEDBACK_RADIUS;
|
||||
|
||||
// Start from source right side, go right, then up, then left to target right side
|
||||
const segments = [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
// Horizontal to the right
|
||||
`L ${rightX - r} ${sourceY}`,
|
||||
// Arc turning upward
|
||||
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
|
||||
// Vertical upward
|
||||
`L ${rightX} ${targetY + r}`,
|
||||
// Arc turning left
|
||||
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
|
||||
// Horizontal left to target
|
||||
`L ${offsetX - d * r} ${sourceY}`,
|
||||
`Q ${offsetX} ${sourceY} ${offsetX} ${sourceY - r}`,
|
||||
`L ${offsetX} ${targetY + r}`,
|
||||
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
|
||||
`L ${targetX} ${targetY}`,
|
||||
];
|
||||
|
||||
@@ -57,10 +55,13 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
let defaultLabelY: number;
|
||||
|
||||
if (isFeedback) {
|
||||
// Custom feedback path routed to the right
|
||||
path = feedbackPath(sourceX, sourceY, targetX, targetY);
|
||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||
defaultLabelX = rightX;
|
||||
const side = edgeData?.feedbackSide ?? "right";
|
||||
path = feedbackPath(sourceX, sourceY, targetX, targetY, side);
|
||||
const offsetX =
|
||||
side === "right"
|
||||
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||
defaultLabelX = offsetX;
|
||||
defaultLabelY = (sourceY + targetY) / 2;
|
||||
} else {
|
||||
const result = getSmoothStepPath({
|
||||
@@ -78,9 +79,8 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
defaultLabelY = result[2];
|
||||
}
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||
const label = edgeData?.condition ?? "";
|
||||
const stroke = "var(--color-accent)";
|
||||
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||
|
||||
// Use pre-computed label position if available, otherwise fall back to default
|
||||
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||
@@ -92,7 +92,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
id={id}
|
||||
path={path}
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||
style={{ stroke, strokeWidth: 1.5 }}
|
||||
/>
|
||||
{label !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
@@ -102,7 +102,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
||||
color: "var(--color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 10,
|
||||
}}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
@@ -45,7 +45,11 @@ export function RoleNode(props: NodeProps) {
|
||||
}}
|
||||
title={data.description}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Left} id="left-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Right} id="right-in" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Left} id="left-out" style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Right} id="right-out" style={handleStyle} isConnectable={false} />
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
{icon !== null && (
|
||||
<span
|
||||
@@ -63,7 +67,7 @@ export function RoleNode(props: NodeProps) {
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -45,11 +45,12 @@ export function TerminalNode(props: NodeProps) {
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
) : (
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
|
||||
)}
|
||||
{isStart ? "▶" : "■"}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ConditionEdgeData = {
|
||||
isFallback: boolean;
|
||||
isFeedback: boolean;
|
||||
isSelfLoop: boolean;
|
||||
feedbackSide: "right" | "left" | null;
|
||||
labelX: number | null;
|
||||
labelY: number | null;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40;
|
||||
// Vertical gap between nodes in the spine
|
||||
const LAYER_GAP = 80;
|
||||
// Horizontal offset for feedback (back) edges routed on the right side
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
const FEEDBACK_OFFSET_X = 80;
|
||||
|
||||
type LayoutInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
@@ -173,6 +173,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
// Build edges with label positions
|
||||
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
||||
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
||||
// Track feedback edge count per target node for alternating sides
|
||||
const feedbackCountByTarget = new Map<string, number>();
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
@@ -185,13 +187,20 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
let feedbackSide: "right" | "left" | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback) {
|
||||
// Label on the right side of the feedback arc
|
||||
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
|
||||
// Alternate feedback edges left/right per target node
|
||||
const count = feedbackCountByTarget.get(e.to) ?? 0;
|
||||
feedbackCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = rightX;
|
||||
labelX = offsetX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
// Forward edge: label between source bottom and target top
|
||||
@@ -207,6 +216,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
@@ -214,6 +225,7 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
|
||||
@@ -32,16 +32,16 @@ const edgeTypes: EdgeTypes = {
|
||||
condition: ConditionEdge,
|
||||
};
|
||||
|
||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
||||
if (node.type !== "role") return;
|
||||
onRoleClick(node.id);
|
||||
function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
|
||||
if (node.type !== "role" && node.type !== "terminal") return;
|
||||
onNodeClick(node.id);
|
||||
}
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkflowDetail } from "../api.ts";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts";
|
||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
client: string;
|
||||
};
|
||||
|
||||
type DetailCacheEntry =
|
||||
@@ -17,6 +17,214 @@ function versionCount(detail: WorkflowDetail): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
type SchemaRow = {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
depth: number;
|
||||
prefix: string;
|
||||
isVariantHeader: boolean;
|
||||
};
|
||||
|
||||
function resolveType(prop: Record<string, unknown>): string {
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined) {
|
||||
const itemType = String(items.type ?? "unknown");
|
||||
return `${itemType}[]`;
|
||||
}
|
||||
return "array";
|
||||
}
|
||||
return String(prop.type ?? "unknown");
|
||||
}
|
||||
|
||||
function flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
parentRequired: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
|
||||
// Handle oneOf / discriminatedUnion
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
// Try to find a distinguishing literal (e.g. status: "approved")
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
let variantLabel = `Variant ${vi + 1}`;
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) {
|
||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue; // skip discriminator field
|
||||
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Handle regular object with properties
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||
if (!required.has(name)) type += "?";
|
||||
const description = String(prop.description ?? "");
|
||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||
|
||||
rows.push({
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: displayName,
|
||||
type,
|
||||
description,
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
});
|
||||
|
||||
// Recurse into nested object
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||
}
|
||||
|
||||
// Recurse into array of objects
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set()));
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into oneOf
|
||||
if (hasOneOf) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function RoleCard({
|
||||
roleName,
|
||||
role,
|
||||
}: {
|
||||
roleName: string;
|
||||
role: WorkflowRoleDescriptor;
|
||||
}) {
|
||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
|
||||
return (
|
||||
<div
|
||||
id={`role-${roleName}`}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<h4
|
||||
className="text-sm font-semibold font-mono mb-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{roleName}
|
||||
</h4>
|
||||
{role.description !== "" && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div>
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Meta Schema
|
||||
</p>
|
||||
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Field</th>
|
||||
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Type</th>
|
||||
<th className="text-left py-1 font-medium" style={{ color: "var(--color-text-muted)" }}>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.key}
|
||||
style={{
|
||||
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="py-1 pr-3 font-mono whitespace-pre"
|
||||
style={{
|
||||
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||
}}
|
||||
>
|
||||
{r.name}
|
||||
</td>
|
||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{r.type}</td>
|
||||
<td className="py-1" style={{ color: "var(--color-text)" }}>{r.description || (r.isVariantHeader ? "" : "—")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||
<pre
|
||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{JSON.stringify(role.schema, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedWorkflowBody({
|
||||
cacheEntry,
|
||||
staticNodeStates,
|
||||
@@ -24,6 +232,24 @@ function ExpandedWorkflowBody({
|
||||
cacheEntry: DetailCacheEntry | undefined;
|
||||
staticNodeStates: Map<string, NodeState>;
|
||||
}) {
|
||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null;
|
||||
const descriptor = detail?.descriptor ?? null;
|
||||
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
||||
|
||||
// All roles are "completed" (static view, all nodes lit) — must be before early returns
|
||||
const allLitStates = useMemo(() => {
|
||||
const m = new Map<string, NodeState>();
|
||||
m.set("__start__", "completed");
|
||||
m.set("__end__", "completed");
|
||||
for (const [name] of roleEntries) {
|
||||
m.set(name, "completed");
|
||||
}
|
||||
return m;
|
||||
}, [roleEntries]);
|
||||
|
||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
@@ -40,76 +266,117 @@ function ExpandedWorkflowBody({
|
||||
);
|
||||
}
|
||||
|
||||
const { detail } = cacheEntry;
|
||||
const descriptor = detail.descriptor;
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const vc = versionCount(detail);
|
||||
const vc = detail !== null ? versionCount(detail) : 0;
|
||||
|
||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||
|
||||
function handleGraphNodeClick(nodeId: string) {
|
||||
const el = document.getElementById(`role-${nodeId}`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pt-3 border-t flex gap-4"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
|
||||
{/* Left: graph sidebar */}
|
||||
{hasGraph && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 120px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={allLitStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
)}
|
||||
|
||||
{/* Right: workflow info + role cards */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Workflow overview */}
|
||||
<div
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</h3>
|
||||
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasGraph ? (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden flex-1"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
Hash:{" "}
|
||||
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 600, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={staticNodeStates}
|
||||
onNodeClick={null}
|
||||
/>
|
||||
<span>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{roleEntries.length > 0 && (
|
||||
<span>
|
||||
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Role cards */}
|
||||
{roleEntries.map(([name, role]) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
transition: "box-shadow 0.3s",
|
||||
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<RoleCard roleName={name} role={role} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowList({ agent }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
||||
export function WorkflowList({ client }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
||||
() => new Map(),
|
||||
@@ -117,11 +384,11 @@ export function WorkflowList({ agent }: Props) {
|
||||
|
||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching clients
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setDetailsByName(new Map());
|
||||
}, [agent]);
|
||||
}, [client]);
|
||||
|
||||
const ensureDetailLoaded = useCallback(
|
||||
(name: string) => {
|
||||
@@ -135,7 +402,7 @@ export function WorkflowList({ agent }: Props) {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getWorkflowDetail(agent, name);
|
||||
const detail = await getWorkflowDetail(client, name);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "ok", detail });
|
||||
@@ -151,7 +418,7 @@ export function WorkflowList({ agent }: Props) {
|
||||
}
|
||||
})();
|
||||
},
|
||||
[agent],
|
||||
[client],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
|
||||
@@ -4,35 +4,35 @@ type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
|
||||
// Format: #client/threads/id or #client/workflows or #threads or #workflows
|
||||
const parts = raw.split("/");
|
||||
|
||||
// Check if first part is a known view
|
||||
if (parts[0] === "threads" || parts[0] === "workflows") {
|
||||
return {
|
||||
view: parts[0] as View,
|
||||
agent: null,
|
||||
client: null,
|
||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
};
|
||||
}
|
||||
|
||||
// First part is agent name
|
||||
const agent = parts[0] || null;
|
||||
// First part is client name
|
||||
const client = parts[0] || null;
|
||||
const viewPart = parts[1] ?? "threads";
|
||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
|
||||
return { view, agent, threadId };
|
||||
return { view, client, threadId };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
const prefix = route.agent ? `${route.agent}/` : "";
|
||||
const prefix = route.client ? `${route.client}/` : "";
|
||||
if (route.view === "workflows") {
|
||||
return `#${prefix}workflows`;
|
||||
}
|
||||
@@ -44,10 +44,10 @@ function buildHash(route: HashRoute): string {
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
agent: string | null;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
setView: (v: View) => void;
|
||||
setAgent: (a: string | null) => void;
|
||||
setClient: (a: string | null) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
@@ -67,26 +67,26 @@ export function useHashRoute(): {
|
||||
}, []);
|
||||
|
||||
const setView = useCallback(
|
||||
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
|
||||
[navigate, route.agent],
|
||||
(v: View) => navigate({ view: v, client: route.client, threadId: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setAgent = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
|
||||
const setClient = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, client: a, threadId: null }),
|
||||
[navigate, route.view],
|
||||
);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
|
||||
[navigate, route.agent],
|
||||
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
return {
|
||||
view: route.view,
|
||||
agent: route.agent,
|
||||
client: route.client,
|
||||
threadId: route.threadId,
|
||||
setView,
|
||||
setAgent,
|
||||
setClient,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,17 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
||||
ctx.cleanupEs();
|
||||
}
|
||||
|
||||
function sseUrl(agent: string, threadId: string): string {
|
||||
function sseUrl(client: string, threadId: string): string {
|
||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||
const key = getApiKey();
|
||||
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
if (gatewayUrl) {
|
||||
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||
return `${gatewayUrl}/api/${client}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||
}
|
||||
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
||||
}
|
||||
|
||||
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
|
||||
export function useSSE(client: string | null, threadId: string | null): UseSSEReturn {
|
||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
@@ -76,7 +76,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadId === null || agent === null) {
|
||||
if (threadId === null || client === null) {
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
@@ -86,7 +86,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
|
||||
const tid = threadId;
|
||||
const agentName = agent;
|
||||
const clientName = client;
|
||||
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
@@ -125,7 +125,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
|
||||
cleanupEs();
|
||||
const url = sseUrl(agentName, tid);
|
||||
const url = sseUrl(clientName, tid);
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
@@ -177,7 +177,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}
|
||||
cleanupEs();
|
||||
};
|
||||
}, [agent, threadId]);
|
||||
}, [client, threadId]);
|
||||
|
||||
return { records, connected, completed };
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
|
||||
/** One Durable Object instance per client name; holds the reverse WebSocket from the client CLI. */
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
|
||||
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
||||
|
||||
type AgentSocketEnv = {
|
||||
type ClientSocketEnv = {
|
||||
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";
|
||||
export const CLIENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/client-socket/status";
|
||||
export const CLIENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/client-socket/proxy";
|
||||
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -32,7 +32,7 @@ function wsResponseToHttp(wr: WsResponse): Response {
|
||||
return new Response(wr.body, { status: wr.status, headers });
|
||||
}
|
||||
|
||||
export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
export class ClientSocket extends DurableObject<ClientSocketEnv> {
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
|
||||
private requireAuth(request: Request): Response | null {
|
||||
@@ -100,11 +100,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
return this.handleStatusGet(request);
|
||||
}
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
if (url.pathname === CLIENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
return this.handleProxyPost(request);
|
||||
}
|
||||
|
||||
@@ -144,11 +144,11 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
_reason: string,
|
||||
_wasClean: boolean,
|
||||
): Promise<void> {
|
||||
this.rejectAllPending("agent websocket closed");
|
||||
this.rejectAllPending("client websocket closed");
|
||||
}
|
||||
|
||||
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
||||
this.rejectAllPending("agent websocket error");
|
||||
this.rejectAllPending("client websocket error");
|
||||
}
|
||||
|
||||
private rejectAllPending(message: string): void {
|
||||
@@ -2,27 +2,27 @@ 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";
|
||||
CLIENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||
CLIENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||
ClientSocket,
|
||||
} from "./client-socket.js";
|
||||
import type { WsRequest } from "./ws-protocol.js";
|
||||
|
||||
export { AgentSocket };
|
||||
export { ClientSocket };
|
||||
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
|
||||
CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
|
||||
};
|
||||
};
|
||||
|
||||
type EndpointRecord = {
|
||||
name: string;
|
||||
url: string;
|
||||
agentToken: string;
|
||||
clientToken: string;
|
||||
registeredAt: number;
|
||||
lastHeartbeat: number;
|
||||
};
|
||||
@@ -43,7 +43,7 @@ function checkDashboardAuth(c: {
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
}
|
||||
|
||||
function isLocalAgentUrl(url: string): boolean {
|
||||
function isLocalClientUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
@@ -52,7 +52,7 @@ function isLocalAgentUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
|
||||
function buildForwardHeaders(raw: Headers, clientToken: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of raw) {
|
||||
const lower = key.toLowerCase();
|
||||
@@ -70,8 +70,8 @@ function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, s
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
if (agentToken !== "") {
|
||||
out["X-Agent-Token"] = agentToken;
|
||||
if (clientToken !== "") {
|
||||
out["X-Client-Token"] = clientToken;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization");
|
||||
if (token !== "") {
|
||||
headers.set("X-Agent-Token", token);
|
||||
headers.set("X-Client-Token", token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -94,15 +94,15 @@ async function readBodyForWsProxy(method: string, req: Request): Promise<string
|
||||
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function fetchThroughAgentSocket(
|
||||
async function fetchThroughClientSocket(
|
||||
bindings: Env["Bindings"],
|
||||
agent: string,
|
||||
client: string,
|
||||
gateSecret: string,
|
||||
wsRequest: WsRequest,
|
||||
): Promise<Response> {
|
||||
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
|
||||
const stub = bindings.CLIENT_SOCKET.get(bindings.CLIENT_SOCKET.idFromName(client));
|
||||
return stub.fetch(
|
||||
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
new Request(`https://do.internal${CLIENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gateSecret}`,
|
||||
@@ -113,7 +113,7 @@ async function fetchThroughAgentSocket(
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAgentWithRecordHeaders(
|
||||
async function fetchClientWithRecordHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
forwardRecord: Record<string, string>,
|
||||
@@ -130,7 +130,7 @@ async function fetchAgentWithRecordHeaders(
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentWithDashboardHeaders(
|
||||
async function fetchClientWithDashboardHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
headers: Headers,
|
||||
@@ -143,15 +143,15 @@ async function fetchAgentWithDashboardHeaders(
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentSocketStatus(
|
||||
async function fetchClientSocketStatus(
|
||||
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 id = env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = env.CLIENT_SOCKET.get(id);
|
||||
const resp = await stub.fetch(
|
||||
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
new Request(`https://do${CLIENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
||||
}),
|
||||
@@ -171,7 +171,7 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
||||
return "online";
|
||||
}
|
||||
if (doConnected === false) {
|
||||
if (isLocalAgentUrl(record.url)) {
|
||||
if (isLocalClientUrl(record.url)) {
|
||||
return "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
@@ -184,7 +184,7 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
// ── Client reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
app.get("/ws/connect", async (c) => {
|
||||
const secret = c.req.query("secret");
|
||||
const name = c.req.query("name");
|
||||
@@ -197,8 +197,8 @@ app.get("/ws/connect", async (c) => {
|
||||
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);
|
||||
const id = c.env.CLIENT_SOCKET.idFromName(name);
|
||||
const stub = c.env.CLIENT_SOCKET.get(id);
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
@@ -210,9 +210,9 @@ gateway.post("/register", async (c) => {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
agentToken?: string;
|
||||
clientToken?: string;
|
||||
}>();
|
||||
const { name, url, secret, agentToken } = body;
|
||||
const { name, url, secret, clientToken } = body;
|
||||
|
||||
if (!name || !url) {
|
||||
return c.json({ error: "name and url required" }, 400);
|
||||
@@ -227,7 +227,7 @@ gateway.post("/register", async (c) => {
|
||||
const record: EndpointRecord = {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ""), // strip trailing slash
|
||||
agentToken: agentToken ?? existing?.agentToken ?? "",
|
||||
clientToken: clientToken ?? existing?.clientToken ?? "",
|
||||
registeredAt: existing?.registeredAt ?? now,
|
||||
lastHeartbeat: now,
|
||||
};
|
||||
@@ -261,7 +261,7 @@ 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 doStatus = await fetchAgentSocketStatus(c.env, record.name);
|
||||
const doStatus = await fetchClientSocketStatus(c.env, record.name);
|
||||
const doConnected = doStatus.ok ? doStatus.connected : null;
|
||||
endpoints.push({
|
||||
name: record.name,
|
||||
@@ -277,25 +277,25 @@ gateway.get("/endpoints", async (c) => {
|
||||
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent tunnel URL (dashboard auth) ──
|
||||
app.all("/api/agents/:agent/*", async (c) => {
|
||||
// ── API proxy: /api/clients/:client/* → WebSocket (preferred) or client tunnel URL (dashboard auth) ──
|
||||
app.all("/api/clients/:client/*", 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");
|
||||
const client = c.req.param("client");
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(client, "json");
|
||||
|
||||
if (!record) {
|
||||
return c.json({ error: "agent not found" }, 404);
|
||||
return c.json({ error: "client not found" }, 404);
|
||||
}
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
||||
const proxyPath = `/api${pathAfterAgent}${url.search}`;
|
||||
const pathAfterClient = url.pathname.replace(`/api/clients/${client}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterClient}${url.search}`;
|
||||
const proxyPath = `/api${pathAfterClient}${url.search}`;
|
||||
const method = c.req.method;
|
||||
const token = record.agentToken ?? "";
|
||||
const token = record.clientToken ?? "";
|
||||
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
||||
|
||||
const doStatus = await fetchAgentSocketStatus(c.env, agent);
|
||||
const doStatus = await fetchClientSocketStatus(c.env, client);
|
||||
if (doStatus.ok && doStatus.connected) {
|
||||
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
||||
const wsRequest: WsRequest = {
|
||||
@@ -305,7 +305,7 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
headers: forwardRecord,
|
||||
body: bodyStr,
|
||||
};
|
||||
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
|
||||
const proxyResp = await fetchThroughClientSocket(c.env, client, c.env.GATEWAY_SECRET, wsRequest);
|
||||
if (proxyResp.status !== 503) {
|
||||
return new Response(proxyResp.body, {
|
||||
status: proxyResp.status,
|
||||
@@ -313,25 +313,25 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
||||
const resp = await fetchClientWithRecordHeaders(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);
|
||||
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
||||
try {
|
||||
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||
const resp = await fetchClientWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
||||
return c.json({ error: "client unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ binding = "ENDPOINTS"
|
||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
|
||||
bindings = [{ name = "CLIENT_SOCKET", class_name = "ClientSocket" }]
|
||||
|
||||
[[migrations]]
|
||||
tag = "add-agent-socket"
|
||||
new_sqlite_classes = ["AgentSocket"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "rename-agent-to-client"
|
||||
renamed_classes = [{ from = "AgentSocket", to = "ClientSocket" }]
|
||||
|
||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -151,6 +151,15 @@ export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promis
|
||||
|
||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options.
|
||||
* Agents with no extra options use `AgentFn` (Opt defaults to void).
|
||||
*/
|
||||
export type AgentFn<Opt = void> = Opt extends void
|
||||
? (ctx: ThreadContext) => Promise<string>
|
||||
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* greet workflow — smoke test entry
|
||||
* Single role: greeter takes a prompt and returns a structured greeting.
|
||||
* 小橘 🍊
|
||||
*/
|
||||
|
||||
import type {
|
||||
AdapterFn,
|
||||
ModeratorTable,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type GreetMeta = {
|
||||
greeter: { greeting: string; language: string };
|
||||
};
|
||||
|
||||
const greeterSchema = z.object({
|
||||
greeting: z.string().describe("A friendly greeting message"),
|
||||
language: z.string().describe("The language of the greeting"),
|
||||
});
|
||||
|
||||
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
greeter: {
|
||||
description: "Generates a friendly greeting",
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
const table: ModeratorTable<GreetMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const descriptor = {
|
||||
name: "greet",
|
||||
description: "A simple greeting workflow for smoke testing",
|
||||
graph: { [START]: ["greeter"], greeter: [END] },
|
||||
roles: { greeter: { description: "Generates a friendly greeting" } },
|
||||
};
|
||||
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
|
||||
function getProvider() {
|
||||
if (cached !== null) return cached;
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
|
||||
cached = {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey,
|
||||
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const provider = getProvider();
|
||||
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: [
|
||||
{ role: "system", content: prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
|
||||
}
|
||||
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
|
||||
const text = data.choices[0]?.message?.content;
|
||||
if (!text) throw new Error("Empty LLM response");
|
||||
const parsed = schema.parse(JSON.parse(text));
|
||||
return { meta: parsed, childThread: null };
|
||||
};
|
||||
}) as AdapterFn;
|
||||
}
|
||||
|
||||
export const run = createWorkflow<GreetMeta>(
|
||||
{ roles, table },
|
||||
{ adapter: createLazyAdapter(), overrides: null },
|
||||
);
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -8,15 +8,6 @@ import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const llmProvider = {
|
||||
baseUrl: optionalEnv(
|
||||
"WORKFLOW_LLM_BASE_URL",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
|
||||
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
|
||||
};
|
||||
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
|
||||
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
|
||||
@@ -24,7 +15,6 @@ const adapter = createCursorAgent({
|
||||
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
|
||||
: 0,
|
||||
workspace: null,
|
||||
llmProvider,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
|
||||
|
||||
@@ -63,5 +63,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.status === "planned" ? meta.phases.map((p) => p.hash) : [],
|
||||
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
AgentFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type ExtractOptionsFn<Opt> = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
runtime: WorkflowRuntime,
|
||||
) => Promise<Opt>;
|
||||
|
||||
/**
|
||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
||||
*
|
||||
* 1. extract(ctx, prompt, runtime) → Opt
|
||||
* 2. agent(ctx, options) → raw string
|
||||
* 3. Store raw string in CAS
|
||||
* 4. runtime.extract(schema, contentHash) → typed meta T
|
||||
*/
|
||||
export function createAgentAdapter<Opt>(
|
||||
agent: AgentFn<Opt>,
|
||||
extract: ExtractOptionsFn<Opt>,
|
||||
): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, prompt, runtime);
|
||||
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
|
||||
ctx,
|
||||
options,
|
||||
);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
|
||||
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
|
||||
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
|
||||
*/
|
||||
export type TextAdapterResult = {
|
||||
output: string;
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that produces raw text output given the thread context and
|
||||
* the system prompt for the current role.
|
||||
*/
|
||||
export type TextProducerFn = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
) => Promise<string | TextAdapterResult>;
|
||||
|
||||
/**
|
||||
* Creates an {@link AdapterFn} from a text-producing function.
|
||||
*
|
||||
* The adapter:
|
||||
* 1. Calls the producer with thread context + system prompt
|
||||
* 2. Stores output in CAS
|
||||
* 3. Runs the extract phase to produce typed meta
|
||||
* 4. Returns `{ meta, childThread }`
|
||||
*/
|
||||
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const result = await producer(ctx, prompt);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
const childThread = typeof result === "string" ? null : result.childThread;
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
|
||||
export { createTextAdapter } from "./create-text-adapter.js";
|
||||
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
|
||||
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* greet workflow — smoke test entry
|
||||
* Single role: greeter takes a prompt and returns a structured greeting.
|
||||
* 小橘 🍊
|
||||
*/
|
||||
|
||||
import type {
|
||||
AdapterFn,
|
||||
ModeratorTable,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type GreetMeta = {
|
||||
greeter: { greeting: string; language: string };
|
||||
};
|
||||
|
||||
const greeterSchema = z.object({
|
||||
greeting: z.string().describe("A friendly greeting message"),
|
||||
language: z.string().describe("The language of the greeting"),
|
||||
});
|
||||
|
||||
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
greeter: {
|
||||
description: "Generates a friendly greeting",
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
const table: ModeratorTable<GreetMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const descriptor = {
|
||||
name: "greet",
|
||||
description: "A simple greeting workflow for smoke testing",
|
||||
graph: { [START]: ["greeter"], greeter: [END] },
|
||||
roles: { greeter: { description: "Generates a friendly greeting" } },
|
||||
};
|
||||
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
|
||||
function getProvider() {
|
||||
if (cached !== null) return cached;
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
|
||||
cached = {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey,
|
||||
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const provider = getProvider();
|
||||
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: [
|
||||
{ role: "system", content: prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
|
||||
}
|
||||
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
|
||||
const text = data.choices[0]?.message?.content;
|
||||
if (!text) throw new Error("Empty LLM response");
|
||||
const parsed = schema.parse(JSON.parse(text));
|
||||
return { meta: parsed, childThread: null };
|
||||
};
|
||||
}) as AdapterFn;
|
||||
}
|
||||
|
||||
export const run = createWorkflow<GreetMeta>(
|
||||
{ roles, table },
|
||||
{ adapter: createLazyAdapter(), overrides: null },
|
||||
);
|
||||
Reference in New Issue
Block a user