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:
2026-05-07 13:21:38 +00:00
parent b5cc0db17e
commit cae59b589e
10 changed files with 340 additions and 10 deletions
+35
View File
@@ -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,
});
}
+3
View File
@@ -17,6 +17,7 @@ export {
} from "./engine.js";
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
export { createExtract, type ExtractFn } from "./extract-fn.js";
export { getExtractProvider } from "./extract-provider.js";
export {
buildForkPlan,
type ForkHistoricalStep,
@@ -54,6 +55,7 @@ export {
type ThreadMerklePayload,
} from "./merkle.js";
export {
type ExtractProviderConfig,
getRegisteredWorkflow,
listRegisteredWorkflowNames,
parseWorkflowRegistryYaml,
@@ -62,6 +64,7 @@ export {
rollbackWorkflowToHistoryHash,
stringifyWorkflowRegistryYaml,
unregisterWorkflow,
type WorkflowConfig,
type WorkflowHistoryEntry,
type WorkflowRegistryEntry,
type WorkflowRegistryFile,
+68 -1
View File
@@ -1,10 +1,68 @@
import type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry-types.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(
workflowName: string,
index: number,
@@ -61,6 +119,15 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
return err(new Error("registry root must be a mapping"));
}
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;
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
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;
}
return ok({ workflows });
return ok({ config, workflows });
}
+13
View File
@@ -9,6 +9,19 @@ export type WorkflowRegistryEntry = {
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 = {
config: WorkflowConfig | null;
workflows: Record<string, WorkflowRegistryEntry>;
};
+5 -2
View File
@@ -12,6 +12,8 @@ import type {
import { err, ok, type Result } from "./result.js";
export type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
@@ -22,7 +24,7 @@ export function workflowRegistryPath(storageRoot: string): string {
}
function emptyRegistry(): WorkflowRegistryFile {
return { workflows: {} };
return { config: null, workflows: {} };
}
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
@@ -103,6 +105,7 @@ export function registerWorkflowVersion(
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
const next: WorkflowRegistryEntry = { hash, timestamp, history };
return {
config: registry.config,
workflows: { ...registry.workflows, [name]: next },
};
}
@@ -150,5 +153,5 @@ export function unregisterWorkflow(
return err(new Error(`workflow not registered: ${name}`));
}
const { [name]: _removed, ...rest } = registry.workflows;
return ok({ workflows: rest });
return ok({ config: registry.config, workflows: rest });
}
+6 -6
View File
@@ -3,15 +3,13 @@ import { join } from "node:path";
import { createCasStore } from "./cas.js";
import { type ExecuteThreadIo, executeThread } from "./engine.js";
import { extractBundleExports } from "./extract-bundle-exports.js";
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js";
import { createLogger } from "./logger.js";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
import type { AgentContext, AgentFn, ThreadInput } from "./types.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 = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
@@ -34,9 +32,6 @@ export function workflowAsAgent(
): AgentFn {
return async (ctx: AgentContext): Promise<string> => {
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);
@@ -45,6 +40,11 @@ export function workflowAsAgent(
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);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;