Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26cf51366f | |||
| 81c582ae0e |
@@ -2,10 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { watch } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
|
||||
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
extractBundleExports,
|
||||
readWorkflowRegistry,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
|
||||
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
|
||||
meta: { workspace: "/tmp" } as unknown as T,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
});
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
|
||||
@@ -48,11 +48,12 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
...ctx,
|
||||
agentContent: "",
|
||||
};
|
||||
const { workspace } = await config.extract(
|
||||
const extracted = await config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const { workspace } = extracted.meta;
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
|
||||
@@ -3,10 +3,14 @@ import { join } from "node:path";
|
||||
|
||||
import { hashString } from "./hash.js";
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
|
||||
import { isCasNodeYaml } from "./nodes.js";
|
||||
import type { CasStore } from "./types.js";
|
||||
|
||||
/** Raw strings become content merkle YAML; already-valid merkle documents pass through. */
|
||||
function normalizeCasPutContent(content: string): string {
|
||||
if (isCasNodeYaml(content)) {
|
||||
return content;
|
||||
}
|
||||
try {
|
||||
parseMerkleNode(content);
|
||||
return content;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import XXH from "xxhashjs";
|
||||
|
||||
import { encodeUint64AsCrockford } from "@uncaged/workflow-util";
|
||||
import XXH from "xxhashjs";
|
||||
|
||||
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
|
||||
const hex = digest.toString(16).padStart(16, "0");
|
||||
|
||||
@@ -10,6 +10,15 @@ export {
|
||||
putThreadMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "./merkle.js";
|
||||
export type { ParsedCasThreadNode } from "./nodes.js";
|
||||
export {
|
||||
isCasNodeYaml,
|
||||
parseCasThreadNode,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
serializeCasNode,
|
||||
} from "./nodes.js";
|
||||
export { findReachableHashes } from "./reachable.js";
|
||||
export type {
|
||||
CasStore,
|
||||
|
||||
@@ -82,7 +82,12 @@ export async function putContentMerkleNode(store: CasStore, content: string): Pr
|
||||
return store.put(content);
|
||||
}
|
||||
|
||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||
/**
|
||||
* Loads a CAS blob and returns the payload string for a `content` node.
|
||||
*
|
||||
* Accepts both the legacy `{type:content, payload, children}` Merkle layout
|
||||
* and the RFC v3 `{type:content, payload, refs}` content node layout.
|
||||
*/
|
||||
export async function getContentMerklePayload(
|
||||
store: CasStore,
|
||||
hash: string,
|
||||
@@ -91,9 +96,13 @@ export async function getContentMerklePayload(
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const node = parseMerkleNode(yamlText);
|
||||
if (node.type !== "content" || typeof node.payload !== "string") {
|
||||
const raw = parse(yamlText) as unknown;
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
return node.payload;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type !== "content" || typeof rec.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
return rec.payload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
ContentMerkleNode,
|
||||
StartNode,
|
||||
StartNodePayload,
|
||||
StateNode,
|
||||
StateNodePayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { collectRefs } from "./collect-refs.js";
|
||||
import type { CasStore } from "./types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.hash === "string" &&
|
||||
typeof value.maxRounds === "number" &&
|
||||
typeof value.depth === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const compact = value.compact;
|
||||
if (!(compact === null || typeof compact === "string")) {
|
||||
return false;
|
||||
}
|
||||
const ancestors = value.ancestors;
|
||||
if (!Array.isArray(ancestors) || !ancestors.every((h) => typeof h === "string")) {
|
||||
return false;
|
||||
}
|
||||
const meta = value.meta;
|
||||
if (!isRecord(meta)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.start === "string" &&
|
||||
typeof value.content === "string" &&
|
||||
typeof value.timestamp === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
|
||||
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(yamlText) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const type = raw.type;
|
||||
if (type !== "start" && type !== "state" && type !== "content") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let refsRaw: unknown = raw.refs;
|
||||
if (refsRaw === undefined && type === "content") {
|
||||
refsRaw = raw.children;
|
||||
}
|
||||
if (!Array.isArray(refsRaw) || !refsRaw.every((r) => typeof r === "string")) {
|
||||
return null;
|
||||
}
|
||||
const refs = refsRaw as string[];
|
||||
|
||||
if (type === "content") {
|
||||
if (typeof raw.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
const node: ContentMerkleNode = { type: "content", payload: raw.payload, refs: [...refs] };
|
||||
return { kind: "content", node };
|
||||
}
|
||||
|
||||
if (type === "start") {
|
||||
if (!isStartPayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
|
||||
return { kind: "start", node };
|
||||
}
|
||||
|
||||
if (!isStatePayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
|
||||
return { kind: "state", node };
|
||||
}
|
||||
|
||||
export type ParsedCasThreadNode =
|
||||
| { kind: "start"; node: StartNode }
|
||||
| { kind: "state"; node: StateNode }
|
||||
| { kind: "content"; node: ContentMerkleNode };
|
||||
|
||||
/** YAML-serialize a CAS node carrying `{type, payload, refs}` (RFC v3 thread storage format). */
|
||||
export function serializeCasNode(node: StartNode | StateNode | ContentMerkleNode): string {
|
||||
return stringify({ type: node.type, payload: node.payload, refs: node.refs }, { indent: 2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognizes a YAML CAS blob with the `{type, payload, refs[]}` shape used by
|
||||
* `start` / `state` / `content` thread nodes. Used by {@link createCasStore}
|
||||
* to skip the legacy auto-wrap step when the caller already supplied a
|
||||
* pre-serialized RFC v3 node.
|
||||
*/
|
||||
export function isCasNodeYaml(content: string): boolean {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(content) as unknown;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return false;
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (typeof rec.type !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(rec.refs)) {
|
||||
return false;
|
||||
}
|
||||
for (const r of rec.refs) {
|
||||
if (typeof r !== "string") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function putStartNode(
|
||||
store: CasStore,
|
||||
payload: StartNode["payload"],
|
||||
promptHash: string,
|
||||
): Promise<string> {
|
||||
const node: StartNode = {
|
||||
type: "start",
|
||||
payload,
|
||||
refs: [promptHash],
|
||||
};
|
||||
return store.put(serializeCasNode(node));
|
||||
}
|
||||
|
||||
export async function putStateNode(
|
||||
store: CasStore,
|
||||
payload: StateNode["payload"],
|
||||
): Promise<string> {
|
||||
const node: StateNode = {
|
||||
type: "state",
|
||||
payload,
|
||||
refs: collectRefs(payload),
|
||||
};
|
||||
return store.put(serializeCasNode(node));
|
||||
}
|
||||
|
||||
export async function putContentNodeWithRefs(
|
||||
store: CasStore,
|
||||
payload: string,
|
||||
refs: readonly string[],
|
||||
): Promise<string> {
|
||||
const node: ContentMerkleNode = {
|
||||
type: "content",
|
||||
payload,
|
||||
refs: [...refs],
|
||||
};
|
||||
return store.put(serializeCasNode(node));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
|
||||
import type {
|
||||
RoleOutput,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
|
||||
const TEST_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 0
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
function noLogger(): (tag: string, content: string) => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: "/tmp/never",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupStorage(): Promise<{
|
||||
storageRoot: string;
|
||||
casDir: string;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
}> {
|
||||
const storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-engine-"));
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_REGISTRY_YAML, "utf8");
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const bundleHash = "TESTHASH00001";
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
return { storageRoot, casDir, bundleHash, bundleDir };
|
||||
}
|
||||
|
||||
function readCasNode(casDir: string, hash: string): Record<string, unknown> {
|
||||
const text = require("node:fs").readFileSync(join(casDir, `${hash}.txt`), "utf8") as string;
|
||||
return parseYaml(text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let bundleHash: string;
|
||||
let bundleDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupStorage();
|
||||
storageRoot = setup.storageRoot;
|
||||
casDir = setup.casDir;
|
||||
bundleHash = setup.bundleHash;
|
||||
bundleDir = setup.bundleDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes a StartNode whose refs[0] is the prompt CAS hash", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
// biome-ignore lint/correctness/useYield: deliberately empty generator — exercises the start/end path with no role steps
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
_runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "no-op" };
|
||||
};
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: "T01",
|
||||
hash: bundleHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", bundleHash, "T01.info.jsonl"),
|
||||
cas,
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "hello", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const historyText = await readFile(
|
||||
(await import("node:fs/promises")).readdir ? await firstHistoryFile(bundleDir) : "",
|
||||
"utf8",
|
||||
);
|
||||
const histLine = historyText.trim().split("\n")[0] ?? "";
|
||||
const histEntry = JSON.parse(histLine) as Record<string, unknown>;
|
||||
expect(histEntry.threadId).toBe("T01");
|
||||
|
||||
const startHash = histEntry.start as string;
|
||||
const startNode = readCasNode(casDir, startHash);
|
||||
expect(startNode.type).toBe("start");
|
||||
expect((startNode.payload as Record<string, unknown>).name).toBe("demo");
|
||||
expect((startNode.payload as Record<string, unknown>).hash).toBe(bundleHash);
|
||||
expect((startNode.payload as Record<string, unknown>).maxRounds).toBe(5);
|
||||
|
||||
const refs = startNode.refs as string[];
|
||||
expect(refs.length).toBe(1);
|
||||
|
||||
const promptBlob = await cas.get(refs[0] ?? "");
|
||||
expect(promptBlob).not.toBeNull();
|
||||
const promptParsed = parseYaml(promptBlob ?? "") as Record<string, unknown>;
|
||||
expect(promptParsed.payload).toBe("hello");
|
||||
});
|
||||
|
||||
test("each role yield produces a chained StateNode and updates threads.json head", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h1 = await runtime.cas.put("plan-text");
|
||||
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1] };
|
||||
const h2 = await runtime.cas.put("code-text");
|
||||
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: "T02",
|
||||
hash: bundleHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", bundleHash, "T02.info.jsonl"),
|
||||
cas,
|
||||
};
|
||||
|
||||
let observedHead: string | null = null;
|
||||
let observedHeadAtSecondYield: string | null = null;
|
||||
|
||||
const opts = makeOptions({
|
||||
storageRoot,
|
||||
maxRounds: 5,
|
||||
awaitAfterEachYield: async () => {
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, { head: string }>;
|
||||
const head = parsed.T02?.head ?? null;
|
||||
if (observedHead === null) {
|
||||
observedHead = head;
|
||||
} else if (observedHeadAtSecondYield === null) {
|
||||
observedHeadAtSecondYield = head;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeThread(
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
opts,
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
expect(observedHead).not.toBeNull();
|
||||
expect(observedHeadAtSecondYield).not.toBeNull();
|
||||
expect(observedHead).not.toBe(observedHeadAtSecondYield);
|
||||
|
||||
const firstState = readCasNode(casDir, observedHead ?? "");
|
||||
expect(firstState.type).toBe("state");
|
||||
expect((firstState.payload as Record<string, unknown>).role).toBe("planner");
|
||||
expect((firstState.payload as Record<string, unknown>).ancestors).toEqual([]);
|
||||
|
||||
const secondState = readCasNode(casDir, observedHeadAtSecondYield ?? "");
|
||||
expect(secondState.type).toBe("state");
|
||||
expect((secondState.payload as Record<string, unknown>).role).toBe("coder");
|
||||
expect((secondState.payload as Record<string, unknown>).ancestors).toEqual([observedHead]);
|
||||
expect((secondState.payload as Record<string, unknown>).start).toBe(
|
||||
(firstState.payload as Record<string, unknown>).start,
|
||||
);
|
||||
});
|
||||
|
||||
test("on completion: removes threads.json entry, appends history with __end__ head", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("only-step");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "completed" };
|
||||
};
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: "T03",
|
||||
hash: bundleHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", bundleHash, "T03.info.jsonl"),
|
||||
cas,
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
|
||||
const indexText = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const indexParsed = JSON.parse(indexText) as Record<string, unknown>;
|
||||
expect(indexParsed).toEqual({});
|
||||
|
||||
const historyPath = await firstHistoryFile(bundleDir);
|
||||
const historyText = await readFile(historyPath, "utf8");
|
||||
const lines = historyText.trim().split("\n");
|
||||
expect(lines.length).toBe(1);
|
||||
const entry = JSON.parse(lines[0] ?? "") as Record<string, unknown>;
|
||||
expect(entry.threadId).toBe("T03");
|
||||
expect(entry.head).toBe(result.rootHash);
|
||||
|
||||
const endNode = readCasNode(casDir, String(entry.head));
|
||||
expect(endNode.type).toBe("state");
|
||||
expect((endNode.payload as Record<string, unknown>).role).toBe("__end__");
|
||||
expect((endNode.payload as Record<string, unknown>).meta).toEqual({
|
||||
returnCode: 0,
|
||||
summary: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not write any .data.jsonl file under storageRoot", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("step");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: "T04",
|
||||
hash: bundleHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", bundleHash, "T04.info.jsonl"),
|
||||
cas,
|
||||
};
|
||||
|
||||
await executeThread(
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const fsp = await import("node:fs/promises");
|
||||
const found: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries: { name: string; isDirectory: () => boolean; isFile: () => boolean }[];
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const p = join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
await walk(p);
|
||||
} else if (ent.isFile() && ent.name.endsWith(".data.jsonl")) {
|
||||
found.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(storageRoot);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function firstHistoryFile(bundleDir: string): Promise<string> {
|
||||
const fsp = await import("node:fs/promises");
|
||||
const dir = join(bundleDir, "history");
|
||||
const entries = await fsp.readdir(dir);
|
||||
const file = entries.find((n) => n.endsWith(".jsonl"));
|
||||
if (file === undefined) {
|
||||
throw new Error(`no history file under ${dir}`);
|
||||
}
|
||||
return join(dir, file);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 { type ExtractContext, START } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createExtract } from "../src/extract/extract-fn.js";
|
||||
|
||||
function installPlainJsonExtractMock(meta: Record<string, unknown>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
const mockFetch = async (): Promise<Response> =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(meta) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
globalThis.fetch = Object.assign(mockFetch, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
describe("createExtract — ExtractResult shape", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("returns meta, contentPayload, and refs[]", async () => {
|
||||
restoreFetch = installPlainJsonExtractMock({ confidence: 0.9 });
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), "wf-extract-refs-"));
|
||||
try {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const extract = createExtract(
|
||||
{ baseUrl: "http://127.0.0.1:9", apiKey: "key", model: "m" },
|
||||
{ cas },
|
||||
);
|
||||
|
||||
const schema = z.object({ confidence: z.number() });
|
||||
const ctx: ExtractContext = {
|
||||
threadId: "01THREADTESTAAAAAAAAAAAAAA",
|
||||
depth: 0,
|
||||
start: {
|
||||
role: START,
|
||||
content: "task text",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: 100,
|
||||
},
|
||||
steps: [],
|
||||
currentRole: { name: "analyst", systemPrompt: "be precise" },
|
||||
agentContent: "model says hello",
|
||||
};
|
||||
|
||||
const out = await extract(schema, "extract fields", ctx);
|
||||
|
||||
expect(out.meta).toEqual({ confidence: 0.9 });
|
||||
expect(out.contentPayload).toBe("model says hello");
|
||||
expect(Array.isArray(out.refs)).toBe(true);
|
||||
expect(out.refs).toEqual([]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
appendThreadHistoryEntry,
|
||||
removeThreadEntry,
|
||||
upsertThreadEntry,
|
||||
} from "../src/engine/threads-index.js";
|
||||
|
||||
describe("threads-index", () => {
|
||||
let bundleDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
bundleDir = await mkdtemp(join(tmpdir(), "uncaged-wf-threads-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(bundleDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("upsertThreadEntry creates threads.json and persists entries", async () => {
|
||||
await upsertThreadEntry(bundleDir, "T1", { head: "H1", start: "S1", updatedAt: 100 });
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
T1: { head: "H1", start: "S1", updatedAt: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
test("upsertThreadEntry overwrites the head while preserving siblings", async () => {
|
||||
await upsertThreadEntry(bundleDir, "T1", { head: "H1", start: "S1", updatedAt: 100 });
|
||||
await upsertThreadEntry(bundleDir, "T2", { head: "H2", start: "S2", updatedAt: 200 });
|
||||
await upsertThreadEntry(bundleDir, "T1", { head: "H1B", start: "S1", updatedAt: 300 });
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
T1: { head: "H1B", start: "S1", updatedAt: 300 },
|
||||
T2: { head: "H2", start: "S2", updatedAt: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
test("removeThreadEntry deletes the entry but keeps the file", async () => {
|
||||
await upsertThreadEntry(bundleDir, "T1", { head: "H1", start: "S1", updatedAt: 100 });
|
||||
await upsertThreadEntry(bundleDir, "T2", { head: "H2", start: "S2", updatedAt: 200 });
|
||||
await removeThreadEntry(bundleDir, "T1");
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
T2: { head: "H2", start: "S2", updatedAt: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
test("removeThreadEntry on a missing thread is a no-op", async () => {
|
||||
await removeThreadEntry(bundleDir, "MISSING");
|
||||
const dirEntries = await readdir(bundleDir);
|
||||
expect(dirEntries.includes("threads.json")).toBe(false);
|
||||
});
|
||||
|
||||
test("appendThreadHistoryEntry writes one JSONL line per call into a date-keyed file", async () => {
|
||||
const ts = Date.UTC(2026, 4, 9, 12, 0, 0);
|
||||
await appendThreadHistoryEntry(bundleDir, {
|
||||
threadId: "T1",
|
||||
head: "H1",
|
||||
start: "S1",
|
||||
completedAt: ts,
|
||||
});
|
||||
await appendThreadHistoryEntry(bundleDir, {
|
||||
threadId: "T2",
|
||||
head: "H2",
|
||||
start: "S2",
|
||||
completedAt: ts,
|
||||
});
|
||||
const text = await readFile(join(bundleDir, "history", "2026-05-09.jsonl"), "utf8");
|
||||
const lines = text.trim().split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
expect(JSON.parse(lines[0] ?? "{}")).toEqual({
|
||||
threadId: "T1",
|
||||
head: "H1",
|
||||
start: "S1",
|
||||
completedAt: ts,
|
||||
});
|
||||
expect(JSON.parse(lines[1] ?? "{}")).toEqual({
|
||||
threadId: "T2",
|
||||
head: "H2",
|
||||
start: "S2",
|
||||
completedAt: ts,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
type CasStore,
|
||||
getContentMerklePayload,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
} from "@uncaged/workflow-cas";
|
||||
import type { StateNode } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
resolveModel,
|
||||
type WorkflowConfig,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type {
|
||||
LlmProvider,
|
||||
RoleOutput,
|
||||
@@ -9,21 +22,38 @@ import type {
|
||||
WorkflowResult,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
getContentMerklePayload,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
} from "@uncaged/workflow-cas";
|
||||
import { resolveModel } from "@uncaged/workflow-register";
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { readWorkflowRegistry, type WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import { err, type LogFn, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
|
||||
import { END, START } from "@uncaged/workflow-runtime";
|
||||
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import {
|
||||
appendThreadHistoryEntry,
|
||||
getBundleDir,
|
||||
removeThreadEntry,
|
||||
upsertThreadEntry,
|
||||
} from "./threads-index.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
|
||||
|
||||
/** Cap for {@link StateNode}.payload.ancestors: 1 parent + 10 skip-list. */
|
||||
const ANCESTORS_CAP = 11;
|
||||
|
||||
type ChainState = {
|
||||
/** State hash of the most recently written {@link StateNode}, or `null` before the first step. */
|
||||
parentStateHash: string | null;
|
||||
/** Ancestors recorded on the most recently written {@link StateNode}. */
|
||||
parentAncestors: readonly string[];
|
||||
};
|
||||
|
||||
const EMPTY_CHAIN: ChainState = { parentStateHash: null, parentAncestors: [] };
|
||||
|
||||
function computeAncestors(chain: ChainState): string[] {
|
||||
if (chain.parentStateHash === null) {
|
||||
return [];
|
||||
}
|
||||
return [chain.parentStateHash, ...chain.parentAncestors].slice(0, ANCESTORS_CAP);
|
||||
}
|
||||
|
||||
async function resolveEngineRegistryRuntime(
|
||||
storageRoot: string,
|
||||
cas: CasStore,
|
||||
@@ -57,51 +87,108 @@ async function resolveEngineRegistryRuntime(
|
||||
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
|
||||
}
|
||||
|
||||
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
await appendFile(path, line, "utf8");
|
||||
async function appendStateForStep(params: {
|
||||
cas: CasStore;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: readonly string[];
|
||||
timestamp: number;
|
||||
}): Promise<{ stateHash: string; chain: ChainState }> {
|
||||
const text = await getContentMerklePayload(params.cas, params.contentHash);
|
||||
if (text === null) {
|
||||
throw new Error(
|
||||
`role step ${params.role}: CAS blob missing for contentHash ${params.contentHash}`,
|
||||
);
|
||||
}
|
||||
const artifactRefs = params.refs.filter((r) => r !== params.contentHash);
|
||||
const contentHash = await putContentNodeWithRefs(params.cas, text, artifactRefs);
|
||||
const ancestors = computeAncestors(params.chain);
|
||||
const payload: StateNode["payload"] = {
|
||||
role: params.role,
|
||||
meta: params.meta,
|
||||
start: params.startHash,
|
||||
content: contentHash,
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
};
|
||||
const stateHash = await putStateNode(params.cas, payload);
|
||||
return {
|
||||
stateHash,
|
||||
chain: { parentStateHash: stateHash, parentAncestors: ancestors },
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeThreadResult(params: {
|
||||
async function appendEndState(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
completion: WorkflowCompletion;
|
||||
timestamp: number;
|
||||
}): Promise<string> {
|
||||
const contentHash = await putContentNodeWithRefs(params.cas, params.completion.summary, []);
|
||||
const ancestors = computeAncestors(params.chain);
|
||||
const payload: StateNode["payload"] = {
|
||||
role: END,
|
||||
meta: { returnCode: params.completion.returnCode, summary: params.completion.summary },
|
||||
start: params.startHash,
|
||||
content: contentHash,
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
};
|
||||
return putStateNode(params.cas, payload);
|
||||
}
|
||||
|
||||
async function finalizeThread(params: {
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: readonly string[];
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
completion: WorkflowCompletion;
|
||||
}): Promise<WorkflowResult> {
|
||||
const rootHash = await putThreadMerkleNode(
|
||||
params.cas,
|
||||
{
|
||||
workflow: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
result: {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
},
|
||||
},
|
||||
params.stepMerkleHashes,
|
||||
);
|
||||
const ts = Date.now();
|
||||
const endHash = await appendEndState({
|
||||
cas: params.cas,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: params.completion,
|
||||
timestamp: ts,
|
||||
});
|
||||
await removeThreadEntry(params.bundleDir, params.threadId);
|
||||
await appendThreadHistoryEntry(params.bundleDir, {
|
||||
threadId: params.threadId,
|
||||
head: endHash,
|
||||
start: params.startHash,
|
||||
completedAt: ts,
|
||||
});
|
||||
return {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
rootHash,
|
||||
rootHash: endHash,
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeAbortedThread(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: string[];
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
logger: LogFn;
|
||||
abortLogTag: string;
|
||||
}): Promise<WorkflowResult> {
|
||||
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
|
||||
return finalizeThreadResult({
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
@@ -114,8 +201,9 @@ async function maybeSupervisorHaltsThread(params: {
|
||||
logger: LogFn;
|
||||
threadId: string;
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
stepMerkleHashes: string[];
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
}): Promise<WorkflowResult | null> {
|
||||
const interval = params.workflowConfig.supervisorInterval;
|
||||
if (interval <= 0 || params.written % interval !== 0) {
|
||||
@@ -135,41 +223,55 @@ async function maybeSupervisorHaltsThread(params: {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
return finalizeThreadResult({
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
});
|
||||
}
|
||||
|
||||
async function publishHead(params: {
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
startHash: string;
|
||||
headHash: string;
|
||||
}): Promise<void> {
|
||||
await upsertThreadEntry(params.bundleDir, params.threadId, {
|
||||
head: params.headHash,
|
||||
start: params.startHash,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function driveWorkflowGenerator(params: {
|
||||
fn: WorkflowFn;
|
||||
workflowName: string;
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
runtime: WorkflowRuntime;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
dataJsonlPath: string;
|
||||
threadId: string;
|
||||
logger: LogFn;
|
||||
cas: CasStore;
|
||||
stepMerkleHashes: string[];
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
}): Promise<WorkflowResult> {
|
||||
const {
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions,
|
||||
dataJsonlPath,
|
||||
threadId,
|
||||
logger,
|
||||
cas,
|
||||
stepMerkleHashes,
|
||||
bundleDir,
|
||||
startHash,
|
||||
} = params;
|
||||
let chain: ChainState = params.chain;
|
||||
const gen = fn(thread, runtime);
|
||||
let written = 0;
|
||||
const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({
|
||||
@@ -181,9 +283,10 @@ async function driveWorkflowGenerator(params: {
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
bundleDir,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP2",
|
||||
});
|
||||
@@ -191,11 +294,12 @@ async function driveWorkflowGenerator(params: {
|
||||
|
||||
if (written >= executeOptions.maxRounds) {
|
||||
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
workflowName,
|
||||
bundleDir,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
@@ -207,39 +311,31 @@ async function driveWorkflowGenerator(params: {
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
const completion = iterResult.value;
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
workflowName,
|
||||
bundleDir,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion,
|
||||
startHash,
|
||||
chain,
|
||||
completion: iterResult.value,
|
||||
});
|
||||
}
|
||||
|
||||
written++;
|
||||
const step = iterResult.value;
|
||||
const resolved = await getContentMerklePayload(cas, step.contentHash);
|
||||
if (resolved === null) {
|
||||
throw new Error(
|
||||
`role step ${step.role}: CAS blob missing for contentHash ${step.contentHash}`,
|
||||
);
|
||||
}
|
||||
const ts = Date.now();
|
||||
await appendDataLine(dataJsonlPath, {
|
||||
const written_ = await appendStateForStep({
|
||||
cas,
|
||||
startHash,
|
||||
chain,
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
refs: step.refs,
|
||||
timestamp: ts,
|
||||
});
|
||||
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
cas,
|
||||
{ role: step.role, meta: step.meta },
|
||||
step.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
chain = written_.chain;
|
||||
await publishHead({ bundleDir, threadId, startHash, headHash: written_.stateHash });
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
@@ -262,9 +358,10 @@ async function driveWorkflowGenerator(params: {
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
bundleDir,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP4",
|
||||
});
|
||||
@@ -278,8 +375,9 @@ async function driveWorkflowGenerator(params: {
|
||||
logger,
|
||||
threadId,
|
||||
cas,
|
||||
workflowName,
|
||||
stepMerkleHashes,
|
||||
bundleDir,
|
||||
startHash,
|
||||
chain,
|
||||
});
|
||||
if (supervised !== null) {
|
||||
return supervised;
|
||||
@@ -288,8 +386,16 @@ async function driveWorkflowGenerator(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow thread: drive the bundle's AsyncGenerator, RFC-001 `.data.jsonl` records,
|
||||
* debug lines via `logger` to `.info.jsonl`.
|
||||
* Execute a workflow thread by driving the bundle's `AsyncGenerator`.
|
||||
*
|
||||
* Persistence layout (RFC v3 — CAS-based thread storage):
|
||||
* - Thread chain is written as immutable CAS blobs: a single {@link StartNode}
|
||||
* plus one {@link StateNode} per role step (including a final `__end__`
|
||||
* state on completion / abort / `maxRounds`).
|
||||
* - The active thread head is published in `<bundleDir>/threads.json`; on
|
||||
* completion it is removed and a record is appended to
|
||||
* `<bundleDir>/history/{YYYY-MM-DD}.jsonl`.
|
||||
* - Debug logging continues to flow through `logger` to `.info.jsonl`.
|
||||
*/
|
||||
export async function executeThread(
|
||||
fn: WorkflowFn,
|
||||
@@ -299,7 +405,6 @@ export async function executeThread(
|
||||
io: ExecuteThreadIo,
|
||||
logger: LogFn,
|
||||
): Promise<WorkflowResult> {
|
||||
await mkdir(dirname(io.dataJsonlPath), { recursive: true });
|
||||
await mkdir(dirname(io.infoJsonlPath), { recursive: true });
|
||||
|
||||
const prefilled = options.prefilledDiskSteps;
|
||||
@@ -309,61 +414,63 @@ export async function executeThread(
|
||||
);
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const startRecord: Record<string, unknown> = {
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
threadId: io.threadId,
|
||||
parameters: {
|
||||
prompt: input.prompt,
|
||||
options: {
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
},
|
||||
},
|
||||
timestamp: nowMs,
|
||||
};
|
||||
if (options.forkSourceThreadId !== null) {
|
||||
startRecord.forkFrom = { threadId: options.forkSourceThreadId };
|
||||
}
|
||||
const bundleDir = getBundleDir(options.storageRoot, io.hash);
|
||||
|
||||
await appendDataLine(io.dataJsonlPath, startRecord);
|
||||
const promptHash = await io.cas.put(input.prompt);
|
||||
const startHash = await putStartNode(
|
||||
io.cas,
|
||||
{
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await publishHead({
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
headHash: startHash,
|
||||
});
|
||||
|
||||
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`);
|
||||
|
||||
const stepMerkleHashes: string[] = [];
|
||||
let chain: ChainState = EMPTY_CHAIN;
|
||||
|
||||
if (prefilled !== null) {
|
||||
for (const row of prefilled) {
|
||||
const prefilledPayload = await getContentMerklePayload(io.cas, row.contentHash);
|
||||
if (prefilledPayload === null) {
|
||||
throw new Error(
|
||||
`prefilled step ${row.role}: CAS blob missing for contentHash ${row.contentHash}`,
|
||||
);
|
||||
}
|
||||
await appendDataLine(io.dataJsonlPath, {
|
||||
const written = await appendStateForStep({
|
||||
cas: io.cas,
|
||||
startHash,
|
||||
chain,
|
||||
role: row.role,
|
||||
contentHash: row.contentHash,
|
||||
meta: row.meta,
|
||||
refs: normalizeRefsField(row.refs),
|
||||
refs: row.refs,
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
io.cas,
|
||||
{ role: row.role, meta: row.meta },
|
||||
row.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
chain = written.chain;
|
||||
await publishHead({
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
headHash: written.stateHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (options.maxRounds <= 0) {
|
||||
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeThread({
|
||||
cas: io.cas,
|
||||
workflowName,
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
stepMerkleHashes,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
@@ -401,15 +508,15 @@ export async function executeThread(
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig: registryRuntime.value.workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions: options,
|
||||
dataJsonlPath: io.dataJsonlPath,
|
||||
threadId: io.threadId,
|
||||
logger,
|
||||
cas: io.cas,
|
||||
stepMerkleHashes,
|
||||
bundleDir,
|
||||
startHash,
|
||||
chain,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ export {
|
||||
} from "./fork-thread.js";
|
||||
export { garbageCollectCas } from "./gc.js";
|
||||
export { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
|
||||
export {
|
||||
appendThreadHistoryEntry,
|
||||
getBundleDir,
|
||||
removeThreadEntry,
|
||||
upsertThreadEntry,
|
||||
} from "./threads-index.js";
|
||||
export type {
|
||||
ExecuteThreadIo,
|
||||
ExecuteThreadOptions,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { resolveModel } from "@uncaged/workflow-register";
|
||||
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import { resolveModel } from "@uncaged/workflow-register";
|
||||
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
|
||||
|
||||
import type { SupervisorDecision } from "./types.js";
|
||||
|
||||
@@ -75,7 +74,7 @@ export async function runSupervisor(
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
args.logger("R9CW4PLM", `supervisor failed: ${result.error}`);
|
||||
args.logger("R9CW4PHM", `supervisor failed: ${result.error}`);
|
||||
return err(`supervisor: ${result.error}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Active-thread index entry stored in `<bundleDir>/threads.json`.
|
||||
*
|
||||
* Once the thread reaches `__end__`, the entry is removed from `threads.json`
|
||||
* and a corresponding line is appended to `history/{YYYY-MM-DD}.jsonl`.
|
||||
*/
|
||||
export type ThreadIndexEntry = {
|
||||
head: string;
|
||||
start: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type ThreadHistoryEntry = {
|
||||
threadId: string;
|
||||
head: string;
|
||||
start: string;
|
||||
completedAt: number;
|
||||
};
|
||||
|
||||
export type ThreadIndex = Record<string, ThreadIndexEntry>;
|
||||
|
||||
export function getBundleDir(storageRoot: string, bundleHash: string): string {
|
||||
return join(storageRoot, "bundles", bundleHash);
|
||||
}
|
||||
|
||||
function threadsJsonPath(bundleDir: string): string {
|
||||
return join(bundleDir, "threads.json");
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function parseThreadIndexEntry(raw: unknown): ThreadIndexEntry | null {
|
||||
if (!isPlainObject(raw)) {
|
||||
return null;
|
||||
}
|
||||
const head = raw.head;
|
||||
const start = raw.start;
|
||||
const updatedAt = raw.updatedAt;
|
||||
if (typeof head !== "string" || typeof start !== "string" || typeof updatedAt !== "number") {
|
||||
return null;
|
||||
}
|
||||
return { head, start, updatedAt };
|
||||
}
|
||||
|
||||
function parseThreadIndex(text: string): ThreadIndex {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") {
|
||||
return {};
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!isPlainObject(raw)) {
|
||||
return {};
|
||||
}
|
||||
const out: ThreadIndex = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const entry = parseThreadIndexEntry(v);
|
||||
if (entry !== null) {
|
||||
out[k] = entry;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readThreadIndex(bundleDir: string): Promise<ThreadIndex> {
|
||||
const path = threadsJsonPath(bundleDir);
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path, "utf8");
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return parseThreadIndex(text);
|
||||
}
|
||||
|
||||
async function writeThreadIndex(bundleDir: string, index: ThreadIndex): Promise<void> {
|
||||
const path = threadsJsonPath(bundleDir);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
||||
const json = `${JSON.stringify(index, null, 2)}\n`;
|
||||
await writeFile(tmp, json, "utf8");
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
/** Insert/update a thread entry in `threads.json`. */
|
||||
export async function upsertThreadEntry(
|
||||
bundleDir: string,
|
||||
threadId: string,
|
||||
entry: ThreadIndexEntry,
|
||||
): Promise<void> {
|
||||
const index = await readThreadIndex(bundleDir);
|
||||
index[threadId] = entry;
|
||||
await writeThreadIndex(bundleDir, index);
|
||||
}
|
||||
|
||||
/** Remove a thread entry from `threads.json` (no-op when absent). */
|
||||
export async function removeThreadEntry(bundleDir: string, threadId: string): Promise<void> {
|
||||
const index = await readThreadIndex(bundleDir);
|
||||
if (!(threadId in index)) {
|
||||
return;
|
||||
}
|
||||
delete index[threadId];
|
||||
await writeThreadIndex(bundleDir, index);
|
||||
}
|
||||
|
||||
function dateKey(epochMs: number): string {
|
||||
const d = new Date(epochMs);
|
||||
const y = d.getUTCFullYear().toString().padStart(4, "0");
|
||||
const m = (d.getUTCMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getUTCDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Append a completion record to `history/{YYYY-MM-DD}.jsonl` keyed off `completedAt`. */
|
||||
export async function appendThreadHistoryEntry(
|
||||
bundleDir: string,
|
||||
entry: ThreadHistoryEntry,
|
||||
): Promise<void> {
|
||||
const path = join(bundleDir, "history", `${dateKey(entry.completedAt)}.jsonl`);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const line = `${JSON.stringify(entry)}\n`;
|
||||
await appendFile(path, line, "utf8");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
|
||||
export type SupervisorDecision = "continue" | "stop";
|
||||
@@ -7,7 +7,6 @@ export type SupervisorDecision = "continue" | "stop";
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
dataJsonlPath: string;
|
||||
infoJsonlPath: string;
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
|
||||
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "@uncaged/workflow-register";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
ensureUncagedWorkflowSymlink,
|
||||
importWorkflowBundleModule,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
err,
|
||||
@@ -364,13 +367,11 @@ async function main(): Promise<void> {
|
||||
|
||||
const threadId = cmd.threadId;
|
||||
const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`);
|
||||
const dataJsonlPath = join(storageRoot, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId,
|
||||
hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas,
|
||||
};
|
||||
@@ -387,7 +388,6 @@ async function main(): Promise<void> {
|
||||
|
||||
try {
|
||||
await mkdir(dirname(runningPath), { recursive: true });
|
||||
await mkdir(dirname(dataJsonlPath), { recursive: true });
|
||||
await writeFile(runningPath, "", "utf8");
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
@@ -407,7 +407,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const runResult = await executeThread(
|
||||
await executeThread(
|
||||
workflowFn,
|
||||
cmd.workflowName,
|
||||
{ prompt: cmd.prompt, steps: cmd.steps },
|
||||
@@ -422,12 +422,9 @@ async function main(): Promise<void> {
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
await appendFile(dataJsonlPath, `${JSON.stringify(runResult)}\n`, "utf8");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
|
||||
const failure: WorkflowResult = { returnCode: 1, summary: message, rootHash: "" };
|
||||
await appendFile(dataJsonlPath, `${JSON.stringify(failure)}\n`, "utf8").catch(() => {});
|
||||
} finally {
|
||||
threads.delete(threadId);
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type {
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
|
||||
export type ExtractDeps = {
|
||||
@@ -121,7 +126,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
): Promise<T> => {
|
||||
): Promise<ExtractResult<T>> => {
|
||||
const text = await buildExtractUserContent(ctx, prompt, deps);
|
||||
const result = await reactor({
|
||||
thread: { cas: deps.cas },
|
||||
@@ -131,6 +136,10 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${result.error}`);
|
||||
}
|
||||
return result.value;
|
||||
return {
|
||||
meta: result.value,
|
||||
contentPayload: ctx.agentContent,
|
||||
refs: [],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { LlmError, LlmExtractArgs } from "./types.js";
|
||||
|
||||
|
||||
@@ -21,15 +21,13 @@ export type {
|
||||
ThreadPauseGate,
|
||||
} from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
} from "./extract/index.js";
|
||||
export {
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export { workflowAsAgent, type WorkflowAsAgentOptions } from "./workflow-as-agent.js";
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { join } from "node:path";
|
||||
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { extractBundleExports } from "@uncaged/workflow-register";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread } from "./engine/index.js";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
@@ -74,13 +77,11 @@ export function workflowAsAgent(
|
||||
};
|
||||
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const dataJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.data.jsonl`);
|
||||
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: entry.hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
export type {
|
||||
ContentMerkleNode,
|
||||
StartNode,
|
||||
StartNodePayload,
|
||||
StateNode,
|
||||
StateNodePayload,
|
||||
} from "./cas-types.js";
|
||||
|
||||
export type {
|
||||
@@ -14,6 +16,7 @@ export type {
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error:
|
||||
// ── CAS ────────────────────────────────────────────────────────────
|
||||
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
|
||||
// ── Workflow Descriptor ────────────────────────────────────────────
|
||||
@@ -23,13 +23,13 @@ export type CasStore = {
|
||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
description: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
};
|
||||
|
||||
// ── Role & Thread ──────────────────────────────────────────────────
|
||||
@@ -37,131 +37,138 @@ export type WorkflowDescriptor = {
|
||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number };
|
||||
timestamp: number;
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
contentHash: string;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
[K in keyof M & string]: {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
contentHash: string;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
}[keyof M & string];
|
||||
|
||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
depth: number;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
threadId: string;
|
||||
depth: number;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
|
||||
|
||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
||||
currentRole: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
currentRole: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
agentContent: string;
|
||||
};
|
||||
|
||||
// ── Workflow Completion ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowCompletion = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type WorkflowResult = WorkflowCompletion & {
|
||||
rootHash: string;
|
||||
rootHash: string;
|
||||
};
|
||||
|
||||
// ── LLM Provider ───────────────────────────────────────────────────
|
||||
|
||||
export type LlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedModel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
||||
export type ExtractResult<T extends Record<string, unknown>> = {
|
||||
meta: T;
|
||||
contentPayload: string;
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
) => Promise<T>;
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// ── Workflow Runtime & Definition ──────────────────────────────────
|
||||
|
||||
export type WorkflowRuntime = {
|
||||
cas: CasStore;
|
||||
extract: ExtractFn;
|
||||
cas: CasStore;
|
||||
extract: ExtractFn;
|
||||
};
|
||||
|
||||
export type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
ctx: ModeratorContext<M>,
|
||||
ctx: ModeratorContext<M>,
|
||||
) => (keyof M & string) | typeof END;
|
||||
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
export type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
|
||||
@@ -5,7 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-protocol" }]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isBuiltin } from "node:module";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import type {
|
||||
CallExpression,
|
||||
ExportAllDeclaration,
|
||||
@@ -12,8 +13,6 @@ import type {
|
||||
} from "acorn";
|
||||
import * as acorn from "acorn";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { WorkflowBundleValidationInput } from "./types.js";
|
||||
|
||||
/** Acorn Node with index-access for property traversal. */
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
|
||||
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowFn,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
export type WorkflowBundleValidationInput = {
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
export {
|
||||
buildDescriptor,
|
||||
importWorkflowBundleModule,
|
||||
validateWorkflowBundle,
|
||||
ensureUncagedWorkflowSymlink,
|
||||
extractBundleExports,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowDescriptor,
|
||||
} from "./bundle/index.js";
|
||||
export type {
|
||||
ExtractBundleExportsOptions,
|
||||
ExtractedBundleExports,
|
||||
@@ -15,7 +6,23 @@ export type {
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
|
||||
export {
|
||||
buildDescriptor,
|
||||
ensureUncagedWorkflowSymlink,
|
||||
extractBundleExports,
|
||||
importWorkflowBundleModule,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowBundle,
|
||||
validateWorkflowDescriptor,
|
||||
} from "./bundle/index.js";
|
||||
export type { ProviderConfig, ResolvedModel } from "./config/index.js";
|
||||
export { resolveModel, splitProviderModelRef } from "./config/index.js";
|
||||
export type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./registry/index.js";
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
@@ -28,12 +35,3 @@ export {
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "./registry/index.js";
|
||||
export type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./registry/index.js";
|
||||
|
||||
export { resolveModel, splitProviderModelRef } from "./config/index.js";
|
||||
export type { ProviderConfig, ResolvedModel } from "./config/index.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderConfig } from "@uncaged/workflow-protocol";
|
||||
import { splitProviderModelRef } from "../config/index.js";
|
||||
import { createLogger, err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import { splitProviderModelRef } from "../config/index.js";
|
||||
import type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { parseDocument, stringify } from "yaml";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import { parseDocument, stringify } from "yaml";
|
||||
import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js";
|
||||
import type { WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" }
|
||||
],
|
||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createCasStore,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
} from "@uncaged/workflow-cas";
|
||||
import { buildThreadContext, END, START } from "../src/index.js";
|
||||
|
||||
describe("buildThreadContext", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "wf-build-ctx-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("walks ancestor chain, resolves prompt, orders steps chronologically", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const promptHash = await cas.put("hello-task");
|
||||
const bundleHash = "BHAAAAAAAAAAA";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const art = await cas.put("artifact-a");
|
||||
const chPlan = await putContentNodeWithRefs(cas, "plan body", [art]);
|
||||
const statePlan = await putStateNode(cas, {
|
||||
role: "planner",
|
||||
meta: { phase: 1 },
|
||||
start: startHash,
|
||||
content: chPlan,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
const chCode = await putContentNodeWithRefs(cas, "code body", []);
|
||||
const stateCode = await putStateNode(cas, {
|
||||
role: "coder",
|
||||
meta: { phase: 2 },
|
||||
start: startHash,
|
||||
content: chCode,
|
||||
ancestors: [statePlan],
|
||||
compact: null,
|
||||
timestamp: 2000,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(stateCode, cas);
|
||||
expect(ctx.threadId).toBe("");
|
||||
expect(ctx.depth).toBe(2);
|
||||
expect(ctx.start.role).toBe(START);
|
||||
expect(ctx.start.content).toBe("hello-task");
|
||||
expect(ctx.start.meta.maxRounds).toBe(99);
|
||||
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
|
||||
expect(ctx.steps[0]?.refs).toEqual([art]);
|
||||
expect(ctx.steps[1]?.refs).toEqual([]);
|
||||
expect(ctx.steps[0]?.timestamp).toBe(1000);
|
||||
expect(ctx.steps[1]?.timestamp).toBe(2000);
|
||||
});
|
||||
|
||||
test("StartNode head yields empty steps", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const promptHash = await cas.put("only-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const ctx = await buildThreadContext(startHash, cas);
|
||||
expect(ctx.steps).toEqual([]);
|
||||
expect(ctx.start.content).toBe("only-prompt");
|
||||
expect(ctx.depth).toBe(1);
|
||||
expect(ctx.start.meta.maxRounds).toBe(3);
|
||||
});
|
||||
|
||||
test("omits __end__ states from steps", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const promptHash = await cas.put("task");
|
||||
const bundleHash = "BHCCCCCCCCCCC";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const ch1 = await putContentNodeWithRefs(cas, "step-one", []);
|
||||
const state1 = await putStateNode(cas, {
|
||||
role: "worker",
|
||||
meta: { done: false },
|
||||
start: startHash,
|
||||
content: ch1,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 500,
|
||||
});
|
||||
|
||||
const endContent = await putContentNodeWithRefs(cas, "finished", []);
|
||||
const endState = await putStateNode(cas, {
|
||||
role: END,
|
||||
meta: { returnCode: 0, summary: "finished" },
|
||||
start: startHash,
|
||||
content: endContent,
|
||||
ancestors: [state1],
|
||||
compact: null,
|
||||
timestamp: 600,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(endState, cas);
|
||||
expect(ctx.steps.map((s) => s.role)).toEqual(["worker"]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
CasStore,
|
||||
RoleMeta,
|
||||
RoleStep,
|
||||
StartNode,
|
||||
StateNode,
|
||||
ThreadContext,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { END, START } from "@uncaged/workflow-protocol";
|
||||
|
||||
async function loadParsedNode(cas: CasStore, hash: string) {
|
||||
const yamlText = await cas.get(hash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
return parseCasThreadNode(yamlText);
|
||||
}
|
||||
|
||||
async function resolvePromptText(cas: CasStore, promptHash: string): Promise<string> {
|
||||
const text = await getContentMerklePayload(cas, promptHash);
|
||||
if (text !== null) {
|
||||
return text;
|
||||
}
|
||||
throw new Error(`buildThreadContext: could not resolve prompt text at ${promptHash}`);
|
||||
}
|
||||
|
||||
async function collectStateChainFromHead(cas: CasStore, headHash: string): Promise<StateNode[]> {
|
||||
const reversed: StateNode[] = [];
|
||||
let hash: string | null = headHash;
|
||||
while (hash !== null) {
|
||||
const parsed = await loadParsedNode(cas, hash);
|
||||
if (parsed === null || parsed.kind !== "state") {
|
||||
throw new Error(`buildThreadContext: expected state node at ${hash}`);
|
||||
}
|
||||
reversed.push(parsed.node);
|
||||
const anc = parsed.node.payload.ancestors;
|
||||
hash = anc.length > 0 ? anc[0] : null;
|
||||
}
|
||||
reversed.reverse();
|
||||
return reversed;
|
||||
}
|
||||
|
||||
async function threadFromStartHead<M extends RoleMeta>(
|
||||
node: StartNode,
|
||||
cas: CasStore,
|
||||
): Promise<ThreadContext<M>> {
|
||||
const promptHash = node.refs[0];
|
||||
if (promptHash === undefined) {
|
||||
throw new Error("buildThreadContext: StartNode missing refs[0] prompt");
|
||||
}
|
||||
const prompt = await resolvePromptText(cas, promptHash);
|
||||
const p = node.payload;
|
||||
return {
|
||||
threadId: "",
|
||||
depth: p.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: p.maxRounds },
|
||||
timestamp: 0,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildRoleStepsFromStates<M extends RoleMeta>(
|
||||
chronologicalStates: StateNode[],
|
||||
cas: CasStore,
|
||||
): Promise<RoleStep<M>[]> {
|
||||
const steps: RoleStep<M>[] = [];
|
||||
for (const st of chronologicalStates) {
|
||||
if (st.payload.role === END) {
|
||||
continue;
|
||||
}
|
||||
const contentParsed = await loadParsedNode(cas, st.payload.content);
|
||||
if (contentParsed === null || contentParsed.kind !== "content") {
|
||||
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
||||
}
|
||||
steps.push({
|
||||
role: st.payload.role,
|
||||
meta: st.payload.meta,
|
||||
contentHash: st.payload.content,
|
||||
refs: [...contentParsed.node.refs],
|
||||
timestamp: st.payload.timestamp,
|
||||
} as RoleStep<M>);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
async function threadFromStateHead<M extends RoleMeta>(
|
||||
headHash: string,
|
||||
cas: CasStore,
|
||||
): Promise<ThreadContext<M>> {
|
||||
const chronologicalStates = await collectStateChainFromHead(cas, headHash);
|
||||
const firstState = chronologicalStates[0];
|
||||
if (firstState === undefined) {
|
||||
throw new Error("buildThreadContext: empty state chain");
|
||||
}
|
||||
const startBlob = await loadParsedNode(cas, firstState.payload.start);
|
||||
if (startBlob === null || startBlob.kind !== "start") {
|
||||
throw new Error(`buildThreadContext: StartNode missing at ${firstState.payload.start}`);
|
||||
}
|
||||
const promptHash = startBlob.node.refs[0];
|
||||
if (promptHash === undefined) {
|
||||
throw new Error("buildThreadContext: StartNode missing refs[0] prompt");
|
||||
}
|
||||
const prompt = await resolvePromptText(cas, promptHash);
|
||||
const sp = startBlob.node.payload;
|
||||
const steps = await buildRoleStepsFromStates<M>(chronologicalStates, cas);
|
||||
const firstTs = steps[0]?.timestamp ?? 0;
|
||||
|
||||
return {
|
||||
threadId: "",
|
||||
depth: sp.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: sp.maxRounds },
|
||||
timestamp: firstTs,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs {@link ThreadContext} by walking the CAS state chain from {@link headHash}.
|
||||
*
|
||||
* Walks each {@link StateNode} via `payload.ancestors[0]` until the ancestor list is empty,
|
||||
* resolves the prompt from the shared {@link StartNode} (`refs[0]` → prompt blob), and builds
|
||||
* steps from non-`__end__` states in chronological order.
|
||||
*
|
||||
* `threadId` is set to `""` — callers that load from `threads.json` should overwrite it.
|
||||
*/
|
||||
export async function buildThreadContext<M extends RoleMeta = RoleMeta>(
|
||||
headHash: string,
|
||||
cas: CasStore,
|
||||
): Promise<ThreadContext<M>> {
|
||||
const headParsed = await loadParsedNode(cas, headHash);
|
||||
if (headParsed === null) {
|
||||
throw new Error(`buildThreadContext: missing or invalid CAS blob ${headHash}`);
|
||||
}
|
||||
|
||||
if (headParsed.kind === "start") {
|
||||
return threadFromStartHead(headParsed.node, cas);
|
||||
}
|
||||
|
||||
if (headParsed.kind !== "state") {
|
||||
throw new Error(`buildThreadContext: head ${headHash} must be start or state node`);
|
||||
}
|
||||
|
||||
return threadFromStateHead(headHash, cas);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import {
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
type CasStore,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ModeratorContext,
|
||||
@@ -38,8 +38,16 @@ function resolveExtractedRefs(
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
|
||||
return store.put(raw);
|
||||
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const h of [...a, ...b]) {
|
||||
if (!seen.has(h)) {
|
||||
seen.add(h);
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
@@ -86,23 +94,29 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
agentContent: raw,
|
||||
};
|
||||
|
||||
const meta = await runtime.extract(
|
||||
const extracted = await runtime.extract(
|
||||
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
);
|
||||
|
||||
const contentHash = await putContentBlob(runtime.cas, raw);
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
meta,
|
||||
extracted.meta,
|
||||
);
|
||||
const refs = refsFromMeta.includes(contentHash) ? refsFromMeta : [...refsFromMeta, contentHash];
|
||||
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
|
||||
|
||||
const contentHash = await putContentNodeWithRefs(
|
||||
runtime.cas,
|
||||
extracted.contentPayload,
|
||||
artifactRefs,
|
||||
);
|
||||
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
|
||||
|
||||
const step = {
|
||||
role: next,
|
||||
contentHash,
|
||||
meta,
|
||||
meta: extracted.meta,
|
||||
refs,
|
||||
timestamp: Date.now(),
|
||||
} as RoleStep<M>;
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
|
||||
@@ -3,31 +3,32 @@
|
||||
// imports from "@uncaged/workflow-runtime" continues to work.
|
||||
|
||||
export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AdvanceOutcome,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
AdvanceOutcome,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -18,7 +18,5 @@
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-cas" }, { "path": "../workflow-protocol" }]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-runtime" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createWorkflow,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleStep,
|
||||
START,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
|
||||
@@ -6,8 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-runtime" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export { err, ok } from "@uncaged/workflow-protocol";
|
||||
export {
|
||||
CROCKFORD_BASE32_ALPHABET,
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
CROCKFORD_BASE32_ALPHABET,
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
} from "./base32.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
|
||||
export { ok, err } from "@uncaged/workflow-protocol";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
|
||||
export { generateUlid } from "./ulid.js";
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { Result } from "@uncaged/workflow-protocol";
|
||||
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
|
||||
|
||||
export type CreateLoggerOptions = {
|
||||
sink: LoggerSink;
|
||||
sink: LoggerSink;
|
||||
};
|
||||
|
||||
export type LogFn = (tag: string, content: string) => void;
|
||||
|
||||
@@ -5,7 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" }
|
||||
]
|
||||
"references": [{ "path": "../workflow-protocol" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user