From cae59b589ec9da0f285e29f1afa690e81475affc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 7 May 2026 13:21:38 +0000 Subject: [PATCH] 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 --- packages/cli-workflow/src/cmd-rollback.ts | 1 + .../__tests__/extract-provider.test.ts | 87 +++++++++++++++++++ packages/workflow/__tests__/registry.test.ts | 83 +++++++++++++++++- .../__tests__/workflow-as-agent.test.ts | 40 +++++++++ packages/workflow/src/extract-provider.ts | 35 ++++++++ packages/workflow/src/index.ts | 3 + packages/workflow/src/registry-normalize.ts | 69 ++++++++++++++- packages/workflow/src/registry-types.ts | 13 +++ packages/workflow/src/registry.ts | 7 +- packages/workflow/src/workflow-as-agent.ts | 12 +-- 10 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 packages/workflow/__tests__/extract-provider.test.ts create mode 100644 packages/workflow/src/extract-provider.ts diff --git a/packages/cli-workflow/src/cmd-rollback.ts b/packages/cli-workflow/src/cmd-rollback.ts index ba034d8..91e834d 100644 --- a/packages/cli-workflow/src/cmd-rollback.ts +++ b/packages/cli-workflow/src/cmd-rollback.ts @@ -44,6 +44,7 @@ export async function cmdRollback( } const nextRegistry = { + config: reg.value.config, workflows: { ...reg.value.workflows, [name]: rolled.value }, }; const written = await writeWorkflowRegistry(storageRoot, nextRegistry); diff --git a/packages/workflow/__tests__/extract-provider.test.ts b/packages/workflow/__tests__/extract-provider.test.ts new file mode 100644 index 0000000..775ee03 --- /dev/null +++ b/packages/workflow/__tests__/extract-provider.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/workflow/__tests__/registry.test.ts b/packages/workflow/__tests__/registry.test.ts index 5806829..a32fe94 100644 --- a/packages/workflow/__tests__/registry.test.ts +++ b/packages/workflow/__tests__/registry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + parseWorkflowRegistryYaml, readWorkflowRegistry, registerWorkflowVersion, rollbackWorkflowToHistoryHash, @@ -21,6 +22,7 @@ describe("workflow registry", () => { if (!empty.ok) { return; } + expect(empty.value.config).toBeNull(); const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100); const w1 = await writeWorkflowRegistry(dir, r1); @@ -68,7 +70,7 @@ describe("workflow registry", () => { }); 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", "H3", 300); const entry = reg.workflows["solve-issue"]; @@ -99,6 +101,85 @@ describe("workflow registry", () => { 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 () => { const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`); await mkdir(dir, { recursive: true }); diff --git a/packages/workflow/__tests__/workflow-as-agent.test.ts b/packages/workflow/__tests__/workflow-as-agent.test.ts index be467c0..7b184be 100644 --- a/packages/workflow/__tests__/workflow-as-agent.test.ts +++ b/packages/workflow/__tests__/workflow-as-agent.test.ts @@ -121,6 +121,46 @@ describe("workflowAsAgent", () => { makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }), ); 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 { await rm(root, { recursive: true, force: true }); } diff --git a/packages/workflow/src/extract-provider.ts b/packages/workflow/src/extract-provider.ts new file mode 100644 index 0000000..45a82e0 --- /dev/null +++ b/packages/workflow/src/extract-provider.ts @@ -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> { + 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, + }); +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 58547f4..6db5579 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -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, diff --git a/packages/workflow/src/registry-normalize.ts b/packages/workflow/src/registry-normalize.ts index d76d7dc..29a98e3 100644 --- a/packages/workflow/src/registry-normalize.ts +++ b/packages/workflow/src/registry-normalize.ts @@ -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 { + 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 { + if (raw === null || typeof raw !== "object") { + return err(new Error('registry config must contain an "extract" mapping')); + } + const e = raw as Record; + 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 { + if (raw === null || typeof raw !== "object") { + return err(new Error('registry "config" must be a mapping')); + } + const c = raw as Record; + 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; + 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; }; diff --git a/packages/workflow/src/registry.ts b/packages/workflow/src/registry.ts index 9f8267b..c81d7e3 100644 --- a/packages/workflow/src/registry.ts +++ b/packages/workflow/src/registry.ts @@ -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 { @@ -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 }); } diff --git a/packages/workflow/src/workflow-as-agent.ts b/packages/workflow/src/workflow-as-agent.ts index b4dbb7a..223dd1f 100644 --- a/packages/workflow/src/workflow-as-agent.ts +++ b/packages/workflow/src/workflow-as-agent.ts @@ -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 => { 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`;