Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc3f2b576c | |||
| 884ff85205 | |||
| a11cc62a81 | |||
| 34f5e655d1 | |||
| 44fb0694aa | |||
| cdcaff15ab | |||
| 402479ddef | |||
| a28dd3050e | |||
| ce0d0a962c | |||
| 46b552ec01 | |||
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 |
@@ -3,3 +3,4 @@ dist/
|
|||||||
bun.lock
|
bun.lock
|
||||||
*.tgz
|
*.tgz
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
.npmrc
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
type Roles = {
|
|
||||||
greeter: { greeting: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
const greeterMetaSchema = z.object({
|
|
||||||
greeting: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const descriptor = {
|
|
||||||
description: "A simple hello world workflow",
|
|
||||||
roles: {
|
|
||||||
greeter: {
|
|
||||||
description: "Generates a greeting",
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
properties: { greeting: { type: "string" } },
|
|
||||||
required: ["greeting"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
|
||||||
description: "Generates a greeting",
|
|
||||||
systemPrompt: "You greet the user briefly.",
|
|
||||||
extractPrompt: "Extract the greeting string produced for the user.",
|
|
||||||
schema: greeterMetaSchema,
|
|
||||||
extractRefs: null,
|
|
||||||
extractMode: "single",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const run = createWorkflow<Roles>(
|
|
||||||
{
|
|
||||||
roles: { greeter },
|
|
||||||
moderator(ctx) {
|
|
||||||
return ctx.steps.length === 0 ? "greeter" : END;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-examples",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
"name": "@uncaged/workflow-monorepo",
|
"name": "@uncaged/workflow-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*"
|
||||||
"examples"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check .",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||||
import {
|
import {
|
||||||
cmdAdd,
|
cmdAdd,
|
||||||
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
|
|||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
describe("cli workflow commands", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
let storageRoot: string;
|
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 () => {
|
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);
|
expect(put.ok).toBe(true);
|
||||||
if (!put.ok) {
|
if (!put.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = put.value;
|
const hash = put.value;
|
||||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
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);
|
const got = await cmdCasGet(storageRoot, hash);
|
||||||
expect(got.ok).toBe(true);
|
expect(got.ok).toBe(true);
|
||||||
if (!got.ok) {
|
if (!got.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(got.value).toBe("phase doc");
|
expect(got.value).toBe(stored);
|
||||||
|
|
||||||
const listed = await cmdCasList(storageRoot);
|
const listed = await cmdCasList(storageRoot);
|
||||||
expect(listed.ok).toBe(true);
|
expect(listed.ok).toBe(true);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe("init template", () => {
|
|||||||
};
|
};
|
||||||
expect(pkg.type).toBe("module");
|
expect(pkg.type).toBe("module");
|
||||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||||
|
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||||
expect(pkg.dependencies.zod).toBeDefined();
|
expect(pkg.dependencies.zod).toBeDefined();
|
||||||
expect(pkg.name).toContain("review-pr");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"hono": "^4.12.18",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getCommandRegistry } from "./cli-registry.js";
|
|||||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
import { createInitDispatcher } from "./commands/init/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 { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||||
@@ -71,6 +72,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
|||||||
skill: dispatchSkill,
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
live: dispatchLive,
|
live: dispatchLive,
|
||||||
|
serve: dispatchServe,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
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:");
|
lines.push("Reference:");
|
||||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
|
|||||||
type: "module",
|
type: "module",
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@uncaged/workflow": "^0.1.0",
|
"@uncaged/workflow": "^0.1.0",
|
||||||
|
"@uncaged/workflow-runtime": "^0.1.0",
|
||||||
zod: "^4.0.0",
|
zod: "^4.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateRolesTs(): 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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||||
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateModeratorTs(): string {
|
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";
|
import type { HelloTemplateMeta } from "./roles.js";
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateIndexTs(): string {
|
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 { helloTemplateModerator } from "./moderator.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
|||||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
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** 注册。
|
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||||
|
|
||||||
## 4. 编码规范
|
## 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,176 @@
|
|||||||
|
import { watch } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
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 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(text: string, state: PumpState): string[] {
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
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 text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(resolvedDataPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, 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 text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(infoPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, 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,
|
getGlobalCasDir,
|
||||||
tryParseRoleStepRecord,
|
tryParseRoleStepRecord,
|
||||||
tryParseWorkflowResultRecord,
|
tryParseWorkflowResultRecord,
|
||||||
type WorkflowCompletion,
|
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ Each role has:
|
|||||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||||
| \`extractMode\` | "single" | Extraction mode |
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../workflow" }],
|
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
threadId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||||
|
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||||
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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">{threadId}</h2>
|
||||||
|
{actionStatus && (
|
||||||
|
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{actionStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||||
|
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||||
|
{status === "ok" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.records.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`}
|
||||||
|
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 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 { 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 type * as z from "zod/v4";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
"name": "@uncaged/workflow-agent-cursor",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*",
|
"@uncaged/workflow-util-agent": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ExtractFn } from "@uncaged/workflow";
|
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
model: string | null;
|
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";
|
import type { CursorAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
"name": "@uncaged/workflow-agent-hermes",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
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";
|
import type { HermesAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||||
|
|
||||||
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
|
function makeCtx(userContent: string): AgentContext {
|
||||||
const testCas = createCasStore(casDir);
|
|
||||||
|
|
||||||
function makeCtx(userContent: string): ThreadContext {
|
|
||||||
return {
|
return {
|
||||||
start: {
|
start: {
|
||||||
role: START,
|
role: START,
|
||||||
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
|
|||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||||
cas: testCas,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-llm",
|
"name": "@uncaged/workflow-agent-llm",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type LlmProvider,
|
type LlmProvider,
|
||||||
ok,
|
ok,
|
||||||
type Result,
|
type Result,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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;
|
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). */
|
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
||||||
export type RoleOutput = {
|
export type RoleOutput = {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -36,35 +33,23 @@ export type WorkflowCompletion = {
|
|||||||
summary: string;
|
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 & {
|
export type WorkflowResult = WorkflowCompletion & {
|
||||||
rootHash: string;
|
rootHash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
|
/** Runtime dependencies passed to a workflow bundle's `run` export (engine-provided). */
|
||||||
export type ThreadInput = {
|
export type WorkflowRuntime = {
|
||||||
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;
|
|
||||||
/** Global CAS store for Merkle content blobs (role step bodies). */
|
/** Global CAS store for Merkle content blobs (role step bodies). */
|
||||||
cas: CasStore;
|
cas: CasStore;
|
||||||
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
|
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
|
||||||
extract: ExtractFn;
|
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. */
|
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
||||||
export type WorkflowFn = (
|
export type WorkflowFn = (
|
||||||
input: ThreadInput,
|
thread: ThreadContext,
|
||||||
options: WorkflowFnOptions,
|
runtime: WorkflowRuntime,
|
||||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||||
|
|
||||||
/** Engine start frame: initial prompt + thread identity. */
|
/** Engine start frame: initial prompt + thread identity. */
|
||||||
@@ -86,22 +71,24 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
};
|
};
|
||||||
}[keyof M & string];
|
}[keyof M & string];
|
||||||
|
|
||||||
/** Phase 1: Moderator decides next role. */
|
/** Thread runtime context shared by moderator/agent/extractor phases. */
|
||||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = {
|
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
/** Same as `WorkflowFnOptions.depth` for the active thread. */
|
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
|
||||||
depth: number;
|
depth: number;
|
||||||
start: StartStep;
|
start: StartStep;
|
||||||
steps: RoleStep<M>[];
|
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. */
|
/** Phase 2: Agent executes — knows its role and prompt. */
|
||||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
||||||
currentRole: {
|
currentRole: {
|
||||||
name: string;
|
name: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
};
|
};
|
||||||
cas: CasStore;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
|
/** 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;
|
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. */
|
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
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 = {
|
export type AgentBinding = {
|
||||||
agent: AgentFn;
|
agent: AgentFn;
|
||||||
overrides?: Partial<Record<string, AgentFn>>;
|
overrides: Partial<Record<string, AgentFn>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Role wiring: prompts, schema, and human-readable description. */
|
/** Role wiring: prompts, schema, and human-readable description. */
|
||||||
@@ -129,7 +113,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
|||||||
schema: z.ZodType<Meta>;
|
schema: z.ZodType<Meta>;
|
||||||
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
||||||
extractRefs: ((meta: Meta) => string[]) | null;
|
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 };
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
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
|
## Install
|
||||||
|
|
||||||
@@ -15,10 +15,10 @@ In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@u
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```typescript
|
```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);
|
const run = createWorkflow(developWorkflowDefinition, binding);
|
||||||
// run(...) executes the develop moderator graph with your AgentBinding
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
@@ -46,7 +46,6 @@ Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `D
|
|||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
|
|
||||||
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
||||||
| `developModerator` | `Moderator<DevelopMeta>` |
|
| `developModerator` | `Moderator<DevelopMeta>` |
|
||||||
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||||
import { developModerator } from "../src/index.js";
|
import { developModerator } from "../src/index.js";
|
||||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-develop",
|
"name": "@uncaged/workflow-template-develop",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
type AgentBinding,
|
|
||||||
createWorkflow,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { developModerator } from "./moderator.js";
|
import { developModerator } from "./moderator.js";
|
||||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||||
@@ -40,7 +35,3 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
|||||||
roles: developRoles,
|
roles: developRoles,
|
||||||
moderator: developModerator,
|
moderator: developModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
|
|
||||||
return createWorkflow(developWorkflowDefinition, binding);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { DevelopMeta } from "./roles.js";
|
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 CoderMeta, coderRole } from "./roles/coder.js";
|
||||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||||
import { type PlannerMeta, plannerRole } from "./roles/planner.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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const coderMetaSchema = z.object({
|
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.",
|
"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,
|
schema: coderMetaSchema,
|
||||||
extractRefs: (meta) => [meta.completedPhase],
|
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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
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.",
|
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||||
schema: committerMetaSchema,
|
schema: committerMetaSchema,
|
||||||
extractRefs: null,
|
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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const phaseSchema = z.object({
|
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).",
|
"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,
|
schema: plannerMetaSchema,
|
||||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
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.",
|
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||||
schema: reviewerMetaSchema,
|
schema: reviewerMetaSchema,
|
||||||
extractRefs: null,
|
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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
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.",
|
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||||
schema: testerMetaSchema,
|
schema: testerMetaSchema,
|
||||||
extractRefs: null,
|
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).
|
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
|
## Install
|
||||||
|
|
||||||
@@ -15,9 +15,10 @@ In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```typescript
|
```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
|
## Roles
|
||||||
@@ -41,7 +42,6 @@ Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod me
|
|||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
|
|
||||||
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
||||||
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
||||||
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
| `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 { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createCasStore, createExtract, createWorkflow } from "@uncaged/workflow";
|
||||||
import {
|
import {
|
||||||
createCasStore,
|
|
||||||
createExtract,
|
|
||||||
END,
|
END,
|
||||||
type ModeratorContext,
|
type ModeratorContext,
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
import type { DeveloperMeta } from "../src/developer.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 { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||||
import type { SolveIssueMeta } from "../src/roles.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>[] {
|
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
|
||||||
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.
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
});
|
});
|
||||||
@@ -74,18 +34,59 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
_input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
_init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
if (args === undefined) {
|
if (args === undefined) {
|
||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
const tools = readToolListFromBody(init);
|
return buildPlainJsonResponse(args);
|
||||||
if (tools.length > 1) {
|
};
|
||||||
return buildReactModeResponse(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, {
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
preconnect: origFetch.preconnect.bind(origFetch),
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
@@ -161,17 +162,30 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const stubExtract = createExtract({
|
function createStubExtract(casDir: string) {
|
||||||
baseUrl: "http://127.0.0.1:9",
|
return createExtract(
|
||||||
apiKey: "",
|
{
|
||||||
model: "test",
|
baseUrl: "http://127.0.0.1:9",
|
||||||
});
|
apiKey: "",
|
||||||
|
model: "test",
|
||||||
|
},
|
||||||
|
{ cas: createCasStore(casDir) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stubLlmProvider = {
|
function makeThread(prompt: string) {
|
||||||
baseUrl: "http://127.0.0.1:9",
|
return {
|
||||||
apiKey: "",
|
threadId: "01TEST000000000000000000TR",
|
||||||
model: "test",
|
depth: 0,
|
||||||
};
|
start: {
|
||||||
|
role: START,
|
||||||
|
content: prompt,
|
||||||
|
meta: { maxRounds: 20 },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("solveIssueModerator", () => {
|
describe("solveIssueModerator", () => {
|
||||||
test("routes initial → preparer → developer → submitter → END", () => {
|
test("routes initial → preparer → developer → submitter → END", () => {
|
||||||
@@ -219,7 +233,7 @@ describe("solveIssueModerator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createSolveIssueRun", () => {
|
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||||
let restoreFetch: (() => void) | null = null;
|
let restoreFetch: (() => void) | null = null;
|
||||||
let casDir: string | undefined;
|
let casDir: string | undefined;
|
||||||
|
|
||||||
@@ -249,22 +263,48 @@ describe("createSolveIssueRun", () => {
|
|||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
// Override developer so the test does not spin up a child workflow.
|
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||||
const run = createSolveIssueRun({
|
|
||||||
agent: async () => "",
|
agent: async () => "",
|
||||||
overrides: { developer: async () => "stub-root-hash" },
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
});
|
});
|
||||||
const gen = run(
|
const gen = run(makeThread("task"), {
|
||||||
{ prompt: "task", steps: [] },
|
cas,
|
||||||
{
|
extract: createStubExtract(casDir),
|
||||||
threadId: "01TEST000000000000000000TR",
|
});
|
||||||
maxRounds: 20,
|
const first = await gen.next();
|
||||||
depth: 0,
|
expect(first.done).toBe(false);
|
||||||
cas,
|
if (first.done) {
|
||||||
extract: stubExtract,
|
throw new Error("expected yield");
|
||||||
llmProvider: stubLlmProvider,
|
}
|
||||||
|
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();
|
const first = await gen.next();
|
||||||
expect(first.done).toBe(false);
|
expect(first.done).toBe(false);
|
||||||
if (first.done) {
|
if (first.done) {
|
||||||
@@ -297,7 +337,7 @@ describe("createSolveIssueRun", () => {
|
|||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const run = createSolveIssueRun({
|
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||||
agent: async () => {
|
agent: async () => {
|
||||||
calls.push("default");
|
calls.push("default");
|
||||||
return "";
|
return "";
|
||||||
@@ -317,17 +357,10 @@ describe("createSolveIssueRun", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const gen = run(
|
const gen = run(makeThread("task"), {
|
||||||
{ prompt: "task", steps: [] },
|
cas,
|
||||||
{
|
extract: createStubExtract(casDir),
|
||||||
threadId: "01TEST000000000000000000TR",
|
});
|
||||||
maxRounds: 20,
|
|
||||||
depth: 0,
|
|
||||||
cas,
|
|
||||||
extract: stubExtract,
|
|
||||||
llmProvider: stubLlmProvider,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["preparer"]);
|
expect(calls).toEqual(["preparer"]);
|
||||||
|
|
||||||
@@ -339,58 +372,6 @@ describe("createSolveIssueRun", () => {
|
|||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["submitter"]);
|
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", () => {
|
describe("buildSolveIssueDescriptor", () => {
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ describe("submitterRole", () => {
|
|||||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses single extract mode without refs", () => {
|
test("has no refs extractor", () => {
|
||||||
expect(submitterRole.extractMode).toBe("single");
|
|
||||||
expect(submitterRole.extractRefs).toBeNull();
|
expect(submitterRole.extractRefs).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-solve-issue",
|
"name": "@uncaged/workflow-template-solve-issue",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const developerMetaSchema = z.object({
|
export const developerMetaSchema = z.object({
|
||||||
@@ -33,5 +33,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
|
|||||||
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||||
schema: developerMetaSchema,
|
schema: developerMetaSchema,
|
||||||
extractRefs: () => [],
|
extractRefs: () => [],
|
||||||
extractMode: "react",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
type AgentBinding,
|
|
||||||
createWorkflow,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
workflowAsAgent,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
@@ -36,22 +30,3 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
|||||||
roles: solveIssueRoles,
|
roles: solveIssueRoles,
|
||||||
moderator: solveIssueModerator,
|
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 type { Moderator } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
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 DeveloperMeta, developerRole } from "./developer.js";
|
||||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
const toolchainSchema = z.object({
|
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).",
|
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||||
schema: preparerMetaSchema,
|
schema: preparerMetaSchema,
|
||||||
extractRefs: null,
|
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";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -40,5 +40,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
|||||||
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
||||||
schema: submitterMetaSchema,
|
schema: submitterMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { buildAgentPrompt } from "../src/index.js";
|
import { buildAgentPrompt } from "../src/index.js";
|
||||||
|
|
||||||
function startTask(content: string): ThreadContext["start"] {
|
function startTask(content: string): AgentContext["start"] {
|
||||||
return {
|
return {
|
||||||
role: START,
|
role: START,
|
||||||
content,
|
content,
|
||||||
@@ -16,25 +13,13 @@ function startTask(content: string): ThreadContext["start"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("buildAgentPrompt", () => {
|
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 () => {
|
test("includes system prompt and full task; omits tools when there are no steps", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("fix the bug"),
|
start: startTask("fix the bug"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||||
cas,
|
|
||||||
};
|
};
|
||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).toContain("You are an agent.");
|
expect(text).toContain("You are an agent.");
|
||||||
@@ -43,15 +28,13 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).not.toContain("## Tools");
|
expect(text).not.toContain("## Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single step shows full content and meta, and includes tools", async () => {
|
test("single step shows hash and meta, and includes tools", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
const onlyHash = await putContentMerkleNode(cas, "only step full body");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
@@ -66,22 +49,20 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
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('Meta: {"files":["a.ts"]}');
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
|
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const plannerHash = "01HASHPLANNER0000000000001";
|
||||||
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
|
const coderHash = "01HASHCODER0000000000000001";
|
||||||
const coderHash = await putContentMerkleNode(cas, "last step full content");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("first message full: task content here"),
|
start: startTask("first message full: task content here"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "System." },
|
currentRole: { name: "coder", systemPrompt: "System." },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
@@ -104,25 +85,22 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Previous Steps");
|
expect(text).toContain("## Previous Steps");
|
||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
expect(text).toContain('Summary: {"plan":"short"}');
|
expect(text).toContain('Summary: {"plan":"short"}');
|
||||||
expect(text).not.toContain("PLANNER_SECRET_FULL_TEXT");
|
|
||||||
expect(text).toContain("## Latest Step: coder");
|
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('Meta: {"done":true}');
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("middle steps show meta summary only, not full content", async () => {
|
test("middle steps show meta summary only and latest shows hash", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const ha = "01HASHA00000000000000000001";
|
||||||
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
|
const hb = "01HASHB00000000000000000001";
|
||||||
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
|
const hc = "01HASHC00000000000000000001";
|
||||||
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("start"),
|
start: startTask("start"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "c", systemPrompt: "S" },
|
currentRole: { name: "c", systemPrompt: "S" },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
@@ -148,11 +126,9 @@ describe("buildAgentPrompt", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const text = await buildAgentPrompt(ctx);
|
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":1}');
|
||||||
expect(text).toContain('Summary: {"n":2}');
|
expect(text).toContain('Summary: {"n":2}');
|
||||||
expect(text).toContain("VISIBLE_LAST");
|
expect(text).toContain(`ContentHash: ${hc}`);
|
||||||
expect(text).toContain("## Latest Step: c");
|
expect(text).toContain("## Latest Step: c");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-util-agent",
|
"name": "@uncaged/workflow-util-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import type { AgentContext } from "@uncaged/workflow";
|
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||||
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||||
@@ -24,12 +15,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
|||||||
|
|
||||||
if (steps.length === 1) {
|
if (steps.length === 1) {
|
||||||
const s = steps[0];
|
const s = steps[0];
|
||||||
const body = await resolveStepText(ctx, s.contentHash);
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`## Step: ${s.role}`);
|
lines.push(`## Step: ${s.role}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(body);
|
lines.push(`ContentHash: ${s.contentHash}`);
|
||||||
lines.push("");
|
|
||||||
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||||
} else {
|
} else {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -41,12 +30,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
|||||||
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||||
}
|
}
|
||||||
const last = steps[steps.length - 1];
|
const last = steps[steps.length - 1];
|
||||||
const lastBody = await resolveStepText(ctx, last.contentHash);
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`## Latest Step: ${last.role}`);
|
lines.push(`## Latest Step: ${last.role}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(lastBody);
|
lines.push(`ContentHash: ${last.contentHash}`);
|
||||||
lines.push("");
|
|
||||||
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
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 =
|
export type SpawnCliError =
|
||||||
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
|
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
|
|
||||||
describe("buildDescriptor", () => {
|
describe("buildDescriptor", () => {
|
||||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
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.",
|
extractPrompt: "Extract title and count from the analysis.",
|
||||||
schema,
|
schema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: () => END,
|
moderator: () => END,
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export const run = async function* (_input, options) {
|
|||||||
expect(r.ok).toBe(true);
|
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", () => {
|
test("rejects wrong filename suffix", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.js",
|
filePath: "/tmp/w.js",
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashString } from "../src/cas/hash.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", () => {
|
describe("createCasStore", () => {
|
||||||
let casDir: string;
|
let casDir: string;
|
||||||
@@ -19,25 +24,30 @@ describe("createCasStore", () => {
|
|||||||
|
|
||||||
test("put returns consistent hash for same content", async () => {
|
test("put returns consistent hash for same content", async () => {
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
const h1 = await cas.put("hello world");
|
const raw = "hello world";
|
||||||
const h2 = await cas.put("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(h2);
|
||||||
|
expect(h1).toBe(hashString(stored));
|
||||||
expect(h1).toHaveLength(13);
|
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 cas = createCasStore(casDir);
|
||||||
const content = "some content to store";
|
const content = "some content to store";
|
||||||
|
const stored = casStoredForm(content);
|
||||||
const h = await cas.put(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 cas = createCasStore(casDir);
|
||||||
const content = "line1\nline2\nline3";
|
const content = "line1\nline2\nline3";
|
||||||
|
const stored = casStoredForm(content);
|
||||||
const h = await cas.put(content);
|
const h = await cas.put(content);
|
||||||
const retrieved = await cas.get(h);
|
const retrieved = await cas.get(h);
|
||||||
expect(retrieved).toBe(content);
|
expect(retrieved).toBe(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("get returns null for missing hash", async () => {
|
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 () => {
|
test("put is idempotent — same content written twice causes no error", async () => {
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
const h1 = await cas.put("idempotent");
|
const raw = "idempotent";
|
||||||
const h2 = await cas.put("idempotent");
|
const stored = casStoredForm(raw);
|
||||||
|
const h1 = await cas.put(raw);
|
||||||
|
const h2 = await cas.put(raw);
|
||||||
expect(h1).toBe(h2);
|
expect(h1).toBe(h2);
|
||||||
const content = await cas.get(h1);
|
const content = await cas.get(h1);
|
||||||
expect(content).toBe("idempotent");
|
expect(content).toBe(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("different content produces different hashes", async () => {
|
test("different content produces different hashes", async () => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import {
|
import {
|
||||||
createContentMerkleNode,
|
createContentMerkleNode,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "../src/cas/merkle.js";
|
} from "../src/cas/merkle.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const plannerMetaSchema = z.object({
|
const plannerMetaSchema = z.object({
|
||||||
@@ -34,41 +33,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
_init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
if (args === undefined) {
|
if (args === undefined) {
|
||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
choices: [
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: toolName,
|
|
||||||
arguments: JSON.stringify(args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
@@ -96,6 +71,81 @@ async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
|
|||||||
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
supervisorInterval: 2
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
extract: stub/model
|
||||||
|
supervisor: stub/supervisor-cheap
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
supervisorInterval: 10
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
extract: stub/model
|
||||||
|
supervisor: stub/supervisor-cheap
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
|
||||||
|
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
onSupervisorCall?: () => void;
|
||||||
|
}): () => void {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let extractI = 0;
|
||||||
|
const mockFetch = async (
|
||||||
|
_input: Parameters<typeof fetch>[0],
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||||
|
const tools = body.tools;
|
||||||
|
const hasTools = Array.isArray(tools) && tools.length > 0;
|
||||||
|
if (hasTools) {
|
||||||
|
const args =
|
||||||
|
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
|
||||||
|
if (args === undefined) {
|
||||||
|
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
|
||||||
|
}
|
||||||
|
extractI += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.onSupervisorCall?.();
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [{ message: { content: params.supervisorContent } }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
|
}) as typeof fetch;
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const demoWorkflow = createWorkflow<DemoMeta>(
|
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||||
{
|
{
|
||||||
roles: {
|
roles: {
|
||||||
@@ -105,7 +155,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
|||||||
extractPrompt: "Extract plan text and affected files list.",
|
extractPrompt: "Extract plan text and affected files list.",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
},
|
},
|
||||||
coder: {
|
coder: {
|
||||||
description: "Demo coder",
|
description: "Demo coder",
|
||||||
@@ -113,7 +162,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
|||||||
extractPrompt: "Extract the code diff summary.",
|
extractPrompt: "Extract the code diff summary.",
|
||||||
schema: coderMetaSchema,
|
schema: coderMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: (ctx) => {
|
moderator: (ctx) => {
|
||||||
@@ -256,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" }]);
|
restoreFetch = installMockChatCompletions([{ diff: "+ok" }]);
|
||||||
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
|
||||||
@@ -462,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() });
|
const dagMetaSchema = z.object({ leafPayload: z.string() });
|
||||||
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
||||||
|
|
||||||
@@ -572,12 +620,11 @@ describe("executeThread", () => {
|
|||||||
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
||||||
schema: dagMetaSchema,
|
schema: dagMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "react",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||||
},
|
},
|
||||||
{ agent: async () => dagRootHash },
|
{ agent: async () => dagRootHash, overrides: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
@@ -623,4 +670,102 @@ describe("executeThread", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("supervisor stops thread when interval elapses and model returns stop", async () => {
|
||||||
|
restoreFetch = installMockExtractThenSupervisor({
|
||||||
|
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||||
|
supervisorContent: "stop",
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-stop-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeRegistryYaml(root, SUPERVISOR_INTERVAL_REGISTRY_YAML);
|
||||||
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"demo-flow",
|
||||||
|
{ prompt: "supervisor-stop-case", steps: [] },
|
||||||
|
{
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
signal: ac.signal,
|
||||||
|
awaitAfterEachYield: async () => {},
|
||||||
|
forkSourceThreadId: null,
|
||||||
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
|
},
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(result.summary).toBe("completed: supervisor stopped thread");
|
||||||
|
|
||||||
|
const dataText = await readFile(dataPath, "utf8");
|
||||||
|
const lines = dataText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(3);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supervisor is not invoked before supervisorInterval rounds", async () => {
|
||||||
|
let supervisorCalls = 0;
|
||||||
|
restoreFetch = installMockExtractThenSupervisor({
|
||||||
|
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||||
|
supervisorContent: "stop",
|
||||||
|
onSupervisorCall: () => {
|
||||||
|
supervisorCalls += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-skip-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeRegistryYaml(root, SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML);
|
||||||
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"demo-flow",
|
||||||
|
{ prompt: "no-supervisor-yet", steps: [] },
|
||||||
|
{
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
signal: ac.signal,
|
||||||
|
awaitAfterEachYield: async () => {},
|
||||||
|
forkSourceThreadId: null,
|
||||||
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
|
},
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(supervisorCalls).toBe(0);
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(result.summary).toBe("completed: moderator returned END");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||||
import { reactExtract } from "../src/extract/react-extract.js";
|
import { reactExtract } from "../src/extract/react-extract.js";
|
||||||
import type { LlmProvider } from "../src/types.js";
|
|
||||||
|
|
||||||
const metaSchema = z.object({ seen: z.string() });
|
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 { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const phaseSchema = z.object({
|
const phaseSchema = z.object({
|
||||||
@@ -28,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
_init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
if (args === undefined) {
|
if (args === undefined) {
|
||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
choices: [
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: toolName,
|
|
||||||
arguments: JSON.stringify(args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
@@ -95,13 +70,13 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
|||||||
extractPrompt: "Extract phases with CAS hashes.",
|
extractPrompt: "Extract phases with CAS hashes.",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||||
extractMode: "single",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
agent: async () => "plan-output",
|
agent: async () => "plan-output",
|
||||||
|
overrides: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,65 @@ workflows:
|
|||||||
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
||||||
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
||||||
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults supervisorInterval to 3 when omitted", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok || r.value.config === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses explicit supervisorInterval", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
supervisorInterval: 7
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok || r.value.config === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse errors when supervisorInterval is negative", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
supervisorInterval: -1
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses config apiKey env: prefix from process.env", () => {
|
test("parses config apiKey env: prefix from process.env", () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user