Compare commits

...

21 Commits

Author SHA1 Message Date
xingyue 61fc1cfe1b perf(serve): SSE live pump reads incrementally instead of full file
Use Bun.file().slice() to read only new bytes from the last known
offset instead of re-reading the entire JSONL file on every fs watch.

- readNewBytes() helper with byte-offset tracking
- Handles file truncation (resets offset)
- Early return when no new data

Closes #130
2026-05-08 18:14:04 +08:00
xiaomo dedab62c49 Merge pull request 'feat(dashboard): connect thread detail to SSE live stream' (#134) from feat/131-dashboard-sse into main 2026-05-08 09:53:10 +00:00
xingyue a44f1f34a8 feat(dashboard): connect thread detail to SSE live stream
Add useSSE hook that connects to /api/threads/:id/live via EventSource.
Thread detail page now shows records in real-time with auto-scroll.

- useSSE hook: EventSource connection, record accumulation, auto-reconnect
  with exponential backoff, cleanup on unmount
- Thread detail: Live badge, SSE-first with fetch fallback, smooth scroll
- Records clear on reconnect (server replays full file)

Closes #131, testing verified per #133
Refs: #118
2026-05-08 17:52:10 +08:00
xiaoju e04e75bdee chore: remove stale self-referencing symlink
小橘 🍊(NEKO Team)
2026-05-08 09:35:32 +00:00
xiaoju c65c29c1b5 Merge pull request 'refactor(workflow): simplify extraction + thread runtime contract' (#132) from refactor/thread-context-runtime into main 2026-05-08 09:34:26 +00:00
Scott Wei cc3f2b576c refactor(workflow): decouple agent context from CAS and fix monorepo checks
Move CAS access into extract dependencies so AgentContext stays state-only, and clean up type/lint/check regressions across CLI/dashboard to keep full check green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:30:07 +08:00
Scott Wei 884ff85205 refactor(workflow): remove dead extract retry export
Drop unused llmExtractWithRetry implementation and public exports.

Add solve-issue template coverage for tool_calls extraction path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:10:31 +08:00
Scott Wei a11cc62a81 refactor(workflow-runtime): use full ThreadContext in WorkflowFn
Redefine WorkflowFn to accept a complete ThreadContext plus WorkflowRuntime dependencies, removing ThreadInput and WorkflowFnOptions.

Move thread context construction into engine executeThread, update runtime loop/agent paths, and align templates/docs/tests with template-only definition exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:08:01 +08:00
Scott Wei 34f5e655d1 refactor(workflow): unify extraction behind ExtractFn
Route createExtract through reactExtract with plain-JSON correction retry.

Remove WorkflowFnOptions.llmProvider, ExtractMode, RoleDefinition.extractMode, ResolveRoleMetaFn.

Runtime createWorkflow calls options.extract directly; engine passes extract only.

Update templates, CLI skill docs, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:08:01 +08:00
xiaomo 44fb0694aa Merge pull request 'feat(serve+dashboard): write endpoints, SSE live, run dialog' (#129) from feat/118-serve-write-sse into main 2026-05-08 09:01:09 +00:00
xingyue cdcaff15ab feat(serve+dashboard): write endpoints, SSE live, run dialog
Serve API:
- POST /api/threads — run a new thread
- POST /api/threads/:id/kill — kill thread
- POST /api/threads/:id/pause — pause thread
- POST /api/threads/:id/resume — resume thread
- GET /api/threads/:id/live — SSE stream of thread records

Dashboard:
- Run Thread dialog (select workflow, enter prompt, set maxRounds)
- Thread detail controls (pause/resume/kill buttons)
- postJson API helper for write operations

262 tests pass. Refs: #118
2026-05-08 16:07:02 +08:00
xiaomo 402479ddef Merge pull request 'feat(dashboard): workflow dashboard' (#127) from feat/118-dashboard into main 2026-05-08 07:22:00 +00:00
xingyue a28dd3050e fix(dashboard): remove unused onBack prop from Sidebar 2026-05-08 15:17:40 +08:00
xingyue ce0d0a962c feat(dashboard): workflow dashboard 2026-05-08 14:48:43 +08:00
xiaomo 46b552ec01 Merge pull request 'refactor: split @uncaged/workflow-runtime from engine' (#126) from refactor/121-split-workflow-runtime into main 2026-05-08 06:42:15 +00:00
xiaoju 587518ac09 refactor(workflow): cleanup engine re-exports, final verification (Phase 4)
- Remove all re-exports from @uncaged/workflow -> @uncaged/workflow-runtime
- Fix cli-workflow imports to use @uncaged/workflow-runtime for types
- Update bundle-validator to allow @uncaged/workflow-runtime imports
- Update init templates to reference @uncaged/workflow-runtime
- 378 tests passing, build + check clean

Refs #121, relates #125
2026-05-08 06:37:56 +00:00
xiaoju e9e4960714 refactor(workflow): migrate downstream packages to workflow-runtime (Phase 2+3)
- Verify createWorkflow in runtime has zero I/O imports
- Migrate agent-cursor, agent-hermes to pure workflow-runtime dependency
- Migrate agent-llm, util-agent, templates to dual dependency
  (runtime for types, engine for CAS/merkle/buildDescriptor)
- All 377 tests passing

Refs #121, relates #123 #124
2026-05-08 06:33:52 +00:00
xiaoju 495c000356 refactor(workflow): split @uncaged/workflow-runtime from engine (Phase 1)
Create packages/workflow-runtime with the minimal runtime subset:
- Types (WorkflowFn, RoleOutput, AgentBinding, etc.)
- createWorkflow (pure orchestration, zero I/O)
- validateWorkflowDescriptor
- Result/ok/err, START/END constants

Zero external dependencies (zod as peer only).
Zero node:fs/node:path imports.

Engine (@uncaged/workflow) now depends on workflow-runtime and
provides CAS/merkle/extract implementations via injection.

Refs #121, relates #122
2026-05-08 06:29:49 +00:00
xiaomo 7e662f9287 Merge pull request 'feat(cli): add serve command — Hono HTTP API server' (#119) from feat/118-serve-api into main 2026-05-08 03:12:44 +00:00
xingyue 3ed38c65ec feat(cli): add serve command — Hono HTTP API server
Adds `uncaged-workflow serve` command that exposes workflow data
via a local HTTP API for the upcoming Web UI (RFC #118 Phase 1).

Routes:
- GET /healthz — health check
- GET /api/workflows — list registered workflows
- GET /api/workflows/:name — show workflow details
- GET /api/workflows/:name/history — version history
- GET /api/threads — list threads (optional ?workflow= filter)
- GET /api/threads/running — list running threads
- GET /api/threads/:id — show thread records (parsed JSONL)
- GET /api/cas — list CAS hashes
- GET /api/cas/:hash — get CAS content
- POST /api/cas — store content, returns hash
- DELETE /api/cas/:hash — remove CAS entry
- POST /api/cas/gc — garbage collect

Default: 127.0.0.1:7860, configurable via --port/-p and --host.

Refs: #118
2026-05-08 11:07:13 +08:00
xiaoju 38f2b0eeb2 Merge pull request 'chore: bump all packages to 0.2.0' (#117) from chore/bump-0.2.0 into main 2026-05-08 02:55:49 +00:00
125 changed files with 2306 additions and 856 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules"]
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
+1
View File
@@ -5,6 +5,7 @@
"packages/*"
],
"scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import {
createContentMerkleNode,
getGlobalCasDir,
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import {
cmdAdd,
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("cli workflow commands", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -402,21 +412,23 @@ export const run = async function* (input, options) {
});
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
const put = await cmdCasPut(storageRoot, "phase doc");
const raw = "phase doc";
const stored = casStoredForm(raw);
const put = await cmdCasPut(storageRoot, raw);
expect(put.ok).toBe(true);
if (!put.ok) {
return;
}
const hash = put.value;
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
expect(await readFile(blobPath, "utf8")).toBe(stored);
const got = await cmdCasGet(storageRoot, hash);
expect(got.ok).toBe(true);
if (!got.ok) {
return;
}
expect(got.value).toBe("phase doc");
expect(got.value).toBe(stored);
const listed = await cmdCasList(storageRoot);
expect(listed.ok).toBe(true);
@@ -51,6 +51,7 @@ describe("init template", () => {
};
expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr");
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
import { createApp } from "../src/commands/serve/app.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
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(casStoredForm("hello world"));
// cleanup
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
expect(delRes.status).toBe(200);
});
});
+2
View File
@@ -6,7 +6,9 @@
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow": "workspace:*",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
@@ -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<string, DispatchFn> = {
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
serve: dispatchServe,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
+11
View File
@@ -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(
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.1.0",
zod: "^4.0.0",
},
},
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
}
export function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION =
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
import type { HelloTemplateMeta } from "./roles.js";
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
}
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { helloTemplateModerator } from "./moderator.js";
import {
@@ -107,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createCasRoutes } from "./routes-cas.js";
import { createLiveRoutes } from "./routes-live.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/threads", createLiveRoutes(storageRoot));
app.route("/api/cas", createCasRoutes(storageRoot));
return app;
}
@@ -0,0 +1,3 @@
export { createApp } from "./app.js";
export { dispatchServe, startServer } from "./serve.js";
export type { ServeOptions } from "./types.js";
@@ -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;
}
@@ -0,0 +1,198 @@
import { statSync, watch } from "node:fs";
import { dirname, join } from "node:path";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { resolveThreadDataPath } from "../../thread-scan.js";
type PumpState = {
contentOffset: number;
carry: string;
};
function fileSize(path: string): number {
try {
return statSync(path).size;
} catch {
return 0;
}
}
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
const size = fileSize(path);
if (size < state.contentOffset) {
// File was truncated — reset
state.contentOffset = 0;
state.carry = "";
}
if (size <= state.contentOffset) {
return null;
}
const blob = Bun.file(path).slice(state.contentOffset, size);
const chunk = await blob.text();
state.contentOffset = size;
return chunk;
}
function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
}
function isWorkflowResult(record: unknown): boolean {
return (
record !== null &&
typeof record === "object" &&
"type" in (record as Record<string, unknown>) &&
(record as Record<string, unknown>).type === "workflow-result"
);
}
function parseNewLines(chunk: string, state: PumpState): string[] {
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
const lines: string[] = [];
for (const line of parts) {
const trimmed = line.trim();
if (trimmed !== "") {
lines.push(trimmed);
}
}
return lines;
}
export function createLiveRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/:threadId/live", 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 resolvedDataPath = dataPath;
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
return streamSSE(c, async (stream) => {
const dataState: PumpState = { contentOffset: 0, carry: "" };
const infoState: PumpState = { contentOffset: 0, carry: "" };
let eventId = 0;
async function pumpData(): Promise<boolean> {
let chunk: string | null;
try {
chunk = await readNewBytes(resolvedDataPath, dataState);
} catch {
return false;
}
if (chunk === null) {
return false;
}
const lines = parseNewLines(chunk, dataState);
for (const line of lines) {
const record = parseJsonLine(line);
eventId++;
await stream.writeSSE({
event: "record",
data: JSON.stringify(record),
id: String(eventId),
});
if (isWorkflowResult(record)) {
return true;
}
}
return false;
}
async function pumpInfo(): Promise<void> {
let chunk: string | null;
try {
chunk = await readNewBytes(infoPath, infoState);
} catch {
return;
}
if (chunk === null) {
return;
}
const lines = parseNewLines(chunk, infoState);
for (const line of lines) {
const record = parseJsonLine(line);
if (
typeof record === "object" &&
record !== null &&
"raw" in (record as Record<string, unknown>)
) {
continue;
}
eventId++;
await stream.writeSSE({
event: "info",
data: JSON.stringify(record),
id: String(eventId),
});
}
}
// Initial pump
const done = await pumpData();
await pumpInfo();
if (done) {
return;
}
// Watch for changes
const controller = new AbortController();
let completed = false;
const dataWatcher = watch(resolvedDataPath, async () => {
if (completed) return;
const finished = await pumpData();
if (finished) {
completed = true;
controller.abort();
}
});
let infoWatcher: ReturnType<typeof watch> | null = null;
try {
infoWatcher = watch(infoPath, async () => {
if (completed) return;
await pumpInfo();
});
} catch {
// info file may not exist
}
stream.onAbort(() => {
completed = true;
dataWatcher.close();
infoWatcher?.close();
});
// Keep stream alive until completion or client disconnect
await new Promise<void>((resolve) => {
if (completed) {
resolve();
return;
}
controller.signal.addEventListener("abort", () => resolve(), { once: true });
stream.onAbort(() => resolve());
});
dataWatcher.close();
infoWatcher?.close();
});
});
return app;
}
@@ -0,0 +1,98 @@
import { Hono } from "hono";
import { readTextFileIfExists } from "../../fs-utils.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadDataPath,
} from "../../thread-scan.js";
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
import { cmdRun } from "../thread/run.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 });
});
app.post("/", async (c) => {
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
const name = body.workflow;
const prompt = body.prompt;
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ threadId: result.value.threadId }, 201);
});
app.post("/:threadId/kill", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/pause", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/resume", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
return app;
}
@@ -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;
}
@@ -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<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 parseServeArgv(argv: string[]): Result<ServeOptions, string> {
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<number> {
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;
}
@@ -0,0 +1,4 @@
export type ServeOptions = {
port: number;
hostname: string;
};
@@ -9,8 +9,8 @@ import {
getGlobalCasDir,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
type WorkflowCompletion,
} from "@uncaged/workflow";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js";
-1
View File
@@ -203,7 +203,6 @@ Each role has:
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow
+1 -1
View File
@@ -17,6 +17,6 @@
"rootDir": "src",
"types": ["bun-types"]
},
"references": [{ "path": "../workflow" }],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
"include": ["src/**/*.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
# @uncaged/workflow-dashboard
Web dashboard for the Uncaged Workflow engine. Connects to the local
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
## Development
```bash
# Start the local API server (in another terminal)
uncaged-workflow serve
# Start the dashboard dev server
bun run dev
```
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
## Build
```bash
bun run build
```
Output goes to `dist/` — static files ready for CF Pages or any host.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.11"
}
}
+84
View File
@@ -0,0 +1,84 @@
const BASE = "/api";
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
throw new Error(err.error || `API ${res.status}`);
}
return res.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
};
export type ThreadSummary = {
threadId: string;
workflow: string | null;
hash: string | null;
startedAt: string | null;
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
}
export function runThread(
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
}
export function killThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/kill`, {});
}
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/pause`, {});
}
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/resume`, {});
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
type View = "threads" | "workflows";
export function App() {
const [view, setView] = useState<View>("threads");
const [selectedThread, setSelectedThread] = useState<string | null>(null);
const [showRun, setShowRun] = useState(false);
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />}
{view === "threads" && selectedThread && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
)}
{view === "workflows" && <WorkflowList />}
</div>
</main>
{showRun && (
<RunDialog
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setView("threads");
setSelectedThread(id);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,147 @@
import { useState } from "react";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(), []);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!workflow || !prompt) return;
setSubmitting(true);
setError(null);
try {
const result = await runThread(workflow, prompt, maxRounds);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
<div>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
type Props = {
view: "threads" | "workflows";
onViewChange: (v: "threads" | "workflows") => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
return (
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
{item.icon} {item.label}
</button>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,38 @@
import { getHealth } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onRun: () => void;
};
export function StatusBar({ onRun }: Props) {
const health = useFetch(() => getHealth(), []);
return (
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<button
type="button"
onClick={onRun}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
>
Run Thread
</button>
</div>
<span>
{health.status === "loading" && "⏳ Connecting..."}
{health.status === "ok" && (
<span style={{ color: "var(--color-success)" }}> Connected</span>
)}
{health.status === "error" && (
<span style={{ color: "var(--color-error)" }}> Offline</span>
)}
</span>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
type Props = {
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const sse = useSSE(threadId);
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
const liveActive = sse.connected && !sse.completed;
const records = liveActive
? sse.records
: status === "ok"
? data.records
: ([] as typeof sse.records);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [records.length]);
async function handleAction(action: "kill" | "pause" | "resume") {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
>
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
Pause
</button>
<button
type="button"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
Resume
</button>
<button
type="button"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
Kill
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
Live
</span>
)}
</h2>
{actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
{actionStatus}
</p>
)}
{status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => (
<div
key={i}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
))}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
</div>
);
}
@@ -0,0 +1,69 @@
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = data.threads;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
{threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
) : (
<div className="space-y-2">
{threads.map((t) => (
<button
type="button"
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
{t.status && (
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "running"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-text-muted)",
color: "#000",
}}
>
{t.status}
</span>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.startedAt}
</p>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
{workflows.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
>
{w.currentHash}
</code>
</div>
))}
</div>
)}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading"; data: null; error: null }
| { status: "ok"; data: T; error: null }
| { status: "error"; data: null; error: string };
export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({ status: "loading", data: null, error: null });
fetcher()
.then((data) => {
if (!cancelled) setState({ status: "ok", data, error: null });
})
.catch((err: unknown) => {
if (!cancelled)
setState({
status: "error",
data: null,
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
}, deps);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
@import "tailwindcss";
:root {
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./app.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
+161
View File
@@ -0,0 +1,161 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import type { ThreadRecord } from "./api.ts";
export type UseSSEReturn = {
records: ThreadRecord[];
connected: boolean;
completed: boolean;
};
function isWorkflowResult(record: ThreadRecord): boolean {
return record.type === "workflow-result";
}
function parseRecord(data: string): ThreadRecord | null {
try {
return JSON.parse(data) as ThreadRecord;
} catch {
return null;
}
}
type RecordEventContext = {
cancelled: boolean;
completedRef: MutableRefObject<boolean>;
setRecords: Dispatch<SetStateAction<ThreadRecord[]>>;
setCompleted: (value: boolean) => void;
setConnected: (value: boolean) => void;
cleanupEs: () => void;
};
function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
if (ctx.cancelled) {
return;
}
const msg = ev as MessageEvent;
const raw = typeof msg.data === "string" ? msg.data : "";
const parsed = parseRecord(raw);
if (parsed === null) {
return;
}
ctx.setRecords((prev) => [...prev, parsed]);
if (!isWorkflowResult(parsed)) {
return;
}
ctx.completedRef.current = true;
ctx.setCompleted(true);
ctx.setConnected(false);
ctx.cleanupEs();
}
export function useSSE(threadId: string | null): UseSSEReturn {
const [records, setRecords] = useState<ThreadRecord[]>([]);
const [connected, setConnected] = useState(false);
const [completed, setCompleted] = useState(false);
const completedRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
if (threadId === null) {
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
setConnected(false);
setCompleted(false);
return;
}
const tid = threadId;
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
setConnected(false);
setCompleted(false);
let es: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
function cleanupEs(): void {
if (es !== null) {
es.close();
es = null;
}
}
function scheduleReconnect(): void {
if (cancelled || completedRef.current) {
return;
}
const delayMs = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 8000);
reconnectAttemptsRef.current += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!cancelled && !completedRef.current) {
connect();
}
}, delayMs);
}
function connect(): void {
if (cancelled || completedRef.current) {
return;
}
cleanupEs();
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
es = new EventSource(url);
es.onopen = () => {
if (cancelled) {
return;
}
reconnectAttemptsRef.current = 0;
setConnected(true);
setRecords([]);
};
es.addEventListener("record", (ev: Event) =>
handleRecordEvent(ev, {
cancelled,
completedRef,
setRecords,
setCompleted,
setConnected,
cleanupEs,
}),
);
es.onerror = () => {
if (cancelled || completedRef.current) {
return;
}
setConnected(false);
cleanupEs();
scheduleReconnect();
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
}
cleanupEs();
};
}, [threadId]);
return { records, connected, completed };
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:7860",
changeOrigin: true,
},
},
},
});
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ExtractFn } from "@uncaged/workflow";
import type { ExtractFn } from "@uncaged/workflow-runtime";
export type CursorAgentConfig = {
model: string | null;
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { CursorAgentConfig } from "./types.js";
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentFn } from "@uncaged/workflow";
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { HermesAgentConfig } from "./types.js";
@@ -1,15 +1,9 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
const testCas = createCasStore(casDir);
function makeCtx(userContent: string): ThreadContext {
function makeCtx(userContent: string): AgentContext {
return {
start: {
role: START,
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
cas: testCas,
};
}
+2 -1
View File
@@ -8,6 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*"
}
}
@@ -5,7 +5,7 @@ import {
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow";
} from "@uncaged/workflow-runtime";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,2 @@
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
@@ -0,0 +1,13 @@
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
@@ -0,0 +1,40 @@
import { err, ok, type Result } from "../util/index.js";
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return err("descriptor must be a non-array object");
}
const root = value as Record<string, unknown>;
const description = root.description;
if (typeof description !== "string") {
return err("descriptor.description must be a string");
}
const rolesRaw = root.roles;
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
return err("descriptor.roles must be a non-array object");
}
const roles: Record<string, WorkflowRoleDescriptor> = {};
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
return err(`descriptor.roles.${roleName} must be a non-array object`);
}
const spec = specUnknown as Record<string, unknown>;
const roleDesc = spec.description;
if (typeof roleDesc !== "string") {
return err(`descriptor.roles.${roleName}.description must be a string`);
}
const schema = spec.schema;
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
}
roles[roleName] = {
description: roleDesc,
schema: schema as WorkflowRoleSchema,
};
}
return ok({ description, roles });
}
@@ -0,0 +1 @@
export type { CasStore } from "./types.js";
@@ -0,0 +1,6 @@
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
@@ -0,0 +1,170 @@
import type * as z from "zod/v4";
import type { CasStore } from "../cas/types.js";
import {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadContext,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowRuntime,
} from "../types.js";
import { mergeRefsWithContentHash } from "../util/index.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
return next !== END;
}
function resolveExtractedRefs(
roleDef: RoleDefinition<Record<string, unknown>>,
meta: unknown,
): string[] {
const extractRefsFn = roleDef.extractRefs;
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
return [];
}
return extractRefsFn(meta as Record<string, unknown>);
}
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
return store.put(raw);
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.agent;
}
type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
},
): Promise<AdvanceOutcome<M>> {
const { thread, runtime } = params;
const modCtx: ModeratorContext<M> = thread;
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return {
kind: "complete",
completion: { returnCode: 0, summary: "completed: moderator returned END" },
};
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
);
const contentHash = await putContentBlob(runtime.cas, raw);
const refs = mergeRefsWithContentHash(
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
contentHash,
);
const step = {
role: next,
contentHash,
meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
return {
kind: "yield",
output: {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
},
step,
};
}
/**
* Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
): WorkflowFn {
return async function* workflowLoop(
thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
if (thread.start.role !== START) {
throw new Error(`workflow loop expected start role to be ${START}`);
}
const maxRounds = thread.start.meta.maxRounds;
let currentThread = thread as ModeratorContext<M>;
while (true) {
if (currentThread.steps.length >= maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, {
thread: currentThread,
runtime,
});
if (outcome.kind === "complete") {
return outcome.completion;
}
yield outcome.output;
currentThread = {
...currentThread,
steps: [...currentThread.steps, outcome.step],
};
}
};
}
@@ -0,0 +1 @@
export { createWorkflow } from "./create-workflow.js";
@@ -0,0 +1 @@
export type { ExtractFn } from "./types.js";
@@ -0,0 +1,9 @@
import type * as z from "zod/v4";
import type { ExtractContext } from "../types.js";
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
+32
View File
@@ -0,0 +1,32 @@
export type {
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/types.js";
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
export type { CasStore } from "./cas/index.js";
export { createWorkflow } from "./engine/index.js";
export type { ExtractFn } from "./extract/index.js";
export type {
AgentBinding,
AgentContext,
AgentFn,
ExtractContext,
LlmProvider,
Moderator,
ModeratorContext,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowFn,
WorkflowResult,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
export type { Result } from "./util/index.js";
export { err, ok } from "./util/index.js";
@@ -17,9 +17,6 @@ export type LlmProvider = {
model: string;
};
/** How the engine runs meta extraction for a role after the agent phase. */
export type ExtractMode = "single" | "react";
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = {
role: string;
@@ -36,35 +33,23 @@ export type WorkflowCompletion = {
summary: string;
};
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
};
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
export type ThreadInput = {
prompt: string;
steps: RoleOutput[];
};
/** Options passed to a workflow bundle's `run` export (engine-provided). */
export type WorkflowFnOptions = {
threadId: string;
maxRounds: number;
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number;
/** Runtime dependencies passed to a workflow bundle's `run` export (engine-provided). */
export type WorkflowRuntime = {
/** Global CAS store for Merkle content blobs (role step bodies). */
cas: CasStore;
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
extract: ExtractFn;
/** Provider for `extractMode: "react"` roles; same backing config as `extract`. */
llmProvider: LlmProvider | null;
};
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
export type WorkflowFn = (
input: ThreadInput,
options: WorkflowFnOptions,
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
/** Engine start frame: initial prompt + thread identity. */
@@ -86,22 +71,24 @@ export type RoleStep<M extends RoleMeta> = {
};
}[keyof M & string];
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = {
/** Thread runtime context shared by moderator/agent/extractor phases. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
/** Same as `WorkflowFnOptions.depth` for the active thread. */
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number;
start: StartStep;
steps: RoleStep<M>[];
};
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
/** Phase 2: Agent executes — knows its role and prompt. */
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
cas: CasStore;
};
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
@@ -109,16 +96,13 @@ export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
};
/** Alias — most external consumers see the agent-phase context. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: AgentContext) => Promise<string>;
/** Runtime agent assignment (optional per-role overrides). */
/** Runtime agent assignment (explicit null when no per-role overrides). */
export type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
overrides: Partial<Record<string, AgentFn>> | null;
};
/** Role wiring: prompts, schema, and human-readable description. */
@@ -129,7 +113,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
schema: z.ZodType<Meta>;
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
extractRefs: ((meta: Meta) => string[]) | null;
extractMode: ExtractMode;
};
/**
@@ -0,0 +1,3 @@
export { mergeRefsWithContentHash } from "./refs-field.js";
export { err, ok } from "./result.js";
export type { Result } from "./types.js";
@@ -0,0 +1,8 @@
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
const out = [...refs];
if (!out.includes(contentHash)) {
out.push(contentHash);
}
return out;
}
@@ -0,0 +1,9 @@
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
@@ -0,0 +1 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
+4 -5
View File
@@ -2,7 +2,7 @@
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle.
Export a pure `WorkflowDefinition` (`developWorkflowDefinition`) and role/moderator pieces. Workflow instantiation (`createWorkflow(definition, binding)`) happens in the workflow instance layer, not in this template package.
## Install
@@ -15,10 +15,10 @@ In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@u
## Usage
```typescript
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
import { createWorkflow } from "@uncaged/workflow";
import { developWorkflowDefinition } from "@uncaged/workflow-template-develop";
const run = createDevelopRun(binding, extract, llmProvider);
// run(...) executes the develop moderator graph with your AgentBinding
const run = createWorkflow(developWorkflowDefinition, binding);
```
## Roles
@@ -46,7 +46,6 @@ Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `D
| Export | Description |
|--------|-------------|
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
| `developModerator` | `Moderator<DevelopMeta>` |
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
@@ -5,7 +5,7 @@ import {
type RoleStep,
START,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
} from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor } from "../src/descriptor.js";
import { developModerator } from "../src/index.js";
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
@@ -9,6 +9,7 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,9 +1,4 @@
import {
type AgentBinding,
createWorkflow,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
@@ -40,7 +35,3 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
roles: developRoles,
moderator: developModerator,
};
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding);
}
@@ -1,5 +1,5 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import type { DevelopMeta } from "./roles.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type CoderMeta, coderRole } from "./roles/coder.js";
import { type CommitterMeta, committerRole } from "./roles/committer.js";
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const coderMetaSchema = z.object({
@@ -31,5 +31,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase],
extractMode: "single",
};
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const committerMetaSchema = z.discriminatedUnion("status", [
@@ -32,5 +32,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
schema: committerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const phaseSchema = z.object({
@@ -48,5 +48,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
};
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const reviewerMetaSchema = z.discriminatedUnion("status", [
@@ -41,5 +41,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
schema: reviewerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const testerMetaSchema = z.discriminatedUnion("status", [
@@ -23,5 +23,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
"Extract the verification result: passed with summary details, or failed with details of what broke.",
schema: testerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -2,7 +2,7 @@
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts).
This package exports a pure `WorkflowDefinition` (`solveIssueWorkflowDefinition`). Workflow instantiation (`createWorkflow(definition, binding)`) and any role-specific agent wiring (for example delegating `developer` to `workflowAsAgent("develop")`) are done in the workflow instance layer.
## Install
@@ -15,9 +15,10 @@ In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
## Usage
```typescript
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
import { createWorkflow } from "@uncaged/workflow";
import { solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
const run = createSolveIssueRun(binding, extract, llmProvider);
const run = createWorkflow(solveIssueWorkflowDefinition, binding);
```
## Roles
@@ -41,7 +42,6 @@ Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod me
| Export | Description |
|--------|-------------|
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
@@ -2,18 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, createExtract, createWorkflow } from "@uncaged/workflow";
import {
createCasStore,
createExtract,
END,
type ModeratorContext,
type RoleStep,
START,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
} from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
@@ -24,46 +23,7 @@ function jsonResponse(payload: Record<string, unknown>): Response {
});
}
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
if (init === undefined || init.body === undefined || init.body === null) {
return [];
}
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
const tools = body.tools;
if (!Array.isArray(tools)) {
return [];
}
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
}
function singleToolName(tools: readonly Record<string, unknown>[]): string {
if (tools.length === 0) {
return "extract";
}
const fn = tools[0].function as Record<string, unknown> | undefined;
return typeof fn?.name === "string" ? fn.name : "extract";
}
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: { name: toolName, arguments: JSON.stringify(args) },
},
],
},
},
],
});
}
function buildReactModeResponse(args: Record<string, unknown>): Response {
// reactExtract accepts a plain-JSON assistant message and validates it
// directly against the schema, so we skip the cas_get / extract tool dance.
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [{ message: { content: JSON.stringify(args) } }],
});
@@ -74,18 +34,59 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
init?: RequestInit,
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
const tools = readToolListFromBody(init);
if (tools.length > 1) {
return buildReactModeResponse(args);
return buildPlainJsonResponse(args);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
function buildToolCallResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
id: "tc_extract_1",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify(args),
},
},
],
},
},
],
});
}
function installMockToolCallCompletions(
sequence: ReadonlyArray<Record<string, unknown>>,
): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockToolCallCompletions: empty sequence");
}
return buildSingleModeResponse(args, singleToolName(tools));
i += 1;
return buildToolCallResponse(args);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
@@ -161,17 +162,30 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
};
}
const stubExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
});
function createStubExtract(casDir: string) {
return createExtract(
{
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
},
{ cas: createCasStore(casDir) },
);
}
const stubLlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
};
function makeThread(prompt: string) {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: {
role: START,
content: prompt,
meta: { maxRounds: 20 },
timestamp: Date.now(),
},
steps: [],
};
}
describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => {
@@ -219,7 +233,7 @@ describe("solveIssueModerator", () => {
});
});
describe("createSolveIssueRun", () => {
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
let restoreFetch: (() => void) | null = null;
let casDir: string | undefined;
@@ -249,22 +263,48 @@ describe("createSolveIssueRun", () => {
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun({
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(
{ prompt: "task", steps: [] },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("structured extraction also accepts tool_calls extraction path", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/tool-call",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
);
};
restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
@@ -297,7 +337,7 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createSolveIssueRun({
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => {
calls.push("default");
return "";
@@ -317,17 +357,10 @@ describe("createSolveIssueRun", () => {
},
},
});
const gen = run(
{ prompt: "task", steps: [] },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -339,58 +372,6 @@ describe("createSolveIssueRun", () => {
await gen.next();
expect(calls).toEqual(["submitter"]);
});
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/y",
commitSha: "def5678",
filesChanged: ["b.ts"],
summary: "more work",
};
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
let developerInvocations = 0;
const run = createSolveIssueRun({
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
},
});
const gen = run(
{ prompt: "task", steps: [] },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
// preparer
await gen.next();
// developer (caller override should be invoked, NOT workflowAsAgent default)
const devYield = await gen.next();
expect(devYield.done).toBe(false);
if (devYield.done) {
throw new Error("expected yield");
}
expect(devYield.value.role).toBe("developer");
expect(devYield.value.meta).toEqual(DEVELOPER_META);
expect(developerInvocations).toBe(1);
});
});
describe("buildSolveIssueDescriptor", () => {
@@ -32,8 +32,7 @@ describe("submitterRole", () => {
expect(submitterRole.systemPrompt).toContain("pull request");
});
test("uses single extract mode without refs", () => {
expect(submitterRole.extractMode).toBe("single");
test("has no refs extractor", () => {
expect(submitterRole.extractRefs).toBeNull();
});
});
@@ -9,6 +9,7 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const developerMetaSchema = z.object({
@@ -33,5 +33,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
schema: developerMetaSchema,
extractRefs: () => [],
extractMode: "react",
};
@@ -1,10 +1,4 @@
import {
type AgentBinding,
createWorkflow,
type WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
} from "@uncaged/workflow";
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { solveIssueModerator } from "./moderator.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
@@ -36,22 +30,3 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
roles: solveIssueRoles,
moderator: solveIssueModerator,
};
/**
* Build the solve-issue {@link WorkflowFn}.
*
* The `developer` role always delegates to the registered `develop` workflow via
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/
export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = {
agent: binding.agent,
overrides: {
...(binding.overrides ?? {}),
developer: developerOverride,
},
};
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
}
@@ -1,5 +1,5 @@
import type { Moderator } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import type { Moderator } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import type { SolveIssueMeta } from "./roles.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type DeveloperMeta, developerRole } from "./developer.js";
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
const toolchainSchema = z.object({
@@ -48,5 +48,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow";
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const submitterMetaSchema = z.discriminatedUnion("status", [
@@ -40,5 +40,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
schema: submitterMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -1,12 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
import { describe, expect, test } from "bun:test";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { buildAgentPrompt } from "../src/index.js";
function startTask(content: string): ThreadContext["start"] {
function startTask(content: string): AgentContext["start"] {
return {
role: START,
content,
@@ -16,25 +13,13 @@ function startTask(content: string): ThreadContext["start"] {
}
describe("buildAgentPrompt", () => {
let casRoot: string;
beforeEach(async () => {
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
});
afterEach(async () => {
await rm(casRoot, { recursive: true, force: true });
});
test("includes system prompt and full task; omits tools when there are no steps", async () => {
const cas = createCasStore(casRoot);
const ctx: ThreadContext = {
const ctx: AgentContext = {
start: startTask("fix the bug"),
depth: 0,
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "You are an agent." },
cas,
};
const text = await buildAgentPrompt(ctx);
expect(text).toContain("You are an agent.");
@@ -43,15 +28,13 @@ describe("buildAgentPrompt", () => {
expect(text).not.toContain("## Tools");
});
test("single step shows full content and meta, and includes tools", async () => {
const cas = createCasStore(casRoot);
const onlyHash = await putContentMerkleNode(cas, "only step full body");
const ctx: ThreadContext = {
test("single step shows hash and meta, and includes tools", async () => {
const onlyHash = "01HASHSINGLESTEP0000000001";
const ctx: AgentContext = {
start: startTask("user task"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." },
cas,
steps: [
{
role: "coder",
@@ -66,22 +49,20 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Task");
expect(text).toContain("user task");
expect(text).toContain("## Step: coder");
expect(text).toContain("only step full body");
expect(text).toContain(`ContentHash: ${onlyHash}`);
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
const cas = createCasStore(casRoot);
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
const coderHash = await putContentMerkleNode(cas, "last step full content");
const ctx: ThreadContext = {
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
const plannerHash = "01HASHPLANNER0000000000001";
const coderHash = "01HASHCODER0000000000000001";
const ctx: AgentContext = {
start: startTask("first message full: task content here"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "System." },
cas,
steps: [
{
role: "planner",
@@ -104,25 +85,22 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Previous Steps");
expect(text).toContain("### Step 1: planner");
expect(text).toContain('Summary: {"plan":"short"}');
expect(text).not.toContain("PLANNER_SECRET_FULL_TEXT");
expect(text).toContain("## Latest Step: coder");
expect(text).toContain("last step full content");
expect(text).toContain(`ContentHash: ${coderHash}`);
expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("middle steps show meta summary only, not full content", async () => {
const cas = createCasStore(casRoot);
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
const ctx: ThreadContext = {
test("middle steps show meta summary only and latest shows hash", async () => {
const ha = "01HASHA00000000000000000001";
const hb = "01HASHB00000000000000000001";
const hc = "01HASHC00000000000000000001";
const ctx: AgentContext = {
start: startTask("start"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "S" },
cas,
steps: [
{
role: "a",
@@ -148,11 +126,9 @@ describe("buildAgentPrompt", () => {
],
};
const text = await buildAgentPrompt(ctx);
expect(text).not.toContain("HIDDEN_A");
expect(text).not.toContain("HIDDEN_B_MIDDLE");
expect(text).toContain('Summary: {"n":1}');
expect(text).toContain('Summary: {"n":2}');
expect(text).toContain("VISIBLE_LAST");
expect(text).toContain(`ContentHash: ${hc}`);
expect(text).toContain("## Latest Step: c");
});
});
+2 -1
View File
@@ -14,6 +14,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*"
}
}
@@ -1,13 +1,4 @@
import type { AgentContext } from "@uncaged/workflow";
import { getContentMerklePayload } from "@uncaged/workflow";
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
const text = await getContentMerklePayload(ctx.cas, contentHash);
if (text === null) {
throw new Error(`buildAgentPrompt: missing CAS blob for ${contentHash}`);
}
return text;
}
import type { AgentContext } from "@uncaged/workflow-runtime";
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
@@ -24,12 +15,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
if (steps.length === 1) {
const s = steps[0];
const body = await resolveStepText(ctx, s.contentHash);
lines.push("");
lines.push(`## Step: ${s.role}`);
lines.push("");
lines.push(body);
lines.push("");
lines.push(`ContentHash: ${s.contentHash}`);
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
} else {
lines.push("");
@@ -41,12 +30,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
}
const last = steps[steps.length - 1];
const lastBody = await resolveStepText(ctx, last.contentHash);
lines.push("");
lines.push(`## Latest Step: ${last.role}`);
lines.push("");
lines.push(lastBody);
lines.push("");
lines.push(`ContentHash: ${last.contentHash}`);
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
}
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
export type SpawnCliError =
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
import { END } from "../src/types.js";
describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
@@ -23,7 +22,6 @@ describe("buildDescriptor", () => {
extractPrompt: "Extract title and count from the analysis.",
schema,
extractRefs: null,
extractMode: "single",
},
},
moderator: () => END,
@@ -39,6 +39,16 @@ export const run = async function* (_input, options) {
expect(r.ok).toBe(true);
});
test("allows static import of @uncaged/workflow-runtime", () => {
const source = `${minimalDescriptor}import { createWorkflow } from "@uncaged/workflow-runtime";
import { putContentMerkleNode } from "@uncaged/workflow";
export const run = createWorkflow({ description: "x", roles: {}, moderator: () => "END" }, {});
`;
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
expect(r.ok).toBe(true);
});
test("rejects wrong filename suffix", () => {
const r = validateWorkflowBundle({
filePath: "/tmp/w.js",
+21 -9
View File
@@ -5,6 +5,11 @@ import { join } from "node:path";
import { createCasStore } from "../src/cas/cas.js";
import { hashString } from "../src/cas/hash.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("createCasStore", () => {
let casDir: string;
@@ -19,25 +24,30 @@ describe("createCasStore", () => {
test("put returns consistent hash for same content", async () => {
const cas = createCasStore(casDir);
const h1 = await cas.put("hello world");
const h2 = await cas.put("hello world");
const raw = "hello world";
const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2);
expect(h1).toBe(hashString(stored));
expect(h1).toHaveLength(13);
});
test("put returns hash matching hashString", async () => {
test("put returns hash matching hashString of merkle-stored form", async () => {
const cas = createCasStore(casDir);
const content = "some content to store";
const stored = casStoredForm(content);
const h = await cas.put(content);
expect(h).toBe(hashString(content));
expect(h).toBe(hashString(stored));
});
test("get returns stored content", async () => {
test("get returns merkle-serialized blob for raw puts", async () => {
const cas = createCasStore(casDir);
const content = "line1\nline2\nline3";
const stored = casStoredForm(content);
const h = await cas.put(content);
const retrieved = await cas.get(h);
expect(retrieved).toBe(content);
expect(retrieved).toBe(stored);
});
test("get returns null for missing hash", async () => {
@@ -76,11 +86,13 @@ describe("createCasStore", () => {
test("put is idempotent — same content written twice causes no error", async () => {
const cas = createCasStore(casDir);
const h1 = await cas.put("idempotent");
const h2 = await cas.put("idempotent");
const raw = "idempotent";
const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2);
const content = await cas.get(h1);
expect(content).toBe("idempotent");
expect(content).toBe(stored);
});
test("different content produces different hashes", async () => {
+9 -54
View File
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import {
createContentMerkleNode,
@@ -13,7 +13,6 @@ import {
} from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({
@@ -34,41 +33,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
input: Parameters<typeof fetch>[0],
init?: RequestInit,
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (tools[0] as Record<string, unknown>)
: null;
const fn =
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
choices: [{ message: { content: JSON.stringify(args) } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
@@ -126,7 +101,7 @@ async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<voi
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
}
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
/** Extract rounds reply with schema-shaped JSON in `content`; supervisor uses plain `content` (no tools advertised). */
function installMockExtractThenSupervisor(params: {
extractArgs: ReadonlyArray<Record<string, unknown>>;
supervisorContent: string;
@@ -148,26 +123,9 @@ function installMockExtractThenSupervisor(params: {
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
}
extractI += 1;
const firstTool = tools[0] as Record<string, unknown>;
const fn = firstTool.function as Record<string, unknown> | undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
choices: [{ message: { content: JSON.stringify(args) } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
@@ -197,7 +155,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract plan text and affected files list.",
schema: plannerMetaSchema,
extractRefs: null,
extractMode: "single",
},
coder: {
description: "Demo coder",
@@ -205,7 +162,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract the code diff summary.",
schema: coderMetaSchema,
extractRefs: null,
extractMode: "single",
},
},
moderator: (ctx) => {
@@ -348,7 +304,7 @@ describe("executeThread", () => {
}
});
test("pre-filled ThreadInput.steps skips roles already present", async () => {
test("pre-filled input.steps skips roles already present", async () => {
restoreFetch = installMockChatCompletions([{ diff: "+ok" }]);
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
@@ -554,7 +510,7 @@ describe("executeThread", () => {
}
});
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => {
test("extract traverses CAS DAG via cas_get during extraction", async () => {
const dagMetaSchema = z.object({ leafPayload: z.string() });
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
@@ -664,12 +620,11 @@ describe("executeThread", () => {
"Set leafPayload to the string payload of the content Merkle node under the root.",
schema: dagMetaSchema,
extractRefs: null,
extractMode: "react",
},
},
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
},
{ agent: async () => dagRootHash },
{ agent: async () => dagRootHash, overrides: null },
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { LlmProvider } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { reactExtract } from "../src/extract/react-extract.js";
import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() });
@@ -2,13 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const phaseSchema = z.object({
@@ -28,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
input: Parameters<typeof fetch>[0],
init?: RequestInit,
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (tools[0] as Record<string, unknown>)
: null;
const fn =
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
choices: [{ message: { content: JSON.stringify(args) } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
@@ -95,13 +70,13 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
extractPrompt: "Extract phases with CAS hashes.",
schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
},
},
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
},
{
agent: async () => "plan-output",
overrides: null,
},
);
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
@@ -14,7 +14,6 @@ import {
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry/registry.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
@@ -28,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
input: Parameters<typeof fetch>[0],
init?: RequestInit,
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (tools[0] as Record<string, unknown>)
: null;
const fn =
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
choices: [{ message: { content: JSON.stringify(args) } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
@@ -97,11 +72,11 @@ export const descriptor = {
},
},
};
export async function* run(input, options) {
const cas = options.cas;
export async function* run(thread, runtime) {
const cas = runtime.cas;
const h = await putContentMerkleNode(cas, "child-body");
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "child-done:" + input.prompt };
return { returnCode: 0, summary: "child-done:" + thread.start.content };
}
`;
@@ -148,12 +123,11 @@ describe("workflowAsAgent integration", () => {
extractPrompt: "extract done flag",
schema: callerMetaSchema,
extractRefs: null,
extractMode: "single",
},
},
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
},
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
{ agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { parseMerkleNode } from "../src/cas/merkle.js";
@@ -11,7 +11,6 @@ import {
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry/registry.js";
import { type AgentContext, START } from "../src/types.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
function makeAgentCtx(params: {
@@ -35,7 +34,6 @@ function makeAgentCtx(params: {
name: "caller",
systemPrompt: "caller",
},
cas: createCasStore(join(params.storageRoot, "agent-ctx-cas")),
};
}
@@ -50,11 +48,11 @@ export const descriptor = {
},
},
};
export async function* run(input, options) {
const cas = options.cas;
export async function* run(thread, runtime) {
const cas = runtime.cas;
const h = await putContentMerkleNode(cas, "child-body");
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "child-done:" + input.prompt };
return { returnCode: 0, summary: "child-done:" + thread.start.content };
}
`;
+1
View File
@@ -8,6 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"acorn": "^8.16.0",
"xxhashjs": "^0.2.2",
"yaml": "^2.8.4"

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