Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc3f2b576c | |||
| 884ff85205 | |||
| a11cc62a81 | |||
| 34f5e655d1 | |||
| 44fb0694aa | |||
| cdcaff15ab | |||
| 402479ddef | |||
| a28dd3050e | |||
| ce0d0a962c | |||
| 46b552ec01 | |||
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 | |||
| 520b17b351 | |||
| 085cdcd3f4 | |||
| a8c1c158d6 | |||
| 83649fd836 | |||
| 9e6cd9d615 | |||
| 1f1128ff4a | |||
| aa01283ce1 |
@@ -3,3 +3,4 @@ dist/
|
|||||||
bun.lock
|
bun.lock
|
||||||
*.tgz
|
*.tgz
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
.npmrc
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
|||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uncaged-workflow help # Show all commands
|
uncaged-workflow # Print full command usage (exits with status 1)
|
||||||
uncaged-workflow workflow list # List registered workflows
|
uncaged-workflow workflow list # List registered workflows
|
||||||
uncaged-workflow run <name> # Start a workflow thread
|
uncaged-workflow run <name> # Start a workflow thread
|
||||||
uncaged-workflow thread list # List all threads
|
uncaged-workflow thread list # List all threads
|
||||||
@@ -56,7 +56,7 @@ uncaged-workflow thread show <id> # Inspect a thread
|
|||||||
uncaged-workflow skill # Agent-consumable reference docs
|
uncaged-workflow skill # Agent-consumable reference docs
|
||||||
```
|
```
|
||||||
|
|
||||||
See `uncaged-workflow help` for the full command reference.
|
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createExtract, 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",
|
|
||||||
};
|
|
||||||
|
|
||||||
const extract = createExtract({
|
|
||||||
baseUrl: "http://127.0.0.1:9",
|
|
||||||
apiKey: "",
|
|
||||||
model: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const run = createWorkflow<Roles>(
|
|
||||||
{
|
|
||||||
roles: { greeter },
|
|
||||||
moderator(ctx) {
|
|
||||||
return ctx.steps.length === 0 ? "greeter" : END;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
|
||||||
},
|
|
||||||
extract,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-examples",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -2,10 +2,10 @@
|
|||||||
"name": "@uncaged/workflow-monorepo",
|
"name": "@uncaged/workflow-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*"
|
||||||
"examples"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check .",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
import {
|
||||||
|
createContentMerkleNode,
|
||||||
|
getGlobalCasDir,
|
||||||
|
getRegisteredWorkflow,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
serializeMerkleNode,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||||
import {
|
import {
|
||||||
cmdAdd,
|
cmdAdd,
|
||||||
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
|
|||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
describe("cli workflow commands", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
let storageRoot: string;
|
let storageRoot: string;
|
||||||
@@ -402,21 +412,23 @@ export const run = async function* (input, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||||
const put = await cmdCasPut(storageRoot, "phase doc");
|
const raw = "phase doc";
|
||||||
|
const stored = casStoredForm(raw);
|
||||||
|
const put = await cmdCasPut(storageRoot, raw);
|
||||||
expect(put.ok).toBe(true);
|
expect(put.ok).toBe(true);
|
||||||
if (!put.ok) {
|
if (!put.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hash = put.value;
|
const hash = put.value;
|
||||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||||
|
|
||||||
const got = await cmdCasGet(storageRoot, hash);
|
const got = await cmdCasGet(storageRoot, hash);
|
||||||
expect(got.ok).toBe(true);
|
expect(got.ok).toBe(true);
|
||||||
if (!got.ok) {
|
if (!got.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(got.value).toBe("phase doc");
|
expect(got.value).toBe(stored);
|
||||||
|
|
||||||
const listed = await cmdCasList(storageRoot);
|
const listed = await cmdCasList(storageRoot);
|
||||||
expect(listed.ok).toBe(true);
|
expect(listed.ok).toBe(true);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
|||||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
@@ -77,6 +78,7 @@ describe("cli fork", () => {
|
|||||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||||
import {
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
|
||||||
formatSkillDoc,
|
|
||||||
formatSkillIndex,
|
|
||||||
formatSkillTopic,
|
|
||||||
getSkillTopics,
|
|
||||||
} from "../src/skill.js";
|
|
||||||
|
|
||||||
const STORAGE_ROOT = "/tmp/help-test-storage";
|
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||||
|
|
||||||
describe("help command", () => {
|
describe("runCli usage", () => {
|
||||||
test("help returns 0", async () => {
|
test("no args prints usage and returns 1", async () => {
|
||||||
const code = await runCli(STORAGE_ROOT, ["help"]);
|
|
||||||
expect(code).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no args prints usage (not red) and returns 1", async () => {
|
|
||||||
const code = await runCli(STORAGE_ROOT, []);
|
const code = await runCli(STORAGE_ROOT, []);
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -70,13 +60,6 @@ describe("--help flag on groups", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("legacy help --skill compat", () => {
|
|
||||||
test("help --skill still works (lists topics)", async () => {
|
|
||||||
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
|
|
||||||
expect(code).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSkillTopics", () => {
|
describe("getSkillTopics", () => {
|
||||||
test("returns all topics", () => {
|
test("returns all topics", () => {
|
||||||
const topics = getSkillTopics();
|
const topics = getSkillTopics();
|
||||||
@@ -128,8 +111,13 @@ describe("formatCliUsage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
|
const cliSkillDoc = formatSkillTopic("cli");
|
||||||
const doc = formatSkillDoc();
|
if (cliSkillDoc === null) {
|
||||||
|
throw new Error("BUG: cli skill topic missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatSkillTopic('cli')", () => {
|
||||||
|
const doc = cliSkillDoc;
|
||||||
|
|
||||||
test("contains title", () => {
|
test("contains title", () => {
|
||||||
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe("init template", () => {
|
|||||||
};
|
};
|
||||||
expect(pkg.type).toBe("module");
|
expect(pkg.type).toBe("module");
|
||||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||||
|
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||||
expect(pkg.dependencies.zod).toBeDefined();
|
expect(pkg.dependencies.zod).toBeDefined();
|
||||||
expect(pkg.name).toContain("review-pr");
|
expect(pkg.name).toContain("review-pr");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { createApp } from "../src/commands/serve/app.js";
|
||||||
|
|
||||||
|
function casStoredForm(raw: string): string {
|
||||||
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(storageRoot: string) {
|
||||||
|
const app = createApp(storageRoot);
|
||||||
|
return {
|
||||||
|
fetch: (path: string, init?: RequestInit) =>
|
||||||
|
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("serve /healthz", () => {
|
||||||
|
test("returns ok", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/healthz");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serve /api/workflows", () => {
|
||||||
|
test("returns empty list for missing storage", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/workflows");
|
||||||
|
// Registry file won't exist, should return error
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serve /api/threads", () => {
|
||||||
|
test("returns empty list for missing storage", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/threads");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { threads: unknown[] };
|
||||||
|
expect(body.threads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for missing thread", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/threads/nonexistent-id");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serve /api/threads/running", () => {
|
||||||
|
test("returns empty list for missing storage", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/threads/running");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { threads: unknown[] };
|
||||||
|
expect(body.threads).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serve /api/cas", () => {
|
||||||
|
test("returns empty list for missing storage", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/cas");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { hashes: unknown[] };
|
||||||
|
expect(body.hashes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for missing hash", async () => {
|
||||||
|
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||||
|
const res = await fetch("/api/cas/nonexistent-hash");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serve CAS round-trip", () => {
|
||||||
|
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
|
||||||
|
|
||||||
|
test("put then get", async () => {
|
||||||
|
const { fetch } = buildApp(tmpDir);
|
||||||
|
|
||||||
|
const putRes = await fetch("/api/cas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content: "hello world" }),
|
||||||
|
});
|
||||||
|
expect(putRes.status).toBe(201);
|
||||||
|
const putBody = (await putRes.json()) as { hash: string };
|
||||||
|
expect(typeof putBody.hash).toBe("string");
|
||||||
|
|
||||||
|
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as { content: string };
|
||||||
|
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.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";
|
||||||
`;
|
`;
|
||||||
@@ -142,6 +143,7 @@ describe("cli thread commands", () => {
|
|||||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
|
||||||
|
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
|
||||||
|
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uncaged-workflow": "src/cli.ts"
|
"uncaged-workflow": "src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"hono": "^4.12.18",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
import { printCliError, printCliLine } from "./cli-output.js";
|
||||||
import { getCommandRegistry } from "./cli-registry.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { createCasDispatcher, dispatchGc } from "./commands/cas/index.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
import { createInitDispatcher } from "./commands/init/index.js";
|
import { createInitDispatcher } from "./commands/init/index.js";
|
||||||
import {
|
import { dispatchServe } from "./commands/serve/index.js";
|
||||||
createThreadDispatcher,
|
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
dispatchFork,
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
dispatchKill,
|
|
||||||
dispatchLive,
|
|
||||||
dispatchPause,
|
|
||||||
dispatchPs,
|
|
||||||
dispatchResume,
|
|
||||||
dispatchRun,
|
|
||||||
dispatchThreadList,
|
|
||||||
} from "./commands/thread/index.js";
|
|
||||||
import {
|
|
||||||
createWorkflowDispatcher,
|
|
||||||
dispatchAdd,
|
|
||||||
dispatchHistory,
|
|
||||||
dispatchList,
|
|
||||||
dispatchRemove,
|
|
||||||
dispatchRollback,
|
|
||||||
dispatchShow,
|
|
||||||
} from "./commands/workflow/index.js";
|
|
||||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||||
|
|
||||||
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
||||||
@@ -54,15 +37,11 @@ function dispatchGroup(
|
|||||||
return entry.handler(storageRoot, argv.slice(1));
|
return entry.handler(storageRoot, argv.slice(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function printDeprecation(oldCmd: string, newCmd: string): void {
|
|
||||||
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCliUsage(): string {
|
export function formatCliUsage(): string {
|
||||||
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup, printDeprecation });
|
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||||
@@ -85,41 +64,15 @@ async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<numb
|
|||||||
return showSkillDocOrIndex(argv[0]);
|
return showSkillDocOrIndex(argv[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
printCliWarn('⚠ "help" is deprecated, use "skill" instead');
|
|
||||||
const skillIdx = argv.indexOf("--skill");
|
|
||||||
if (skillIdx !== -1) {
|
|
||||||
return showSkillDocOrIndex(argv[skillIdx + 1]);
|
|
||||||
}
|
|
||||||
printCliLine(formatCliUsage());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||||
workflow: dispatchWorkflow,
|
workflow: dispatchWorkflow,
|
||||||
thread: dispatchThread,
|
thread: dispatchThread,
|
||||||
cas: dispatchCas,
|
cas: dispatchCas,
|
||||||
init: dispatchInit,
|
init: dispatchInit,
|
||||||
help: dispatchHelp,
|
|
||||||
skill: dispatchSkill,
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
live: dispatchLive,
|
live: dispatchLive,
|
||||||
};
|
serve: dispatchServe,
|
||||||
|
|
||||||
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
|
|
||||||
add: { newCmd: "workflow add", handler: dispatchAdd },
|
|
||||||
list: { newCmd: "workflow list", handler: dispatchList },
|
|
||||||
show: { newCmd: "workflow show", handler: dispatchShow },
|
|
||||||
remove: { newCmd: "workflow rm", handler: dispatchRemove },
|
|
||||||
ps: { newCmd: "thread ps", handler: dispatchPs },
|
|
||||||
kill: { newCmd: "thread kill", handler: dispatchKill },
|
|
||||||
pause: { newCmd: "thread pause", handler: dispatchPause },
|
|
||||||
resume: { newCmd: "thread resume", handler: dispatchResume },
|
|
||||||
threads: { newCmd: "thread list", handler: dispatchThreadList },
|
|
||||||
fork: { newCmd: "thread fork", handler: dispatchFork },
|
|
||||||
gc: { newCmd: "cas gc", handler: dispatchGc },
|
|
||||||
history: { newCmd: "workflow history", handler: dispatchHistory },
|
|
||||||
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
@@ -139,12 +92,6 @@ export async function runCli(storageRoot: string, argv: string[]): Promise<numbe
|
|||||||
return dispatch(storageRoot, rest);
|
return dispatch(storageRoot, rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deprecated = DEPRECATED_ALIASES[command];
|
|
||||||
if (deprecated !== undefined) {
|
|
||||||
printDeprecation(command, deprecated.newCmd);
|
|
||||||
return deprecated.handler(storageRoot, rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Server:");
|
||||||
|
lines.push(
|
||||||
|
...formatUsageCommandLines([
|
||||||
|
{
|
||||||
|
prefix: "serve [--port N] [--host ADDR]",
|
||||||
|
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Reference:");
|
lines.push("Reference:");
|
||||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
@@ -13,19 +13,7 @@ import {
|
|||||||
templateTsconfigJson,
|
templateTsconfigJson,
|
||||||
} from "./templates.js";
|
} from "./templates.js";
|
||||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||||
|
import { validateWorkspaceSegment } from "./validate.js";
|
||||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
|
||||||
if (name.length === 0) {
|
|
||||||
return err("workspace name must not be empty");
|
|
||||||
}
|
|
||||||
if (name === "." || name === "..") {
|
|
||||||
return err("invalid workspace name");
|
|
||||||
}
|
|
||||||
if (name.includes("/") || name.includes("\\")) {
|
|
||||||
return err("workspace name must not contain path separators");
|
|
||||||
}
|
|
||||||
return ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
|
|||||||
type: "module",
|
type: "module",
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@uncaged/workflow": "^0.1.0",
|
"@uncaged/workflow": "^0.1.0",
|
||||||
|
"@uncaged/workflow-runtime": "^0.1.0",
|
||||||
zod: "^4.0.0",
|
zod: "^4.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateRolesTs(): string {
|
export function templateRolesTs(): string {
|
||||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||||
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateModeratorTs(): string {
|
export function templateModeratorTs(): string {
|
||||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { HelloTemplateMeta } from "./roles.js";
|
import type { HelloTemplateMeta } from "./roles.js";
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateIndexTs(): string {
|
export function templateIndexTs(): string {
|
||||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { helloTemplateModerator } from "./moderator.js";
|
import { helloTemplateModerator } from "./moderator.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||||
|
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||||
|
if (name.length === 0) {
|
||||||
|
return err("workspace name must not be empty");
|
||||||
|
}
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
return err("invalid workspace name");
|
||||||
|
}
|
||||||
|
if (name.includes("/") || name.includes("\\")) {
|
||||||
|
return err("workspace name must not contain path separators");
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -5,19 +5,7 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
|||||||
|
|
||||||
import { pathExists } from "../../fs-utils.js";
|
import { pathExists } from "../../fs-utils.js";
|
||||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||||
|
import { validateWorkspaceSegment } from "./validate.js";
|
||||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
|
||||||
if (name.length === 0) {
|
|
||||||
return err("workspace name must not be empty");
|
|
||||||
}
|
|
||||||
if (name === "." || name === "..") {
|
|
||||||
return err("invalid workspace name");
|
|
||||||
}
|
|
||||||
if (name.includes("/") || name.includes("\\")) {
|
|
||||||
return err("workspace name must not contain path separators");
|
|
||||||
}
|
|
||||||
return ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootPackageJson(workspaceName: string): string {
|
function rootPackageJson(workspaceName: string): string {
|
||||||
return `${JSON.stringify(
|
return `${JSON.stringify(
|
||||||
@@ -119,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
|||||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
|
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||||
|
|
||||||
## 4. 编码规范
|
## 4. 编码规范
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
|
import { createCasRoutes } from "./routes-cas.js";
|
||||||
|
import { createLiveRoutes } from "./routes-live.js";
|
||||||
|
import { createThreadRoutes } from "./routes-thread.js";
|
||||||
|
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||||
|
|
||||||
|
export function createApp(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use("*", cors());
|
||||||
|
|
||||||
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||||
|
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||||
|
app.route("/api/threads", createLiveRoutes(storageRoot));
|
||||||
|
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createApp } from "./app.js";
|
||||||
|
export { dispatchServe, startServer } from "./serve.js";
|
||||||
|
export type { ServeOptions } from "./types.js";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
export function createCasRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hashes = await cas.list();
|
||||||
|
return c.json({ hashes });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:hash", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const content = await cas.get(c.req.param("hash"));
|
||||||
|
if (content === null) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ hash: c.req.param("hash"), content });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/", async (c) => {
|
||||||
|
const body = await c.req.json<{ content: string }>();
|
||||||
|
if (typeof body.content !== "string") {
|
||||||
|
return c.json({ error: "content field required" }, 400);
|
||||||
|
}
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hash = await cas.put(body.content);
|
||||||
|
return c.json({ hash }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/:hash", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hash = c.req.param("hash");
|
||||||
|
const content = await cas.get(hash);
|
||||||
|
if (content === null) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
await cas.delete(hash);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/gc", async (c) => {
|
||||||
|
const result = await garbageCollectCas(storageRoot);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 500);
|
||||||
|
}
|
||||||
|
return c.json(result.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { watch } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { streamSSE } from "hono/streaming";
|
||||||
|
|
||||||
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
|
|
||||||
|
type PumpState = {
|
||||||
|
contentOffset: number;
|
||||||
|
carry: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseJsonLine(line: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { raw: line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowResult(record: unknown): boolean {
|
||||||
|
return (
|
||||||
|
record !== null &&
|
||||||
|
typeof record === "object" &&
|
||||||
|
"type" in (record as Record<string, unknown>) &&
|
||||||
|
(record as Record<string, unknown>).type === "workflow-result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNewLines(text: string, state: PumpState): string[] {
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of parts) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed !== "") {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/:threadId/live", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
const resolvedDataPath = dataPath;
|
||||||
|
|
||||||
|
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const dataState: PumpState = { contentOffset: 0, carry: "" };
|
||||||
|
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||||
|
let eventId = 0;
|
||||||
|
|
||||||
|
async function pumpData(): Promise<boolean> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(resolvedDataPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, dataState);
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = parseJsonLine(line);
|
||||||
|
eventId++;
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: "record",
|
||||||
|
data: JSON.stringify(record),
|
||||||
|
id: String(eventId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isWorkflowResult(record)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpInfo(): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(infoPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseNewLines(text, infoState);
|
||||||
|
for (const line of lines) {
|
||||||
|
const record = parseJsonLine(line);
|
||||||
|
if (
|
||||||
|
typeof record === "object" &&
|
||||||
|
record !== null &&
|
||||||
|
"raw" in (record as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventId++;
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: "info",
|
||||||
|
data: JSON.stringify(record),
|
||||||
|
id: String(eventId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial pump
|
||||||
|
const done = await pumpData();
|
||||||
|
await pumpInfo();
|
||||||
|
if (done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
const controller = new AbortController();
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
const dataWatcher = watch(resolvedDataPath, async () => {
|
||||||
|
if (completed) return;
|
||||||
|
const finished = await pumpData();
|
||||||
|
if (finished) {
|
||||||
|
completed = true;
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||||
|
try {
|
||||||
|
infoWatcher = watch(infoPath, async () => {
|
||||||
|
if (completed) return;
|
||||||
|
await pumpInfo();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// info file may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.onAbort(() => {
|
||||||
|
completed = true;
|
||||||
|
dataWatcher.close();
|
||||||
|
infoWatcher?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep stream alive until completion or client disconnect
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (completed) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
stream.onAbort(() => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
dataWatcher.close();
|
||||||
|
infoWatcher?.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||||
|
import {
|
||||||
|
listHistoricalThreads,
|
||||||
|
listRunningThreads,
|
||||||
|
resolveThreadDataPath,
|
||||||
|
} from "../../thread-scan.js";
|
||||||
|
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||||
|
import { cmdRun } from "../thread/run.js";
|
||||||
|
|
||||||
|
export function createThreadRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const nameFilter = c.req.query("workflow") ?? null;
|
||||||
|
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||||
|
return c.json({ threads: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/running", async (c) => {
|
||||||
|
const rows = await listRunningThreads(storageRoot);
|
||||||
|
return c.json({ threads: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:threadId", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
const records = lines.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { raw: line };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return c.json({ threadId, records });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/", async (c) => {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await c.req.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = body.workflow;
|
||||||
|
const prompt = body.prompt;
|
||||||
|
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||||
|
|
||||||
|
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||||
|
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ threadId: result.value.threadId }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/kill", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdKill(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/pause", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdPause(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:threadId/resume", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const result = await cmdResume(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
getRegisteredWorkflow,
|
||||||
|
listRegisteredWorkflowNames,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const names = listRegisteredWorkflowNames(reg.value);
|
||||||
|
const workflows = names.map((name) => {
|
||||||
|
const entry = reg.value.workflows[name];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
hash: entry?.hash ?? null,
|
||||||
|
timestamp: entry?.timestamp ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return c.json({ workflows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:name", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const name = c.req.param("name");
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ name, ...entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:name/history", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const name = c.req.param("name");
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ name, history: entry.history });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
import { serve } from "bun";
|
||||||
|
|
||||||
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import type { ServeOptions } from "./types.js";
|
||||||
|
|
||||||
|
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||||
|
const app = createApp(storageRoot);
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port: options.port,
|
||||||
|
hostname: options.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||||
|
if (value === undefined) {
|
||||||
|
return err("--port requires a value");
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||||
|
return err(`invalid port: ${value}`);
|
||||||
|
}
|
||||||
|
return ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||||
|
let port = 7860;
|
||||||
|
let hostname = "127.0.0.1";
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--port" || arg === "-p") {
|
||||||
|
const portResult = parsePortValue(argv[i + 1]);
|
||||||
|
if (!portResult.ok) {
|
||||||
|
return portResult;
|
||||||
|
}
|
||||||
|
port = portResult.value;
|
||||||
|
i++;
|
||||||
|
} else if (arg === "--host") {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined) {
|
||||||
|
return err("--host requires a value");
|
||||||
|
}
|
||||||
|
hostname = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ port, hostname });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseServeArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliLine(`error: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer(storageRoot, parsed.value);
|
||||||
|
|
||||||
|
// Keep process alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ServeOptions = {
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
getGlobalCasDir,
|
getGlobalCasDir,
|
||||||
tryParseRoleStepRecord,
|
tryParseRoleStepRecord,
|
||||||
tryParseWorkflowResultRecord,
|
tryParseWorkflowResultRecord,
|
||||||
type WorkflowCompletion,
|
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
||||||
const { dispatchGroup, printDeprecation } = deps;
|
const { dispatchGroup } = deps;
|
||||||
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
@@ -150,7 +150,6 @@ export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
|||||||
}
|
}
|
||||||
const sub = argv[0];
|
const sub = argv[0];
|
||||||
if (sub === "remove") {
|
if (sub === "remove") {
|
||||||
printDeprecation("workflow remove", "workflow rm");
|
|
||||||
return dispatchRemove(storageRoot, argv.slice(1));
|
return dispatchRemove(storageRoot, argv.slice(1));
|
||||||
}
|
}
|
||||||
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ export type CmdAddSuccess = {
|
|||||||
|
|
||||||
export type WorkflowDispatchDeps = {
|
export type WorkflowDispatchDeps = {
|
||||||
dispatchGroup: DispatchGroupFn;
|
dispatchGroup: DispatchGroupFn;
|
||||||
printDeprecation: (oldCmd: string, newCmd: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ Each role has:
|
|||||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||||
| \`extractMode\` | "single" | Extraction mode |
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
@@ -229,10 +228,3 @@ uncaged-workflow live --latest
|
|||||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy compat ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @deprecated Use formatSkillTopic("cli") instead */
|
|
||||||
export function formatSkillDoc(): string {
|
|
||||||
return formatSkillCli();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../workflow" }],
|
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# @uncaged/workflow-dashboard
|
||||||
|
|
||||||
|
Web dashboard for the Uncaged Workflow engine. Connects to the local
|
||||||
|
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the local API server (in another terminal)
|
||||||
|
uncaged-workflow serve
|
||||||
|
|
||||||
|
# Start the dashboard dev server
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `dist/` — static files ready for CF Pages or any host.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Workflow Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
|
||||||
|
throw new Error(err.error || `API ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${path}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowSummary = {
|
||||||
|
name: string;
|
||||||
|
currentHash: string;
|
||||||
|
versions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadSummary = {
|
||||||
|
threadId: string;
|
||||||
|
workflow: string | null;
|
||||||
|
hash: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadRecord = {
|
||||||
|
type: string;
|
||||||
|
role: string | null;
|
||||||
|
content: string | null;
|
||||||
|
timestamp: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
|
||||||
|
return fetchJson("/workflows");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||||
|
return fetchJson("/threads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
|
||||||
|
return fetchJson("/threads/running");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
|
||||||
|
return fetchJson(`/threads/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runThread(
|
||||||
|
workflow: string,
|
||||||
|
prompt: string,
|
||||||
|
maxRounds: number = 10,
|
||||||
|
): Promise<{ threadId: string }> {
|
||||||
|
return postJson("/threads", { workflow, prompt, maxRounds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/kill`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/pause`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
|
||||||
|
return postJson(`/threads/${threadId}/resume`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth(): Promise<{ ok: boolean }> {
|
||||||
|
return fetchJson("/healthz");
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { RunDialog } from "./components/run-dialog.tsx";
|
||||||
|
import { Sidebar } from "./components/sidebar.tsx";
|
||||||
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
|
import { ThreadList } from "./components/thread-list.tsx";
|
||||||
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
|
|
||||||
|
type View = "threads" | "workflows";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [view, setView] = useState<View>("threads");
|
||||||
|
const [selectedThread, setSelectedThread] = useState<string | null>(null);
|
||||||
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar view={view} onViewChange={setView} />
|
||||||
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<StatusBar onRun={() => setShowRun(true)} />
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />}
|
||||||
|
{view === "threads" && selectedThread && (
|
||||||
|
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
|
||||||
|
)}
|
||||||
|
{view === "workflows" && <WorkflowList />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{showRun && (
|
||||||
|
<RunDialog
|
||||||
|
onClose={() => setShowRun(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setShowRun(false);
|
||||||
|
setView("threads");
|
||||||
|
setSelectedThread(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { listWorkflows, runThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (threadId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RunDialog({ onClose, onCreated }: Props) {
|
||||||
|
const workflows = useFetch(() => listWorkflows(), []);
|
||||||
|
const [workflow, setWorkflow] = useState("");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [maxRounds, setMaxRounds] = useState(10);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!workflow || !prompt) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await runThread(workflow, prompt, maxRounds);
|
||||||
|
onCreated(result.threadId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ background: "rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg p-6 rounded-lg border"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-workflow"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Workflow
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="run-workflow"
|
||||||
|
value={workflow}
|
||||||
|
onChange={(e) => setWorkflow(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select a workflow...</option>
|
||||||
|
{workflows.status === "ok" &&
|
||||||
|
workflows.data.workflows.map((w) => (
|
||||||
|
<option key={w.name} value={w.name}>
|
||||||
|
{w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-prompt"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="run-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
placeholder="Enter the task prompt..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="run-max-rounds"
|
||||||
|
className="text-sm block mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Max Rounds
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="run-max-rounds"
|
||||||
|
type="number"
|
||||||
|
value={maxRounds}
|
||||||
|
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
className="w-24 px-3 py-2 rounded border text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded border"
|
||||||
|
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !workflow || !prompt}
|
||||||
|
className="px-4 py-2 text-sm rounded"
|
||||||
|
style={{
|
||||||
|
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: !workflow || !prompt ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? "Starting..." : "Run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
type Props = {
|
||||||
|
view: "threads" | "workflows";
|
||||||
|
onViewChange: (v: "threads" | "workflows") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar({ view, onViewChange }: Props) {
|
||||||
|
const items = [
|
||||||
|
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||||
|
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="w-56 border-r flex flex-col"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||||
|
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||||
|
⚙ Workflow
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-2 space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => onViewChange(item.key)}
|
||||||
|
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
|
||||||
|
color: view === item.key ? "#fff" : "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon} {item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getHealth } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onRun: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBar({ onRun }: Props) {
|
||||||
|
const health = useFetch(() => getHealth(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-6 py-2 text-xs border-b"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRun}
|
||||||
|
className="px-3 py-1 rounded text-xs font-medium"
|
||||||
|
style={{ background: "var(--color-accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
▶ Run Thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{health.status === "loading" && "⏳ Connecting..."}
|
||||||
|
{health.status === "ok" && (
|
||||||
|
<span style={{ color: "var(--color-success)" }}>● Connected</span>
|
||||||
|
)}
|
||||||
|
{health.status === "error" && (
|
||||||
|
<span style={{ color: "var(--color-error)" }}>● Offline</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
threadId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||||
|
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||||
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleAction(action: "kill" | "pause" | "resume") {
|
||||||
|
setActionStatus(`${action}ing...`);
|
||||||
|
try {
|
||||||
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
|
await fn(threadId);
|
||||||
|
setActionStatus(`${action} sent ✓`);
|
||||||
|
} catch (e) {
|
||||||
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
← Back to threads
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("pause")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||||
|
>
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("resume")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
▶ Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("kill")}
|
||||||
|
className="px-3 py-1 text-xs rounded border"
|
||||||
|
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||||
|
>
|
||||||
|
✕ Kill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-2 font-mono">{threadId}</h2>
|
||||||
|
{actionStatus && (
|
||||||
|
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{actionStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||||
|
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||||
|
{status === "ok" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.records.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`}
|
||||||
|
className="p-3 rounded border text-sm"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded font-mono"
|
||||||
|
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{r.type}
|
||||||
|
</span>
|
||||||
|
{r.role && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{r.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.timestamp && (
|
||||||
|
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{new Date(r.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.content && (
|
||||||
|
<pre
|
||||||
|
className="whitespace-pre-wrap text-xs mt-1"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { listThreads } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadList({ onSelect }: Props) {
|
||||||
|
const { status, data, error } = useFetch(() => listThreads(), []);
|
||||||
|
|
||||||
|
if (status === "loading")
|
||||||
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||||
|
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||||
|
|
||||||
|
const threads = data.threads;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Threads</h2>
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{threads.map((t) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={t.threadId}
|
||||||
|
onClick={() => onSelect(t.threadId)}
|
||||||
|
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
|
||||||
|
{t.threadId}
|
||||||
|
</code>
|
||||||
|
{t.status && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
t.status === "running"
|
||||||
|
? "var(--color-success)"
|
||||||
|
: t.status === "failed"
|
||||||
|
? "var(--color-error)"
|
||||||
|
: "var(--color-text-muted)",
|
||||||
|
color: "#000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{t.workflow && (
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{t.workflow}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{t.startedAt && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{t.startedAt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { listWorkflows } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
|
export function WorkflowList() {
|
||||||
|
const { status, data, error } = useFetch(() => listWorkflows(), []);
|
||||||
|
|
||||||
|
if (status === "loading")
|
||||||
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||||
|
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||||
|
|
||||||
|
const workflows = data.workflows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
|
||||||
|
{workflows.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{workflows.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.name}
|
||||||
|
className="p-4 rounded-lg border"
|
||||||
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{w.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{w.versions} version{w.versions !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code
|
||||||
|
className="text-xs mt-1 block font-mono"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{w.currentHash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type FetchState<T> =
|
||||||
|
| { status: "loading"; data: null; error: null }
|
||||||
|
| { status: "ok"; data: T; error: null }
|
||||||
|
| { status: "error"; data: null; error: string };
|
||||||
|
|
||||||
|
export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): FetchState<T> {
|
||||||
|
const [state, setState] = useState<FetchState<T>>({
|
||||||
|
status: "loading",
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: "loading", data: null, error: null });
|
||||||
|
fetcher()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setState({ status: "ok", data, error: null });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
data: null,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a0a0f;
|
||||||
|
--color-surface: #12121a;
|
||||||
|
--color-border: #1e1e2e;
|
||||||
|
--color-text: #e4e4ef;
|
||||||
|
--color-text-muted: #6b6b8a;
|
||||||
|
--color-accent: #7c6df0;
|
||||||
|
--color-accent-dim: #5a4db8;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-error: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import { App } from "./app.tsx";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (root) {
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:7860",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
"name": "@uncaged/workflow-agent-cursor",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*",
|
"@uncaged/workflow-util-agent": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
|
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ExtractFn } from "@uncaged/workflow";
|
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { CursorAgentConfig } from "./types.js";
|
import type { CursorAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
"name": "@uncaged/workflow-agent-hermes",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"@uncaged/workflow-util-agent": "workspace:*"
|
"@uncaged/workflow-util-agent": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentFn } from "@uncaged/workflow";
|
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
import type { HermesAgentConfig } from "./types.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
import type { HermesAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||||
|
|
||||||
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
|
function makeCtx(userContent: string): AgentContext {
|
||||||
const testCas = createCasStore(casDir);
|
|
||||||
|
|
||||||
function makeCtx(userContent: string): ThreadContext {
|
|
||||||
return {
|
return {
|
||||||
start: {
|
start: {
|
||||||
role: START,
|
role: START,
|
||||||
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
|
|||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||||
cas: testCas,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-llm",
|
"name": "@uncaged/workflow-agent-llm",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type LlmProvider,
|
type LlmProvider,
|
||||||
ok,
|
ok,
|
||||||
type Result,
|
type Result,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-runtime",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
|
||||||
|
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type WorkflowRoleDescriptor = {
|
||||||
|
description: string;
|
||||||
|
schema: WorkflowRoleSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
||||||
|
export type WorkflowDescriptor = {
|
||||||
|
description: string;
|
||||||
|
roles: Record<string, WorkflowRoleDescriptor>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { err, ok, type Result } from "../util/index.js";
|
||||||
|
|
||||||
|
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
|
||||||
|
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return err("descriptor must be a non-array object");
|
||||||
|
}
|
||||||
|
const root = value as Record<string, unknown>;
|
||||||
|
const description = root.description;
|
||||||
|
if (typeof description !== "string") {
|
||||||
|
return err("descriptor.description must be a string");
|
||||||
|
}
|
||||||
|
const rolesRaw = root.roles;
|
||||||
|
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
|
||||||
|
return err("descriptor.roles must be a non-array object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||||
|
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||||
|
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||||
|
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||||
|
}
|
||||||
|
const spec = specUnknown as Record<string, unknown>;
|
||||||
|
const roleDesc = spec.description;
|
||||||
|
if (typeof roleDesc !== "string") {
|
||||||
|
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||||
|
}
|
||||||
|
const schema = spec.schema;
|
||||||
|
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||||
|
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||||
|
}
|
||||||
|
roles[roleName] = {
|
||||||
|
description: roleDesc,
|
||||||
|
schema: schema as WorkflowRoleSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ description, roles });
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type { CasStore } from "./types.js";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type CasStore = {
|
||||||
|
put(content: string): Promise<string>;
|
||||||
|
get(hash: string): Promise<string | null>;
|
||||||
|
delete(hash: string): Promise<void>;
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
|
import type { CasStore } from "../cas/types.js";
|
||||||
|
import {
|
||||||
|
type AgentBinding,
|
||||||
|
type AgentContext,
|
||||||
|
type AgentFn,
|
||||||
|
END,
|
||||||
|
type ExtractContext,
|
||||||
|
type ModeratorContext,
|
||||||
|
type RoleDefinition,
|
||||||
|
type RoleMeta,
|
||||||
|
type RoleOutput,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
type ThreadContext,
|
||||||
|
type WorkflowCompletion,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowFn,
|
||||||
|
type WorkflowRuntime,
|
||||||
|
} from "../types.js";
|
||||||
|
import { mergeRefsWithContentHash } from "../util/index.js";
|
||||||
|
|
||||||
|
function isRoleNext<M extends RoleMeta>(
|
||||||
|
next: (keyof M & string) | typeof END,
|
||||||
|
): next is keyof M & string {
|
||||||
|
return next !== END;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtractedRefs(
|
||||||
|
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||||
|
meta: unknown,
|
||||||
|
): string[] {
|
||||||
|
const extractRefsFn = roleDef.extractRefs;
|
||||||
|
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return extractRefsFn(meta as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
|
||||||
|
return store.put(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||||
|
const overrides = binding.overrides;
|
||||||
|
const overrideFn: AgentFn | undefined =
|
||||||
|
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
|
||||||
|
return overrideFn !== undefined ? overrideFn : binding.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvanceOutcome<M extends RoleMeta> =
|
||||||
|
| { kind: "complete"; completion: WorkflowCompletion }
|
||||||
|
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||||
|
|
||||||
|
async function advanceOneRound<M extends RoleMeta>(
|
||||||
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
|
binding: AgentBinding,
|
||||||
|
params: {
|
||||||
|
thread: ModeratorContext<M>;
|
||||||
|
runtime: WorkflowRuntime;
|
||||||
|
},
|
||||||
|
): Promise<AdvanceOutcome<M>> {
|
||||||
|
const { thread, runtime } = params;
|
||||||
|
const modCtx: ModeratorContext<M> = thread;
|
||||||
|
|
||||||
|
const next = def.moderator(modCtx);
|
||||||
|
if (!isRoleNext(next)) {
|
||||||
|
return {
|
||||||
|
kind: "complete",
|
||||||
|
completion: { returnCode: 0, summary: "completed: moderator returned END" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDef = def.roles[next];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentCtx: AgentContext<M> = {
|
||||||
|
...modCtx,
|
||||||
|
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = agentForRole(binding, next);
|
||||||
|
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||||
|
|
||||||
|
const extractCtx: ExtractContext<M> = {
|
||||||
|
...agentCtx,
|
||||||
|
agentContent: raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = await runtime.extract(
|
||||||
|
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||||
|
roleDef.extractPrompt,
|
||||||
|
extractCtx as unknown as ExtractContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentHash = await putContentBlob(runtime.cas, raw);
|
||||||
|
const refs = mergeRefsWithContentHash(
|
||||||
|
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
|
||||||
|
contentHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = {
|
||||||
|
role: next,
|
||||||
|
contentHash,
|
||||||
|
meta,
|
||||||
|
refs,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as RoleStep<M>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "yield",
|
||||||
|
output: {
|
||||||
|
role: step.role,
|
||||||
|
contentHash: step.contentHash,
|
||||||
|
meta: step.meta,
|
||||||
|
refs: step.refs,
|
||||||
|
},
|
||||||
|
step,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds pure role definitions + moderator to runtime agents.
|
||||||
|
* Assign with `export const run = createWorkflow(def, binding)`.
|
||||||
|
*
|
||||||
|
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
|
||||||
|
* engine resolves from the workflow registry's `extract` scene.
|
||||||
|
*/
|
||||||
|
export function createWorkflow<M extends RoleMeta>(
|
||||||
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
|
binding: AgentBinding,
|
||||||
|
): WorkflowFn {
|
||||||
|
return async function* workflowLoop(
|
||||||
|
thread: ThreadContext,
|
||||||
|
runtime: WorkflowRuntime,
|
||||||
|
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||||
|
if (thread.start.role !== START) {
|
||||||
|
throw new Error(`workflow loop expected start role to be ${START}`);
|
||||||
|
}
|
||||||
|
const maxRounds = thread.start.meta.maxRounds;
|
||||||
|
let currentThread = thread as ModeratorContext<M>;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (currentThread.steps.length >= maxRounds) {
|
||||||
|
return {
|
||||||
|
returnCode: 0,
|
||||||
|
summary: `completed: reached maxRounds (${maxRounds})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await advanceOneRound(def, binding, {
|
||||||
|
thread: currentThread,
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome.kind === "complete") {
|
||||||
|
return outcome.completion;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield outcome.output;
|
||||||
|
currentThread = {
|
||||||
|
...currentThread,
|
||||||
|
steps: [...currentThread.steps, outcome.step],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createWorkflow } from "./create-workflow.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type { ExtractFn } from "./types.js";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
|
import type { ExtractContext } from "../types.js";
|
||||||
|
|
||||||
|
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
prompt: string,
|
||||||
|
ctx: ExtractContext,
|
||||||
|
) => Promise<T>;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type {
|
||||||
|
WorkflowDescriptor,
|
||||||
|
WorkflowRoleDescriptor,
|
||||||
|
WorkflowRoleSchema,
|
||||||
|
} from "./bundle/types.js";
|
||||||
|
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
|
||||||
|
export type { CasStore } from "./cas/index.js";
|
||||||
|
export { createWorkflow } from "./engine/index.js";
|
||||||
|
export type { ExtractFn } from "./extract/index.js";
|
||||||
|
export type {
|
||||||
|
AgentBinding,
|
||||||
|
AgentContext,
|
||||||
|
AgentFn,
|
||||||
|
ExtractContext,
|
||||||
|
LlmProvider,
|
||||||
|
Moderator,
|
||||||
|
ModeratorContext,
|
||||||
|
RoleDefinition,
|
||||||
|
RoleMeta,
|
||||||
|
RoleOutput,
|
||||||
|
RoleStep,
|
||||||
|
StartStep,
|
||||||
|
ThreadContext,
|
||||||
|
WorkflowCompletion,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowFn,
|
||||||
|
WorkflowResult,
|
||||||
|
WorkflowRuntime,
|
||||||
|
} from "./types.js";
|
||||||
|
export { END, START } from "./types.js";
|
||||||
|
export type { Result } from "./util/index.js";
|
||||||
|
export { err, ok } from "./util/index.js";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
import type { CasStore } from "./cas/index.js";
|
import type { CasStore } from "./cas/index.js";
|
||||||
|
import type { ExtractFn } from "./extract/types.js";
|
||||||
|
|
||||||
/** Sentinel values for automaton control flow. */
|
/** Sentinel values for automaton control flow. */
|
||||||
export const START = "__start__" as const;
|
export const START = "__start__" as const;
|
||||||
@@ -16,9 +17,6 @@ export type LlmProvider = {
|
|||||||
model: string;
|
model: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How the engine runs meta extraction for a role after the agent phase. */
|
|
||||||
export type ExtractMode = "single" | "react";
|
|
||||||
|
|
||||||
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
|
||||||
export type RoleOutput = {
|
export type RoleOutput = {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -35,31 +33,23 @@ export type WorkflowCompletion = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
|
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
|
||||||
export type WorkflowResult = WorkflowCompletion & {
|
export type WorkflowResult = WorkflowCompletion & {
|
||||||
rootHash: string;
|
rootHash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
|
/** Runtime dependencies passed to a workflow bundle's `run` export (engine-provided). */
|
||||||
export type ThreadInput = {
|
export type WorkflowRuntime = {
|
||||||
prompt: string;
|
|
||||||
steps: RoleOutput[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Options passed to a workflow bundle's `run` export (engine-provided). */
|
|
||||||
export type WorkflowFnOptions = {
|
|
||||||
threadId: string;
|
|
||||||
maxRounds: number;
|
|
||||||
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
|
|
||||||
depth: number;
|
|
||||||
/** Global CAS store for Merkle content blobs (role step bodies). */
|
/** Global CAS store for Merkle content blobs (role step bodies). */
|
||||||
cas: CasStore;
|
cas: CasStore;
|
||||||
|
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
|
||||||
|
extract: ExtractFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
||||||
export type WorkflowFn = (
|
export type WorkflowFn = (
|
||||||
input: ThreadInput,
|
thread: ThreadContext,
|
||||||
options: WorkflowFnOptions,
|
runtime: WorkflowRuntime,
|
||||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||||
|
|
||||||
/** Engine start frame: initial prompt + thread identity. */
|
/** Engine start frame: initial prompt + thread identity. */
|
||||||
@@ -81,22 +71,24 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
};
|
};
|
||||||
}[keyof M & string];
|
}[keyof M & string];
|
||||||
|
|
||||||
/** Phase 1: Moderator decides next role. */
|
/** Thread runtime context shared by moderator/agent/extractor phases. */
|
||||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = {
|
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
/** Same as `WorkflowFnOptions.depth` for the active thread. */
|
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
|
||||||
depth: number;
|
depth: number;
|
||||||
start: StartStep;
|
start: StartStep;
|
||||||
steps: RoleStep<M>[];
|
steps: RoleStep<M>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Phase 1: Moderator decides next role. */
|
||||||
|
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
|
||||||
|
|
||||||
/** Phase 2: Agent executes — knows its role and prompt. */
|
/** Phase 2: Agent executes — knows its role and prompt. */
|
||||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
||||||
currentRole: {
|
currentRole: {
|
||||||
name: string;
|
name: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
};
|
};
|
||||||
cas: CasStore;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
|
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
|
||||||
@@ -104,16 +96,13 @@ export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
|
|||||||
agentContent: string;
|
agentContent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Alias — most external consumers see the agent-phase context. */
|
|
||||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
|
|
||||||
|
|
||||||
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||||
|
|
||||||
/** Runtime agent assignment (optional per-role overrides). */
|
/** Runtime agent assignment (explicit null when no per-role overrides). */
|
||||||
export type AgentBinding = {
|
export type AgentBinding = {
|
||||||
agent: AgentFn;
|
agent: AgentFn;
|
||||||
overrides?: Partial<Record<string, AgentFn>>;
|
overrides: Partial<Record<string, AgentFn>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Role wiring: prompts, schema, and human-readable description. */
|
/** Role wiring: prompts, schema, and human-readable description. */
|
||||||
@@ -124,7 +113,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
|||||||
schema: z.ZodType<Meta>;
|
schema: z.ZodType<Meta>;
|
||||||
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
|
||||||
extractRefs: ((meta: Meta) => string[]) | null;
|
extractRefs: ((meta: Meta) => string[]) | null;
|
||||||
extractMode: ExtractMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { mergeRefsWithContentHash } from "./refs-field.js";
|
||||||
|
export { err, ok } from "./result.js";
|
||||||
|
export type { Result } from "./types.js";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
|
||||||
|
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
|
||||||
|
const out = [...refs];
|
||||||
|
if (!out.includes(contentHash)) {
|
||||||
|
out.push(contentHash);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Result } from "./types.js";
|
||||||
|
|
||||||
|
export function ok<T>(value: T): Result<T, never> {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err<E>(error: E): Result<never, E> {
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
||||||
|
|
||||||
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle.
|
Export a pure `WorkflowDefinition` (`developWorkflowDefinition`) and role/moderator pieces. Workflow instantiation (`createWorkflow(definition, binding)`) happens in the workflow instance layer, not in this template package.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -15,10 +15,10 @@ In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@u
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
import { createWorkflow } from "@uncaged/workflow";
|
||||||
|
import { developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
||||||
|
|
||||||
const run = createDevelopRun(binding, extract, llmProvider);
|
const run = createWorkflow(developWorkflowDefinition, binding);
|
||||||
// run(...) executes the develop moderator graph with your AgentBinding
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
@@ -46,7 +46,6 @@ Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `D
|
|||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
|
|
||||||
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
||||||
| `developModerator` | `Moderator<DevelopMeta>` |
|
| `developModerator` | `Moderator<DevelopMeta>` |
|
||||||
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||||
import { developModerator } from "../src/index.js";
|
import { developModerator } from "../src/index.js";
|
||||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-develop",
|
"name": "@uncaged/workflow-template-develop",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
type AgentBinding,
|
|
||||||
createWorkflow,
|
|
||||||
type ExtractFn,
|
|
||||||
type LlmProvider,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { developModerator } from "./moderator.js";
|
import { developModerator } from "./moderator.js";
|
||||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||||
@@ -42,11 +35,3 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
|||||||
roles: developRoles,
|
roles: developRoles,
|
||||||
moderator: developModerator,
|
moderator: developModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDevelopRun(
|
|
||||||
binding: AgentBinding,
|
|
||||||
extract: ExtractFn,
|
|
||||||
llmProvider: LlmProvider | null,
|
|
||||||
): WorkflowFn {
|
|
||||||
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { DevelopMeta } from "./roles.js";
|
import type { DevelopMeta } from "./roles.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||||
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const coderMetaSchema = z.object({
|
export const coderMetaSchema = z.object({
|
||||||
@@ -15,7 +15,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
|
|||||||
|
|
||||||
## Reading phase details
|
## Reading phase details
|
||||||
|
|
||||||
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
|
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
|
||||||
|
|
||||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
@@ -31,5 +31,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
|||||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||||
schema: coderMetaSchema,
|
schema: coderMetaSchema,
|
||||||
extractRefs: (meta) => [meta.completedPhase],
|
extractRefs: (meta) => [meta.completedPhase],
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -32,5 +32,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
|||||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||||
schema: committerMetaSchema,
|
schema: committerMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const phaseSchema = z.object({
|
export const phaseSchema = z.object({
|
||||||
@@ -18,7 +18,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
|
|||||||
|
|
||||||
## Storing phase details — MANDATORY
|
## Storing phase details — MANDATORY
|
||||||
|
|
||||||
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||||
|
|
||||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
@@ -48,5 +48,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
|||||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -41,5 +41,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
|||||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||||
schema: reviewerMetaSchema,
|
schema: reviewerMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -23,5 +23,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
|
|||||||
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||||
schema: testerMetaSchema,
|
schema: testerMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
|
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
|
||||||
|
|
||||||
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts).
|
This package exports a pure `WorkflowDefinition` (`solveIssueWorkflowDefinition`). Workflow instantiation (`createWorkflow(definition, binding)`) and any role-specific agent wiring (for example delegating `developer` to `workflowAsAgent("develop")`) are done in the workflow instance layer.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -15,9 +15,10 @@ In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
import { createWorkflow } from "@uncaged/workflow";
|
||||||
|
import { solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
||||||
|
|
||||||
const run = createSolveIssueRun(binding, extract, llmProvider);
|
const run = createWorkflow(solveIssueWorkflowDefinition, binding);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
@@ -41,7 +42,6 @@ Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod me
|
|||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
|
|
||||||
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
||||||
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
||||||
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createCasStore, createExtract, createWorkflow } from "@uncaged/workflow";
|
||||||
import {
|
import {
|
||||||
createCasStore,
|
|
||||||
createExtract,
|
|
||||||
END,
|
END,
|
||||||
type ModeratorContext,
|
type ModeratorContext,
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
import type { DeveloperMeta } from "../src/developer.js";
|
import type { DeveloperMeta } from "../src/developer.js";
|
||||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||||
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||||
import type { SolveIssueMeta } from "../src/roles.js";
|
import type { SolveIssueMeta } from "../src/roles.js";
|
||||||
|
|
||||||
@@ -24,46 +23,7 @@ function jsonResponse(payload: Record<string, unknown>): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
|
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
|
||||||
if (init === undefined || init.body === undefined || init.body === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
||||||
const tools = body.tools;
|
|
||||||
if (!Array.isArray(tools)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
|
|
||||||
}
|
|
||||||
|
|
||||||
function singleToolName(tools: readonly Record<string, unknown>[]): string {
|
|
||||||
if (tools.length === 0) {
|
|
||||||
return "extract";
|
|
||||||
}
|
|
||||||
const fn = tools[0].function as Record<string, unknown> | undefined;
|
|
||||||
return typeof fn?.name === "string" ? fn.name : "extract";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
|
|
||||||
return jsonResponse({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: { name: toolName, arguments: JSON.stringify(args) },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReactModeResponse(args: Record<string, unknown>): Response {
|
|
||||||
// reactExtract accepts a plain-JSON assistant message and validates it
|
|
||||||
// directly against the schema, so we skip the cas_get / extract tool dance.
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
});
|
});
|
||||||
@@ -74,18 +34,59 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
_input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
_init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
if (args === undefined) {
|
if (args === undefined) {
|
||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
const tools = readToolListFromBody(init);
|
return buildPlainJsonResponse(args);
|
||||||
if (tools.length > 1) {
|
};
|
||||||
return buildReactModeResponse(args);
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
|
}) as typeof fetch;
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolCallResponse(args: Record<string, unknown>): Response {
|
||||||
|
return jsonResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "tc_extract_1",
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "extract",
|
||||||
|
arguments: JSON.stringify(args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMockToolCallCompletions(
|
||||||
|
sequence: ReadonlyArray<Record<string, unknown>>,
|
||||||
|
): () => void {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let i = 0;
|
||||||
|
const mockFetch = async (
|
||||||
|
_input: Parameters<typeof fetch>[0],
|
||||||
|
_init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
|
if (args === undefined) {
|
||||||
|
throw new Error("installMockToolCallCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
return buildSingleModeResponse(args, singleToolName(tools));
|
i += 1;
|
||||||
|
return buildToolCallResponse(args);
|
||||||
};
|
};
|
||||||
globalThis.fetch = Object.assign(mockFetch, {
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
preconnect: origFetch.preconnect.bind(origFetch),
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
@@ -161,17 +162,30 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const stubExtract = createExtract({
|
function createStubExtract(casDir: string) {
|
||||||
baseUrl: "http://127.0.0.1:9",
|
return createExtract(
|
||||||
apiKey: "",
|
{
|
||||||
model: "test",
|
baseUrl: "http://127.0.0.1:9",
|
||||||
});
|
apiKey: "",
|
||||||
|
model: "test",
|
||||||
|
},
|
||||||
|
{ cas: createCasStore(casDir) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stubLlmProvider = {
|
function makeThread(prompt: string) {
|
||||||
baseUrl: "http://127.0.0.1:9",
|
return {
|
||||||
apiKey: "",
|
threadId: "01TEST000000000000000000TR",
|
||||||
model: "test",
|
depth: 0,
|
||||||
};
|
start: {
|
||||||
|
role: START,
|
||||||
|
content: prompt,
|
||||||
|
meta: { maxRounds: 20 },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("solveIssueModerator", () => {
|
describe("solveIssueModerator", () => {
|
||||||
test("routes initial → preparer → developer → submitter → END", () => {
|
test("routes initial → preparer → developer → submitter → END", () => {
|
||||||
@@ -219,7 +233,7 @@ describe("solveIssueModerator", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createSolveIssueRun", () => {
|
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||||
let restoreFetch: (() => void) | null = null;
|
let restoreFetch: (() => void) | null = null;
|
||||||
let casDir: string | undefined;
|
let casDir: string | undefined;
|
||||||
|
|
||||||
@@ -249,19 +263,48 @@ describe("createSolveIssueRun", () => {
|
|||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
// Override developer so the test does not spin up a child workflow.
|
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||||
const run = createSolveIssueRun(
|
agent: async () => "",
|
||||||
{
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
agent: async () => "",
|
});
|
||||||
overrides: { developer: async () => "stub-root-hash" },
|
const gen = run(makeThread("task"), {
|
||||||
|
cas,
|
||||||
|
extract: createStubExtract(casDir),
|
||||||
|
});
|
||||||
|
const first = await gen.next();
|
||||||
|
expect(first.done).toBe(false);
|
||||||
|
if (first.done) {
|
||||||
|
throw new Error("expected yield");
|
||||||
|
}
|
||||||
|
expect(first.value.role).toBe("preparer");
|
||||||
|
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("structured extraction also accepts tool_calls extraction path", async () => {
|
||||||
|
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||||
|
repoPath: "/home/user/repos/tool-call",
|
||||||
|
defaultBranch: "main",
|
||||||
|
conventions: null,
|
||||||
|
toolchain: {
|
||||||
|
packageManager: "bun",
|
||||||
|
testCommand: "bun test",
|
||||||
|
lintCommand: null,
|
||||||
|
buildCommand: "bun run build",
|
||||||
},
|
},
|
||||||
stubExtract,
|
};
|
||||||
stubLlmProvider,
|
restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]);
|
||||||
);
|
|
||||||
const gen = run(
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
{ prompt: "task", steps: [] },
|
const cas = createCasStore(casDir);
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
|
||||||
);
|
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||||
|
agent: async () => "",
|
||||||
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
|
});
|
||||||
|
const gen = run(makeThread("task"), {
|
||||||
|
cas,
|
||||||
|
extract: createStubExtract(casDir),
|
||||||
|
});
|
||||||
const first = await gen.next();
|
const first = await gen.next();
|
||||||
expect(first.done).toBe(false);
|
expect(first.done).toBe(false);
|
||||||
if (first.done) {
|
if (first.done) {
|
||||||
@@ -294,34 +337,30 @@ describe("createSolveIssueRun", () => {
|
|||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const run = createSolveIssueRun(
|
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||||
{
|
agent: async () => {
|
||||||
agent: async () => {
|
calls.push("default");
|
||||||
calls.push("default");
|
return "";
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
preparer: async () => {
|
||||||
|
calls.push("preparer");
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
overrides: {
|
developer: async () => {
|
||||||
preparer: async () => {
|
calls.push("developer");
|
||||||
calls.push("preparer");
|
return "stub-root-hash";
|
||||||
return "";
|
},
|
||||||
},
|
submitter: async () => {
|
||||||
developer: async () => {
|
calls.push("submitter");
|
||||||
calls.push("developer");
|
return "";
|
||||||
return "stub-root-hash";
|
|
||||||
},
|
|
||||||
submitter: async () => {
|
|
||||||
calls.push("submitter");
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stubExtract,
|
});
|
||||||
stubLlmProvider,
|
const gen = run(makeThread("task"), {
|
||||||
);
|
cas,
|
||||||
const gen = run(
|
extract: createStubExtract(casDir),
|
||||||
{ prompt: "task", steps: [] },
|
});
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
|
||||||
);
|
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["preparer"]);
|
expect(calls).toEqual(["preparer"]);
|
||||||
|
|
||||||
@@ -333,55 +372,6 @@ describe("createSolveIssueRun", () => {
|
|||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["submitter"]);
|
expect(calls).toEqual(["submitter"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
|
|
||||||
const PREPARER_META: PreparerMeta = {
|
|
||||||
repoPath: "/tmp/r",
|
|
||||||
defaultBranch: "main",
|
|
||||||
conventions: null,
|
|
||||||
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
|
||||||
};
|
|
||||||
const DEVELOPER_META: DeveloperMeta = {
|
|
||||||
branch: "feat/y",
|
|
||||||
commitSha: "def5678",
|
|
||||||
filesChanged: ["b.ts"],
|
|
||||||
summary: "more work",
|
|
||||||
};
|
|
||||||
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
|
|
||||||
|
|
||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
|
||||||
const cas = createCasStore(casDir);
|
|
||||||
|
|
||||||
let developerInvocations = 0;
|
|
||||||
const run = createSolveIssueRun(
|
|
||||||
{
|
|
||||||
agent: async () => "",
|
|
||||||
overrides: {
|
|
||||||
developer: async () => {
|
|
||||||
developerInvocations += 1;
|
|
||||||
return "stub-root-hash";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stubExtract,
|
|
||||||
stubLlmProvider,
|
|
||||||
);
|
|
||||||
const gen = run(
|
|
||||||
{ prompt: "task", steps: [] },
|
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
|
||||||
);
|
|
||||||
// preparer
|
|
||||||
await gen.next();
|
|
||||||
// developer (caller override should be invoked, NOT workflowAsAgent default)
|
|
||||||
const devYield = await gen.next();
|
|
||||||
expect(devYield.done).toBe(false);
|
|
||||||
if (devYield.done) {
|
|
||||||
throw new Error("expected yield");
|
|
||||||
}
|
|
||||||
expect(devYield.value.role).toBe("developer");
|
|
||||||
expect(devYield.value.meta).toEqual(DEVELOPER_META);
|
|
||||||
expect(developerInvocations).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSolveIssueDescriptor", () => {
|
describe("buildSolveIssueDescriptor", () => {
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ describe("submitterRole", () => {
|
|||||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses single extract mode without refs", () => {
|
test("has no refs extractor", () => {
|
||||||
expect(submitterRole.extractMode).toBe("single");
|
|
||||||
expect(submitterRole.extractRefs).toBeNull();
|
expect(submitterRole.extractRefs).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-solve-issue",
|
"name": "@uncaged/workflow-template-solve-issue",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const developerMetaSchema = z.object({
|
export const developerMetaSchema = z.object({
|
||||||
@@ -33,5 +33,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
|
|||||||
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||||
schema: developerMetaSchema,
|
schema: developerMetaSchema,
|
||||||
extractRefs: () => [],
|
extractRefs: () => [],
|
||||||
extractMode: "react",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
type AgentBinding,
|
|
||||||
createWorkflow,
|
|
||||||
type ExtractFn,
|
|
||||||
type LlmProvider,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
workflowAsAgent,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
@@ -38,26 +30,3 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
|||||||
roles: solveIssueRoles,
|
roles: solveIssueRoles,
|
||||||
moderator: solveIssueModerator,
|
moderator: solveIssueModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the solve-issue {@link WorkflowFn}.
|
|
||||||
*
|
|
||||||
* The `developer` role always delegates to the registered `develop` workflow via
|
|
||||||
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
|
||||||
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
|
||||||
*/
|
|
||||||
export function createSolveIssueRun(
|
|
||||||
binding: AgentBinding,
|
|
||||||
extract: ExtractFn,
|
|
||||||
llmProvider: LlmProvider | null,
|
|
||||||
): WorkflowFn {
|
|
||||||
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
|
||||||
const mergedBinding: AgentBinding = {
|
|
||||||
agent: binding.agent,
|
|
||||||
overrides: {
|
|
||||||
...(binding.overrides ?? {}),
|
|
||||||
developer: developerOverride,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Moderator } from "@uncaged/workflow";
|
import type { Moderator } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
import type { SolveIssueMeta } from "./roles.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import { type DeveloperMeta, developerRole } from "./developer.js";
|
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
const toolchainSchema = z.object({
|
const toolchainSchema = z.object({
|
||||||
@@ -48,5 +48,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
|||||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||||
schema: preparerMetaSchema,
|
schema: preparerMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||||
@@ -40,5 +40,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
|||||||
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
||||||
schema: submitterMetaSchema,
|
schema: submitterMetaSchema,
|
||||||
extractRefs: null,
|
extractRefs: null,
|
||||||
extractMode: "single",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { buildAgentPrompt } from "../src/index.js";
|
import { buildAgentPrompt } from "../src/index.js";
|
||||||
|
|
||||||
function startTask(content: string): ThreadContext["start"] {
|
function startTask(content: string): AgentContext["start"] {
|
||||||
return {
|
return {
|
||||||
role: START,
|
role: START,
|
||||||
content,
|
content,
|
||||||
@@ -16,25 +13,13 @@ function startTask(content: string): ThreadContext["start"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("buildAgentPrompt", () => {
|
describe("buildAgentPrompt", () => {
|
||||||
let casRoot: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await rm(casRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("includes system prompt and full task; omits tools when there are no steps", async () => {
|
test("includes system prompt and full task; omits tools when there are no steps", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("fix the bug"),
|
start: startTask("fix the bug"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
steps: [],
|
steps: [],
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||||
cas,
|
|
||||||
};
|
};
|
||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).toContain("You are an agent.");
|
expect(text).toContain("You are an agent.");
|
||||||
@@ -43,15 +28,13 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).not.toContain("## Tools");
|
expect(text).not.toContain("## Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single step shows full content and meta, and includes tools", async () => {
|
test("single step shows hash and meta, and includes tools", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
const onlyHash = await putContentMerkleNode(cas, "only step full body");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
@@ -66,22 +49,20 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
expect(text).toContain("## Step: coder");
|
||||||
expect(text).toContain("only step full body");
|
expect(text).toContain(`ContentHash: ${onlyHash}`);
|
||||||
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
|
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const plannerHash = "01HASHPLANNER0000000000001";
|
||||||
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
|
const coderHash = "01HASHCODER0000000000000001";
|
||||||
const coderHash = await putContentMerkleNode(cas, "last step full content");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("first message full: task content here"),
|
start: startTask("first message full: task content here"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "coder", systemPrompt: "System." },
|
currentRole: { name: "coder", systemPrompt: "System." },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
@@ -104,25 +85,22 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Previous Steps");
|
expect(text).toContain("## Previous Steps");
|
||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
expect(text).toContain('Summary: {"plan":"short"}');
|
expect(text).toContain('Summary: {"plan":"short"}');
|
||||||
expect(text).not.toContain("PLANNER_SECRET_FULL_TEXT");
|
|
||||||
expect(text).toContain("## Latest Step: coder");
|
expect(text).toContain("## Latest Step: coder");
|
||||||
expect(text).toContain("last step full content");
|
expect(text).toContain(`ContentHash: ${coderHash}`);
|
||||||
expect(text).toContain('Meta: {"done":true}');
|
expect(text).toContain('Meta: {"done":true}');
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("middle steps show meta summary only, not full content", async () => {
|
test("middle steps show meta summary only and latest shows hash", async () => {
|
||||||
const cas = createCasStore(casRoot);
|
const ha = "01HASHA00000000000000000001";
|
||||||
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
|
const hb = "01HASHB00000000000000000001";
|
||||||
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
|
const hc = "01HASHC00000000000000000001";
|
||||||
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
|
const ctx: AgentContext = {
|
||||||
const ctx: ThreadContext = {
|
|
||||||
start: startTask("start"),
|
start: startTask("start"),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
threadId: "01TEST000000000000000000TR",
|
threadId: "01TEST000000000000000000TR",
|
||||||
currentRole: { name: "c", systemPrompt: "S" },
|
currentRole: { name: "c", systemPrompt: "S" },
|
||||||
cas,
|
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
@@ -148,11 +126,9 @@ describe("buildAgentPrompt", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).not.toContain("HIDDEN_A");
|
|
||||||
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
|
||||||
expect(text).toContain('Summary: {"n":1}');
|
expect(text).toContain('Summary: {"n":1}');
|
||||||
expect(text).toContain('Summary: {"n":2}');
|
expect(text).toContain('Summary: {"n":2}');
|
||||||
expect(text).toContain("VISIBLE_LAST");
|
expect(text).toContain(`ContentHash: ${hc}`);
|
||||||
expect(text).toContain("## Latest Step: c");
|
expect(text).toContain("## Latest Step: c");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-util-agent",
|
"name": "@uncaged/workflow-util-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user