Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc2481a16 | |||
| 44018bd17d | |||
| 28c35bb3e0 | |||
| b8b557baf6 | |||
| 727b4bb3ed | |||
| 9bbdfc41bd | |||
| b07f8cf166 | |||
| 1a1e8b3398 | |||
| 39d2a61686 | |||
| bf0bc47a3f | |||
| 2cffaad127 | |||
| 9a3daac657 | |||
| b8f9ffcb59 | |||
| a7171f05f6 | |||
| b53667a2aa | |||
| 5b60fa6454 | |||
| 2c0e744ebf | |||
| ae16f09688 | |||
| 73a3638ad9 | |||
| 7b0260cedd | |||
| 61fc1cfe1b | |||
| 6b1e728700 | |||
| dedab62c49 | |||
| a44f1f34a8 | |||
| 8ff6f7e778 | |||
| e04e75bdee | |||
| c65c29c1b5 | |||
| 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
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -1,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",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -3,7 +3,9 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
@@ -19,9 +21,13 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -402,21 +408,23 @@ export const run = async function* (input, options) {
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const put = await cmdCasPut(storageRoot, "phase doc");
|
||||
const raw = "phase doc";
|
||||
const stored = casStoredForm(raw);
|
||||
const put = await cmdCasPut(storageRoot, raw);
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
||||
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||
|
||||
const got = await cmdCasGet(storageRoot, hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe("phase doc");
|
||||
expect(got.value).toBe(stored);
|
||||
|
||||
const listed = await cmdCasList(storageRoot);
|
||||
expect(listed.ok).toBe(true);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
@@ -10,7 +11,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
description: "fork-cli",
|
||||
|
||||
@@ -4,12 +4,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createCasStore,
|
||||
garbageCollectCas,
|
||||
getGlobalCasDir,
|
||||
putContentMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("init template", () => {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("init workspace", () => {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(wfPkg.type).toBe("module");
|
||||
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import {
|
||||
formatLiveDebugLine,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
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 error handling", () => {
|
||||
test("POST /api/threads with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/cas with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/threads with missing required fields → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ foo: "bar" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("required");
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const res = await app.fetch(new Request("http://localhost/test-error"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
}),
|
||||
);
|
||||
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
test("POST with body > 1MB → 413", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const largeBody = "x".repeat(1_048_577);
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(largeBody.length),
|
||||
},
|
||||
body: largeBody,
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Payload too large");
|
||||
});
|
||||
});
|
||||
|
||||
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,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdKill,
|
||||
@@ -21,7 +21,7 @@ import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -71,6 +72,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -57,6 +57,17 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||
lines.push(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -31,7 +31,7 @@ export function templateTsconfigJson(): string {
|
||||
}
|
||||
|
||||
export function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
@@ -58,7 +58,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
@@ -74,7 +74,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
}
|
||||
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
@@ -28,7 +28,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -107,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
});
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:7860",
|
||||
"http://127.0.0.1:7860",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (c.req.method === "POST") {
|
||||
const contentLength = c.req.header("content-length");
|
||||
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Payload too large" }, 413);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
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,57 @@
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const hashes = await cas.list();
|
||||
return c.json({ hashes });
|
||||
});
|
||||
|
||||
app.get("/:hash", async (c) => {
|
||||
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) => {
|
||||
let body: { content: string };
|
||||
try {
|
||||
body = (await c.req.json()) as { content: string };
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
if (typeof body.content !== "string") {
|
||||
return c.json({ error: "content field required" }, 400);
|
||||
}
|
||||
const hash = await cas.put(body.content);
|
||||
return c.json({ hash }, 201);
|
||||
});
|
||||
|
||||
app.delete("/:hash", async (c) => {
|
||||
const hash = c.req.param("hash");
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
await cas.delete(hash);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/gc", async (c) => {
|
||||
const result = await garbageCollectCas(storageRoot);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
}
|
||||
return c.json(result.value);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { statSync, watch } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
type PumpState = {
|
||||
contentOffset: number;
|
||||
carry: string;
|
||||
};
|
||||
|
||||
function fileSize(path: string): number {
|
||||
try {
|
||||
return statSync(path).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
|
||||
const size = fileSize(path);
|
||||
if (size < state.contentOffset) {
|
||||
// File was truncated — reset
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
if (size <= state.contentOffset) {
|
||||
return null;
|
||||
}
|
||||
const blob = Bun.file(path).slice(state.contentOffset, size);
|
||||
const chunk = await blob.text();
|
||||
state.contentOffset = size;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowResult(record: unknown): boolean {
|
||||
return (
|
||||
record !== null &&
|
||||
typeof record === "object" &&
|
||||
"type" in (record as Record<string, unknown>) &&
|
||||
(record as Record<string, unknown>).type === "workflow-result"
|
||||
);
|
||||
}
|
||||
|
||||
function parseNewLines(chunk: string, state: PumpState): string[] {
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed !== "") {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function createLiveRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:threadId/live", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const resolvedDataPath = dataPath;
|
||||
|
||||
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const dataState: PumpState = { contentOffset: 0, carry: "" };
|
||||
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||
let eventId = 0;
|
||||
|
||||
async function pumpData(): Promise<boolean> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(resolvedDataPath, dataState);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, dataState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
|
||||
if (isWorkflowResult(record)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pumpInfo(): Promise<void> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(infoPath, infoState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, infoState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
if (
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
"raw" in (record as Record<string, unknown>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "info",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initial pump
|
||||
const done = await pumpData();
|
||||
await pumpInfo();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for changes
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
const dataWatcher = watch(resolvedDataPath, async () => {
|
||||
if (completed) return;
|
||||
const finished = await pumpData();
|
||||
if (finished) {
|
||||
completed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||
try {
|
||||
infoWatcher = watch(infoPath, async () => {
|
||||
if (completed) return;
|
||||
await pumpInfo();
|
||||
});
|
||||
} catch {
|
||||
// info file may not exist
|
||||
}
|
||||
|
||||
stream.onAbort(() => {
|
||||
completed = true;
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
|
||||
// Keep stream alive until completion or client disconnect
|
||||
await new Promise<void>((resolve) => {
|
||||
if (completed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadDataPath,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||
}
|
||||
const lines = text.trim().split("\n");
|
||||
const records = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
});
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ threadId: result.value.threadId }, 201);
|
||||
});
|
||||
|
||||
app.post("/:threadId/kill", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/pause", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/resume", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
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-protocol";
|
||||
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;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "@uncaged/workflow";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import {
|
||||
readWorkerCtl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedForkArgv } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
@@ -2,15 +2,10 @@ import { watch } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
type CasStore,
|
||||
createCasStore,
|
||||
getContentMerklePayload,
|
||||
getGlobalCasDir,
|
||||
tryParseRoleStepRecord,
|
||||
tryParseWorkflowResultRecord,
|
||||
type WorkflowCompletion,
|
||||
} from "@uncaged/workflow";
|
||||
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
|
||||
|
||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
err,
|
||||
generateUlid,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedAddArgv } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
err,
|
||||
extractBundleExports,
|
||||
hashWorkflowBundleBytes,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowBundle,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
listRegisteredWorkflowNames,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryFile,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
unregisterWorkflow,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryEntry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type ParsedLiveArgv = {
|
||||
threadId: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
|
||||
@@ -203,7 +203,6 @@ Each role has:
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`extractMode\` | "single" | Extraction mode |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
|
||||
/**
|
||||
* Resolve storage root with env var override support.
|
||||
|
||||
@@ -3,7 +3,8 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [{ "path": "../workflow" }],
|
||||
"references": [
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-execute" },
|
||||
{ "path": "../workflow-register" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
model: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "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 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";
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
|
||||
const testCas = createCasStore(casDir);
|
||||
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
function makeCtx(userContent: string): AgentContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
cas: testCas,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,6 +8,6 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow-runtime" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,19 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
|
||||
import { join } from "node:path";
|
||||
|
||||
import { hashString } from "./hash.js";
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
|
||||
import type { CasStore } from "./types.js";
|
||||
|
||||
/** Raw strings become content merkle YAML; already-valid merkle documents pass through. */
|
||||
function normalizeCasPutContent(content: string): string {
|
||||
try {
|
||||
parseMerkleNode(content);
|
||||
return content;
|
||||
} catch {
|
||||
return serializeMerkleNode(createContentMerkleNode(content));
|
||||
}
|
||||
}
|
||||
|
||||
export function createCasStore(casDir: string): CasStore {
|
||||
async function ensureDir(): Promise<void> {
|
||||
await mkdir(casDir, { recursive: true });
|
||||
@@ -15,11 +26,12 @@ export function createCasStore(casDir: string): CasStore {
|
||||
|
||||
return {
|
||||
async put(content: string): Promise<string> {
|
||||
const hash = hashString(content);
|
||||
const toStore = normalizeCasPutContent(content);
|
||||
const hash = hashString(toStore);
|
||||
await ensureDir();
|
||||
const target = filePath(hash);
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await writeFile(tmp, content, "utf8");
|
||||
await writeFile(tmp, toStore, "utf8");
|
||||
await rename(tmp, target);
|
||||
return hash;
|
||||
},
|
||||
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
|
||||
|
||||
import XXH from "xxhashjs";
|
||||
|
||||
import { encodeUint64AsCrockford } from "../util/index.js";
|
||||
import { encodeUint64AsCrockford } from "@uncaged/workflow-util";
|
||||
|
||||
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
|
||||
const hex = digest.toString(16).padStart(16, "0");
|
||||
@@ -77,10 +77,9 @@ export async function putThreadMerkleNode(
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Serializes a content Merkle node and stores it in CAS; returns its hash. */
|
||||
/** Stores agent/content text via CAS; {@link createCasStore} wraps raw strings as merkle content nodes. */
|
||||
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
|
||||
const yamlText = serializeMerkleNode(createContentMerkleNode(content));
|
||||
return store.put(yamlText);
|
||||
return store.put(content);
|
||||
}
|
||||
|
||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||
@@ -1,9 +1,4 @@
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
export type { CasStore } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type MerkleNodeType = "content" | "step" | "thread";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" }
|
||||
]
|
||||
}
|
||||
@@ -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,38 @@
|
||||
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";
|
||||
import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const { view, threadId, setView, setThreadId } = useHashRoute();
|
||||
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" && threadId === null && <ThreadList onSelect={setThreadId} />}
|
||||
{view === "threads" && threadId !== null && (
|
||||
<ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{view === "workflows" && <WorkflowList />}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && (
|
||||
<RunDialog
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
setThreadId(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,65 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getHealth } from "../api.ts";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
type Props = {
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
if (status === "connected") {
|
||||
return { text: "● Connected", color: "var(--color-success)" };
|
||||
}
|
||||
if (status === "reconnecting") {
|
||||
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
|
||||
}
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
}
|
||||
|
||||
export function StatusBar({ onRun }: Props) {
|
||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
await getHealth();
|
||||
wasConnectedRef.current = true;
|
||||
setStatus("connected");
|
||||
} catch {
|
||||
if (wasConnectedRef.current) {
|
||||
setStatus("reconnecting");
|
||||
} else {
|
||||
setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
|
||||
const label = statusLabel(status);
|
||||
|
||||
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 style={{ color: label.color }}>{label.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { useSSE } from "../use-sse.ts";
|
||||
|
||||
type Props = {
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
const sse = useSSE(threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const liveActive = sse.connected && !sse.completed;
|
||||
const records = liveActive
|
||||
? sse.records
|
||||
: status === "ok"
|
||||
? data.records
|
||||
: ([] as typeof sse.records);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||
useEffect(() => {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [records.length]);
|
||||
|
||||
async function handleAction(action: "kill" | "pause" | "resume") {
|
||||
setActionStatus(`${action}ing...`);
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
← Back to threads
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("pause")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("resume")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||
>
|
||||
▶ Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("kill")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||
>
|
||||
✕ Kill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||
<span>{threadId}</span>
|
||||
{sse.connected && (
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{actionStatus && (
|
||||
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||
{actionStatus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r) => (
|
||||
<div
|
||||
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${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 !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
{new Date(r.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.content && (
|
||||
<pre
|
||||
className="whitespace-pre-wrap text-xs mt-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ThreadList({ onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listThreads(), []);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||
|
||||
const threads = data.threads;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Threads</h2>
|
||||
{threads.length === 0 ? (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{threads.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.threadId}
|
||||
onClick={() => onSelect(t.threadId)}
|
||||
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
|
||||
{t.threadId}
|
||||
</code>
|
||||
{t.status && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background:
|
||||
t.status === "running"
|
||||
? "var(--color-success)"
|
||||
: t.status === "failed"
|
||||
? "var(--color-error)"
|
||||
: "var(--color-text-muted)",
|
||||
color: "#000",
|
||||
}}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.workflow && (
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{t.workflow}
|
||||
</p>
|
||||
)}
|
||||
{t.startedAt && (
|
||||
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{t.startedAt}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
export function WorkflowList() {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(), []);
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||
|
||||
const workflows = data.workflows;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
|
||||
{workflows.length === 0 ? (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workflows.map((w) => (
|
||||
<div
|
||||
key={w.name}
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{w.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{w.versions} version{w.versions !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{w.currentHash}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,64 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
if (raw.startsWith("threads/")) {
|
||||
const id = raw.slice("threads/".length);
|
||||
if (id.length > 0) {
|
||||
return { view: "threads", threadId: id };
|
||||
}
|
||||
}
|
||||
if (raw === "workflows") {
|
||||
return { view: "workflows", threadId: null };
|
||||
}
|
||||
return { view: "threads", threadId: null };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
if (route.view === "workflows") {
|
||||
return "#workflows";
|
||||
}
|
||||
if (route.threadId !== null) {
|
||||
return `#threads/${route.threadId}`;
|
||||
}
|
||||
return "#threads";
|
||||
}
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
threadId: string | null;
|
||||
setView: (v: View) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange(): void {
|
||||
setRoute(parseHash(window.location.hash));
|
||||
}
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((next: HashRoute) => {
|
||||
const hash = buildHash(next);
|
||||
window.location.hash = hash;
|
||||
setRoute(next);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((v: View) => navigate({ view: v, threadId: null }), [navigate]);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", threadId: id }),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return { view: route.view, threadId: route.threadId, setView, setThreadId };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { ThreadRecord } from "./api.ts";
|
||||
|
||||
export type UseSSEReturn = {
|
||||
records: ThreadRecord[];
|
||||
connected: boolean;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
function isWorkflowResult(record: ThreadRecord): boolean {
|
||||
return record.type === "workflow-result";
|
||||
}
|
||||
|
||||
function parseRecord(data: string): ThreadRecord | null {
|
||||
try {
|
||||
return JSON.parse(data) as ThreadRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type RecordEventContext = {
|
||||
cancelled: boolean;
|
||||
completedRef: MutableRefObject<boolean>;
|
||||
setRecords: Dispatch<SetStateAction<ThreadRecord[]>>;
|
||||
setCompleted: (value: boolean) => void;
|
||||
setConnected: (value: boolean) => void;
|
||||
cleanupEs: () => void;
|
||||
};
|
||||
|
||||
function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
||||
if (ctx.cancelled) {
|
||||
return;
|
||||
}
|
||||
const msg = ev as MessageEvent;
|
||||
const raw = typeof msg.data === "string" ? msg.data : "";
|
||||
const parsed = parseRecord(raw);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
ctx.setRecords((prev) => [...prev, parsed]);
|
||||
if (!isWorkflowResult(parsed)) {
|
||||
return;
|
||||
}
|
||||
ctx.completedRef.current = true;
|
||||
ctx.setCompleted(true);
|
||||
ctx.setConnected(false);
|
||||
ctx.cleanupEs();
|
||||
}
|
||||
|
||||
export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const completedRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadId === null) {
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
setConnected(false);
|
||||
setCompleted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tid = threadId;
|
||||
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
setConnected(false);
|
||||
setCompleted(false);
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
function cleanupEs(): void {
|
||||
if (es !== null) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 8000);
|
||||
reconnectAttemptsRef.current += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (!cancelled && !completedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupEs();
|
||||
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setConnected(true);
|
||||
setRecords([]);
|
||||
};
|
||||
|
||||
es.addEventListener("record", (ev: Event) =>
|
||||
handleRecordEvent(ev, {
|
||||
cancelled,
|
||||
completedRef,
|
||||
setRecords,
|
||||
setCompleted,
|
||||
setConnected,
|
||||
cleanupEs,
|
||||
}),
|
||||
);
|
||||
|
||||
es.onerror = () => {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
setConnected(false);
|
||||
cleanupEs();
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
cleanupEs();
|
||||
};
|
||||
}, [threadId]);
|
||||
|
||||
return { records, connected, completed };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-reactor": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`.
|
||||
*
|
||||
* The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop
|
||||
* and delegates structured meta extraction to `WorkflowRuntime.extract`, which the engine
|
||||
* supplies (resolved from the `extract` scene in workflow.yaml).
|
||||
*/
|
||||
export { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
+143
-37
@@ -1,31 +1,40 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type {
|
||||
LlmProvider,
|
||||
RoleOutput,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
getContentMerklePayload,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
} from "../cas/index.js";
|
||||
import { resolveModel } from "../config/index.js";
|
||||
} from "@uncaged/workflow-cas";
|
||||
import { resolveModel } from "@uncaged/workflow-register";
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { readWorkflowRegistry } from "../registry/index.js";
|
||||
import type {
|
||||
LlmProvider,
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} from "../types.js";
|
||||
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
import { readWorkflowRegistry, type WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import { err, type LogFn, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
|
||||
|
||||
async function resolveExtractRuntime(
|
||||
async function resolveEngineRegistryRuntime(
|
||||
storageRoot: string,
|
||||
cas: CasStore,
|
||||
): Promise<
|
||||
Result<{ extract: ReturnType<typeof createExtract>; llmProvider: LlmProvider }, string>
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
>
|
||||
> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
@@ -45,7 +54,7 @@ async function resolveExtractRuntime(
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({ extract: createExtract(llmProvider), llmProvider });
|
||||
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
|
||||
}
|
||||
|
||||
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
@@ -79,11 +88,68 @@ async function finalizeThreadResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeAbortedThread(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: string[];
|
||||
logger: LogFn;
|
||||
abortLogTag: string;
|
||||
}): Promise<WorkflowResult> {
|
||||
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeSupervisorHaltsThread(params: {
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
written: number;
|
||||
recentSupervisorSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
threadId: string;
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
stepMerkleHashes: string[];
|
||||
}): Promise<WorkflowResult | null> {
|
||||
const interval = params.workflowConfig.supervisorInterval;
|
||||
if (interval <= 0 || params.written % interval !== 0) {
|
||||
return null;
|
||||
}
|
||||
const sup = await runSupervisor({
|
||||
config: params.workflowConfig,
|
||||
prompt: params.thread.start.content,
|
||||
recentSteps: params.recentSupervisorSteps,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!sup.ok) {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "stop") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
});
|
||||
}
|
||||
|
||||
async function driveWorkflowGenerator(params: {
|
||||
fn: WorkflowFn;
|
||||
workflowName: string;
|
||||
input: ThreadInput;
|
||||
bundleOptions: WorkflowFnOptions;
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
runtime: WorkflowRuntime;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
dataJsonlPath: string;
|
||||
threadId: string;
|
||||
@@ -94,8 +160,9 @@ async function driveWorkflowGenerator(params: {
|
||||
const {
|
||||
fn,
|
||||
workflowName,
|
||||
input,
|
||||
bundleOptions,
|
||||
workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions,
|
||||
dataJsonlPath,
|
||||
threadId,
|
||||
@@ -103,18 +170,22 @@ async function driveWorkflowGenerator(params: {
|
||||
cas,
|
||||
stepMerkleHashes,
|
||||
} = params;
|
||||
const gen = fn(input, bundleOptions);
|
||||
const gen = fn(thread, runtime);
|
||||
let written = 0;
|
||||
const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({
|
||||
role: s.role,
|
||||
summary: JSON.stringify(s.meta),
|
||||
}));
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP2", `thread ${threadId} aborted`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP2",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,6 +243,11 @@ async function driveWorkflowGenerator(params: {
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
recentSupervisorSteps.push({
|
||||
role: step.role,
|
||||
summary: JSON.stringify(step.meta),
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
executeOptions.awaitAfterEachYield(),
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -184,15 +260,30 @@ async function driveWorkflowGenerator(params: {
|
||||
]);
|
||||
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP4", `thread ${threadId} aborted`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP4",
|
||||
});
|
||||
}
|
||||
|
||||
const supervised = await maybeSupervisorHaltsThread({
|
||||
workflowConfig,
|
||||
thread,
|
||||
written,
|
||||
recentSupervisorSteps,
|
||||
logger,
|
||||
threadId,
|
||||
cas,
|
||||
workflowName,
|
||||
stepMerkleHashes,
|
||||
});
|
||||
if (supervised !== null) {
|
||||
return supervised;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +294,7 @@ async function driveWorkflowGenerator(params: {
|
||||
export async function executeThread(
|
||||
fn: WorkflowFn,
|
||||
workflowName: string,
|
||||
input: ThreadInput,
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: ExecuteThreadOptions,
|
||||
io: ExecuteThreadIo,
|
||||
logger: LogFn,
|
||||
@@ -280,25 +371,40 @@ export async function executeThread(
|
||||
});
|
||||
}
|
||||
|
||||
const extractRuntime = await resolveExtractRuntime(options.storageRoot);
|
||||
if (!extractRuntime.ok) {
|
||||
throw new Error(extractRuntime.error);
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
}
|
||||
|
||||
const bundleOptions: WorkflowFnOptions = {
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
timestamp: nowMs,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||
})),
|
||||
};
|
||||
|
||||
const runtime: WorkflowRuntime = {
|
||||
cas: io.cas,
|
||||
extract: extractRuntime.value.extract,
|
||||
llmProvider: extractRuntime.value.llmProvider,
|
||||
extract: registryRuntime.value.extract,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowName,
|
||||
input,
|
||||
bundleOptions,
|
||||
workflowConfig: registryRuntime.value.workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions: options,
|
||||
dataJsonlPath: io.dataJsonlPath,
|
||||
threadId: io.threadId,
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowCompletion } from "../types.js";
|
||||
import { err, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||
import { err, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user