Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee0763015 | |||
| d1a0a135d4 | |||
| 34e00bebdf | |||
| 33cf23ed01 | |||
| 94c719870f | |||
| 5af2d54e0f |
+2
-2
@@ -5,8 +5,8 @@
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cmdJsonCasInit,
|
||||
cmdNodeGet,
|
||||
cmdNodeList,
|
||||
cmdNodeWalk,
|
||||
cmdWorkflowRegister,
|
||||
cmdWorkflowShow,
|
||||
formatNodeWalk,
|
||||
formatWorkflowShow,
|
||||
getJsonCasDir,
|
||||
} from "../src/commands/json-cas/index.js";
|
||||
|
||||
const SIMPLE_WORKFLOW = {
|
||||
name: "test-workflow",
|
||||
description: "A test workflow for CLI tests",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyses the input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract the analysis.",
|
||||
schema: { type: "object", properties: { result: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
moderator: [{ from: "analyst", to: "__end__", when: null }],
|
||||
};
|
||||
|
||||
describe("json-cas CLI commands", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getJsonCasDir returns path under storageRoot", () => {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
expect(dir).toBe(join(storageRoot, "json-cas"));
|
||||
});
|
||||
|
||||
test("init bootstraps the store and returns a workflow type hash", async () => {
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
expect(typeof workflowTypeHash).toBe("string");
|
||||
expect(workflowTypeHash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("init is idempotent", async () => {
|
||||
const hash1 = await cmdJsonCasInit(storageRoot);
|
||||
const hash2 = await cmdJsonCasInit(storageRoot);
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("workflow register returns a hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const result = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(typeof result.hash).toBe("string");
|
||||
expect(result.hash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("workflow register is idempotent", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(r1.hash).toBe(r2.hash);
|
||||
});
|
||||
|
||||
test("workflow show loads a registered workflow", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
|
||||
expect(wf).not.toBeNull();
|
||||
if (wf === null) return;
|
||||
|
||||
expect(wf.name).toBe("test-workflow");
|
||||
expect(wf.description).toBe("A test workflow for CLI tests");
|
||||
expect(Object.keys(wf.roles)).toContain("analyst");
|
||||
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
|
||||
expect(wf.moderator).toHaveLength(1);
|
||||
expect(wf.moderator[0].from).toBe("analyst");
|
||||
});
|
||||
|
||||
test("workflow show returns null for unknown hash", async () => {
|
||||
await cmdJsonCasInit(storageRoot);
|
||||
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatWorkflowShow produces expected output", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) throw new Error("workflow not found");
|
||||
|
||||
const output = formatWorkflowShow(hash, wf);
|
||||
expect(output).toContain("test-workflow");
|
||||
expect(output).toContain(hash);
|
||||
expect(output).toContain("analyst");
|
||||
expect(output).toContain("moderator:");
|
||||
});
|
||||
|
||||
test("node list returns hashes after registration", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
|
||||
expect(hashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("node list returns empty array for empty store", async () => {
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("node get returns JSON for a known hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const json = await cmdNodeGet(storageRoot, hash);
|
||||
|
||||
expect(json).not.toBeNull();
|
||||
if (json === null) return;
|
||||
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
expect(parsed).toMatchObject({ payload: expect.anything() });
|
||||
});
|
||||
|
||||
test("node get returns null for unknown hash", async () => {
|
||||
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("node walk traverses the workflow DAG", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
|
||||
expect(entries).not.toBeNull();
|
||||
if (entries === null) return;
|
||||
|
||||
// should include at least the workflow node, role node, and role-schema node
|
||||
expect(entries.length).toBeGreaterThanOrEqual(3);
|
||||
const hashes = entries.map((e) => e.hash);
|
||||
expect(hashes).toContain(hash);
|
||||
});
|
||||
|
||||
test("node walk returns null for unknown root", async () => {
|
||||
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatNodeWalk produces output with node hashes", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) throw new Error("walk failed");
|
||||
|
||||
const output = formatNodeWalk(hash, entries);
|
||||
expect(output).toContain(`walk from: ${hash}`);
|
||||
expect(output).toContain(hash);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,17 @@
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.1.1",
|
||||
"@uncaged/json-cas-fs": "^0.1.1",
|
||||
"@uncaged/json-cas-workflow": "^0.1.1",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-json-def": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { createJsonCasDispatcher } from "./commands/json-cas/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
@@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
@@ -72,6 +74,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
connect: dispatchConnect,
|
||||
"json-cas": dispatchJsonCas,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
@@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
{
|
||||
name: "json-cas",
|
||||
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
"json-cas": "JSON-CAS engine:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { cmdJsonCasInit } from "./init.js";
|
||||
import { cmdNodeGet } from "./node-get.js";
|
||||
import { cmdNodeList } from "./node-list.js";
|
||||
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
import type { JsonCasDispatchDeps } from "./types.js";
|
||||
import { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
|
||||
// ── node subcommands ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node get requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdNodeGet(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas node list takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
if (hashes.length === 0) {
|
||||
printCliLine("(no nodes)");
|
||||
return 0;
|
||||
}
|
||||
for (const hash of hashes) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node walk requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatNodeWalk(hash, entries));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
|
||||
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
|
||||
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
|
||||
walk: {
|
||||
handler: dispatchNodeWalk,
|
||||
args: "<hash>",
|
||||
description: "Walk the DAG from a node, show referenced nodes",
|
||||
},
|
||||
};
|
||||
|
||||
// ── workflow subcommands ─────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchWorkflowRegister(
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const file = argv[0];
|
||||
if (file === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow register requires <file.json>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdWorkflowRegister(storageRoot, file);
|
||||
printCliLine(`registered workflow: ${result.hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow show requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) {
|
||||
printCliError(`error: workflow not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatWorkflowShow(hash, wf));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
|
||||
register: {
|
||||
handler: dispatchWorkflowRegister,
|
||||
args: "<file.json>",
|
||||
description: "Register a workflow definition from a JSON file",
|
||||
},
|
||||
show: {
|
||||
handler: dispatchWorkflowShow,
|
||||
args: "<hash>",
|
||||
description: "Show a workflow by its CAS hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── thread subcommands ───────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas thread show requires <start-hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: thread start node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatThreadShow(result));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
|
||||
show: {
|
||||
handler: dispatchThreadShow,
|
||||
args: "<start-hash>",
|
||||
description: "Walk and display thread steps from a thread-start hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── top-level json-cas subcommands ───────────────────────────────────────────
|
||||
|
||||
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas init takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
printCliLine(`initialized json-cas store at ${dir}`);
|
||||
printCliLine(`workflow type hash: ${workflowTypeHash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function printSubgroupHelp(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
sub: string | undefined,
|
||||
): number {
|
||||
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||
const lines = [`json-cas ${groupPath} subcommands:\n`];
|
||||
for (const [name, e] of Object.entries(table)) {
|
||||
const args = e.args ? ` ${e.args}` : "";
|
||||
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
|
||||
lines.push(` ${e.description}\n`);
|
||||
}
|
||||
printCliLine(lines.join("\n"));
|
||||
return sub === undefined ? 1 : 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function dispatchSubgroup(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const sub = argv[0];
|
||||
const helpResult = printSubgroupHelp(groupPath, table, sub);
|
||||
if (helpResult >= 0) return helpResult;
|
||||
|
||||
const entry = table[sub as string];
|
||||
if (entry === undefined) {
|
||||
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
init: {
|
||||
handler: dispatchJsonCasInit,
|
||||
args: "",
|
||||
description: "Initialize json-cas store and register workflow schemas",
|
||||
},
|
||||
workflow: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
|
||||
args: "<register|show>",
|
||||
description: "Manage json-cas workflow definitions",
|
||||
},
|
||||
thread: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
|
||||
args: "<show>",
|
||||
description: "Inspect json-cas thread execution records",
|
||||
},
|
||||
node: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
|
||||
args: "<get|list|walk>",
|
||||
description: "Low-level access to json-cas store nodes",
|
||||
},
|
||||
};
|
||||
|
||||
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`error: unknown json-cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
createJsonCasDispatcher,
|
||||
dispatchJsonCasInit,
|
||||
dispatchNodeGet,
|
||||
dispatchNodeList,
|
||||
dispatchNodeWalk,
|
||||
dispatchThreadShow,
|
||||
dispatchWorkflowRegister,
|
||||
dispatchWorkflowShow,
|
||||
JSON_CAS_NODE_TABLE,
|
||||
JSON_CAS_SUBCOMMAND_TABLE,
|
||||
JSON_CAS_THREAD_TABLE,
|
||||
JSON_CAS_WORKFLOW_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdJsonCasInit } from "./init.js";
|
||||
export { cmdNodeGet } from "./node-get.js";
|
||||
export { cmdNodeList } from "./node-list.js";
|
||||
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
export { getJsonCasDir, openStore } from "./store.js";
|
||||
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
|
||||
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
export type { JsonCasDispatchDeps } from "./types.js";
|
||||
export type { WorkflowRegisterResult } from "./workflow-register.js";
|
||||
export { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
|
||||
const { typeHashes } = await openStore(storageRoot);
|
||||
return typeHashes.workflow;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
const node = store.get(hash);
|
||||
if (node === null) return null;
|
||||
return JSON.stringify(node, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
return store.list();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CasNode, Hash } from "@uncaged/json-cas";
|
||||
import { walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export type WalkEntry = {
|
||||
hash: Hash;
|
||||
type: Hash;
|
||||
timestamp: number;
|
||||
payloadPreview: string;
|
||||
};
|
||||
|
||||
export async function cmdNodeWalk(
|
||||
storageRoot: string,
|
||||
rootHash: string,
|
||||
): Promise<WalkEntry[] | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
|
||||
if (!store.has(rootHash)) return null;
|
||||
|
||||
const entries: WalkEntry[] = [];
|
||||
|
||||
walk(store, rootHash, (hash: Hash, node: CasNode) => {
|
||||
const preview = JSON.stringify(node.payload).slice(0, 100);
|
||||
entries.push({
|
||||
hash,
|
||||
type: node.type,
|
||||
timestamp: node.timestamp,
|
||||
payloadPreview: preview,
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`walk from: ${rootHash}`);
|
||||
lines.push(`nodes: ${entries.length}`);
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push("");
|
||||
lines.push(` hash: ${entry.hash}`);
|
||||
lines.push(` type: ${entry.type}`);
|
||||
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
|
||||
lines.push(` data: ${entry.payloadPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from "node:path";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
|
||||
export function getJsonCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "json-cas");
|
||||
}
|
||||
|
||||
export type OpenStoreResult = {
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
};
|
||||
|
||||
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
const store = createFsStore(dir);
|
||||
await bootstrap(store);
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
type StepEntry = {
|
||||
hash: Hash;
|
||||
payload: ThreadStepPayload;
|
||||
};
|
||||
|
||||
function collectStepsInOrder(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
): StepEntry[] {
|
||||
const stepMap = new Map<Hash, StepEntry>();
|
||||
|
||||
for (const hash of store.list()) {
|
||||
const node = store.get(hash);
|
||||
if (node === null || node.type !== typeHashes.threadStep) continue;
|
||||
const p = node.payload as ThreadStepPayload;
|
||||
if (p.start === startHash) {
|
||||
stepMap.set(hash, { hash, payload: p });
|
||||
}
|
||||
}
|
||||
|
||||
if (stepMap.size === 0) return [];
|
||||
|
||||
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
|
||||
const previousSet = new Set<Hash>();
|
||||
for (const entry of stepMap.values()) {
|
||||
if (entry.payload.previous !== null) {
|
||||
previousSet.add(entry.payload.previous);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
|
||||
if (terminalEntries.length === 0) return [...stepMap.values()];
|
||||
|
||||
// Walk backward from terminal to build ordered list
|
||||
const ordered: StepEntry[] = [];
|
||||
let current: StepEntry | undefined = terminalEntries[0];
|
||||
while (current !== undefined) {
|
||||
ordered.unshift(current);
|
||||
const prevHash = current.payload.previous;
|
||||
if (prevHash === null) break;
|
||||
current = stepMap.get(prevHash);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getContentText(store: Store, contentHash: Hash): string {
|
||||
const node = store.get(contentHash);
|
||||
if (node === null) return "(not found)";
|
||||
const payload = node.payload as ContentPayload;
|
||||
return typeof payload.text === "string" ? payload.text : "(no text)";
|
||||
}
|
||||
|
||||
export type ThreadStep = {
|
||||
hash: Hash;
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentPreview: string;
|
||||
};
|
||||
|
||||
export type ThreadShowResult = {
|
||||
startHash: Hash;
|
||||
steps: ThreadStep[];
|
||||
};
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
startHash: string,
|
||||
): Promise<ThreadShowResult | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) return null;
|
||||
|
||||
const entries = collectStepsInOrder(store, typeHashes, startHash);
|
||||
|
||||
const steps: ThreadStep[] = entries.map((entry) => {
|
||||
const rawText = getContentText(store, entry.payload.content);
|
||||
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
|
||||
return {
|
||||
hash: entry.hash,
|
||||
role: entry.payload.role,
|
||||
meta: entry.payload.meta,
|
||||
contentPreview: preview,
|
||||
};
|
||||
});
|
||||
|
||||
return { startHash, steps };
|
||||
}
|
||||
|
||||
export function formatThreadShow(result: ThreadShowResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`thread: ${result.startHash}`);
|
||||
lines.push(`steps: ${result.steps.length}`);
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
lines.push("");
|
||||
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
|
||||
if (Object.keys(step.meta).length > 0) {
|
||||
lines.push(` meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
lines.push(` > ${step.contentPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type JsonCasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
import { registerWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export type WorkflowRegisterResult = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export async function cmdWorkflowRegister(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowRegisterResult> {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const workflowDef = JSON.parse(raw) as WorkflowInput;
|
||||
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
const hash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||
|
||||
return { hash };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { loadWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdWorkflowShow(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<HydratedWorkflow | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
return loadWorkflow(store, typeHashes, hash);
|
||||
}
|
||||
|
||||
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`workflow: ${wf.name}`);
|
||||
lines.push(`hash: ${hash}`);
|
||||
lines.push(`desc: ${wf.description}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("roles:");
|
||||
for (const [name, role] of Object.entries(wf.roles)) {
|
||||
lines.push(` ${name}:`);
|
||||
lines.push(` description: ${role.description}`);
|
||||
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
}
|
||||
|
||||
if (wf.moderator.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("moderator:");
|
||||
for (const rule of wf.moderator) {
|
||||
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
|
||||
lines.push(` ${rule.from} → ${rule.to} [${when}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires 'command' and 'timeout'", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("command");
|
||||
expect(required).toContain("timeout");
|
||||
});
|
||||
|
||||
test("configSchema properties include command, model, timeout, workspace", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("command");
|
||||
expect(props).toHaveProperty("model");
|
||||
expect(props).toHaveProperty("timeout");
|
||||
expect(props).toHaveProperty("workspace");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-cursor.
|
||||
* Config maps to {@link CursorAgentConfig}.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-cursor",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["cursor-cli", "workspace-agent"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["command", "timeout"],
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Absolute path to the cursor-agent CLI binary.",
|
||||
},
|
||||
model: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Model identifier passed to cursor-agent --model; null means auto.",
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
description: "Timeout in milliseconds; 0 means no limit.",
|
||||
},
|
||||
workspace: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Override workspace path; null resolves from thread context.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires 'command'", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("command");
|
||||
});
|
||||
|
||||
test("configSchema properties include command, model, timeout", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("command");
|
||||
expect(props).toHaveProperty("model");
|
||||
expect(props).toHaveProperty("timeout");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-hermes.
|
||||
* Config maps to {@link HermesAgentConfig}.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-hermes",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["hermes-cli", "yolo-mode"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Absolute path to the hermes CLI binary.",
|
||||
},
|
||||
model: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Model identifier passed to hermes --model; null uses the CLI default.",
|
||||
},
|
||||
timeout: {
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
description: "Timeout in milliseconds; null means no limit.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires baseUrl, apiKey, model", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("baseUrl");
|
||||
expect(required).toContain("apiKey");
|
||||
expect(required).toContain("model");
|
||||
});
|
||||
|
||||
test("configSchema properties include baseUrl, apiKey, model", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("baseUrl");
|
||||
expect(props).toHaveProperty("apiKey");
|
||||
expect(props).toHaveProperty("model");
|
||||
});
|
||||
});
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
type LlmChatError,
|
||||
type LlmMessage,
|
||||
} from "./create-llm-adapter.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-llm.
|
||||
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-llm",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["llm-single-turn"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["baseUrl", "apiKey", "model"],
|
||||
properties: {
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
description: "API key for the provider.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
description: "Model identifier passed as the `model` field in the request body.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/index.js";
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has the correct package name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
|
||||
});
|
||||
|
||||
test("has a non-empty version string", () => {
|
||||
expect(typeof packageDescriptor.version).toBe("string");
|
||||
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("capabilities is a non-empty array of strings", () => {
|
||||
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||
for (const cap of packageDescriptor.capabilities) {
|
||||
expect(typeof cap).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("configSchema is an object with type 'object'", () => {
|
||||
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||
});
|
||||
|
||||
test("configSchema requires maxRounds", () => {
|
||||
const required = packageDescriptor.configSchema.required as string[];
|
||||
expect(required).toContain("maxRounds");
|
||||
});
|
||||
|
||||
test("configSchema properties include maxRounds", () => {
|
||||
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("maxRounds");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||
|
||||
/**
|
||||
* Static metadata for @uncaged/workflow-agent-react.
|
||||
*
|
||||
* Config represents the serializable subset of {@link ReactAdapterConfig}.
|
||||
* The `llm` function and `toolHandler` are runtime constructs and are not
|
||||
* stored in the CAS agent node; only `maxRounds` is serializable.
|
||||
*/
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-react",
|
||||
version: "0.5.0-alpha.4",
|
||||
capabilities: ["react-loop", "tool-calling"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["maxRounds"],
|
||||
properties: {
|
||||
maxRounds: {
|
||||
type: "number",
|
||||
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
|
||||
import {
|
||||
registerWorkflowSchemas,
|
||||
type WorkflowSchemaHashes,
|
||||
type ContentPayload,
|
||||
registerWorkflowSchemas,
|
||||
type ThreadEndPayload,
|
||||
type ThreadStartPayload,
|
||||
type ThreadStepPayload,
|
||||
type WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
|
||||
@@ -145,11 +145,7 @@ function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngi
|
||||
};
|
||||
}
|
||||
|
||||
function makeIo(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
threadId: string,
|
||||
): JsonCasEngineIo {
|
||||
function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo {
|
||||
return { threadId, store, typeHashes };
|
||||
}
|
||||
|
||||
@@ -164,7 +160,7 @@ function createMockAgent(
|
||||
if (resp === undefined) {
|
||||
throw new Error(`mock agent: no response configured for role "${role}"`);
|
||||
}
|
||||
return resp;
|
||||
return { ...resp, react: null };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,11 +452,11 @@ describe("executeJsonCasThread", () => {
|
||||
if (role === "checker") {
|
||||
checkerCallCount++;
|
||||
if (checkerCallCount === 1) {
|
||||
return { text: "found issue", meta: { status: "bad" } };
|
||||
return { text: "found issue", meta: { status: "bad" }, react: null };
|
||||
}
|
||||
return { text: "all good now", meta: { status: "ok" } };
|
||||
return { text: "all good now", meta: { status: "ok" }, react: null };
|
||||
}
|
||||
return { text: "fixed it", meta: { fix: "patched" } };
|
||||
return { text: "fixed it", meta: { fix: "patched" }, react: null };
|
||||
};
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
@@ -508,7 +504,7 @@ describe("executeJsonCasThread", () => {
|
||||
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => {
|
||||
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
@@ -541,7 +537,7 @@ describe("executeJsonCasThread", () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => {
|
||||
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
|
||||
@@ -586,7 +582,7 @@ describe("executeJsonCasThread", () => {
|
||||
stepCount: snapshot.steps.length,
|
||||
input: snapshot.start.input,
|
||||
});
|
||||
return { text: `output for ${role}`, meta: {} };
|
||||
return { text: `output for ${role}`, meta: {}, react: null };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
@@ -617,17 +613,20 @@ describe("executeJsonCasThread", () => {
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||
|
||||
let round = 0;
|
||||
const snapshots: Array<{ role: string; steps: readonly { role: string; meta: Record<string, unknown> }[] }> = [];
|
||||
const snapshots: Array<{
|
||||
role: string;
|
||||
steps: readonly { role: string; meta: Record<string, unknown> }[];
|
||||
}> = [];
|
||||
|
||||
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
|
||||
snapshots.push({ role, steps: [...snapshot.steps] });
|
||||
round++;
|
||||
if (role === "checker") {
|
||||
return round === 1
|
||||
? { text: "bad", meta: { status: "bad" } }
|
||||
: { text: "ok", meta: { status: "ok" } };
|
||||
? { text: "bad", meta: { status: "bad" }, react: null }
|
||||
: { text: "ok", meta: { status: "ok" }, react: null };
|
||||
}
|
||||
return { text: "fixed", meta: { fix: "yes" } };
|
||||
return { text: "fixed", meta: { fix: "yes" }, react: null };
|
||||
};
|
||||
|
||||
await executeJsonCasThread({
|
||||
@@ -682,7 +681,11 @@ describe("buildJsonCasThreadSnapshot", () => {
|
||||
const lastStepHash = endPayload.lastStep;
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(
|
||||
store, typeHashes, startHash, lastStepHash, "THREAD_SNAP",
|
||||
store,
|
||||
typeHashes,
|
||||
startHash,
|
||||
lastStepHash,
|
||||
"THREAD_SNAP",
|
||||
);
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP");
|
||||
@@ -707,9 +710,7 @@ describe("buildJsonCasThreadSnapshot", () => {
|
||||
agents: {},
|
||||
});
|
||||
|
||||
const snapshot = buildJsonCasThreadSnapshot(
|
||||
store, typeHashes, startHash, null, "THREAD_SNAP2",
|
||||
);
|
||||
const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2");
|
||||
|
||||
expect(snapshot.threadId).toBe("THREAD_SNAP2");
|
||||
expect(snapshot.start.input).toBe("just started");
|
||||
@@ -739,9 +740,7 @@ describe("buildJsonCasThreadContext", () => {
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||
const ctx = buildJsonCasThreadContext(
|
||||
store, typeHashes, endPayload.start, endPayload.lastStep,
|
||||
);
|
||||
const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep);
|
||||
|
||||
expect(ctx.threadId).toBe("");
|
||||
expect(ctx.depth).toBe(3);
|
||||
@@ -835,7 +834,7 @@ describe("CAS graph integrity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("react session nodes have placeholder structure", async () => {
|
||||
test("react session nodes have empty structure when agent returns react: null", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore } from "@uncaged/json-cas";
|
||||
import {
|
||||
type ContentPayload,
|
||||
type ReactSessionPayload,
|
||||
type ReactToolCallPayload,
|
||||
type ReactTurnPayload,
|
||||
registerWorkflowSchemas,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
|
||||
import { writeReactSession } from "../src/engine/json-cas-react-recorder.js";
|
||||
import type { ReactTrace } from "../src/engine/json-cas-types.js";
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
async function setupStore() {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
|
||||
async function makeFakeAgent(
|
||||
store: Awaited<ReturnType<typeof setupStore>>["store"],
|
||||
typeHashes: Awaited<ReturnType<typeof setupStore>>["typeHashes"],
|
||||
) {
|
||||
return store.put(typeHashes.agent, {
|
||||
package: "test-agent",
|
||||
version: "1.0.0",
|
||||
config: {},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("writeReactSession", () => {
|
||||
describe("empty trace", () => {
|
||||
test("produces a react-session with zero turns", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = { turns: [], totalTokens: 0, durationMs: 0 };
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "worker",
|
||||
trace,
|
||||
});
|
||||
|
||||
const node = store.get(sessionHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as ReactSessionPayload;
|
||||
expect(payload.agent).toBe(agentHash);
|
||||
expect(payload.role).toBe("worker");
|
||||
expect(payload.turns).toEqual([]);
|
||||
expect(payload.totalTokens).toBe(0);
|
||||
expect(payload.durationMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("single turn, no tool calls", () => {
|
||||
test("produces react-session → react-turn → content nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "What is 2+2?",
|
||||
output: "4",
|
||||
toolCalls: [],
|
||||
tokens: { input: 10, output: 5 },
|
||||
latencyMs: 200,
|
||||
},
|
||||
],
|
||||
totalTokens: 15,
|
||||
durationMs: 200,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "solver",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.turns.length).toBe(1);
|
||||
expect(session.totalTokens).toBe(15);
|
||||
expect(session.durationMs).toBe(200);
|
||||
expect(session.role).toBe("solver");
|
||||
|
||||
const turnHash = session.turns[0]!;
|
||||
const turn = store.get(turnHash)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls).toEqual([]);
|
||||
expect(turn.tokens).toEqual({ input: 10, output: 5 });
|
||||
expect(turn.latencyMs).toBe(200);
|
||||
|
||||
const inputContent = store.get(turn.input)!.payload as ContentPayload;
|
||||
expect(inputContent.text).toBe("What is 2+2?");
|
||||
|
||||
const outputContent = store.get(turn.output)!.payload as ContentPayload;
|
||||
expect(outputContent.text).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("single turn with tool calls", () => {
|
||||
test("serialises tool calls to react-tool-call → content nodes", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Search for cats",
|
||||
output: "Found 42 cats",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "search",
|
||||
arguments: '{"query":"cats"}',
|
||||
result: '{"count":42}',
|
||||
durationMs: 80,
|
||||
},
|
||||
],
|
||||
tokens: { input: 20, output: 10 },
|
||||
latencyMs: 350,
|
||||
},
|
||||
],
|
||||
totalTokens: 30,
|
||||
durationMs: 350,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "searcher",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls.length).toBe(1);
|
||||
|
||||
const toolCall = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||
expect(toolCall.name).toBe("search");
|
||||
expect(toolCall.durationMs).toBe(80);
|
||||
|
||||
const argsContent = store.get(toolCall.arguments)!.payload as ContentPayload;
|
||||
expect(argsContent.text).toBe('{"query":"cats"}');
|
||||
|
||||
const resultContent = store.get(toolCall.result)!.payload as ContentPayload;
|
||||
expect(resultContent.text).toBe('{"count":42}');
|
||||
});
|
||||
|
||||
test("multiple tool calls in one turn are all recorded", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Do two things",
|
||||
output: "Done",
|
||||
toolCalls: [
|
||||
{ name: "tool_a", arguments: '{"x":1}', result: '"ok_a"', durationMs: 10 },
|
||||
{ name: "tool_b", arguments: '{"y":2}', result: '"ok_b"', durationMs: 20 },
|
||||
],
|
||||
tokens: { input: 5, output: 3 },
|
||||
latencyMs: 100,
|
||||
},
|
||||
],
|
||||
totalTokens: 8,
|
||||
durationMs: 100,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "doer",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.toolCalls.length).toBe(2);
|
||||
|
||||
const tc0 = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||
expect(tc0.name).toBe("tool_a");
|
||||
|
||||
const tc1 = store.get(turn.toolCalls[1]!)!.payload as ReactToolCallPayload;
|
||||
expect(tc1.name).toBe("tool_b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple turns", () => {
|
||||
test("each turn is stored as a separate react-turn node", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "Round 1 prompt",
|
||||
output: "Round 1 response",
|
||||
toolCalls: [],
|
||||
tokens: { input: 10, output: 8 },
|
||||
latencyMs: 100,
|
||||
},
|
||||
{
|
||||
input: "Round 2 prompt",
|
||||
output: "Round 2 response",
|
||||
toolCalls: [],
|
||||
tokens: { input: 12, output: 6 },
|
||||
latencyMs: 120,
|
||||
},
|
||||
],
|
||||
totalTokens: 36,
|
||||
durationMs: 220,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "multi",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.turns.length).toBe(2);
|
||||
expect(session.totalTokens).toBe(36);
|
||||
expect(session.durationMs).toBe(220);
|
||||
|
||||
// Turns must be distinct nodes
|
||||
expect(session.turns[0]).not.toBe(session.turns[1]);
|
||||
|
||||
const turn0 = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect((store.get(turn0.input)!.payload as ContentPayload).text).toBe("Round 1 prompt");
|
||||
expect(turn0.tokens).toEqual({ input: 10, output: 8 });
|
||||
|
||||
const turn1 = store.get(session.turns[1]!)!.payload as ReactTurnPayload;
|
||||
expect((store.get(turn1.input)!.payload as ContentPayload).text).toBe("Round 2 prompt");
|
||||
expect(turn1.tokens).toEqual({ input: 12, output: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("token and duration values", () => {
|
||||
test("token counts and latency are preserved exactly", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||
|
||||
const trace: ReactTrace = {
|
||||
turns: [
|
||||
{
|
||||
input: "p",
|
||||
output: "r",
|
||||
toolCalls: [],
|
||||
tokens: { input: 9999, output: 1234 },
|
||||
latencyMs: 5678,
|
||||
},
|
||||
],
|
||||
totalTokens: 11233,
|
||||
durationMs: 5678,
|
||||
};
|
||||
|
||||
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: "counter",
|
||||
trace,
|
||||
});
|
||||
|
||||
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||
expect(session.totalTokens).toBe(11233);
|
||||
expect(session.durationMs).toBe(5678);
|
||||
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.tokens.input).toBe(9999);
|
||||
expect(turn.tokens.output).toBe(1234);
|
||||
expect(turn.latencyMs).toBe(5678);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeReactSession + executeJsonCasThread integration", () => {
|
||||
test("engine stores real react session when agent provides react trace", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||
name: "react-test",
|
||||
description: "Tests react instrumentation",
|
||||
roles: {
|
||||
solver: {
|
||||
description: "Solves",
|
||||
systemPrompt: "Solve it.",
|
||||
extractPrompt: "Extract.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["answer"],
|
||||
properties: { answer: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: "__start__", to: "solver", when: null },
|
||||
{ from: "solver", to: "__end__", when: null },
|
||||
],
|
||||
});
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => ({
|
||||
text: "The answer is 42",
|
||||
meta: { answer: "42" },
|
||||
react: {
|
||||
turns: [
|
||||
{
|
||||
input: "Solve it. What is the answer?",
|
||||
output: "The answer is 42",
|
||||
toolCalls: [],
|
||||
tokens: { input: 15, output: 8 },
|
||||
latencyMs: 300,
|
||||
},
|
||||
],
|
||||
totalTokens: 23,
|
||||
durationMs: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "What is the answer?",
|
||||
moderatorRules: [
|
||||
{ from: "__start__", to: "solver", when: null },
|
||||
{ from: "solver", to: "__end__", when: null },
|
||||
],
|
||||
io: { threadId: "REACT_INTEG", store, typeHashes },
|
||||
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||
agentFn,
|
||||
logger: () => {},
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||
|
||||
expect(session.turns.length).toBe(1);
|
||||
expect(session.totalTokens).toBe(23);
|
||||
expect(session.durationMs).toBe(300);
|
||||
expect(session.role).toBe("solver");
|
||||
|
||||
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||
expect(turn.tokens).toEqual({ input: 15, output: 8 });
|
||||
expect(turn.latencyMs).toBe(300);
|
||||
expect((store.get(turn.input)!.payload as ContentPayload).text).toBe(
|
||||
"Solve it. What is the answer?",
|
||||
);
|
||||
});
|
||||
|
||||
test("engine falls back to empty react-session when react is null", async () => {
|
||||
const { store, typeHashes } = await setupStore();
|
||||
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||
|
||||
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||
name: "null-react-test",
|
||||
description: "Tests null react fallback",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Works",
|
||||
systemPrompt: "Work.",
|
||||
extractPrompt: "Extract.",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["result"],
|
||||
properties: { result: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
moderator: [
|
||||
{ from: "__start__", to: "worker", when: null },
|
||||
{ from: "worker", to: "__end__", when: null },
|
||||
],
|
||||
});
|
||||
|
||||
const agentFn: JsonCasAgentFn = async () => ({
|
||||
text: "done",
|
||||
meta: { result: "done" },
|
||||
react: null,
|
||||
});
|
||||
|
||||
const result = await executeJsonCasThread({
|
||||
workflowHash,
|
||||
input: "do it",
|
||||
moderatorRules: [
|
||||
{ from: "__start__", to: "worker", when: null },
|
||||
{ from: "worker", to: "__end__", when: null },
|
||||
],
|
||||
io: { threadId: "NULL_REACT", store, typeHashes },
|
||||
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||
agentFn,
|
||||
logger: () => {},
|
||||
workflow: null,
|
||||
});
|
||||
|
||||
const endPayload = store.get(result.rootHash)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||
const stepPayload = store.get(endPayload.lastStep)!
|
||||
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||
|
||||
expect(session.turns).toEqual([]);
|
||||
expect(session.totalTokens).toBe(0);
|
||||
expect(session.durationMs).toBe(0);
|
||||
expect(session.role).toBe("worker");
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,8 @@
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow",
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0",
|
||||
"@uncaged/workflow-json-def": "workspace:^",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
|
||||
@@ -7,17 +7,26 @@ export {
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./fork-thread.js";
|
||||
export { garbageCollectCas } from "./gc.js";
|
||||
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./json-cas-context.js";
|
||||
export {
|
||||
buildJsonCasThreadContext,
|
||||
buildJsonCasThreadSnapshot,
|
||||
readContentText,
|
||||
} from "./json-cas-context.js";
|
||||
export { executeJsonCasThread } from "./json-cas-engine.js";
|
||||
export { writeReactSession } from "./json-cas-react-recorder.js";
|
||||
export type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
JsonCasAgentResult,
|
||||
JsonCasEngineIo,
|
||||
JsonCasEngineOptions,
|
||||
JsonCasStartSnapshot,
|
||||
JsonCasStepSnapshot,
|
||||
JsonCasThreadPauseGate,
|
||||
JsonCasThreadSnapshot,
|
||||
ReactToolCallTrace,
|
||||
ReactTrace,
|
||||
ReactTurnTrace,
|
||||
} from "./json-cas-types.js";
|
||||
export { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { ModeratorRule, WorkflowResult } from "@uncaged/workflow-protocol";
|
||||
import { END, evaluateModerator, START } from "@uncaged/workflow-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
import { writeReactSession } from "./json-cas-react-recorder.js";
|
||||
|
||||
import type {
|
||||
AgentBindings,
|
||||
JsonCasAgentFn,
|
||||
@@ -31,7 +33,7 @@ async function writeContent(
|
||||
return store.put(typeHashes.content, payload);
|
||||
}
|
||||
|
||||
async function writePlaceholderReactSession(
|
||||
async function writeEmptyReactSession(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
role: string,
|
||||
@@ -216,7 +218,7 @@ export async function executeJsonCasThread(params: {
|
||||
|
||||
if (headStepHash === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "no-op");
|
||||
const dummyReactHash = await writePlaceholderReactSession(
|
||||
const dummyReactHash = await writeEmptyReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
@@ -252,7 +254,14 @@ export async function executeJsonCasThread(params: {
|
||||
const contentHash = await writeContent(store, typeHashes, agentResult.text);
|
||||
|
||||
const agentHash = options.agents[nextRole] ?? placeholderAgentHash;
|
||||
const reactHash = await writePlaceholderReactSession(store, typeHashes, nextRole, agentHash);
|
||||
const reactHash =
|
||||
agentResult.react !== null
|
||||
? await writeReactSession(store, typeHashes, {
|
||||
agentHash,
|
||||
role: nextRole,
|
||||
trace: agentResult.react,
|
||||
})
|
||||
: await writeEmptyReactSession(store, typeHashes, nextRole, agentHash);
|
||||
|
||||
const stepHash = await writeThreadStep(store, typeHashes, {
|
||||
role: nextRole,
|
||||
@@ -290,7 +299,7 @@ async function abortThread(
|
||||
let lastStep = headStepHash;
|
||||
if (lastStep === null) {
|
||||
const dummyContentHash = await writeContent(store, typeHashes, "thread aborted");
|
||||
const dummyReactHash = await writePlaceholderReactSession(
|
||||
const dummyReactHash = await writeEmptyReactSession(
|
||||
store,
|
||||
typeHashes,
|
||||
END,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ReactSessionPayload,
|
||||
ReactToolCallPayload,
|
||||
ReactTurnPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
|
||||
import type { ReactToolCallTrace, ReactTrace, ReactTurnTrace } from "./json-cas-types.js";
|
||||
|
||||
// ── Node writers ──────────────────────────────────────────────────────
|
||||
|
||||
async function writeContent(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
text: string,
|
||||
): Promise<Hash> {
|
||||
const payload: ContentPayload = { text };
|
||||
return store.put(typeHashes.content, payload);
|
||||
}
|
||||
|
||||
async function writeToolCall(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
toolCall: ReactToolCallTrace,
|
||||
): Promise<Hash> {
|
||||
const [argsHash, resultHash] = await Promise.all([
|
||||
writeContent(store, typeHashes, toolCall.arguments),
|
||||
writeContent(store, typeHashes, toolCall.result),
|
||||
]);
|
||||
const payload: ReactToolCallPayload = {
|
||||
name: toolCall.name,
|
||||
arguments: argsHash,
|
||||
result: resultHash,
|
||||
durationMs: toolCall.durationMs,
|
||||
};
|
||||
return store.put(typeHashes.reactToolCall, payload);
|
||||
}
|
||||
|
||||
async function writeTurn(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
turn: ReactTurnTrace,
|
||||
): Promise<Hash> {
|
||||
const [inputHash, outputHash, toolCallHashes] = await Promise.all([
|
||||
writeContent(store, typeHashes, turn.input),
|
||||
writeContent(store, typeHashes, turn.output),
|
||||
Promise.all(turn.toolCalls.map((tc) => writeToolCall(store, typeHashes, tc))),
|
||||
]);
|
||||
const payload: ReactTurnPayload = {
|
||||
input: inputHash,
|
||||
output: outputHash,
|
||||
toolCalls: toolCallHashes,
|
||||
tokens: turn.tokens,
|
||||
latencyMs: turn.latencyMs,
|
||||
};
|
||||
return store.put(typeHashes.reactTurn, payload);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialise a {@link ReactTrace} captured during an agent run into CAS nodes:
|
||||
*
|
||||
* content (args/result) → react-tool-call
|
||||
* content (input/output) + react-tool-calls → react-turn
|
||||
* react-turns → react-session
|
||||
*
|
||||
* Returns the hash of the written react-session node.
|
||||
*/
|
||||
export async function writeReactSession(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
params: {
|
||||
agentHash: Hash;
|
||||
role: string;
|
||||
trace: ReactTrace;
|
||||
},
|
||||
): Promise<Hash> {
|
||||
const turnHashes = await Promise.all(
|
||||
params.trace.turns.map((turn) => writeTurn(store, typeHashes, turn)),
|
||||
);
|
||||
const payload: ReactSessionPayload = {
|
||||
agent: params.agentHash,
|
||||
role: params.role,
|
||||
turns: turnHashes,
|
||||
totalTokens: params.trace.totalTokens,
|
||||
durationMs: params.trace.durationMs,
|
||||
};
|
||||
return store.put(typeHashes.reactSession, payload);
|
||||
}
|
||||
@@ -28,18 +28,57 @@ export type JsonCasEngineOptions = {
|
||||
agents: AgentBindings;
|
||||
};
|
||||
|
||||
// ── React trace (raw data before CAS serialisation) ───────────────────
|
||||
|
||||
export type ReactToolCallTrace = {
|
||||
name: string;
|
||||
/** JSON-serialised arguments */
|
||||
arguments: string;
|
||||
/** JSON-serialised result */
|
||||
result: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ReactTurnTrace = {
|
||||
/** Full prompt text sent to the LLM */
|
||||
input: string;
|
||||
/** Raw assistant response text */
|
||||
output: string;
|
||||
toolCalls: ReactToolCallTrace[];
|
||||
tokens: { input: number; output: number };
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
export type ReactTrace = {
|
||||
turns: ReactTurnTrace[];
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
// ── Agent function result ─────────────────────────────────────────────
|
||||
|
||||
export type JsonCasAgentResult = {
|
||||
text: string;
|
||||
meta: Record<string, unknown>;
|
||||
/**
|
||||
* React trace captured during the agent run.
|
||||
* Null when the agent has no trace to record (e.g. a mock or passthrough).
|
||||
*/
|
||||
react: ReactTrace | null;
|
||||
};
|
||||
|
||||
// ── Agent function (mock-friendly) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoked for each role step. Returns the agent's raw text output and
|
||||
* structured meta. The engine stores the text in a content node and the
|
||||
* meta inside the thread-step node.
|
||||
* Invoked for each role step. Returns the agent's raw text output,
|
||||
* structured meta, and an optional react trace. The engine stores the
|
||||
* text in a content node and the trace in react-* CAS nodes.
|
||||
*/
|
||||
export type JsonCasAgentFn = (
|
||||
role: string,
|
||||
systemPrompt: string,
|
||||
context: JsonCasThreadSnapshot,
|
||||
) => Promise<{ text: string; meta: Record<string, unknown> }>;
|
||||
) => Promise<JsonCasAgentResult>;
|
||||
|
||||
// ── Thread snapshot (read-only view for agents & moderator) ───────────
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CasNode } from "@uncaged/json-cas";
|
||||
import { createMemoryStore, refs, validate } from "@uncaged/json-cas";
|
||||
import type { ThreadStartPayload } from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
import { putAgentNode } from "../src/index.js";
|
||||
|
||||
// ── Step 6: putAgentNode — CAS agent instance nodes ──────────────────────────
|
||||
|
||||
describe("Step 6: putAgentNode", () => {
|
||||
test("returns a 13-char Crockford Base32 hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{ baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" },
|
||||
);
|
||||
|
||||
expect(hash).toHaveLength(13);
|
||||
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("stored agent node is present in the store", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-cursor",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
command: "/usr/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(store.get(hash)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("agent node payload contains package, version, and config", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const config = { command: "/usr/bin/hermes", model: "claude-3-5-sonnet", timeout: null };
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-hermes",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
expect(payload.package).toBe("@uncaged/workflow-agent-hermes");
|
||||
expect(payload.version).toBe("0.5.0-alpha.4");
|
||||
expect(payload.config).toEqual(config);
|
||||
});
|
||||
|
||||
test("idempotent: same package + version + config returns the same hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const config = { baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" };
|
||||
const hash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
const hash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
config,
|
||||
);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("different configs produce different hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
},
|
||||
);
|
||||
const hash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-mini",
|
||||
},
|
||||
);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("agent node passes validation against the agent schema", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-react",
|
||||
"0.5.0-alpha.4",
|
||||
{
|
||||
maxRounds: 10,
|
||||
},
|
||||
);
|
||||
|
||||
const node = store.get(hash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
|
||||
test("agent node with empty config is valid", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const hash = await putAgentNode(store, typeHashes, "placeholder", "0.0.0", {});
|
||||
const node = store.get(hash) as CasNode;
|
||||
expect(validate(store, node)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Step 6: refs from thread-start includes agent refs ────────────────────────
|
||||
|
||||
describe("Step 6: refs() from thread-start extracts agent refs", () => {
|
||||
test("thread-start with agents: refs() returns the agent hashes", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const agentHash1 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-llm",
|
||||
"0.5.0-alpha.4",
|
||||
{ baseUrl: "https://api.example.com", apiKey: "sk-1", model: "gpt-4o" },
|
||||
);
|
||||
const agentHash2 = await putAgentNode(
|
||||
store,
|
||||
typeHashes,
|
||||
"@uncaged/workflow-agent-cursor",
|
||||
"0.5.0-alpha.4",
|
||||
{ command: "/usr/bin/cursor-agent", model: null, timeout: 0, workspace: null },
|
||||
);
|
||||
|
||||
const fakeWorkflowHash = "FAKEWF0000001";
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "test",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: { planner: agentHash1, coder: agentHash2 },
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
expect(startRefs).toContain(agentHash1);
|
||||
expect(startRefs).toContain(agentHash2);
|
||||
});
|
||||
|
||||
test("thread-start with no agents: refs() returns only the workflow ref", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const fakeWorkflowHash = "FAKEWF0000002";
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "empty agents",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: {},
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
expect(startRefs).toContain(fakeWorkflowHash);
|
||||
expect(startRefs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("thread-start with 3 agents: refs() count includes workflow + 3 agents", async () => {
|
||||
const store = createMemoryStore();
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
|
||||
const makeAgent = (model: string) =>
|
||||
putAgentNode(store, typeHashes, "@uncaged/workflow-agent-llm", "0.5.0-alpha.4", {
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk-x",
|
||||
model,
|
||||
});
|
||||
|
||||
const [a1, a2, a3] = await Promise.all([makeAgent("m1"), makeAgent("m2"), makeAgent("m3")]);
|
||||
const fakeWorkflowHash = "FAKEWF0000003";
|
||||
|
||||
const startHash = await store.put(typeHashes.threadStart, {
|
||||
workflow: fakeWorkflowHash,
|
||||
input: "multi-agent",
|
||||
depth: 0,
|
||||
parentThread: null,
|
||||
agents: { r1: a1, r2: a2, r3: a3 },
|
||||
} satisfies ThreadStartPayload);
|
||||
|
||||
const startNode = store.get(startHash) as CasNode;
|
||||
const startRefs = refs(store, startNode);
|
||||
|
||||
// 1 workflow ref + 3 agent refs = 4
|
||||
expect(startRefs).toHaveLength(4);
|
||||
expect(startRefs).toContain(fakeWorkflowHash);
|
||||
expect(startRefs).toContain(a1);
|
||||
expect(startRefs).toContain(a2);
|
||||
expect(startRefs).toContain(a3);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow"
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
|
||||
/**
|
||||
* Store an agent instance in CAS.
|
||||
*
|
||||
* Writes an `agent` node with `{ package, version, config }` and returns
|
||||
* the content-addressed hash. Idempotent: the same inputs always produce
|
||||
* the same hash.
|
||||
*/
|
||||
export async function putAgentNode(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
pkg: string,
|
||||
version: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Hash> {
|
||||
return store.put(typeHashes.agent, { package: pkg, version, config });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { putAgentNode } from "./agent.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
developWorkflow,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
PackageDescriptor,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
|
||||
@@ -130,6 +130,26 @@ export type WorkflowConfig = {
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Package Descriptor ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Static metadata describing a workflow agent npm package.
|
||||
* Stored alongside the CAS agent node to document what an agent instance is.
|
||||
*/
|
||||
export type PackageDescriptor = {
|
||||
/** The npm package name, e.g. `@uncaged/workflow-agent-cursor`. */
|
||||
name: string;
|
||||
/** Semver version of the package at the time the descriptor was written. */
|
||||
version: string;
|
||||
/** Human-readable capability tags, e.g. `["cursor-cli", "workspace-agent"]`. */
|
||||
capabilities: string[];
|
||||
/**
|
||||
* JSON Schema that describes the serializable config stored in the CAS
|
||||
* agent node's `config` field.
|
||||
*/
|
||||
configSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
||||
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
PackageDescriptor,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
@@ -1,52 +0,0 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-develop @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow } from "@uncaged/workflow";
|
||||
import { developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
||||
|
||||
const run = createWorkflow(developWorkflowDefinition, binding);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **planner** | Break work into ordered phases (hashes) |
|
||||
| **coder** | Implement current phase; repeats until phases complete or limits hit |
|
||||
| **reviewer** | Code review gate (`approved` vs send back to coder) |
|
||||
| **tester** | Verify via tests/build/lint (`passed` vs send back to coder) |
|
||||
| **committer** | Final commit step |
|
||||
|
||||
Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `DevelopMeta`, `developRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `planner`
|
||||
2. After **planner** → `coder`
|
||||
3. After **coder** → if all planned phases are done (or last phase completed) → `reviewer`; else `coder` again, until `maxRounds` then `END`
|
||||
4. After **reviewer** → if approved → `tester`; else `coder` (or `END` if out of rounds)
|
||||
5. After **tester** → if passed → `committer`; else `coder` (or `END` if out of rounds)
|
||||
6. After **committer** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
||||
| `developModerator` | `Moderator<DevelopMeta>` |
|
||||
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
||||
| `DEVELOP_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { committerMetaSchema, committerRole } from "../src/roles/committer.js";
|
||||
|
||||
describe("committerRole", () => {
|
||||
test("committed sample validates against schema", () => {
|
||||
const parsed = committerMetaSchema.safeParse({
|
||||
status: "committed" as const,
|
||||
branch: "feat/example",
|
||||
commitSha: "abc1234",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("exposes generic committer system prompt", () => {
|
||||
expect(committerRole.systemPrompt).toContain("git committer");
|
||||
expect(committerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developTable } from "../src/moderator.js";
|
||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||
import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const developModerator = tableToModerator(developTable);
|
||||
|
||||
type PlannedMeta = Extract<PlannerMeta, { status: "planned" }>;
|
||||
|
||||
const DEFAULT_PHASES: PlannedMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
title: "Do the work",
|
||||
},
|
||||
];
|
||||
|
||||
function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Implement the feature",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContext<DevelopMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(phases: PlannedMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { status: "planned" as const, phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
contentHash: "STUBHASHCODER00001",
|
||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
|
||||
refs: [completedPhase],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "reviewer",
|
||||
contentHash: "STUBHASHREVIEWER01",
|
||||
meta: approved
|
||||
? { status: "approved" as const }
|
||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||
refs: [],
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "tester",
|
||||
contentHash: "STUBHASHTESTER01",
|
||||
meta: passed
|
||||
? { status: "passed" as const, details: "all checks passed" }
|
||||
: { status: "failed" as const, details: "lint failed" },
|
||||
refs: [],
|
||||
timestamp: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
contentHash: "STUBHASHCOMMITTER1",
|
||||
meta,
|
||||
refs: [],
|
||||
timestamp: 5,
|
||||
};
|
||||
}
|
||||
|
||||
describe("developModerator", () => {
|
||||
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
|
||||
expect(developModerator(makeCtx([]))).toBe("planner");
|
||||
expect(developModerator(makeCtx([plannerStep()]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"tester",
|
||||
);
|
||||
expect(
|
||||
developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])),
|
||||
).toBe("committer");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx([
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "AA000001", title: "first phase" },
|
||||
{ hash: "AA000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||
),
|
||||
).toBe("reviewer");
|
||||
});
|
||||
|
||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "BB000001", title: "setup branch" },
|
||||
{ hash: "BB000002", title: "write tests" },
|
||||
{ hash: "BB000003", title: "verify" },
|
||||
{ hash: "BB000004", title: "polish" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||
"reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "CC000001", title: "first phase" },
|
||||
{ hash: "CC000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder");
|
||||
});
|
||||
|
||||
test("incomplete phases → coder retry (supervisor controls termination)", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "DD000001", title: "first phase" },
|
||||
{ hash: "DD000002", title: "second phase" },
|
||||
];
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(phases),
|
||||
coderStep("DD000001"),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("planner aborted → END", () => {
|
||||
const abortedStep: RoleStep<DevelopMeta> = {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHABORT001",
|
||||
meta: { status: "aborted", reason: "No workspace path provided" },
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
expect(developModerator(makeCtx([abortedStep]))).toBe("__end__");
|
||||
});
|
||||
|
||||
test("committer → END for any committer meta status", () => {
|
||||
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
|
||||
const recoverable = committerStep({
|
||||
status: "recoverable",
|
||||
error: "merge conflict",
|
||||
logRef: null,
|
||||
});
|
||||
const unrecoverable = committerStep({
|
||||
status: "unrecoverable",
|
||||
error: "repo missing",
|
||||
logRef: "log1",
|
||||
});
|
||||
const base: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
];
|
||||
expect(developModerator(makeCtx([...base, committed]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, recoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDevelopDescriptor", () => {
|
||||
test("lists all roles with schemas that validate", () => {
|
||||
const descriptor = buildDevelopDescriptor();
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||
"coder",
|
||||
"committer",
|
||||
"planner",
|
||||
"reviewer",
|
||||
"tester",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBeGreaterThan(0);
|
||||
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
expect(role.schema).not.toBeNull();
|
||||
expect(Array.isArray(role.schema)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/roles/reviewer.js";
|
||||
|
||||
describe("reviewerRole", () => {
|
||||
test("approved sample validates against schema", () => {
|
||||
const parsed = reviewerMetaSchema.safeParse({ status: "approved" as const });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("system prompt is generic (no cwd)", () => {
|
||||
expect(reviewerRole.systemPrompt).toContain("code reviewer");
|
||||
expect(reviewerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* develop bundle entry — 小橘 🍊
|
||||
*
|
||||
* planner/coder/reviewer → cursor-agent (needs code editing)
|
||||
* tester/committer → hermes-agent (lightweight, no editing needed)
|
||||
*/
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const cursorAdapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/azureuser/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "0")),
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
const hermesAdapter = createHermesAgent({
|
||||
command: env("WORKFLOW_HERMES_COMMAND", "/home/azureuser/.local/bin/hermes"),
|
||||
model: env("WORKFLOW_HERMES_MODEL", "") || null,
|
||||
timeout: Number(env("WORKFLOW_HERMES_TIMEOUT", "0")) || null,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, {
|
||||
adapter: cursorAdapter,
|
||||
overrides: {
|
||||
tester: hermesAdapter,
|
||||
committer: hermesAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = wf;
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
export function buildDevelopDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developTable } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
type CommitterMeta,
|
||||
coderMetaSchema,
|
||||
coderRole,
|
||||
committerMetaSchema,
|
||||
committerRole,
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
type TesterMeta,
|
||||
testerMetaSchema,
|
||||
testerRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
type DevelopMeta,
|
||||
type DevelopRoles,
|
||||
developRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
END,
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function coderFinishedAllPlannedPhases(
|
||||
phases: ReadonlyArray<{ hash: string }>,
|
||||
coderCompletedPhases: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
if (phases.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const plannedHashes = new Set(phases.map((p) => p.hash));
|
||||
const lastHash = phases[phases.length - 1].hash;
|
||||
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
||||
if (phases.every((p) => explicit.has(p.hash))) {
|
||||
return true;
|
||||
}
|
||||
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────
|
||||
|
||||
const plannerAborted: ModeratorCondition<DevelopMeta> = {
|
||||
name: "plannerAborted",
|
||||
description: "The planner aborted due to insufficient information",
|
||||
check: (ctx) => {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return false;
|
||||
}
|
||||
return plannerStep.meta.status === "aborted";
|
||||
},
|
||||
};
|
||||
|
||||
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
|
||||
name: "allPhasesComplete",
|
||||
description: "All planned phases have been completed by the coder",
|
||||
check: (ctx) => {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return true;
|
||||
}
|
||||
const phases = plannerStep.meta.status === "planned" ? plannerStep.meta.phases : [];
|
||||
if (!Array.isArray(phases)) {
|
||||
return true;
|
||||
}
|
||||
const coderCompletedPhases = ctx.steps
|
||||
.filter((s) => s.role === "coder")
|
||||
.map((s) => s.meta.completedPhase);
|
||||
return coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||
},
|
||||
};
|
||||
|
||||
const reviewApproved: ModeratorCondition<DevelopMeta> = {
|
||||
name: "reviewApproved",
|
||||
description: "The last reviewer approved the changes",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "reviewer" && last.meta.status === "approved";
|
||||
},
|
||||
};
|
||||
|
||||
const testsPassed: ModeratorCondition<DevelopMeta> = {
|
||||
name: "testsPassed",
|
||||
description: "The last tester reported tests passed",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "tester" && last.meta.status === "passed";
|
||||
},
|
||||
};
|
||||
|
||||
// ── Transition Table ───────────────────────────────────────────────
|
||||
|
||||
const table: ModeratorTable<DevelopMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [
|
||||
{ condition: plannerAborted, role: END },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
coder: [
|
||||
{ condition: allPhasesComplete, role: "reviewer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
reviewer: [
|
||||
{ condition: reviewApproved, role: "tester" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
tester: [
|
||||
{ condition: testsPassed, role: "committer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as developTable };
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||
import { type ReviewerMeta, reviewerRole } from "./roles/reviewer.js";
|
||||
import { type TesterMeta, testerRole } from "./roles/tester.js";
|
||||
|
||||
export const DEVELOP_WORKFLOW_DESCRIPTION =
|
||||
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
|
||||
|
||||
export type DevelopMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
export type DevelopRoles = {
|
||||
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
|
||||
};
|
||||
|
||||
export const developRoles: DevelopRoles = {
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
tester: testerRole,
|
||||
committer: committerRole,
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z
|
||||
.string()
|
||||
.meta({ casRef: true })
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Reading phase details
|
||||
|
||||
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\`.
|
||||
|
||||
## Completing a phase
|
||||
|
||||
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — a brief summary paragraph plus the structured meta output. Do NOT paste diffs, file contents, or code blocks in your response. The actual changes are already on disk; repeating them wastes tokens. Just say what you did and why.`;
|
||||
|
||||
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("committed"),
|
||||
branch: z.string(),
|
||||
commitSha: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("recoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unrecoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
|
||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||
Do not attempt to fix failures yourself.`;
|
||||
|
||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
schema: committerMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "./planner.js";
|
||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string().describe("Why the task cannot proceed"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Prerequisites — check FIRST
|
||||
|
||||
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
|
||||
|
||||
## Storing phase details — MANDATORY
|
||||
|
||||
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\`.
|
||||
|
||||
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
|
||||
|
||||
## Phase granularity
|
||||
|
||||
Match the number of phases to task complexity:
|
||||
- Trivial (add a config option, fix a typo, rename): 1 phase
|
||||
- Small (a new feature touching 2-3 files): 1-2 phases
|
||||
- Medium (cross-module refactor): 2-3 phases
|
||||
- Large (new subsystem, architectural change): 3-5 phases
|
||||
|
||||
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
|
||||
|
||||
## Output format
|
||||
|
||||
After storing all phases via the CLI, output compact JSON only:
|
||||
{ "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
|
||||
|
||||
If aborting:
|
||||
{ "status": "aborted", "reason": "<what is missing>" }
|
||||
|
||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — just the JSON with phases. Do NOT paste code snippets, diffs, or implementation details in your response. Phase details are already stored in CAS; your response should only contain the compact phases JSON.`;
|
||||
|
||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("approved"),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("rejected"),
|
||||
issues: z.array(z.string()).describe("blocking issues that must be fixed"),
|
||||
}),
|
||||
]);
|
||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
||||
|
||||
const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
|
||||
|
||||
## Review process
|
||||
|
||||
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
|
||||
2. Review the diff against these conventions.
|
||||
3. For documentation changes, verify that names, paths, and references match the actual codebase.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
|
||||
- **Conventions** — naming, imports, code style per project rules?
|
||||
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
|
||||
- **Edge cases** — missing error handling, null checks, boundary conditions?
|
||||
|
||||
## Verdict
|
||||
|
||||
- **Approve** only if there are zero issues
|
||||
- **Reject** with specific issues that must be fixed — every issue you find is blocking
|
||||
|
||||
Be thorough. A false approve costs more than a false reject.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Summarize findings in a few bullet points, then output the structured verdict. Do NOT paste the full diff or large code blocks in your response.`;
|
||||
|
||||
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("passed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Report pass/fail with a brief summary of failures (if any). Do NOT paste full test output or build logs — just the key error lines.`;
|
||||
|
||||
export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
schema: testerMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
@@ -1,48 +0,0 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-solve-issue @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow } from "@uncaged/workflow";
|
||||
import { solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
||||
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, binding);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **preparer** | Set up context / repo state for the issue |
|
||||
| **developer** | Implementation; default runs the registered `develop` workflow as a sub-agent |
|
||||
| **submitter** | Finalize and submit the outcome (e.g. PR) |
|
||||
|
||||
Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod meta schemas, `SolveIssueMeta`, `solveIssueRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `preparer`
|
||||
2. After **preparer** → `developer`
|
||||
3. After **developer** → `submitter`
|
||||
4. After **submitter** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
||||
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
||||
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
||||
| `SOLVE_ISSUE_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,298 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import {
|
||||
type AdapterFn,
|
||||
createWorkflow,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleResult,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||
import type { SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const solveIssueModerator = tableToModerator(solveIssueTable);
|
||||
|
||||
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
steps: ModeratorContext<SolveIssueMeta>["steps"],
|
||||
): ModeratorContext<SolveIssueMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function preparerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "preparer",
|
||||
contentHash: "STUBHASHPREPARER01",
|
||||
meta: {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
},
|
||||
refs: [],
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function developerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "developer",
|
||||
contentHash: "STUBHASHDEVELOPER1",
|
||||
meta: {
|
||||
branch: "feat/issue-1",
|
||||
commitSha: "abc1234",
|
||||
filesChanged: ["src/login.ts"],
|
||||
summary: "Fixed flaky login test by stabilising async setup.",
|
||||
},
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "submitter",
|
||||
contentHash: "STUBHASHSUBMITTER1",
|
||||
meta,
|
||||
refs: [],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function makeThread(prompt: string) {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that returns a fixed sequence of meta values. */
|
||||
function createSequenceAdapter(sequence: ReadonlyArray<Record<string, unknown>>): AdapterFn {
|
||||
let i = 0;
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const meta = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (meta === undefined) {
|
||||
throw new Error("createSequenceAdapter: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that tracks calls and returns fixed meta. */
|
||||
function createTrackingAdapter(
|
||||
name: string,
|
||||
calls: string[],
|
||||
meta: Record<string, unknown>,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
calls.push(name);
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes initial → preparer → developer → submitter → END", () => {
|
||||
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({
|
||||
status: "submitted",
|
||||
prUrl: "https://github.com/example/repo/pull/1",
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("submitter failed → END", () => {
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "failed", error: "gh not authenticated" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("returns END for any unexpected last step (defensive)", () => {
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
let casDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (casDir !== undefined) {
|
||||
await rm(casDir, { recursive: true, force: true }).catch(() => {});
|
||||
casDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("adapter yields preparer meta directly", async () => {
|
||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
};
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const adapter = createSequenceAdapter([EXPECT_PREPARER_META]);
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter,
|
||||
overrides: null,
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
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("per-role adapter overrides default", 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/x",
|
||||
commitSha: "abc1234",
|
||||
filesChanged: ["a.ts"],
|
||||
summary: "did the work",
|
||||
};
|
||||
const SUBMITTER_META: SubmitterMeta = {
|
||||
status: "submitted",
|
||||
prUrl: "https://github.com/example/repo/pull/2",
|
||||
};
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter: createTrackingAdapter("default", calls, PREPARER_META),
|
||||
overrides: {
|
||||
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
|
||||
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
|
||||
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
|
||||
},
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["developer"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["submitter"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSolveIssueDescriptor", () => {
|
||||
test("lists preparer, developer, submitter with schemas that validate", () => {
|
||||
const descriptor = buildSolveIssueDescriptor();
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||
"developer",
|
||||
"preparer",
|
||||
"submitter",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBe(4);
|
||||
for (const key of ["preparer", "developer", "submitter"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
expect(role.schema).not.toBeNull();
|
||||
expect(Array.isArray(role.schema)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { submitterMetaSchema, submitterRole } from "../src/roles/submitter.js";
|
||||
|
||||
describe("submitterRole", () => {
|
||||
test("submitted sample validates against schema", () => {
|
||||
const parsed = submitterMetaSchema.safeParse({
|
||||
status: "submitted" as const,
|
||||
prUrl: "https://github.com/example/repo/pull/42",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("failed sample validates against schema", () => {
|
||||
const parsed = submitterMetaSchema.safeParse({
|
||||
status: "failed" as const,
|
||||
error: "gh not authenticated",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown status discriminant", () => {
|
||||
const parsed = submitterMetaSchema.safeParse({
|
||||
status: "queued",
|
||||
prUrl: "https://example.com",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("exposes submitter system prompt", () => {
|
||||
expect(submitterRole.systemPrompt).toContain("submitter");
|
||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* solve-issue bundle entry — 小橘 🍊
|
||||
*
|
||||
* preparer + submitter → hermes agent
|
||||
* developer → workflow adapter (delegates to "develop" workflow)
|
||||
*/
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { workflowAdapter } from "@uncaged/workflow-execute";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const adapter = createHermesAgent({
|
||||
command: env("WORKFLOW_HERMES_COMMAND", "/home/azureuser/.local/bin/hermes"),
|
||||
model: env("WORKFLOW_HERMES_MODEL", "") || null,
|
||||
timeout: Number(env("WORKFLOW_HERMES_TIMEOUT", "0")) || null,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter,
|
||||
overrides: {
|
||||
developer: workflowAdapter("develop"),
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptor = buildSolveIssueDescriptor();
|
||||
export const run = wf;
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-execute':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-execute
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const developerMetaSchema = z.object({
|
||||
branch: z.string(),
|
||||
commitSha: z.string(),
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export type DeveloperMeta = z.infer<typeof developerMetaSchema>;
|
||||
|
||||
const DEVELOPER_SYSTEM = `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
|
||||
|
||||
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
|
||||
|
||||
Pass through the task and let the child workflow do the work.`;
|
||||
|
||||
export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||
description:
|
||||
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||
systemPrompt: DEVELOPER_SYSTEM,
|
||||
schema: developerMetaSchema,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export {
|
||||
type DeveloperMeta,
|
||||
developerMetaSchema,
|
||||
developerRole,
|
||||
} from "./developer.js";
|
||||
export { solveIssueTable } from "./moderator.js";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
type SubmitterMeta,
|
||||
submitterMetaSchema,
|
||||
submitterRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRoles,
|
||||
solveIssueRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { END, type ModeratorTable, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
const table: ModeratorTable<SolveIssueMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "preparer" }],
|
||||
preparer: [{ condition: "FALLBACK", role: "developer" }],
|
||||
developer: [{ condition: "FALLBACK", role: "submitter" }],
|
||||
submitter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as solveIssueTable };
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
||||
|
||||
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
||||
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
|
||||
|
||||
export type SolveIssueMeta = {
|
||||
preparer: PreparerMeta;
|
||||
developer: DeveloperMeta;
|
||||
submitter: SubmitterMeta;
|
||||
};
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
|
||||
};
|
||||
|
||||
export const solveIssueRoles: SolveIssueRoles = {
|
||||
preparer: preparerRole,
|
||||
developer: developerRole,
|
||||
submitter: submitterRole,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "./preparer.js";
|
||||
export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js";
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const toolchainSchema = z.object({
|
||||
packageManager: z.union([z.string(), z.null()]),
|
||||
testCommand: z.union([z.string(), z.null()]),
|
||||
lintCommand: z.union([z.string(), z.null()]),
|
||||
buildCommand: z.union([z.string(), z.null()]),
|
||||
});
|
||||
|
||||
export const preparerMetaSchema = z.object({
|
||||
repoPath: z.string(),
|
||||
defaultBranch: z.string(),
|
||||
conventions: z.union([z.string(), z.null()]),
|
||||
toolchain: toolchainSchema,
|
||||
});
|
||||
|
||||
export type PreparerMeta = z.infer<typeof preparerMetaSchema>;
|
||||
|
||||
const PREPARER_SYSTEM = `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
|
||||
2. Search for an existing local clone in these locations (in order):
|
||||
- ~/Code/<repo-name>/
|
||||
- ~/repos/<repo-name>/
|
||||
- ~/Code/<org>/<repo-name>/
|
||||
- ~/repos/<org>/<repo-name>/
|
||||
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
|
||||
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
|
||||
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
|
||||
6. Detect toolchain: package manager, test runner, linter, build system.
|
||||
|
||||
## Output
|
||||
|
||||
Report your findings as structured data:
|
||||
- **repoPath**: absolute path to the local repo
|
||||
- **defaultBranch**: the default branch name (e.g. "main")
|
||||
- **conventions**: a summary of project conventions found, or null if none
|
||||
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`;
|
||||
|
||||
export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
description:
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
schema: preparerMetaSchema,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("submitted"),
|
||||
prUrl: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type SubmitterMeta = z.infer<typeof submitterMetaSchema>;
|
||||
|
||||
const SUBMITTER_SYSTEM = `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request.
|
||||
|
||||
## Inputs
|
||||
|
||||
Read the thread for context:
|
||||
- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo).
|
||||
- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. \`cd\` into the repo path from the preparer's output.
|
||||
2. Push the developer's branch to the remote: \`git push -u origin <branch>\`.
|
||||
3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable.
|
||||
4. Report the resulting PR URL.
|
||||
|
||||
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`;
|
||||
|
||||
export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: SUBMITTER_SYSTEM,
|
||||
schema: submitterMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
Reference in New Issue
Block a user