feat: global extract provider config
- workflow.yaml supports config section (maxDepth, extract provider) - ExtractProviderConfig with env: prefix for apiKey resolution - getExtractProvider(storageRoot) returns LlmProvider from config - workflowAsAgent uses config maxDepth (fallback 3) - Registry read/write preserves config - 158 tests passing Fixes #43
This commit is contained in:
@@ -44,6 +44,7 @@ export async function cmdRollback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextRegistry = {
|
const nextRegistry = {
|
||||||
|
config: reg.value.config,
|
||||||
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
||||||
};
|
};
|
||||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { 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 { getExtractProvider } from "../src/extract-provider.js";
|
||||||
|
|
||||||
|
describe("getExtractProvider", () => {
|
||||||
|
test("returns provider when config.extract is present", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(root, "workflow.yaml"),
|
||||||
|
`config:
|
||||||
|
maxDepth: 3
|
||||||
|
extract:
|
||||||
|
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
model: qwen-plus
|
||||||
|
apiKey: literal-key
|
||||||
|
workflows: {}
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const r = await getExtractProvider(root);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||||
|
expect(r.value.model).toBe("qwen-plus");
|
||||||
|
expect(r.value.apiKey).toBe("literal-key");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errs when registry has no config section", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
|
||||||
|
const r = await getExtractProvider(root);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.error).toContain("no global config");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves apiKey from env at registry read time", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
|
||||||
|
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||||
|
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(root, "workflow.yaml"),
|
||||||
|
`config:
|
||||||
|
maxDepth: 1
|
||||||
|
extract:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
model: m
|
||||||
|
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
||||||
|
workflows: {}
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const r = await getExtractProvider(root);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.apiKey).toBe("resolved-secret");
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
|
||||||
|
}
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
parseWorkflowRegistryYaml,
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
rollbackWorkflowToHistoryHash,
|
rollbackWorkflowToHistoryHash,
|
||||||
@@ -21,6 +22,7 @@ describe("workflow registry", () => {
|
|||||||
if (!empty.ok) {
|
if (!empty.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
expect(empty.value.config).toBeNull();
|
||||||
|
|
||||||
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
||||||
const w1 = await writeWorkflowRegistry(dir, r1);
|
const w1 = await writeWorkflowRegistry(dir, r1);
|
||||||
@@ -68,7 +70,7 @@ describe("workflow registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
|
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
|
||||||
let reg = registerWorkflowVersion({ workflows: {} }, "solve-issue", "H1", 100);
|
let reg = registerWorkflowVersion({ config: null, workflows: {} }, "solve-issue", "H1", 100);
|
||||||
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
|
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
|
||||||
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
|
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
|
||||||
const entry = reg.workflows["solve-issue"];
|
const entry = reg.workflows["solve-issue"];
|
||||||
@@ -99,6 +101,85 @@ describe("workflow registry", () => {
|
|||||||
expect(bad.ok).toBe(false);
|
expect(bad.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses config section and literal apiKey", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 3
|
||||||
|
extract:
|
||||||
|
baseUrl: https://example.com/v1
|
||||||
|
model: qwen-plus
|
||||||
|
apiKey: secret-key
|
||||||
|
workflows:
|
||||||
|
solve-issue:
|
||||||
|
hash: SPVR4BDMSGC1W
|
||||||
|
timestamp: 1
|
||||||
|
history: []
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config).not.toBeNull();
|
||||||
|
if (r.value.config === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config.maxDepth).toBe(3);
|
||||||
|
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
|
||||||
|
expect(r.value.config.extract.model).toBe("qwen-plus");
|
||||||
|
expect(r.value.config.extract.apiKey).toBe("secret-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses config apiKey env: prefix from process.env", () => {
|
||||||
|
const prev = process.env.WF_REGISTRY_TEST_API_KEY;
|
||||||
|
process.env.WF_REGISTRY_TEST_API_KEY = "from-env";
|
||||||
|
try {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 1
|
||||||
|
extract:
|
||||||
|
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
model: qwen-plus
|
||||||
|
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config?.extract.apiKey).toBe("from-env");
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||||
|
} else {
|
||||||
|
process.env.WF_REGISTRY_TEST_API_KEY = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse errors when env: apiKey variable is unset", () => {
|
||||||
|
const prev = process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||||
|
delete process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||||
|
try {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 1
|
||||||
|
extract:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
model: m
|
||||||
|
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (prev !== undefined) {
|
||||||
|
process.env.WF_REGISTRY_TEST_API_KEY_UNSET = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("parse errors on invalid shape", async () => {
|
test("parse errors on invalid shape", async () => {
|
||||||
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
|
|||||||
@@ -121,6 +121,46 @@ describe("workflowAsAgent", () => {
|
|||||||
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
|
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
|
||||||
);
|
);
|
||||||
expect(out).toContain("depth limit");
|
expect(out).toContain("depth limit");
|
||||||
|
expect(out).toContain("max 3");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses registry config maxDepth when set", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-waa-maxdepth-cfg-"));
|
||||||
|
try {
|
||||||
|
await installChildWorkflow(root);
|
||||||
|
const reg = await readWorkflowRegistry(root);
|
||||||
|
expect(reg.ok).toBe(true);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const withCfg = {
|
||||||
|
...reg.value,
|
||||||
|
config: {
|
||||||
|
maxDepth: 2,
|
||||||
|
extract: {
|
||||||
|
baseUrl: "http://127.0.0.1:9",
|
||||||
|
model: "m",
|
||||||
|
apiKey: "k",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wr = await writeWorkflowRegistry(root, withCfg);
|
||||||
|
expect(wr.ok).toBe(true);
|
||||||
|
|
||||||
|
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||||
|
const okOut = await agent(
|
||||||
|
makeAgentCtx({ storageRoot: root, depth: 1, prompt: "nest-once", maxRounds: 5 }),
|
||||||
|
);
|
||||||
|
expect(okOut).not.toContain("depth limit");
|
||||||
|
|
||||||
|
const badOut = await agent(
|
||||||
|
makeAgentCtx({ storageRoot: root, depth: 2, prompt: "x", maxRounds: 5 }),
|
||||||
|
);
|
||||||
|
expect(badOut).toContain("depth limit");
|
||||||
|
expect(badOut).toContain("max 2");
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { readWorkflowRegistry } from "./registry.js";
|
||||||
|
import type { WorkflowConfig } from "./registry-types.js";
|
||||||
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
import { getDefaultWorkflowStorageRoot } from "./storage-root.js";
|
||||||
|
import type { LlmProvider } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||||
|
|
||||||
|
export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
|
||||||
|
if (config === null) {
|
||||||
|
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
|
||||||
|
}
|
||||||
|
return config.maxDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */
|
||||||
|
export async function getExtractProvider(
|
||||||
|
storageRoot: string | undefined,
|
||||||
|
): Promise<Result<LlmProvider, string>> {
|
||||||
|
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||||
|
const regResult = await readWorkflowRegistry(root);
|
||||||
|
if (!regResult.ok) {
|
||||||
|
return err(regResult.error.message);
|
||||||
|
}
|
||||||
|
const cfg = regResult.value.config;
|
||||||
|
if (cfg === null) {
|
||||||
|
return err("workflow registry has no global config section");
|
||||||
|
}
|
||||||
|
const ex = cfg.extract;
|
||||||
|
return ok({
|
||||||
|
baseUrl: ex.baseUrl,
|
||||||
|
apiKey: ex.apiKey,
|
||||||
|
model: ex.model,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
} from "./engine.js";
|
} from "./engine.js";
|
||||||
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||||
export { createExtract, type ExtractFn } from "./extract-fn.js";
|
export { createExtract, type ExtractFn } from "./extract-fn.js";
|
||||||
|
export { getExtractProvider } from "./extract-provider.js";
|
||||||
export {
|
export {
|
||||||
buildForkPlan,
|
buildForkPlan,
|
||||||
type ForkHistoricalStep,
|
type ForkHistoricalStep,
|
||||||
@@ -54,6 +55,7 @@ export {
|
|||||||
type ThreadMerklePayload,
|
type ThreadMerklePayload,
|
||||||
} from "./merkle.js";
|
} from "./merkle.js";
|
||||||
export {
|
export {
|
||||||
|
type ExtractProviderConfig,
|
||||||
getRegisteredWorkflow,
|
getRegisteredWorkflow,
|
||||||
listRegisteredWorkflowNames,
|
listRegisteredWorkflowNames,
|
||||||
parseWorkflowRegistryYaml,
|
parseWorkflowRegistryYaml,
|
||||||
@@ -62,6 +64,7 @@ export {
|
|||||||
rollbackWorkflowToHistoryHash,
|
rollbackWorkflowToHistoryHash,
|
||||||
stringifyWorkflowRegistryYaml,
|
stringifyWorkflowRegistryYaml,
|
||||||
unregisterWorkflow,
|
unregisterWorkflow,
|
||||||
|
type WorkflowConfig,
|
||||||
type WorkflowHistoryEntry,
|
type WorkflowHistoryEntry,
|
||||||
type WorkflowRegistryEntry,
|
type WorkflowRegistryEntry,
|
||||||
type WorkflowRegistryFile,
|
type WorkflowRegistryFile,
|
||||||
|
|||||||
@@ -1,10 +1,68 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ExtractProviderConfig,
|
||||||
|
WorkflowConfig,
|
||||||
WorkflowHistoryEntry,
|
WorkflowHistoryEntry,
|
||||||
WorkflowRegistryEntry,
|
WorkflowRegistryEntry,
|
||||||
WorkflowRegistryFile,
|
WorkflowRegistryFile,
|
||||||
} from "./registry-types.js";
|
} from "./registry-types.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
|
function resolveRegistryApiKey(raw: string): Result<string, Error> {
|
||||||
|
if (raw.startsWith("env:")) {
|
||||||
|
const name = raw.slice("env:".length);
|
||||||
|
if (name === "") {
|
||||||
|
return err(new Error('config.extract.apiKey "env:" reference must name a variable'));
|
||||||
|
}
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined) {
|
||||||
|
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`));
|
||||||
|
}
|
||||||
|
return ok(value);
|
||||||
|
}
|
||||||
|
return ok(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> {
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
return err(new Error('registry config must contain an "extract" mapping'));
|
||||||
|
}
|
||||||
|
const e = raw as Record<string, unknown>;
|
||||||
|
const baseUrl = e.baseUrl;
|
||||||
|
const model = e.model;
|
||||||
|
const apiKeyRaw = e.apiKey;
|
||||||
|
if (typeof baseUrl !== "string" || baseUrl === "") {
|
||||||
|
return err(new Error("config.extract.baseUrl must be a non-empty string"));
|
||||||
|
}
|
||||||
|
if (typeof model !== "string" || model === "") {
|
||||||
|
return err(new Error("config.extract.model must be a non-empty string"));
|
||||||
|
}
|
||||||
|
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
|
||||||
|
return err(new Error("config.extract.apiKey must be a non-empty string"));
|
||||||
|
}
|
||||||
|
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw);
|
||||||
|
if (!apiKeyResult.ok) {
|
||||||
|
return apiKeyResult;
|
||||||
|
}
|
||||||
|
return ok({ baseUrl, model, apiKey: apiKeyResult.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
|
||||||
|
if (raw === null || typeof raw !== "object") {
|
||||||
|
return err(new Error('registry "config" must be a mapping'));
|
||||||
|
}
|
||||||
|
const c = raw as Record<string, unknown>;
|
||||||
|
const maxDepth = c.maxDepth;
|
||||||
|
const extractRaw = c.extract;
|
||||||
|
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
|
||||||
|
return err(new Error("config.maxDepth must be a non-negative integer"));
|
||||||
|
}
|
||||||
|
const extractResult = normalizeExtractProviderConfig(extractRaw);
|
||||||
|
if (!extractResult.ok) {
|
||||||
|
return extractResult;
|
||||||
|
}
|
||||||
|
return ok({ maxDepth, extract: extractResult.value });
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeWorkflowHistoryEntry(
|
export function normalizeWorkflowHistoryEntry(
|
||||||
workflowName: string,
|
workflowName: string,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -61,6 +119,15 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
|
|||||||
return err(new Error("registry root must be a mapping"));
|
return err(new Error("registry root must be a mapping"));
|
||||||
}
|
}
|
||||||
const root = raw as Record<string, unknown>;
|
const root = raw as Record<string, unknown>;
|
||||||
|
const configRaw = root.config;
|
||||||
|
let config: WorkflowConfig | null = null;
|
||||||
|
if (configRaw !== undefined && configRaw !== null) {
|
||||||
|
const configResult = normalizeWorkflowConfig(configRaw);
|
||||||
|
if (!configResult.ok) {
|
||||||
|
return configResult;
|
||||||
|
}
|
||||||
|
config = configResult.value;
|
||||||
|
}
|
||||||
const workflowsRaw = root.workflows;
|
const workflowsRaw = root.workflows;
|
||||||
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
||||||
return err(new Error('registry must contain a "workflows" mapping'));
|
return err(new Error('registry must contain a "workflows" mapping'));
|
||||||
@@ -73,5 +140,5 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
|
|||||||
}
|
}
|
||||||
workflows[name] = entryResult.value;
|
workflows[name] = entryResult.value;
|
||||||
}
|
}
|
||||||
return ok({ workflows });
|
return ok({ config, workflows });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ export type WorkflowRegistryEntry = {
|
|||||||
history: WorkflowHistoryEntry[];
|
history: WorkflowHistoryEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
|
||||||
|
export type ExtractProviderConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
model: string;
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowConfig = {
|
||||||
|
maxDepth: number;
|
||||||
|
extract: ExtractProviderConfig;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowRegistryFile = {
|
export type WorkflowRegistryFile = {
|
||||||
|
config: WorkflowConfig | null;
|
||||||
workflows: Record<string, WorkflowRegistryEntry>;
|
workflows: Record<string, WorkflowRegistryEntry>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
ExtractProviderConfig,
|
||||||
|
WorkflowConfig,
|
||||||
WorkflowHistoryEntry,
|
WorkflowHistoryEntry,
|
||||||
WorkflowRegistryEntry,
|
WorkflowRegistryEntry,
|
||||||
WorkflowRegistryFile,
|
WorkflowRegistryFile,
|
||||||
@@ -22,7 +24,7 @@ export function workflowRegistryPath(storageRoot: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyRegistry(): WorkflowRegistryFile {
|
function emptyRegistry(): WorkflowRegistryFile {
|
||||||
return { workflows: {} };
|
return { config: null, workflows: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
||||||
@@ -103,6 +105,7 @@ export function registerWorkflowVersion(
|
|||||||
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
|
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
|
||||||
const next: WorkflowRegistryEntry = { hash, timestamp, history };
|
const next: WorkflowRegistryEntry = { hash, timestamp, history };
|
||||||
return {
|
return {
|
||||||
|
config: registry.config,
|
||||||
workflows: { ...registry.workflows, [name]: next },
|
workflows: { ...registry.workflows, [name]: next },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -150,5 +153,5 @@ export function unregisterWorkflow(
|
|||||||
return err(new Error(`workflow not registered: ${name}`));
|
return err(new Error(`workflow not registered: ${name}`));
|
||||||
}
|
}
|
||||||
const { [name]: _removed, ...rest } = registry.workflows;
|
const { [name]: _removed, ...rest } = registry.workflows;
|
||||||
return ok({ workflows: rest });
|
return ok({ config: registry.config, workflows: rest });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import { join } from "node:path";
|
|||||||
import { createCasStore } from "./cas.js";
|
import { createCasStore } from "./cas.js";
|
||||||
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
import { type ExecuteThreadIo, executeThread } from "./engine.js";
|
||||||
import { extractBundleExports } from "./extract-bundle-exports.js";
|
import { extractBundleExports } from "./extract-bundle-exports.js";
|
||||||
|
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js";
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js";
|
||||||
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||||
import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
|
import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
|
||||||
import { generateUlid } from "./ulid.js";
|
import { generateUlid } from "./ulid.js";
|
||||||
|
|
||||||
/** Maximum `WorkflowFnOptions.depth` allowed for a child spawned via `workflowAsAgent`. */
|
|
||||||
const WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
|
||||||
|
|
||||||
export type WorkflowAsAgentOptions = {
|
export type WorkflowAsAgentOptions = {
|
||||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||||
storageRoot: string | null;
|
storageRoot: string | null;
|
||||||
@@ -34,9 +32,6 @@ export function workflowAsAgent(
|
|||||||
): AgentFn {
|
): AgentFn {
|
||||||
return async (ctx: AgentContext): Promise<string> => {
|
return async (ctx: AgentContext): Promise<string> => {
|
||||||
const nextDepth = ctx.depth + 1;
|
const nextDepth = ctx.depth + 1;
|
||||||
if (nextDepth > WORKFLOW_AS_AGENT_MAX_DEPTH) {
|
|
||||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${WORKFLOW_AS_AGENT_MAX_DEPTH})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||||
|
|
||||||
@@ -45,6 +40,11 @@ export function workflowAsAgent(
|
|||||||
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxDepth = getWorkflowAsAgentMaxDepth(registryResult.value.config);
|
||||||
|
if (nextDepth > maxDepth) {
|
||||||
|
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||||
|
}
|
||||||
|
|
||||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||||
if (entry === null) {
|
if (entry === null) {
|
||||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||||
|
|||||||
Reference in New Issue
Block a user