chore: reorganize repo — legacy packages to legacy-packages/, templates to examples/
- Move 15 old workflow-* packages to legacy-packages/ (inactive, preserved for reference)
- Rename templates/ → examples/ for clarity
- Rewrite docs/architecture.md to reflect current uwf architecture
- Active packages remain in packages/: cli-uwf, uwf-agent-hermes, uwf-agent-kit, uwf-moderator, uwf-protocol, workflow-util
小橘 🍊(NEKO Team)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# @uncaged/workflow-execute
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -0,0 +1,33 @@
|
||||
# @uncaged/workflow-execute
|
||||
|
||||
Thread engine: execution, fork/GC, extract pipeline, supervisor/worker wiring, and workflow-as-agent.
|
||||
|
||||
## What This Package Does
|
||||
|
||||
It runs `WorkflowFn` generators against disk-backed threads, integrates CAS and registry-backed extract (`createExtract`), coordinates LLM tool usage via `@uncaged/workflow-reactor`, handles fork plans and garbage collection, and exposes `workflowAsAgent` for nesting workflows.
|
||||
|
||||
## Key Exports
|
||||
|
||||
From `src/index.ts`:
|
||||
|
||||
- **Engine:** `createWorkflow` (engine-local re-export), `executeThread`, `getWorkerHostScriptPath`
|
||||
- **Fork / parse:** `buildForkPlan`, `parseThreadDataJsonl`, `selectForkHistoricalSteps`, `tryParseRoleStepRecord`, `tryParseWorkflowResultRecord`
|
||||
- **GC / pause:** `garbageCollectCas`, `createThreadPauseGate`
|
||||
- **Engine types:** `ExecuteThreadIo`, `ExecuteThreadOptions`, `ForkHistoricalStep`, `ForkPlan`, `GcResult`, `ParsedThreadStartRecord`, `PrefilledDiskStep`, `SupervisorDecision`, `ThreadPauseGate`
|
||||
- **Extract:** `buildExtractUserContent`, `createExtract`, `extractFunctionToolFromZodSchema`, `llmErrorToCause`, `llmExtract`, types `ExtractFn`, `ExtractThreadContext`, `LlmError`, `LlmExtractArgs`
|
||||
- **Agent composition:** `workflowAsAgent`, `WorkflowAsAgentOptions`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Workspace:** `@uncaged/workflow-protocol`, `@uncaged/workflow-runtime`, `@uncaged/workflow-util`, `@uncaged/workflow-cas`, `@uncaged/workflow-reactor`, `@uncaged/workflow-register`
|
||||
- **npm:** `yaml`
|
||||
- **Peer:** `zod` ^4
|
||||
|
||||
`@uncaged/workflow-reactor` is used for LLM-backed extract and supervisor flows (`extract-fn.ts`, `supervisor.ts`).
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { executeThread } from "@uncaged/workflow-execute";
|
||||
// Typical callers are CLI/tests that supply ExecuteThreadIo (paths, CAS, abort, logger, …).
|
||||
```
|
||||
@@ -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 {
|
||||
depth: 0,
|
||||
parentStateHash: null,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: 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 }),
|
||||
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);
|
||||
|
||||
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], childThread: null };
|
||||
const h2 = await runtime.cas.put("code-text");
|
||||
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2], childThread: null };
|
||||
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,
|
||||
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], childThread: null };
|
||||
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 }),
|
||||
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], childThread: null };
|
||||
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 }),
|
||||
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,59 @@
|
||||
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, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
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 contentHash = await putContentNodeWithRefs(cas, "model says hello", []);
|
||||
|
||||
const out = await extract(schema, contentHash);
|
||||
|
||||
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,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createCasStore,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
} from "@uncaged/workflow-cas";
|
||||
import type { StateNodePayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { FORK_BRANCH_ROLE } from "../src/engine/fork-thread.js";
|
||||
import { garbageCollectCas } from "../src/engine/gc.js";
|
||||
import { getBundleDir, removeThreadEntry, upsertThreadEntry } from "../src/engine/threads-index.js";
|
||||
|
||||
describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-ms-"));
|
||||
casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(storageRoot, "workflow.yaml"),
|
||||
"config:\n maxDepth: 1\n supervisorInterval: 0\n providers: {}\n models: {}\nworkflows: {}\n",
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("shared CAS prefix survives when one fork thread index entry is removed", async () => {
|
||||
const bundleHash = "TESTGC0000001";
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(casDir);
|
||||
const promptHash = await cas.put("prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const c1 = await putContentNodeWithRefs(cas, "p1", []);
|
||||
const h1 = await putStateNode(cas, {
|
||||
role: "planner",
|
||||
meta: {},
|
||||
start: startHash,
|
||||
content: c1,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
||||
const h2 = await putStateNode(cas, {
|
||||
role: "coder",
|
||||
meta: {},
|
||||
start: startHash,
|
||||
content: c2,
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 2,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const ec = await putContentNodeWithRefs(cas, "", []);
|
||||
const fm = await putStateNode(cas, {
|
||||
role: FORK_BRANCH_ROLE,
|
||||
meta: {},
|
||||
start: startHash,
|
||||
content: ec,
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 3,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
|
||||
head: h2,
|
||||
start: startHash,
|
||||
updatedAt: 10,
|
||||
});
|
||||
await upsertThreadEntry(bundleDir, "THREAD_BBBBBBB", {
|
||||
head: fm,
|
||||
start: startHash,
|
||||
updatedAt: 20,
|
||||
});
|
||||
|
||||
await removeThreadEntry(bundleDir, "THREAD_AAAAAAA");
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(await cas.get(h2)).toBeNull();
|
||||
expect(await cas.get(h1)).not.toBeNull();
|
||||
expect(await cas.get(startHash)).not.toBeNull();
|
||||
expect(await cas.get(promptHash)).not.toBeNull();
|
||||
expect(await cas.get(fm)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
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 type { CasStore } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import type { StartNode, StateNode } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
RoleOutput,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import type { 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 {
|
||||
depth: 0,
|
||||
parentStateHash: null,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot: "/tmp/never",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupStorage(): Promise<{
|
||||
storageRoot: string;
|
||||
casDir: string;
|
||||
}> {
|
||||
const storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-merkle-"));
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_REGISTRY_YAML, "utf8");
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
return { storageRoot, casDir };
|
||||
}
|
||||
|
||||
async function loadStartNode(cas: CasStore, endHash: string): Promise<StartNode> {
|
||||
const endBlob = await cas.get(endHash);
|
||||
const endParsed = parseCasThreadNode(endBlob ?? "");
|
||||
if (endParsed?.kind !== "state") throw new Error("expected state node");
|
||||
const startBlob = await cas.get(endParsed.node.payload.start);
|
||||
const startParsed = parseCasThreadNode(startBlob ?? "");
|
||||
if (startParsed?.kind !== "start") throw new Error("expected start node");
|
||||
return startParsed.node;
|
||||
}
|
||||
|
||||
async function loadStateNode(cas: CasStore, hash: string): Promise<StateNode> {
|
||||
const blob = await cas.get(hash);
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
if (parsed?.kind !== "state") throw new Error("expected state node");
|
||||
return parsed.node;
|
||||
}
|
||||
|
||||
describe("Merkle call stack — cross-thread DAG linking (Phase 2)", () => {
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupStorage();
|
||||
storageRoot = setup.storageRoot;
|
||||
casDir = setup.casDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("parentStateHash is written into child start node's parentState and refs", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
_runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "parent done" };
|
||||
};
|
||||
|
||||
const parentResult = await executeThread(
|
||||
parentWf,
|
||||
"parent-wf",
|
||||
{ prompt: "parent task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "P_THREAD_01",
|
||||
hash: "PARENTHASH0001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "PARENTHASH0001", "P1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const childWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
_runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "child done" };
|
||||
};
|
||||
|
||||
const childResult = await executeThread(
|
||||
childWf,
|
||||
"child-wf",
|
||||
{ prompt: "child task", steps: [] },
|
||||
makeOptions({ storageRoot, depth: 1, parentStateHash: parentResult.rootHash }),
|
||||
{
|
||||
threadId: "C_THREAD_01",
|
||||
hash: "CHILDHASH00001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "CHILDHASH00001", "C1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const childStart = await loadStartNode(cas, childResult.rootHash);
|
||||
expect(childStart.payload.parentState).toBe(parentResult.rootHash);
|
||||
expect(childStart.refs).toContain(parentResult.rootHash);
|
||||
});
|
||||
|
||||
test("childThread on parent state node points to child's final state and is in refs", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const childFinalHash = "CHILD_FINAL_001";
|
||||
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("developer output");
|
||||
yield {
|
||||
role: "developer",
|
||||
contentHash: h,
|
||||
meta: { action: "delegate" },
|
||||
refs: [h],
|
||||
childThread: childFinalHash,
|
||||
};
|
||||
return { returnCode: 0, summary: "parent complete" };
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
parentWf,
|
||||
"parent-wf",
|
||||
{ prompt: "parent task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "P_THREAD_02",
|
||||
hash: "CTHREAD_TEST01",
|
||||
infoJsonlPath: join(storageRoot, "logs", "CTHREAD_TEST01", "P2.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const endNode = await loadStateNode(cas, result.rootHash);
|
||||
const devStateHash = endNode.payload.ancestors[0] ?? "";
|
||||
const devNode = await loadStateNode(cas, devStateHash);
|
||||
|
||||
expect(devNode.payload.role).toBe("developer");
|
||||
expect(devNode.payload.childThread).toBe(childFinalHash);
|
||||
expect(devNode.refs).toContain(childFinalHash);
|
||||
});
|
||||
|
||||
test("parent state with no child has childThread: null", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("prep output");
|
||||
yield { role: "preparer", contentHash: h, meta: {}, refs: [h], childThread: null };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
wf,
|
||||
"test-wf",
|
||||
{ prompt: "task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "NULL_CT_01",
|
||||
hash: "NULLCT_TEST001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "NULLCT_TEST001", "N1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const endNode = await loadStateNode(cas, result.rootHash);
|
||||
const prepHash = endNode.payload.ancestors[0] ?? "";
|
||||
const prepNode = await loadStateNode(cas, prepHash);
|
||||
|
||||
expect(prepNode.payload.childThread).toBeNull();
|
||||
expect(prepNode.refs).not.toContain(null);
|
||||
});
|
||||
|
||||
test("full bidirectional: child parentState is traversable to parent's context", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const parentHash = "BIDIR_PARENT01";
|
||||
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h1 = await runtime.cas.put("preparation output");
|
||||
yield {
|
||||
role: "preparer",
|
||||
contentHash: h1,
|
||||
meta: { repoPath: "/test" },
|
||||
refs: [h1],
|
||||
childThread: null,
|
||||
};
|
||||
const h2 = await runtime.cas.put("developer output");
|
||||
yield {
|
||||
role: "developer",
|
||||
contentHash: h2,
|
||||
meta: { action: "code" },
|
||||
refs: [h2],
|
||||
childThread: "CHILD_END_HASH1",
|
||||
};
|
||||
return { returnCode: 0, summary: "all done" };
|
||||
};
|
||||
|
||||
const observedHeads: string[] = [];
|
||||
const opts = makeOptions({
|
||||
storageRoot,
|
||||
awaitAfterEachYield: async () => {
|
||||
const bundleDir = join(storageRoot, "bundles", parentHash);
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, { head: string }>;
|
||||
const head = parsed.BIDIR_T_001?.head ?? null;
|
||||
if (head !== null) observedHeads.push(head);
|
||||
},
|
||||
});
|
||||
|
||||
await executeThread(
|
||||
parentWf,
|
||||
"bidir-wf",
|
||||
{ prompt: "bidir test", steps: [] },
|
||||
opts,
|
||||
{
|
||||
threadId: "BIDIR_T_001",
|
||||
hash: parentHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", parentHash, "BD1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
expect(observedHeads.length).toBe(2);
|
||||
const preparerStateHash = observedHeads[0] ?? "";
|
||||
|
||||
// Execute child with parentState pointing to parent's preparer state
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const childWf: WorkflowFn = async function* (
|
||||
_t: ThreadContext,
|
||||
_r: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "child ok" };
|
||||
};
|
||||
|
||||
const childResult = await executeThread(
|
||||
childWf,
|
||||
"bidir-child",
|
||||
{ prompt: "child bidir", steps: [] },
|
||||
makeOptions({ storageRoot, depth: 1, parentStateHash: preparerStateHash }),
|
||||
{
|
||||
threadId: "BIDIR_C_001",
|
||||
hash: "BIDIR_CHILD001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "BIDIR_CHILD001", "BC1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
// Upward traversal: child start → parentState → preparer state → meta.repoPath
|
||||
const childStart = await loadStartNode(cas, childResult.rootHash);
|
||||
expect(childStart.payload.parentState).toBe(preparerStateHash);
|
||||
|
||||
const parentPrep = await loadStateNode(cas, preparerStateHash);
|
||||
expect(parentPrep.payload.meta.repoPath).toBe("/test");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-reactor':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-reactor
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
yaml:
|
||||
specifier: ^2.7.1
|
||||
version: 2.8.4
|
||||
devDependencies:
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { ThreadReactorFn } from "@uncaged/workflow-reactor";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { extractFunctionToolFromZodSchema } from "./extract/index.js";
|
||||
|
||||
export type CasReactorThread = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description:
|
||||
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and refs or children fields (content nodes use refs).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "The CAS hash to retrieve" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export type CasReactorOpts = {
|
||||
maxRounds: number;
|
||||
systemPromptForStructuredTool: (structuredToolName: string) => string;
|
||||
};
|
||||
|
||||
export function createCasReactor(
|
||||
provider: LlmProvider,
|
||||
cas: CasStore,
|
||||
opts: CasReactorOpts,
|
||||
): ThreadReactorFn<CasReactorThread> {
|
||||
return createThreadReactor<CasReactorThread>({
|
||||
llm: createLlmFn(provider),
|
||||
maxRounds: opts.maxRounds,
|
||||
staticTools: [CAS_GET_TOOL_DEFINITION],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: opts.systemPromptForStructuredTool,
|
||||
toolHandler: async (call, _thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `Unknown tool: ${call.function.name}`;
|
||||
}
|
||||
let hash: string;
|
||||
try {
|
||||
const ta = JSON.parse(call.function.arguments) as unknown;
|
||||
if (!isRecord(ta) || typeof ta.hash !== "string") {
|
||||
return 'cas_get requires {"hash": "<cas-hash>"}.';
|
||||
}
|
||||
hash = ta.hash;
|
||||
} catch {
|
||||
return "cas_get arguments were not valid JSON.";
|
||||
}
|
||||
const blob = await cas.get(hash);
|
||||
return blob === null ? "null" : blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`.
|
||||
*
|
||||
* The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop
|
||||
* and delegates structured meta extraction to `WorkflowRuntime.extract`, which the engine
|
||||
* supplies (resolved from the `extract` scene in workflow.yaml).
|
||||
*/
|
||||
export { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
@@ -0,0 +1,562 @@
|
||||
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,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
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 { createSummarizer, type SummarizeFn } from "./summarizer.js";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import {
|
||||
appendThreadHistoryEntry,
|
||||
getBundleDir,
|
||||
removeThreadEntry,
|
||||
upsertThreadEntry,
|
||||
} from "./threads-index.js";
|
||||
import type { ChainState, ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
|
||||
import { EMPTY_CHAIN_STATE } from "./types.js";
|
||||
|
||||
/** Cap for {@link StateNode}.payload.ancestors: 1 parent + 10 skip-list. */
|
||||
const ANCESTORS_CAP = 11;
|
||||
|
||||
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,
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
summarize: SummarizeFn;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
>
|
||||
> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return err(reg.error.message);
|
||||
}
|
||||
const cfg = reg.value.config;
|
||||
if (cfg === null) {
|
||||
return err("workflow registry has no global config section");
|
||||
}
|
||||
const resolved = resolveModel(cfg, "extract");
|
||||
if (!resolved.ok) {
|
||||
return resolved;
|
||||
}
|
||||
const ex = resolved.value;
|
||||
const llmProvider: LlmProvider = {
|
||||
baseUrl: ex.baseUrl,
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({
|
||||
extract: createExtract(llmProvider, { cas }),
|
||||
summarize: createSummarizer(llmProvider, cas),
|
||||
workflowConfig: cfg,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendStateForStep(params: {
|
||||
cas: CasStore;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: readonly string[];
|
||||
timestamp: number;
|
||||
childThread: string | null;
|
||||
}): 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,
|
||||
childThread: params.childThread,
|
||||
};
|
||||
const stateHash = await putStateNode(params.cas, payload);
|
||||
return {
|
||||
stateHash,
|
||||
chain: { parentStateHash: stateHash, parentAncestors: ancestors },
|
||||
};
|
||||
}
|
||||
|
||||
async function appendEndState(params: {
|
||||
cas: CasStore;
|
||||
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,
|
||||
childThread: null,
|
||||
};
|
||||
return putStateNode(params.cas, payload);
|
||||
}
|
||||
|
||||
async function finalizeThread(params: {
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
completion: WorkflowCompletion;
|
||||
}): Promise<WorkflowResult> {
|
||||
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: endHash,
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeAbortedThread(params: {
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
logger: LogFn;
|
||||
abortLogTag: string;
|
||||
}): Promise<WorkflowResult> {
|
||||
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeSupervisorHaltsThread(params: {
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
written: number;
|
||||
recentSupervisorSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
threadId: string;
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
}): Promise<WorkflowResult | null> {
|
||||
const interval = params.workflowConfig.supervisorInterval;
|
||||
if (interval <= 0 || params.written % interval !== 0) {
|
||||
return null;
|
||||
}
|
||||
const sup = await runSupervisor({
|
||||
config: params.workflowConfig,
|
||||
prompt: params.thread.start.content,
|
||||
recentSteps: params.recentSupervisorSteps,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!sup.ok) {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "kill") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} killed by supervisor`);
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 1, summary: "killed: supervisor detected pathological behavior" },
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
runtime: WorkflowRuntime;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
threadId: string;
|
||||
logger: LogFn;
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
startHash: string;
|
||||
chain: ChainState;
|
||||
summarize: SummarizeFn;
|
||||
}): Promise<WorkflowResult> {
|
||||
const {
|
||||
fn,
|
||||
workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions,
|
||||
threadId,
|
||||
logger,
|
||||
cas,
|
||||
bundleDir,
|
||||
startHash,
|
||||
summarize,
|
||||
} = params;
|
||||
let chain: ChainState = params.chain;
|
||||
const gen = fn(thread, runtime);
|
||||
let written = 0;
|
||||
const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({
|
||||
role: s.role,
|
||||
summary: JSON.stringify(s.meta),
|
||||
}));
|
||||
const summarizerSteps: { role: string; contentHash: string }[] = thread.steps.map((s) => ({
|
||||
role: s.role,
|
||||
contentHash: s.contentHash,
|
||||
}));
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP2",
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await Promise.race([
|
||||
gen.next(),
|
||||
new Promise<never>((_, reject) => {
|
||||
if (executeOptions.signal.aborted) {
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
executeOptions.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]).catch((e) => {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
return { done: true as const, value: { returnCode: 130, summary: "thread aborted" } };
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (executeOptions.signal.aborted || (iterResult.done && iterResult.value.returnCode === 130)) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "H4KQ7RW3",
|
||||
});
|
||||
}
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
const rawCompletion = iterResult.value;
|
||||
const llmSummary = await summarize({
|
||||
prompt: thread.start.content,
|
||||
recentSteps: summarizerSteps,
|
||||
fallback: rawCompletion.summary,
|
||||
logger,
|
||||
});
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: { ...rawCompletion, summary: llmSummary },
|
||||
});
|
||||
}
|
||||
|
||||
written++;
|
||||
const step = iterResult.value;
|
||||
const ts = Date.now();
|
||||
const written_ = await appendStateForStep({
|
||||
cas,
|
||||
startHash,
|
||||
chain,
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
timestamp: ts,
|
||||
childThread: step.childThread ?? null,
|
||||
});
|
||||
chain = written_.chain;
|
||||
await publishHead({ bundleDir, threadId, startHash, headHash: written_.stateHash });
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
recentSupervisorSteps.push({
|
||||
role: step.role,
|
||||
summary: JSON.stringify(step.meta),
|
||||
});
|
||||
summarizerSteps.push({ role: step.role, contentHash: step.contentHash });
|
||||
|
||||
await Promise.race([
|
||||
executeOptions.awaitAfterEachYield(),
|
||||
new Promise<void>((resolve) => {
|
||||
if (executeOptions.signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
executeOptions.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
]);
|
||||
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP4",
|
||||
});
|
||||
}
|
||||
|
||||
const supervised = await maybeSupervisorHaltsThread({
|
||||
workflowConfig,
|
||||
thread,
|
||||
written,
|
||||
recentSupervisorSteps,
|
||||
logger,
|
||||
threadId,
|
||||
cas,
|
||||
bundleDir,
|
||||
startHash,
|
||||
chain,
|
||||
});
|
||||
if (supervised !== null) {
|
||||
return supervised;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* - 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,
|
||||
workflowName: string,
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: ExecuteThreadOptions,
|
||||
io: ExecuteThreadIo,
|
||||
logger: LogFn,
|
||||
): Promise<WorkflowResult> {
|
||||
await mkdir(dirname(io.infoJsonlPath), { recursive: true });
|
||||
|
||||
const prefilled = options.prefilledDiskSteps;
|
||||
const fork = options.forkContinuation;
|
||||
|
||||
if (fork !== null && prefilled !== null) {
|
||||
throw new Error("forkContinuation and prefilledDiskSteps cannot both be set");
|
||||
}
|
||||
|
||||
if (prefilled !== null && prefilled.length !== input.steps.length) {
|
||||
throw new Error(
|
||||
`prefilledDiskSteps length (${prefilled.length}) must match input.steps length (${input.steps.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const replayTs = options.replayTimestamps;
|
||||
if (replayTs !== null && replayTs.length !== input.steps.length) {
|
||||
throw new Error(
|
||||
`replayTimestamps length (${replayTs.length}) must match input.steps length (${input.steps.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const bundleDir = getBundleDir(options.storageRoot, io.hash);
|
||||
|
||||
let startHash: string;
|
||||
|
||||
if (fork !== null) {
|
||||
startHash = fork.startHash;
|
||||
logger("T9HQ2KHM", `thread ${io.threadId} continued fork for workflow ${workflowName}`);
|
||||
} else {
|
||||
const promptHash = await io.cas.put(input.prompt);
|
||||
startHash = await putStartNode(
|
||||
io.cas,
|
||||
{
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
depth: options.depth,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await publishHead({
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
headHash: startHash,
|
||||
});
|
||||
|
||||
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`);
|
||||
}
|
||||
|
||||
let chain: ChainState = fork !== null ? fork.initialChain : EMPTY_CHAIN_STATE;
|
||||
|
||||
if (prefilled !== null) {
|
||||
for (const row of prefilled) {
|
||||
const written = await appendStateForStep({
|
||||
cas: io.cas,
|
||||
startHash,
|
||||
chain,
|
||||
role: row.role,
|
||||
contentHash: row.contentHash,
|
||||
meta: row.meta,
|
||||
refs: row.refs,
|
||||
timestamp: row.timestamp,
|
||||
childThread: null,
|
||||
});
|
||||
chain = written.chain;
|
||||
await publishHead({
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
headHash: written.stateHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
}
|
||||
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
depth: options.depth,
|
||||
bundleHash: io.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||
})),
|
||||
};
|
||||
|
||||
const runtime: WorkflowRuntime = {
|
||||
cas: io.cas,
|
||||
extract: registryRuntime.value.extract,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowConfig: registryRuntime.value.workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions: options,
|
||||
threadId: io.threadId,
|
||||
logger,
|
||||
cas: io.cas,
|
||||
bundleDir,
|
||||
startHash,
|
||||
chain,
|
||||
summarize: registryRuntime.value.summarize,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import { parseCasThreadNode, putContentNodeWithRefs, putStateNode } from "@uncaged/workflow-cas";
|
||||
import type { StateNodePayload } from "@uncaged/workflow-protocol";
|
||||
import type { RoleOutput, WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { upsertThreadEntry } from "./threads-index.js";
|
||||
import type { CasForkPlan, ChainState, ForkContinuationOptions } from "./types.js";
|
||||
import { EMPTY_CHAIN_STATE } from "./types.js";
|
||||
|
||||
/** Internal branch marker; skipped when presenting fork selection / replay slices. */
|
||||
export const FORK_BRANCH_ROLE = "__fork__";
|
||||
|
||||
/** Cap for {@link StateNodePayload}.ancestors: 1 parent + 10 skip-list. */
|
||||
const ANCESTORS_CAP = 11;
|
||||
|
||||
function computeAncestors(chain: ChainState): string[] {
|
||||
if (chain.parentStateHash === null) {
|
||||
return [];
|
||||
}
|
||||
return [chain.parentStateHash, ...chain.parentAncestors].slice(0, ANCESTORS_CAP);
|
||||
}
|
||||
|
||||
/** Recognizes a persisted workflow completion line (no `role`; has numeric `returnCode` and string `summary`). */
|
||||
export function tryParseWorkflowResultRecord(
|
||||
obj: Record<string, unknown>,
|
||||
): WorkflowCompletion | null {
|
||||
if (obj.role !== undefined) {
|
||||
return null;
|
||||
}
|
||||
const returnCode = obj.returnCode;
|
||||
const summary = obj.summary;
|
||||
if (typeof returnCode !== "number" || typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { returnCode, summary };
|
||||
}
|
||||
|
||||
/** Walk {@link StateNode} hashes from head toward the first step (newest → oldest). */
|
||||
export async function walkStateFramesNewestFirst(
|
||||
cas: CasStore,
|
||||
headHash: string,
|
||||
): Promise<Array<{ hash: string; payload: StateNodePayload }>> {
|
||||
const frames: Array<{ hash: string; payload: StateNodePayload }> = [];
|
||||
let cur = headHash;
|
||||
while (true) {
|
||||
const yamlText = await cas.get(cur);
|
||||
if (yamlText === null) {
|
||||
break;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "state") {
|
||||
break;
|
||||
}
|
||||
frames.push({ hash: cur, payload: parsed.node.payload });
|
||||
const ancestors = parsed.node.payload.ancestors;
|
||||
if (ancestors.length === 0) {
|
||||
break;
|
||||
}
|
||||
const parent = ancestors[0];
|
||||
if (parent === undefined || parent === "") {
|
||||
break;
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function orderedUniqueRoles(roles: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const r of roles) {
|
||||
if (!seen.has(r)) {
|
||||
seen.add(r);
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readPromptText(cas: CasStore, promptHash: string): Promise<Result<string, string>> {
|
||||
const yamlText = await cas.get(promptHash);
|
||||
if (yamlText === null) {
|
||||
return err(`prompt CAS blob missing: ${promptHash}`);
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parseYaml(yamlText) as unknown;
|
||||
} catch {
|
||||
return err(`prompt CAS blob is not valid YAML: ${promptHash}`);
|
||||
}
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(`prompt CAS blob has unexpected shape: ${promptHash}`);
|
||||
}
|
||||
const payload = (raw as Record<string, unknown>).payload;
|
||||
if (typeof payload !== "string") {
|
||||
return err(`prompt CAS blob missing string payload: ${promptHash}`);
|
||||
}
|
||||
return ok(payload);
|
||||
}
|
||||
|
||||
async function readStartWorkflowIdentity(params: {
|
||||
cas: CasStore;
|
||||
startHash: string;
|
||||
}): Promise<Result<{ workflowName: string; depth: number; prompt: string }, string>> {
|
||||
const yamlText = await params.cas.get(params.startHash);
|
||||
if (yamlText === null) {
|
||||
return err(`start node missing in CAS: ${params.startHash}`);
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return err(`CAS blob is not a StartNode: ${params.startHash}`);
|
||||
}
|
||||
const refs = parsed.node.refs;
|
||||
const promptHash = refs[0];
|
||||
if (typeof promptHash !== "string") {
|
||||
return err("StartNode refs[0] must be the prompt hash");
|
||||
}
|
||||
const prompt = await readPromptText(params.cas, promptHash);
|
||||
if (!prompt.ok) {
|
||||
return prompt;
|
||||
}
|
||||
const p = parsed.node.payload;
|
||||
return ok({
|
||||
workflowName: p.name,
|
||||
depth: p.depth,
|
||||
prompt: prompt.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function payloadToRoleOutput(cas: CasStore, payload: StateNodePayload): Promise<RoleOutput> {
|
||||
let refs: string[] = [];
|
||||
const blob = await cas.get(payload.content);
|
||||
if (blob !== null) {
|
||||
const cn = parseCasThreadNode(blob);
|
||||
if (cn?.kind === "content") {
|
||||
refs = [...cn.node.refs];
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: payload.role,
|
||||
contentHash: payload.content,
|
||||
meta: payload.meta,
|
||||
refs,
|
||||
childThread: payload.childThread,
|
||||
};
|
||||
}
|
||||
|
||||
function meaningfulFramesOldestFirst(
|
||||
newestFirst: Array<{ hash: string; payload: StateNodePayload }>,
|
||||
): Array<{ hash: string; payload: StateNodePayload }> {
|
||||
const chronological = [...newestFirst].reverse();
|
||||
return chronological.filter((f) => f.payload.role !== END && f.payload.role !== FORK_BRANCH_ROLE);
|
||||
}
|
||||
|
||||
function selectForkPointStateHash(
|
||||
meaningfulOldestFirst: Array<{ hash: string; payload: StateNodePayload }>,
|
||||
fromRole: string | null,
|
||||
): Result<string | null, string> {
|
||||
if (meaningfulOldestFirst.length === 0) {
|
||||
return err("thread has no completed role steps to fork from");
|
||||
}
|
||||
|
||||
if (fromRole === null) {
|
||||
if (meaningfulOldestFirst.length === 1) {
|
||||
return ok(null);
|
||||
}
|
||||
const forkFrame = meaningfulOldestFirst[meaningfulOldestFirst.length - 2];
|
||||
if (forkFrame === undefined) {
|
||||
return err("thread has no completed role steps to fork from");
|
||||
}
|
||||
return ok(forkFrame.hash);
|
||||
}
|
||||
|
||||
const idx = meaningfulOldestFirst.findIndex((f) => f.payload.role === fromRole);
|
||||
if (idx < 0) {
|
||||
const available = orderedUniqueRoles(meaningfulOldestFirst.map((f) => f.payload.role));
|
||||
return err(`role not found in thread: ${fromRole} (available: ${available.join(", ")})`);
|
||||
}
|
||||
const forkFrame = meaningfulOldestFirst[idx];
|
||||
if (forkFrame === undefined) {
|
||||
return err("fork frame missing");
|
||||
}
|
||||
return ok(forkFrame.hash);
|
||||
}
|
||||
|
||||
function replayFramesThroughForkPoint(
|
||||
meaningfulOldestFirst: Array<{ hash: string; payload: StateNodePayload }>,
|
||||
forkPointHash: string | null,
|
||||
): Array<{ hash: string; payload: StateNodePayload }> {
|
||||
if (forkPointHash === null) {
|
||||
return [];
|
||||
}
|
||||
const idx = meaningfulOldestFirst.findIndex((f) => f.hash === forkPointHash);
|
||||
if (idx < 0) {
|
||||
return [];
|
||||
}
|
||||
return meaningfulOldestFirst.slice(0, idx + 1);
|
||||
}
|
||||
|
||||
async function buildForkContinuation(params: {
|
||||
cas: CasStore;
|
||||
sourceThreadId: string;
|
||||
startHash: string;
|
||||
forkPointStateHash: string | null;
|
||||
}): Promise<Result<ForkContinuationOptions, string>> {
|
||||
const { cas, sourceThreadId, startHash, forkPointStateHash } = params;
|
||||
|
||||
if (forkPointStateHash === null) {
|
||||
return ok({
|
||||
startHash,
|
||||
forkHeadHash: startHash,
|
||||
initialChain: EMPTY_CHAIN_STATE,
|
||||
});
|
||||
}
|
||||
|
||||
const yamlText = await cas.get(forkPointStateHash);
|
||||
if (yamlText === null) {
|
||||
return err(`fork point state missing in CAS: ${forkPointStateHash}`);
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "state") {
|
||||
return err(`fork point blob is not a StateNode: ${forkPointStateHash}`);
|
||||
}
|
||||
const fpPayload = parsed.node.payload;
|
||||
|
||||
const chainBefore: ChainState = {
|
||||
parentStateHash: forkPointStateHash,
|
||||
parentAncestors: fpPayload.ancestors,
|
||||
};
|
||||
const ancestorsMarker = computeAncestors(chainBefore);
|
||||
|
||||
const emptyContentHash = await putContentNodeWithRefs(cas, "", []);
|
||||
const markerPayload: StateNodePayload = {
|
||||
role: FORK_BRANCH_ROLE,
|
||||
meta: { forkFrom: sourceThreadId },
|
||||
start: startHash,
|
||||
content: emptyContentHash,
|
||||
ancestors: ancestorsMarker,
|
||||
compact: null,
|
||||
timestamp: Date.now(),
|
||||
childThread: null,
|
||||
};
|
||||
const markerHash = await putStateNode(cas, markerPayload);
|
||||
|
||||
const initialChain: ChainState = {
|
||||
parentStateHash: markerHash,
|
||||
parentAncestors: ancestorsMarker,
|
||||
};
|
||||
|
||||
return ok({
|
||||
startHash,
|
||||
forkHeadHash: markerHash,
|
||||
initialChain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a CAS fork: writes the branch marker {@link StateNode}, registers `threads.json`,
|
||||
* and returns worker payload fields (shared {@link StartNode}, zero ancestor duplication).
|
||||
*/
|
||||
export async function prepareCasFork(params: {
|
||||
cas: CasStore;
|
||||
bundleDir: string;
|
||||
bundleHash: string;
|
||||
sourceThreadId: string;
|
||||
headHash: string;
|
||||
startHash: string;
|
||||
newThreadId: string;
|
||||
fromRole: string | null;
|
||||
}): Promise<Result<CasForkPlan, string>> {
|
||||
const id = await readStartWorkflowIdentity({
|
||||
cas: params.cas,
|
||||
startHash: params.startHash,
|
||||
});
|
||||
if (!id.ok) {
|
||||
return id;
|
||||
}
|
||||
|
||||
const newestFirst = await walkStateFramesNewestFirst(params.cas, params.headHash);
|
||||
const meaningful = meaningfulFramesOldestFirst(newestFirst);
|
||||
|
||||
const forkPoint = selectForkPointStateHash(meaningful, params.fromRole);
|
||||
if (!forkPoint.ok) {
|
||||
return forkPoint;
|
||||
}
|
||||
|
||||
const replayFrames = replayFramesThroughForkPoint(meaningful, forkPoint.value);
|
||||
const steps: RoleOutput[] = [];
|
||||
const stepTimestamps: number[] = [];
|
||||
for (const fr of replayFrames) {
|
||||
steps.push(await payloadToRoleOutput(params.cas, fr.payload));
|
||||
stepTimestamps.push(fr.payload.timestamp);
|
||||
}
|
||||
|
||||
const cont = await buildForkContinuation({
|
||||
cas: params.cas,
|
||||
sourceThreadId: params.sourceThreadId,
|
||||
startHash: params.startHash,
|
||||
forkPointStateHash: forkPoint.value,
|
||||
});
|
||||
if (!cont.ok) {
|
||||
return cont;
|
||||
}
|
||||
|
||||
await upsertThreadEntry(params.bundleDir, params.newThreadId, {
|
||||
head: cont.value.forkHeadHash,
|
||||
start: params.startHash,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return ok({
|
||||
workflowName: id.value.workflowName,
|
||||
hash: params.bundleHash,
|
||||
sourceThreadId: params.sourceThreadId,
|
||||
prompt: id.value.prompt,
|
||||
runOptions: { depth: id.value.depth },
|
||||
steps,
|
||||
stepTimestamps,
|
||||
forkContinuation: cont.value,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { type CasStore, createCasStore, findReachableHashes } from "@uncaged/workflow-cas";
|
||||
import { err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { ThreadHistoryEntry, ThreadIndex } from "./threads-index.js";
|
||||
import { readThreadsIndex } from "./threads-index.js";
|
||||
import type { GcResult } from "./types.js";
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function parseHistoryLine(jsonLine: string): ThreadHistoryEntry | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(jsonLine) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPlainObject(raw)) {
|
||||
return null;
|
||||
}
|
||||
const threadId = raw.threadId;
|
||||
const head = raw.head;
|
||||
const start = raw.start;
|
||||
const completedAt = raw.completedAt;
|
||||
if (
|
||||
typeof threadId !== "string" ||
|
||||
typeof head !== "string" ||
|
||||
typeof start !== "string" ||
|
||||
typeof completedAt !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { threadId, head, start, completedAt };
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walks threads index + optional history dir
|
||||
async function collectGcRootsFromBundle(bundleDir: string): Promise<Result<string[], string>> {
|
||||
const roots: string[] = [];
|
||||
|
||||
let activeIndex: ThreadIndex;
|
||||
try {
|
||||
activeIndex = await readThreadsIndex(bundleDir);
|
||||
} catch (e) {
|
||||
return err(`failed to read threads.json under ${bundleDir}: ${String(e)}`);
|
||||
}
|
||||
|
||||
for (const entry of Object.values(activeIndex)) {
|
||||
roots.push(entry.head);
|
||||
roots.push(entry.start);
|
||||
}
|
||||
|
||||
const histDir = join(bundleDir, "history");
|
||||
let histFiles: string[];
|
||||
try {
|
||||
histFiles = await readdir(histDir);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return ok(roots);
|
||||
}
|
||||
return err(`failed to read history directory ${histDir}: ${String(e)}`);
|
||||
}
|
||||
|
||||
for (const name of histFiles) {
|
||||
if (!name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(join(histDir, name), "utf8");
|
||||
} catch (e) {
|
||||
return err(`failed to read history file ${name}: ${String(e)}`);
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
const entry = parseHistoryLine(trimmed);
|
||||
if (entry === null) {
|
||||
continue;
|
||||
}
|
||||
roots.push(entry.head);
|
||||
roots.push(entry.start);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(roots);
|
||||
}
|
||||
|
||||
async function collectAllGcRoots(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const bundlesRoot = join(storageRoot, "bundles");
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(bundlesRoot);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return ok([]);
|
||||
}
|
||||
return err(`failed to read bundles directory: ${String(e)}`);
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
for (const name of entries) {
|
||||
const bundleDir = join(bundlesRoot, name);
|
||||
let st: Stats;
|
||||
try {
|
||||
st = await stat(bundleDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const chunk = await collectGcRootsFromBundle(bundleDir);
|
||||
if (!chunk.ok) {
|
||||
return chunk;
|
||||
}
|
||||
roots.push(...chunk.value);
|
||||
}
|
||||
|
||||
return ok(roots);
|
||||
}
|
||||
|
||||
async function deleteCasNotMarked(cas: CasStore, marked: ReadonlySet<string>): Promise<string[]> {
|
||||
let listed: string[];
|
||||
try {
|
||||
listed = await cas.list();
|
||||
} catch (e) {
|
||||
throw new Error(`failed to list cas entries: ${String(e)}`);
|
||||
}
|
||||
|
||||
const deletedHashes: string[] = [];
|
||||
for (const hash of listed) {
|
||||
if (marked.has(hash)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await cas.delete(hash);
|
||||
} catch (e) {
|
||||
throw new Error(`failed to delete cas ${hash}: ${String(e)}`);
|
||||
}
|
||||
deletedHashes.push(hash);
|
||||
}
|
||||
|
||||
deletedHashes.sort();
|
||||
return deletedHashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark-and-sweep CAS GC: roots are every `head` / `start` hash from `threads.json` and
|
||||
* `history/*.jsonl` across bundle dirs; marks closure via `refs[]`; deletes unreachable blobs.
|
||||
*/
|
||||
export async function garbageCollectCas(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
const rootsResult = await collectAllGcRoots(storageRoot);
|
||||
if (!rootsResult.ok) {
|
||||
return rootsResult;
|
||||
}
|
||||
const roots = rootsResult.value;
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const marked = await findReachableHashes(roots, cas);
|
||||
|
||||
let deletedHashes: string[];
|
||||
try {
|
||||
deletedHashes = await deleteCasNotMarked(cas, marked);
|
||||
} catch (e) {
|
||||
return err(String(e));
|
||||
}
|
||||
|
||||
return ok({
|
||||
scannedThreads: roots.length,
|
||||
activeRefs: marked.size,
|
||||
deletedEntries: deletedHashes.length,
|
||||
deletedHashes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { executeThread } from "./engine.js";
|
||||
export {
|
||||
FORK_BRANCH_ROLE,
|
||||
prepareCasFork,
|
||||
tryParseWorkflowResultRecord,
|
||||
walkStateFramesNewestFirst,
|
||||
} 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,
|
||||
readThreadsIndex,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
upsertThreadEntry,
|
||||
writeThreadsIndex,
|
||||
} from "./threads-index.js";
|
||||
export type {
|
||||
CasForkPlan,
|
||||
ChainState,
|
||||
ExecuteThreadIo,
|
||||
ExecuteThreadOptions,
|
||||
ForkContinuationOptions,
|
||||
GcResult,
|
||||
PrefilledDiskStep,
|
||||
SupervisorDecision,
|
||||
ThreadPauseGate,
|
||||
} from "./types.js";
|
||||
export { EMPTY_CHAIN_STATE } from "./types.js";
|
||||
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasReactor } from "../cas-reactor.js";
|
||||
|
||||
/** Max ReAct rounds: 3 cas_get reads + 1 structured output = 4 rounds is sufficient. */
|
||||
const SUMMARIZER_MAX_REACT_ROUNDS = 4;
|
||||
/** Only pass the last N steps; each step is just a role+contentHash reference (~60 chars), not full content. */
|
||||
const SUMMARIZER_RECENT_STEP_LIMIT = 20;
|
||||
|
||||
const summarySchema = z.object({ summary: z.string() }).meta({
|
||||
title: "workflow_summary",
|
||||
description: "A concise summary of the completed workflow's results and outcome.",
|
||||
});
|
||||
|
||||
function buildSummarizerInput(args: {
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; contentHash: string }[];
|
||||
}): string {
|
||||
const recent = args.recentSteps.slice(-SUMMARIZER_RECENT_STEP_LIMIT);
|
||||
const stepsBlock = recent
|
||||
.map((s, i) => `${i + 1}. [${s.role}] contentHash: ${s.contentHash}`)
|
||||
.join("\n");
|
||||
return `Original task:\n${args.prompt}\n\nCompleted steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}\n\nUse cas_get to read step content if needed. Summarize the workflow outcome concisely.`;
|
||||
}
|
||||
|
||||
export type SummarizeFn = (args: {
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; contentHash: string }[];
|
||||
fallback: string;
|
||||
logger: LogFn;
|
||||
}) => Promise<string>;
|
||||
|
||||
export function createSummarizer(provider: LlmProvider, cas: CasStore): SummarizeFn {
|
||||
const reactor = createCasReactor(provider, cas, {
|
||||
maxRounds: SUMMARIZER_MAX_REACT_ROUNDS,
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You summarize completed workflow threads. You have access to cas_get to read step content by hash. After reviewing the steps, call the ${structuredToolName} tool with a concise summary of the workflow outcome and results. Or reply with only a JSON object such as {"summary":"..."}.`,
|
||||
});
|
||||
|
||||
return async (args) => {
|
||||
const result = await reactor({
|
||||
thread: { cas },
|
||||
input: buildSummarizerInput(args),
|
||||
schema: summarySchema,
|
||||
});
|
||||
if (!result.ok) {
|
||||
args.logger("P2WX7KNR", `summarizer failed: ${result.error}`);
|
||||
return args.fallback;
|
||||
}
|
||||
args.logger("Q5MT3VBF", "summarizer produced workflow summary");
|
||||
return result.value.summary;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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";
|
||||
|
||||
const SUPERVISOR_RECENT_STEP_LIMIT = 12;
|
||||
const SUPERVISOR_MAX_REACT_ROUNDS = 4;
|
||||
|
||||
const supervisorDecisionSchema = z
|
||||
.object({
|
||||
decision: z.enum(["continue", "kill"]),
|
||||
})
|
||||
.meta({
|
||||
title: "supervisor_decision",
|
||||
description:
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress or following its normal role sequence; "kill" only when the thread is stuck in an infinite loop, producing no meaningful progress, or has gone off the rails. Normal workflow completion is handled by the moderator — the supervisor should NOT kill a thread just because it looks done.',
|
||||
});
|
||||
|
||||
type SupervisorThreadContext = Record<string, never>;
|
||||
|
||||
type RunSupervisorArgs = {
|
||||
config: WorkflowConfig;
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
};
|
||||
|
||||
function buildSupervisorInput(args: RunSupervisorArgs): string {
|
||||
const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT);
|
||||
const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n");
|
||||
return `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`;
|
||||
}
|
||||
|
||||
/** Calls the `supervisor` scene via {@link createThreadReactor}; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */
|
||||
export async function runSupervisor(
|
||||
args: RunSupervisorArgs,
|
||||
): Promise<Result<SupervisorDecision, string>> {
|
||||
const resolved = resolveModel(args.config, "supervisor");
|
||||
if (!resolved.ok) {
|
||||
return ok("continue");
|
||||
}
|
||||
|
||||
const reactor = createThreadReactor<SupervisorThreadContext>({
|
||||
llm: createLlmFn(resolved.value),
|
||||
maxRounds: SUPERVISOR_MAX_REACT_ROUNDS,
|
||||
staticTools: [],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You supervise a multi-step workflow. Your job is to detect pathological situations — NOT to decide when the workflow is "done" (that is the moderator's job). Reply with "continue" when the thread is making progress or following its normal role sequence. Reply with "kill" ONLY when the thread is stuck in an infinite loop, producing repetitive/meaningless output, or has clearly gone off the rails. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"continue"}.`,
|
||||
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
|
||||
});
|
||||
|
||||
const result = await reactor({
|
||||
thread: {} as SupervisorThreadContext,
|
||||
input: buildSupervisorInput(args),
|
||||
schema: supervisorDecisionSchema,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
args.logger("R9CW4PHM", `supervisor failed: ${result.error}`);
|
||||
return err(`supervisor: ${result.error}`);
|
||||
}
|
||||
|
||||
const decision: SupervisorDecision = result.value.decision;
|
||||
args.logger("Z8KM5QWT", `supervisor says ${decision}`);
|
||||
return ok(decision);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { ThreadPauseGate } from "./types.js";
|
||||
|
||||
/**
|
||||
* Pause/resume gate for workflow threads: after each generator yield the engine awaits
|
||||
* {@link ThreadPauseGate.awaitAfterYield}. Calling {@link ThreadPauseGate.pause} makes the next
|
||||
* await block until {@link ThreadPauseGate.resume}.
|
||||
*/
|
||||
export function createThreadPauseGate(): ThreadPauseGate {
|
||||
let resumeResolver: (() => void) | null = null;
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
let paused = false;
|
||||
|
||||
function awaitAfterYield(): Promise<void> {
|
||||
return chain;
|
||||
}
|
||||
|
||||
function pause(): Result<void, string> {
|
||||
if (paused) {
|
||||
return err("thread already paused");
|
||||
}
|
||||
paused = true;
|
||||
chain = new Promise<void>((resolve) => {
|
||||
resumeResolver = resolve;
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function resume(): Result<void, string> {
|
||||
if (!paused) {
|
||||
return err("thread not paused");
|
||||
}
|
||||
paused = false;
|
||||
const resolveFn = resumeResolver;
|
||||
resumeResolver = null;
|
||||
if (resolveFn !== null) {
|
||||
resolveFn();
|
||||
}
|
||||
chain = Promise.resolve();
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function isPaused(): boolean {
|
||||
return paused;
|
||||
}
|
||||
|
||||
return { awaitAfterYield, pause, resume, isPaused };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { appendFile, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Read `<bundleDir>/threads.json` (empty object when missing or invalid). */
|
||||
export async function readThreadsIndex(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);
|
||||
}
|
||||
|
||||
export async function writeThreadsIndex(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 readThreadsIndex(bundleDir);
|
||||
index[threadId] = entry;
|
||||
await writeThreadsIndex(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 readThreadsIndex(bundleDir);
|
||||
if (!(threadId in index)) {
|
||||
return;
|
||||
}
|
||||
delete index[threadId];
|
||||
await writeThreadsIndex(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");
|
||||
}
|
||||
|
||||
/** Removes every `history/*.jsonl` line whose `threadId` matches (rewrite files in place). */
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-file JSONL filtering keeps RM deterministic
|
||||
export async function removeThreadHistoryEntries(
|
||||
bundleDir: string,
|
||||
threadId: string,
|
||||
): Promise<Result<number, string>> {
|
||||
const histRoot = join(bundleDir, "history");
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(histRoot);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return ok(0);
|
||||
}
|
||||
return err(`failed to read history directory: ${String(e)}`);
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const name of files) {
|
||||
if (!name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const path = join(histRoot, name);
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const kept: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
kept.push(`${trimmed}\n`);
|
||||
continue;
|
||||
}
|
||||
if (rec === null || typeof rec !== "object") {
|
||||
kept.push(`${trimmed}\n`);
|
||||
continue;
|
||||
}
|
||||
const id = (rec as Record<string, unknown>).threadId;
|
||||
if (id === threadId) {
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
kept.push(`${trimmed}\n`);
|
||||
}
|
||||
await writeFile(path, kept.join(""), "utf8");
|
||||
}
|
||||
|
||||
return ok(removed);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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" | "kill";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
infoJsonlPath: string;
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
/** CAS chain tail state before the next appended {@link StateNode}. */
|
||||
export type ChainState = {
|
||||
parentStateHash: string | null;
|
||||
parentAncestors: readonly string[];
|
||||
};
|
||||
|
||||
export const EMPTY_CHAIN_STATE: ChainState = { parentStateHash: null, parentAncestors: [] };
|
||||
|
||||
/**
|
||||
* When forking, the worker continues from an existing {@link StartNode} plus an optional
|
||||
* branch marker {@link StateNode} instead of allocating a new start blob.
|
||||
*/
|
||||
export type ForkContinuationOptions = {
|
||||
startHash: string;
|
||||
forkHeadHash: string;
|
||||
initialChain: ChainState;
|
||||
};
|
||||
|
||||
/** One replayed role step (prefill) before the generator runs (same layout as disk replay rows). */
|
||||
export type PrefilledDiskStep = {
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ExecuteThreadOptions = {
|
||||
/** Passed to the bundle thread context as `ThreadContext.depth`. */
|
||||
depth: number;
|
||||
/** Parent thread's head state hash at spawn time; `null` for top-level threads. */
|
||||
parentStateHash: string | null;
|
||||
signal: AbortSignal;
|
||||
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
|
||||
awaitAfterEachYield: () => Promise<void>;
|
||||
/** When non-null, written into the start record so tooling can trace lineage. */
|
||||
forkSourceThreadId: string | null;
|
||||
/**
|
||||
* When non-null, replays these steps into CAS before the generator runs.
|
||||
* Must match `input.steps` length and order when present.
|
||||
*/
|
||||
prefilledDiskSteps: PrefilledDiskStep[] | null;
|
||||
/** When non-null, skip creating a new {@link StartNode} and continue this CAS chain. */
|
||||
forkContinuation: ForkContinuationOptions | null;
|
||||
/**
|
||||
* When non-null, must match `input.steps.length`; supplies persisted timestamps for
|
||||
* {@link ThreadContext.steps} (used when restoring history without prefilled CAS replay).
|
||||
*/
|
||||
replayTimestamps: readonly number[] | null;
|
||||
/** Workspace root containing `workflow.yaml`; used to resolve the `extract` scene for meta extraction. */
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
export type CasForkPlan = {
|
||||
workflowName: string;
|
||||
hash: string;
|
||||
sourceThreadId: string;
|
||||
prompt: string;
|
||||
runOptions: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
stepTimestamps: number[];
|
||||
forkContinuation: ForkContinuationOptions;
|
||||
};
|
||||
|
||||
export type GcResult = {
|
||||
/** Count of root hashes seeded from thread indexes (`head`/`start` per entry). */
|
||||
scannedThreads: number;
|
||||
/** Reachable CAS blobs after the mark phase. */
|
||||
activeRefs: number;
|
||||
deletedEntries: number;
|
||||
deletedHashes: string[];
|
||||
};
|
||||
|
||||
export type ThreadPauseGate = {
|
||||
awaitAfterYield: () => Promise<void>;
|
||||
pause: () => Result<void, string>;
|
||||
resume: () => Result<void, string>;
|
||||
isPaused: () => boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */
|
||||
export function getWorkerHostScriptPath(): string {
|
||||
return fileURLToPath(new URL("./worker.ts", import.meta.url));
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { importWorkflowBundleModule } from "@uncaged/workflow-register";
|
||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
err,
|
||||
getGlobalCasDir,
|
||||
normalizeRefsField,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow-util";
|
||||
import { executeThread } from "./engine.js";
|
||||
import { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
import type {
|
||||
ExecuteThreadIo,
|
||||
ForkContinuationOptions,
|
||||
PrefilledDiskStep,
|
||||
ThreadPauseGate,
|
||||
} from "./types.js";
|
||||
|
||||
const bootLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
type RunCommand = {
|
||||
type: "run";
|
||||
threadId: string;
|
||||
workflowName: string;
|
||||
prompt: string;
|
||||
options: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
/** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */
|
||||
stepTimestamps: number[] | null;
|
||||
forkSourceThreadId: string | null;
|
||||
forkContinuation: ForkContinuationOptions | null;
|
||||
};
|
||||
|
||||
type KillCommand = {
|
||||
type: "kill";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type PauseCommand = {
|
||||
type: "pause";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type ResumeCommand = {
|
||||
type: "resume";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type ControlCommand = RunCommand | KillCommand | PauseCommand | ResumeCommand;
|
||||
|
||||
type ThreadHandle = {
|
||||
abortController: AbortController;
|
||||
pauseGate: ThreadPauseGate;
|
||||
};
|
||||
|
||||
function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null {
|
||||
const role = obj.role;
|
||||
const contentHash = obj.contentHash;
|
||||
const meta = obj.meta;
|
||||
if (typeof role !== "string" || typeof contentHash !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
return null;
|
||||
}
|
||||
const childThread = obj.childThread;
|
||||
return {
|
||||
role,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
childThread: typeof childThread === "string" ? childThread : null,
|
||||
};
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: mirrors permissive worker IPC decoding shape checks
|
||||
function parseRunStepsPayload(rec: Record<string, unknown>): {
|
||||
steps: RoleOutput[];
|
||||
stepTimestamps: number[] | null;
|
||||
} | null {
|
||||
const raw = rec.steps;
|
||||
if (raw === undefined || raw === null) {
|
||||
return { steps: [], stepTimestamps: null };
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const steps: RoleOutput[] = [];
|
||||
const timestamps: number[] = [];
|
||||
let anyTimestamp = false;
|
||||
for (const item of raw) {
|
||||
if (item === null || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const o = item as Record<string, unknown>;
|
||||
const out = parseRoleOutputRecord(o);
|
||||
if (out === null) {
|
||||
return null;
|
||||
}
|
||||
steps.push(out);
|
||||
const ts = o.timestamp;
|
||||
if (ts === undefined) {
|
||||
timestamps.push(0);
|
||||
} else if (typeof ts === "number") {
|
||||
timestamps.push(ts);
|
||||
anyTimestamp = true;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const parallelTsRaw = rec.stepTimestamps;
|
||||
if (
|
||||
steps.length > 0 &&
|
||||
Array.isArray(parallelTsRaw) &&
|
||||
parallelTsRaw.length === steps.length &&
|
||||
parallelTsRaw.every((x): x is number => typeof x === "number")
|
||||
) {
|
||||
return { steps, stepTimestamps: [...parallelTsRaw] };
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
stepTimestamps: anyTimestamp ? timestamps : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseForkContinuation(rec: Record<string, unknown>): ForkContinuationOptions | null {
|
||||
const raw = rec.forkContinuation;
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const o = raw as Record<string, unknown>;
|
||||
const startHash = o.startHash;
|
||||
const forkHeadHash = o.forkHeadHash;
|
||||
const ic = o.initialChain;
|
||||
if (typeof startHash !== "string" || typeof forkHeadHash !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (ic === null || typeof ic !== "object") {
|
||||
return null;
|
||||
}
|
||||
const ich = ic as Record<string, unknown>;
|
||||
const pph = ich.parentStateHash;
|
||||
const pa = ich.parentAncestors;
|
||||
if (!(pph === null || typeof pph === "string")) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(pa) || !pa.every((x) => typeof x === "string")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
startHash,
|
||||
forkHeadHash,
|
||||
initialChain: {
|
||||
parentStateHash: pph,
|
||||
parentAncestors: pa,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null {
|
||||
const threadId = rec.threadId;
|
||||
const workflowName = rec.workflowName;
|
||||
const prompt = rec.prompt;
|
||||
const options = rec.options;
|
||||
if (
|
||||
typeof threadId !== "string" ||
|
||||
typeof workflowName !== "string" ||
|
||||
typeof prompt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (options === null || typeof options !== "object") {
|
||||
return null;
|
||||
}
|
||||
const optRec = options as Record<string, unknown>;
|
||||
const depthRaw = optRec.depth;
|
||||
const depth =
|
||||
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
|
||||
const parsedSteps = parseRunStepsPayload(rec);
|
||||
if (parsedSteps === null) {
|
||||
return null;
|
||||
}
|
||||
const rawFork = rec.forkSourceThreadId;
|
||||
let forkSourceThreadId: string | null = null;
|
||||
if (rawFork !== undefined && rawFork !== null) {
|
||||
if (typeof rawFork !== "string" || rawFork === "") {
|
||||
return null;
|
||||
}
|
||||
forkSourceThreadId = rawFork;
|
||||
}
|
||||
const forkContinuation = parseForkContinuation(rec);
|
||||
return {
|
||||
type: "run",
|
||||
threadId,
|
||||
workflowName,
|
||||
prompt,
|
||||
options: { depth },
|
||||
steps: parsedSteps.steps,
|
||||
stepTimestamps: parsedSteps.stepTimestamps,
|
||||
forkSourceThreadId,
|
||||
forkContinuation,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLifecycleThreadPayload(
|
||||
rec: Record<string, unknown>,
|
||||
): KillCommand | PauseCommand | ResumeCommand | null {
|
||||
const type = rec.type;
|
||||
const threadId = rec.threadId;
|
||||
if (typeof threadId !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (type === "kill") {
|
||||
return { type: "kill", threadId };
|
||||
}
|
||||
if (type === "pause") {
|
||||
return { type: "pause", threadId };
|
||||
}
|
||||
if (type === "resume") {
|
||||
return { type: "resume", threadId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseControlPayload(payload: unknown): ControlCommand | null {
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rec = payload as Record<string, unknown>;
|
||||
const lifecycle = parseLifecycleThreadPayload(rec);
|
||||
if (lifecycle !== null) {
|
||||
return lifecycle;
|
||||
}
|
||||
if (rec.type === "run") {
|
||||
return parseRunControlPayload(rec);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCommandLine(line: string): ControlCommand | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
bootLog("S8KQ3WJP", "worker received invalid JSON control line");
|
||||
return null;
|
||||
}
|
||||
return parseControlPayload(parsed);
|
||||
}
|
||||
|
||||
function isWorkflowFnLike(value: unknown): value is WorkflowFn {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function writeTcpResponse(socket: Socket | null, result: Result<void, string>): void {
|
||||
if (socket === null) {
|
||||
return;
|
||||
}
|
||||
const body = result.ok ? { ok: true as const } : { ok: false as const, error: result.error };
|
||||
socket.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function dispatchThreadLifecycleCommand(
|
||||
threads: Map<string, ThreadHandle>,
|
||||
socket: Socket | null,
|
||||
cmd: KillCommand | PauseCommand | ResumeCommand,
|
||||
): void {
|
||||
const handle = threads.get(cmd.threadId);
|
||||
if (handle === undefined) {
|
||||
writeTcpResponse(socket, err(`thread not found: ${cmd.threadId}`));
|
||||
return;
|
||||
}
|
||||
switch (cmd.type) {
|
||||
case "kill":
|
||||
handle.abortController.abort();
|
||||
bootLog("P9XK2WNQ", `kill requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
case "pause": {
|
||||
const paused = handle.pauseGate.pause();
|
||||
if (!paused.ok) {
|
||||
writeTcpResponse(socket, paused);
|
||||
return;
|
||||
}
|
||||
bootLog("K7WQ2NXP", `pause requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
}
|
||||
case "resume": {
|
||||
const resumed = handle.pauseGate.resume();
|
||||
if (!resumed.ok) {
|
||||
writeTcpResponse(socket, resumed);
|
||||
return;
|
||||
}
|
||||
bootLog("M4YT8HKR", `resume requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readLineFromSocket(socket: Socket): Promise<string | null> {
|
||||
return await new Promise((resolve) => {
|
||||
let buf = "";
|
||||
function onData(chunk: Buffer): void {
|
||||
buf += chunk.toString("utf8");
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl >= 0) {
|
||||
cleanup();
|
||||
resolve(buf.slice(0, nl));
|
||||
}
|
||||
}
|
||||
function onEnd(): void {
|
||||
cleanup();
|
||||
resolve(buf === "" ? null : buf);
|
||||
}
|
||||
function onError(): void {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
function cleanup(): void {
|
||||
socket.off("data", onData);
|
||||
socket.off("end", onEnd);
|
||||
socket.off("error", onError);
|
||||
}
|
||||
socket.on("data", onData);
|
||||
socket.on("end", onEnd);
|
||||
socket.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const bundlePath = process.argv[2];
|
||||
const storageRoot = process.argv[3];
|
||||
const hash = process.argv[4];
|
||||
|
||||
if (
|
||||
bundlePath === undefined ||
|
||||
storageRoot === undefined ||
|
||||
hash === undefined ||
|
||||
bundlePath === "" ||
|
||||
storageRoot === "" ||
|
||||
hash === ""
|
||||
) {
|
||||
bootLog("H7XN4MKQ", "worker usage: worker <bundlePath> <storageRoot> <hash>");
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
||||
const modRec = modUnknown as Record<string, unknown>;
|
||||
const runExport = modRec.run;
|
||||
if (!isWorkflowFnLike(runExport)) {
|
||||
bootLog("T4BW9YJX", "workflow bundle must export run as a function (AsyncGenerator workflow)");
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
const workflowFn = runExport;
|
||||
|
||||
const threads = new Map<string, ThreadHandle>();
|
||||
let activeThreads = 0;
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cleanupAllRunningMarkersSync(): void {
|
||||
for (const threadId of threads.keys()) {
|
||||
try {
|
||||
unlinkSync(join(storageRoot, "logs", hash, `${threadId}.running`));
|
||||
} catch {
|
||||
// ignore missing file or other fs errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(sig, () => {
|
||||
cleanupAllRunningMarkersSync();
|
||||
process.exit(sig === "SIGINT" ? 130 : 143);
|
||||
});
|
||||
}
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
|
||||
function cancelShutdownTimer(): void {
|
||||
if (shutdownTimer !== null) {
|
||||
clearTimeout(shutdownTimer);
|
||||
shutdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleShutdown(): void {
|
||||
cancelShutdownTimer();
|
||||
shutdownTimer = setTimeout(() => {
|
||||
void unlink(workerCtlPath).catch(() => {});
|
||||
process.exit(0);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function bumpStart(): void {
|
||||
cancelShutdownTimer();
|
||||
activeThreads++;
|
||||
}
|
||||
|
||||
function bumpDone(): void {
|
||||
activeThreads--;
|
||||
if (activeThreads <= 0) {
|
||||
activeThreads = 0;
|
||||
scheduleShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TCP worker multiplexes lifecycle + runs
|
||||
async function dispatchCommand(cmd: ControlCommand, socket: Socket | null): Promise<void> {
|
||||
if (cmd.type !== "run") {
|
||||
dispatchThreadLifecycleCommand(threads, socket, cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
bumpStart();
|
||||
|
||||
const threadId = cmd.threadId;
|
||||
const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`);
|
||||
const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId,
|
||||
hash,
|
||||
infoJsonlPath,
|
||||
cas,
|
||||
};
|
||||
|
||||
const existing = threads.get(threadId);
|
||||
if (existing !== undefined) {
|
||||
existing.abortController.abort();
|
||||
threads.delete(threadId);
|
||||
}
|
||||
|
||||
const pauseGate = createThreadPauseGate();
|
||||
const ac = new AbortController();
|
||||
threads.set(threadId, { abortController: ac, pauseGate });
|
||||
|
||||
try {
|
||||
await mkdir(dirname(runningPath), { recursive: true });
|
||||
await writeFile(runningPath, "", "utf8");
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
|
||||
const baseTs = Date.now();
|
||||
let prefilledDiskSteps: PrefilledDiskStep[] | null = null;
|
||||
let replayTimestamps: readonly number[] | null = null;
|
||||
|
||||
if (cmd.forkContinuation !== null) {
|
||||
if (
|
||||
cmd.steps.length > 0 &&
|
||||
(cmd.stepTimestamps === null || cmd.stepTimestamps.length !== cmd.steps.length)
|
||||
) {
|
||||
bootLog("J5WQ8NXT", "forkContinuation requires stepTimestamps aligned with steps");
|
||||
throw new Error("forkContinuation requires stepTimestamps aligned with steps");
|
||||
}
|
||||
replayTimestamps =
|
||||
cmd.steps.length === 0 ? null : (cmd.stepTimestamps as readonly number[]);
|
||||
} else if (cmd.steps.length > 0) {
|
||||
prefilledDiskSteps = cmd.steps.map((step, i) => {
|
||||
const ts = cmd.stepTimestamps?.[i];
|
||||
return {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await executeThread(
|
||||
workflowFn,
|
||||
cmd.workflowName,
|
||||
{ prompt: cmd.prompt, steps: cmd.steps },
|
||||
{
|
||||
...cmd.options,
|
||||
parentStateHash: null,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
|
||||
forkSourceThreadId: cmd.forkSourceThreadId,
|
||||
prefilledDiskSteps,
|
||||
forkContinuation: cmd.forkContinuation,
|
||||
replayTimestamps,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
|
||||
} finally {
|
||||
await unlink(runningPath).catch(() => {});
|
||||
threads.delete(threadId);
|
||||
bumpDone();
|
||||
socket?.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof process.send === "function") {
|
||||
process.on("message", (msg: unknown) => {
|
||||
const cmd = parseControlPayload(msg);
|
||||
if (cmd === null) {
|
||||
return;
|
||||
}
|
||||
void dispatchCommand(cmd, null);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((socket: Socket) => {
|
||||
void (async () => {
|
||||
const line = await readLineFromSocket(socket);
|
||||
if (line === null) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
const cmd = parseCommandLine(line);
|
||||
if (cmd === null) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
await dispatchCommand(cmd, socket);
|
||||
})();
|
||||
});
|
||||
|
||||
server.on("error", (errObj: Error) => {
|
||||
bootLog("W8YK4NPX", `worker server error: ${errObj.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
if (addr === null || typeof addr === "string") {
|
||||
bootLog("R9XK4MNW", "worker failed to bind TCP address");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`READY ${addr.port}\n`);
|
||||
|
||||
await new Promise<void>(() => {});
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { createCasReactor } from "../cas-reactor.js";
|
||||
|
||||
export type ExtractDeps = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
const MAX_REACT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
*
|
||||
* Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the
|
||||
* Merkle DAG and a schema-shaped extract tool); the loop also accepts a plain-JSON
|
||||
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
|
||||
*/
|
||||
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
|
||||
const reactor = createCasReactor(provider, deps.cas, {
|
||||
maxRounds: MAX_REACT_ROUNDS,
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You extract structured metadata from content. The content is from a CAS node. Use cas_get to read referenced nodes if needed. When ready, call the ${structuredToolName} tool with JSON matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
});
|
||||
|
||||
return async <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
contentHash: string,
|
||||
): Promise<ExtractResult<T>> => {
|
||||
const payload = await getContentMerklePayload(deps.cas, contentHash);
|
||||
if (payload === null) {
|
||||
throw new Error(`extract: missing CAS content node for hash ${contentHash}`);
|
||||
}
|
||||
const text = `${payload}\n\nExtract structured metadata according to the schema.`;
|
||||
const result = await reactor({
|
||||
thread: { cas: deps.cas },
|
||||
input: text,
|
||||
schema,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${result.error}`);
|
||||
}
|
||||
return {
|
||||
meta: result.value,
|
||||
contentPayload: payload,
|
||||
refs: [],
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createExtract } from "./extract-fn.js";
|
||||
export {
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./llm-extract.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./types.js";
|
||||
@@ -0,0 +1,193 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { LlmError, LlmExtractArgs } from "./types.js";
|
||||
|
||||
function chatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function readToolName(parametersSchema: Record<string, unknown>): string {
|
||||
const title = parametersSchema.title;
|
||||
if (typeof title === "string" && title.trim().length > 0) {
|
||||
return title.trim();
|
||||
}
|
||||
return "extract";
|
||||
}
|
||||
|
||||
function readToolDescription(parametersSchema: Record<string, unknown>): string {
|
||||
const d = parametersSchema.description;
|
||||
if (typeof d === "string" && d.trim().length > 0) {
|
||||
return d.trim();
|
||||
}
|
||||
return "Extract structured data from the input text.";
|
||||
}
|
||||
|
||||
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
|
||||
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
} {
|
||||
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
return {
|
||||
name: readToolName(parameters),
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
|
||||
if (!isRecord(parsed)) {
|
||||
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
|
||||
}
|
||||
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const first = choices[0];
|
||||
if (!isRecord(first)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const messageObj = first.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const toolCalls = messageObj.tool_calls;
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const call0 = toolCalls[0];
|
||||
if (!isRecord(call0)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const fn = call0.function;
|
||||
if (!isRecord(fn)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const argsRaw = fn.arguments;
|
||||
if (typeof argsRaw !== "string") {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
return ok(argsRaw);
|
||||
}
|
||||
|
||||
export function llmErrorToCause(error: LlmError): Error {
|
||||
switch (error.kind) {
|
||||
case "http_error":
|
||||
return new Error(`HTTP ${error.status}: ${error.body.slice(0, 500)}`);
|
||||
case "invalid_response_json":
|
||||
return new Error(error.message);
|
||||
case "no_tool_call":
|
||||
return new Error(`No tool call in response: ${error.preview}`);
|
||||
case "tool_arguments_invalid_json":
|
||||
return new Error(error.message);
|
||||
case "schema_validation_failed":
|
||||
return new Error(error.message);
|
||||
case "network_error":
|
||||
return new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function performLlmExtract<T>(
|
||||
options: LlmExtractArgs<T> & { userContent: string },
|
||||
): Promise<Result<T, LlmError>> {
|
||||
const extractTool = extractFunctionToolFromZodSchema(options.schema);
|
||||
|
||||
const body = {
|
||||
model: options.provider.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: "Extract the requested information from the provided text. Be precise.",
|
||||
},
|
||||
{ role: "user" as const, content: options.userContent },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: extractTool.name,
|
||||
description: extractTool.description,
|
||||
parameters: extractTool.parameters,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function" as const, function: { name: extractTool.name } },
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatCompletionsUrl(options.provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "network_error", message });
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "invalid_response_json", message });
|
||||
}
|
||||
|
||||
const argsJson = readToolArgumentsJson(parsed, responseText);
|
||||
if (!argsJson.ok) {
|
||||
return argsJson;
|
||||
}
|
||||
|
||||
let argsParsed: unknown;
|
||||
try {
|
||||
argsParsed = JSON.parse(argsJson.value) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "tool_arguments_invalid_json", message });
|
||||
}
|
||||
|
||||
const validated = options.schema.safeParse(argsParsed);
|
||||
if (!validated.success) {
|
||||
return err({
|
||||
kind: "schema_validation_failed",
|
||||
message: validated.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(validated.data);
|
||||
}
|
||||
|
||||
/** Single LLM extract attempt over OpenAI-compatible chat completions with forced tool call. */
|
||||
export async function llmExtract<T>(options: LlmExtractArgs<T>): Promise<Result<T, LlmError>> {
|
||||
return performLlmExtract({ ...options, userContent: options.text });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type LlmExtractArgs<T> = {
|
||||
text: string;
|
||||
schema: z.ZodType<T>;
|
||||
provider: LlmProvider;
|
||||
};
|
||||
|
||||
export type LlmError =
|
||||
| { kind: "http_error"; status: number; body: string }
|
||||
| { kind: "invalid_response_json"; message: string }
|
||||
| { kind: "no_tool_call"; preview: string }
|
||||
| { kind: "tool_arguments_invalid_json"; message: string }
|
||||
| { kind: "schema_validation_failed"; message: string }
|
||||
| { kind: "network_error"; message: string };
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
FORK_BRANCH_ROLE,
|
||||
prepareCasFork,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./engine/fork-thread.js";
|
||||
export { garbageCollectCas } from "./engine/gc.js";
|
||||
export type {
|
||||
ThreadHistoryEntry,
|
||||
ThreadIndex,
|
||||
ThreadIndexEntry,
|
||||
} from "./engine/threads-index.js";
|
||||
export {
|
||||
getBundleDir,
|
||||
readThreadsIndex,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
upsertThreadEntry,
|
||||
} from "./engine/threads-index.js";
|
||||
export type { GcResult } from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export { createExtract } from "./extract/index.js";
|
||||
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
|
||||
@@ -0,0 +1,165 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAdapterMaxDepth(config: WorkflowConfig | null): number {
|
||||
return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAdapterOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveStorageRoot(options: WorkflowAdapterOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(
|
||||
storageRoot: string,
|
||||
ctx: ThreadContext,
|
||||
): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/** Resolve the workflow bundle and validate depth limits. */
|
||||
async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) {
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
throw new Error(`failed to read workflow registry: ${registryResult.error.message}`);
|
||||
}
|
||||
|
||||
const maxDepth = workflowAdapterMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`);
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
throw new Error(`workflow "${workflowName}" not found in registry`);
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath);
|
||||
if (!bundleExportsResult.ok) {
|
||||
throw new Error(String(bundleExportsResult.error));
|
||||
}
|
||||
|
||||
return { entry, run: bundleExportsResult.value.run };
|
||||
}
|
||||
|
||||
/** Execute the child workflow thread and return a summary + root hash. */
|
||||
async function runChildThread(params: {
|
||||
workflowName: string;
|
||||
storageRoot: string;
|
||||
ctx: ThreadContext;
|
||||
run: WorkflowFn;
|
||||
bundleHash: string;
|
||||
nextDepth: number;
|
||||
}) {
|
||||
const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params;
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: bundleHash,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
const result = await executeThread(
|
||||
run,
|
||||
workflowName,
|
||||
{ prompt: ctx.start.content, steps: [] },
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`,
|
||||
rootHash: result.rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AdapterFn} that runs another registered workflow in a new child thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
*
|
||||
* The child thread's root hash is returned as `childThread` in the result,
|
||||
* enabling parent→child tracking in the CAS Merkle tree.
|
||||
*/
|
||||
export function workflowAdapter(
|
||||
workflowName: string,
|
||||
options: WorkflowAdapterOptions | null = null,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const storageRoot = resolveStorageRoot(options);
|
||||
const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1);
|
||||
|
||||
try {
|
||||
const { summary, rootHash } = await runChildThread({
|
||||
workflowName,
|
||||
storageRoot,
|
||||
ctx,
|
||||
run,
|
||||
bundleHash: entry.hash,
|
||||
nextDepth: ctx.depth + 1,
|
||||
});
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`child workflow "${workflowName}" failed: ${message}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
|
||||
* This module is kept for backward compatibility and will be removed in a future release.
|
||||
*/
|
||||
export {
|
||||
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
|
||||
workflowAdapter as workflowAsAgent,
|
||||
} from "./workflow-adapter.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-register" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user