diff --git a/packages/cli-workflow/__tests__/serve.test.ts b/packages/cli-workflow/__tests__/serve.test.ts new file mode 100644 index 0000000..bba5047 --- /dev/null +++ b/packages/cli-workflow/__tests__/serve.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { createApp } from "../src/commands/serve/app.js"; + +function buildApp(storageRoot: string) { + const app = createApp(storageRoot); + return { + fetch: (path: string, init?: RequestInit) => + app.fetch(new Request(`http://localhost${path}`, init)), + }; +} + +describe("serve /healthz", () => { + test("returns ok", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/healthz"); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); +}); + +describe("serve /api/workflows", () => { + test("returns empty list for missing storage", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/workflows"); + // Registry file won't exist, should return error + expect(res.status).toBe(200); + }); +}); + +describe("serve /api/threads", () => { + test("returns empty list for missing storage", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/threads"); + expect(res.status).toBe(200); + const body = (await res.json()) as { threads: unknown[] }; + expect(body.threads).toEqual([]); + }); + + test("returns 404 for missing thread", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/threads/nonexistent-id"); + expect(res.status).toBe(404); + }); +}); + +describe("serve /api/threads/running", () => { + test("returns empty list for missing storage", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/threads/running"); + expect(res.status).toBe(200); + const body = (await res.json()) as { threads: unknown[] }; + expect(body.threads).toEqual([]); + }); +}); + +describe("serve /api/cas", () => { + test("returns empty list for missing storage", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/cas"); + expect(res.status).toBe(200); + const body = (await res.json()) as { hashes: unknown[] }; + expect(body.hashes).toEqual([]); + }); + + test("returns 404 for missing hash", async () => { + const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent"); + const res = await fetch("/api/cas/nonexistent-hash"); + expect(res.status).toBe(404); + }); +}); + +describe("serve CAS round-trip", () => { + const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`; + + test("put then get", async () => { + const { fetch } = buildApp(tmpDir); + + const putRes = await fetch("/api/cas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "hello world" }), + }); + expect(putRes.status).toBe(201); + const putBody = (await putRes.json()) as { hash: string }; + expect(typeof putBody.hash).toBe("string"); + + const getRes = await fetch(`/api/cas/${putBody.hash}`); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { content: string }; + expect(getBody.content).toBe("hello world"); + + // cleanup + const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" }); + expect(delRes.status).toBe(200); + }); +}); diff --git a/packages/cli-workflow/package.json b/packages/cli-workflow/package.json index a51aadc..c156c29 100644 --- a/packages/cli-workflow/package.json +++ b/packages/cli-workflow/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", + "hono": "^4.12.18", "yaml": "^2.8.4" }, "scripts": { diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index 84778d7..5b58fa0 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -4,6 +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 { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js"; import { createWorkflowDispatcher } from "./commands/workflow/index.js"; import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js"; @@ -71,6 +72,7 @@ const COMMAND_TABLE: Record = { skill: dispatchSkill, run: dispatchRun, live: dispatchLive, + serve: dispatchServe, }; export async function runCli(storageRoot: string, argv: string[]): Promise { diff --git a/packages/cli-workflow/src/cli-usage.ts b/packages/cli-workflow/src/cli-usage.ts index 0834800..81c6d35 100644 --- a/packages/cli-workflow/src/cli-usage.ts +++ b/packages/cli-workflow/src/cli-usage.ts @@ -57,6 +57,17 @@ export function formatCliUsage( ); lines.push(""); + lines.push("Server:"); + lines.push( + ...formatUsageCommandLines([ + { + prefix: "serve [--port N] [--host ADDR]", + description: "Start HTTP API server (default: 127.0.0.1:7860)", + }, + ]), + ); + lines.push(""); + lines.push("Reference:"); const skillTopicNames = skillTopics.map((t) => t.name).join(", "); lines.push( diff --git a/packages/cli-workflow/src/commands/serve/app.ts b/packages/cli-workflow/src/commands/serve/app.ts new file mode 100644 index 0000000..76cadfc --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/app.ts @@ -0,0 +1,20 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; + +import { createCasRoutes } from "./routes-cas.js"; +import { createThreadRoutes } from "./routes-thread.js"; +import { createWorkflowRoutes } from "./routes-workflow.js"; + +export function createApp(storageRoot: string): Hono { + const app = new Hono(); + + app.use("*", cors()); + + app.get("/healthz", (c) => c.json({ ok: true })); + + app.route("/api/workflows", createWorkflowRoutes(storageRoot)); + app.route("/api/threads", createThreadRoutes(storageRoot)); + app.route("/api/cas", createCasRoutes(storageRoot)); + + return app; +} diff --git a/packages/cli-workflow/src/commands/serve/index.ts b/packages/cli-workflow/src/commands/serve/index.ts new file mode 100644 index 0000000..ce37a75 --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/index.ts @@ -0,0 +1,3 @@ +export { createApp } from "./app.js"; +export { dispatchServe, startServer } from "./serve.js"; +export type { ServeOptions } from "./types.js"; diff --git a/packages/cli-workflow/src/commands/serve/routes-cas.ts b/packages/cli-workflow/src/commands/serve/routes-cas.ts new file mode 100644 index 0000000..7aeb89a --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/routes-cas.ts @@ -0,0 +1,56 @@ +import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow"; +import { Hono } from "hono"; + +export function createCasRoutes(storageRoot: string): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + const casDir = getGlobalCasDir(storageRoot); + const cas = createCasStore(casDir); + const hashes = await cas.list(); + return c.json({ hashes }); + }); + + app.get("/:hash", async (c) => { + const casDir = getGlobalCasDir(storageRoot); + const cas = createCasStore(casDir); + const content = await cas.get(c.req.param("hash")); + if (content === null) { + return c.json({ error: "not found" }, 404); + } + return c.json({ hash: c.req.param("hash"), content }); + }); + + app.post("/", async (c) => { + const body = await c.req.json<{ content: string }>(); + if (typeof body.content !== "string") { + return c.json({ error: "content field required" }, 400); + } + const casDir = getGlobalCasDir(storageRoot); + const cas = createCasStore(casDir); + const hash = await cas.put(body.content); + return c.json({ hash }, 201); + }); + + app.delete("/:hash", async (c) => { + const casDir = getGlobalCasDir(storageRoot); + const cas = createCasStore(casDir); + const hash = c.req.param("hash"); + const content = await cas.get(hash); + if (content === null) { + return c.json({ error: "not found" }, 404); + } + await cas.delete(hash); + return c.json({ ok: true }); + }); + + app.post("/gc", async (c) => { + const result = await garbageCollectCas(storageRoot); + if (!result.ok) { + return c.json({ error: result.error }, 500); + } + return c.json(result.value); + }); + + return app; +} diff --git a/packages/cli-workflow/src/commands/serve/routes-thread.ts b/packages/cli-workflow/src/commands/serve/routes-thread.ts new file mode 100644 index 0000000..573d1a9 --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/routes-thread.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; + +import { readTextFileIfExists } from "../../fs-utils.js"; +import { + listHistoricalThreads, + listRunningThreads, + resolveThreadDataPath, +} from "../../thread-scan.js"; + +export function createThreadRoutes(storageRoot: string): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + const nameFilter = c.req.query("workflow") ?? null; + const rows = await listHistoricalThreads(storageRoot, nameFilter); + return c.json({ threads: rows }); + }); + + app.get("/running", async (c) => { + const rows = await listRunningThreads(storageRoot); + return c.json({ threads: rows }); + }); + + app.get("/:threadId", async (c) => { + const threadId = c.req.param("threadId"); + const dataPath = await resolveThreadDataPath(storageRoot, threadId); + if (dataPath === null) { + return c.json({ error: `thread not found: ${threadId}` }, 404); + } + const text = await readTextFileIfExists(dataPath); + if (text === null) { + return c.json({ error: `thread data missing: ${threadId}` }, 404); + } + const lines = text.trim().split("\n"); + const records = lines.map((line) => { + try { + return JSON.parse(line) as unknown; + } catch { + return { raw: line }; + } + }); + return c.json({ threadId, records }); + }); + + return app; +} diff --git a/packages/cli-workflow/src/commands/serve/routes-workflow.ts b/packages/cli-workflow/src/commands/serve/routes-workflow.ts new file mode 100644 index 0000000..ea728c3 --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/routes-workflow.ts @@ -0,0 +1,55 @@ +import { + getRegisteredWorkflow, + listRegisteredWorkflowNames, + readWorkflowRegistry, +} from "@uncaged/workflow"; +import { Hono } from "hono"; + +export function createWorkflowRoutes(storageRoot: string): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + const reg = await readWorkflowRegistry(storageRoot); + if (!reg.ok) { + return c.json({ error: reg.error.message }, 500); + } + const names = listRegisteredWorkflowNames(reg.value); + const workflows = names.map((name) => { + const entry = reg.value.workflows[name]; + return { + name, + hash: entry?.hash ?? null, + timestamp: entry?.timestamp ?? null, + }; + }); + return c.json({ workflows }); + }); + + app.get("/:name", async (c) => { + const reg = await readWorkflowRegistry(storageRoot); + if (!reg.ok) { + return c.json({ error: reg.error.message }, 500); + } + const name = c.req.param("name"); + const entry = getRegisteredWorkflow(reg.value, name); + if (entry === null) { + return c.json({ error: `workflow not found: ${name}` }, 404); + } + return c.json({ name, ...entry }); + }); + + app.get("/:name/history", async (c) => { + const reg = await readWorkflowRegistry(storageRoot); + if (!reg.ok) { + return c.json({ error: reg.error.message }, 500); + } + const name = c.req.param("name"); + const entry = getRegisteredWorkflow(reg.value, name); + if (entry === null) { + return c.json({ error: `workflow not found: ${name}` }, 404); + } + return c.json({ name, history: entry.history }); + }); + + return app; +} diff --git a/packages/cli-workflow/src/commands/serve/serve.ts b/packages/cli-workflow/src/commands/serve/serve.ts new file mode 100644 index 0000000..8609efb --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/serve.ts @@ -0,0 +1,69 @@ +import { err, ok, type Result } from "@uncaged/workflow"; +import { serve } from "bun"; + +import { printCliLine } from "../../cli-output.js"; +import { createApp } from "./app.js"; +import type { ServeOptions } from "./types.js"; + +export function startServer(storageRoot: string, options: ServeOptions): void { + const app = createApp(storageRoot); + + 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 { + 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 parseServeArgv(argv: string[]): Result { + let port = 7860; + let hostname = "127.0.0.1"; + + 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 === "--host") { + const next = argv[i + 1]; + if (next === undefined) { + return err("--host requires a value"); + } + hostname = next; + i++; + } + } + + return ok({ port, hostname }); +} + +export async function dispatchServe(storageRoot: string, argv: string[]): Promise { + const parsed = parseServeArgv(argv); + if (!parsed.ok) { + printCliLine(`error: ${parsed.error}`); + return 1; + } + + startServer(storageRoot, parsed.value); + + // Keep process alive + await new Promise(() => {}); + return 0; +} diff --git a/packages/cli-workflow/src/commands/serve/types.ts b/packages/cli-workflow/src/commands/serve/types.ts new file mode 100644 index 0000000..ad8ef10 --- /dev/null +++ b/packages/cli-workflow/src/commands/serve/types.ts @@ -0,0 +1,4 @@ +export type ServeOptions = { + port: number; + hostname: string; +};