From b6caaac85ff2fea480ea5deece25e1e1ecae4b78 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 10:29:17 +0200 Subject: [PATCH 1/3] feat(workflow-executor): make collection schema cache TTL configurable [PRD-430] The collection-schema cache duration was fixed (~10 min) with no way to tune it, so schema changes on the orchestrator were only picked up after that fixed window. Wire a `schemaCacheTtlMs` executor option through the CLI (env var `SCHEMA_CACHE_TTL_MS`), the same way as the other tunables (polling interval, step timeout, max chain depth). SchemaCache already accepted a ttl in its constructor; default behaviour is unchanged (10 min when unset). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/build-workflow-executor.ts | 5 ++++- packages/workflow-executor/src/cli-core.ts | 3 +++ packages/workflow-executor/src/defaults.ts | 1 + packages/workflow-executor/test/cli.test.ts | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/build-workflow-executor.ts b/packages/workflow-executor/src/build-workflow-executor.ts index 8087d22d0d..708ba8c2d4 100644 --- a/packages/workflow-executor/src/build-workflow-executor.ts +++ b/packages/workflow-executor/src/build-workflow-executor.ts @@ -16,6 +16,7 @@ import ServerAiAdapter from './adapters/server-ai-adapter'; import { DEFAULT_FOREST_SERVER_URL, DEFAULT_POLLING_INTERVAL_MS, + DEFAULT_SCHEMA_CACHE_TTL_MS, DEFAULT_STEP_TIMEOUT_MS, } from './defaults'; import ExecutorHttpServer from './http/executor-http-server'; @@ -45,6 +46,8 @@ export interface ExecutorOptions { stepTimeoutMs?: number; // Max auto-chained steps per entry (see RunnerConfig.maxChainDepth). 0 disables chaining. maxChainDepth?: number; + // Collection schema cache TTL in ms. Lower it to pick up orchestrator schema changes sooner. + schemaCacheTtlMs?: number; // Dev only: makes every AI call fail immediately so error paths can be exercised locally. forceAiError?: boolean; } @@ -83,7 +86,7 @@ function buildCommonDependencies(options: ExecutorOptions) { aiModelPort = new ServerAiAdapter({ forestServerUrl, envSecret: options.envSecret }); } - const schemaCache = new SchemaCache(); + const schemaCache = new SchemaCache(options.schemaCacheTtlMs ?? DEFAULT_SCHEMA_CACHE_TTL_MS); const agentPort = new AgentClientAgentPort({ agentUrl: options.agentUrl, diff --git a/packages/workflow-executor/src/cli-core.ts b/packages/workflow-executor/src/cli-core.ts index 1c683af027..0be7c30c47 100644 --- a/packages/workflow-executor/src/cli-core.ts +++ b/packages/workflow-executor/src/cli-core.ts @@ -17,6 +17,7 @@ import { DEFAULT_HTTP_PORT, DEFAULT_MAX_CHAIN_DEPTH, DEFAULT_POLLING_INTERVAL_MS, + DEFAULT_SCHEMA_CACHE_TTL_MS, DEFAULT_STEP_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, } from './defaults'; @@ -159,6 +160,7 @@ export function readEnvConfig(env: NodeJS.ProcessEnv, args: CliArgs): CliConfig stopTimeoutMs: parsePositiveIntEnv('STOP_TIMEOUT_MS', env.STOP_TIMEOUT_MS), stepTimeoutMs: parsePositiveIntEnv('STEP_TIMEOUT_MS', env.STEP_TIMEOUT_MS), maxChainDepth: parsePositiveIntEnv('MAX_CHAIN_DEPTH', env.MAX_CHAIN_DEPTH), + schemaCacheTtlMs: parsePositiveIntEnv('SCHEMA_CACHE_TTL_MS', env.SCHEMA_CACHE_TTL_MS), ...(aiConfigurations && { aiConfigurations }), ...(env.FORCE_AI_ERROR === 'true' && { forceAiError: true }), }; @@ -195,6 +197,7 @@ Optional environment variables: STOP_TIMEOUT_MS Default: ${DEFAULT_STOP_TIMEOUT_MS} STEP_TIMEOUT_MS Max duration of a step in ms (default: ${DEFAULT_STEP_TIMEOUT_MS}) MAX_CHAIN_DEPTH Max steps auto-executed per run before yielding (default: ${DEFAULT_MAX_CHAIN_DEPTH}) + SCHEMA_CACHE_TTL_MS Collection schema cache TTL in ms (default: ${DEFAULT_SCHEMA_CACHE_TTL_MS}) NO_COLOR Set to any value to disable ANSI colors in pretty logs FORCE_AI_ERROR Set to "true" to make every AI call fail (dev only, to test error paths) diff --git a/packages/workflow-executor/src/defaults.ts b/packages/workflow-executor/src/defaults.ts index b03e4877e4..9ae411c66d 100644 --- a/packages/workflow-executor/src/defaults.ts +++ b/packages/workflow-executor/src/defaults.ts @@ -4,3 +4,4 @@ export const DEFAULT_POLLING_INTERVAL_MS = 30_000; export const DEFAULT_STEP_TIMEOUT_MS = 5 * 60_000; export const DEFAULT_STOP_TIMEOUT_MS = 30_000; export const DEFAULT_MAX_CHAIN_DEPTH = 50; +export const DEFAULT_SCHEMA_CACHE_TTL_MS = 10 * 60_000; diff --git a/packages/workflow-executor/test/cli.test.ts b/packages/workflow-executor/test/cli.test.ts index 2a74307cd2..f5d10e41f6 100644 --- a/packages/workflow-executor/test/cli.test.ts +++ b/packages/workflow-executor/test/cli.test.ts @@ -141,6 +141,7 @@ describe('readEnvConfig', () => { STOP_TIMEOUT_MS: '10000', STEP_TIMEOUT_MS: '60000', MAX_CHAIN_DEPTH: '10', + SCHEMA_CACHE_TTL_MS: '120000', }, args, ); @@ -150,6 +151,19 @@ describe('readEnvConfig', () => { expect(config.executorOptions.stopTimeoutMs).toBe(10000); expect(config.executorOptions.stepTimeoutMs).toBe(60000); expect(config.executorOptions.maxChainDepth).toBe(10); + expect(config.executorOptions.schemaCacheTtlMs).toBe(120000); + }); + + it('leaves schemaCacheTtlMs undefined when SCHEMA_CACHE_TTL_MS is unset (default applied downstream in build)', () => { + const config = readEnvConfig(baseEnv, args); + + expect(config.executorOptions.schemaCacheTtlMs).toBeUndefined(); + }); + + it('throws ConfigurationError when SCHEMA_CACHE_TTL_MS is non-numeric', () => { + expect(() => readEnvConfig({ ...baseEnv, SCHEMA_CACHE_TTL_MS: 'abc' }, args)).toThrow( + /SCHEMA_CACHE_TTL_MS must be a positive integer/, + ); }); it('leaves stepTimeoutMs undefined when STEP_TIMEOU_MS is unset (default applied downstream in build)', () => { From 801303efb6498c52041fc5de5a645ca58f13f6b8 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 17:45:08 +0200 Subject: [PATCH 2/3] refactor(workflow-executor): reuse shared schema cache TTL default + cover wiring [PRD-430] Address review feedback on #1621: - schema-cache.ts now imports DEFAULT_SCHEMA_CACHE_TTL_MS from defaults instead of redefining DEFAULT_TTL_MS, so the help text and constructor fallback can't diverge. - build-workflow-executor.test.ts: mock SchemaCache and assert the TTL reaches it (default applied / caller value respected), mirroring the stepTimeoutMs pair. - cli.test.ts: assert the SCHEMA_CACHE_TTL_MS default in the printHelp test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workflow-executor/src/schema-cache.ts | 4 ++-- .../test/build-workflow-executor.test.ts | 15 +++++++++++++++ packages/workflow-executor/test/cli.test.ts | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/schema-cache.ts b/packages/workflow-executor/src/schema-cache.ts index 68b1a3db0b..d6f04056e7 100644 --- a/packages/workflow-executor/src/schema-cache.ts +++ b/packages/workflow-executor/src/schema-cache.ts @@ -1,13 +1,13 @@ import type { CollectionSchema } from './types/validated/collection'; -const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes +import { DEFAULT_SCHEMA_CACHE_TTL_MS } from './defaults'; export default class SchemaCache { private readonly store = new Map(); private readonly ttlMs: number; private readonly now: () => number; - constructor(ttlMs: number = DEFAULT_TTL_MS, now: () => number = Date.now) { + constructor(ttlMs: number = DEFAULT_SCHEMA_CACHE_TTL_MS, now: () => number = Date.now) { this.ttlMs = ttlMs; this.now = now; } diff --git a/packages/workflow-executor/test/build-workflow-executor.test.ts b/packages/workflow-executor/test/build-workflow-executor.test.ts index a3206aa002..e50547dd0b 100644 --- a/packages/workflow-executor/test/build-workflow-executor.test.ts +++ b/packages/workflow-executor/test/build-workflow-executor.test.ts @@ -1,5 +1,6 @@ import ForestServerWorkflowPort from '../src/adapters/forest-server-workflow-port'; import { buildDatabaseExecutor, buildInMemoryExecutor } from '../src/build-workflow-executor'; +import { DEFAULT_SCHEMA_CACHE_TTL_MS } from '../src/defaults'; import Runner from '../src/runner'; import SchemaCache from '../src/schema-cache'; import DatabaseStore from '../src/stores/database-store'; @@ -9,6 +10,7 @@ jest.mock('../src/runner'); jest.mock('../src/stores/in-memory-store'); jest.mock('../src/stores/database-store'); jest.mock('../src/adapters/agent-client-agent-port'); +jest.mock('../src/schema-cache'); jest.mock('../src/adapters/forest-server-workflow-port'); jest.mock('../src/http/executor-http-server'); jest.mock('../src/adapters/ai-client-adapter'); @@ -20,6 +22,7 @@ jest.mock('sequelize', () => ({ })); const MockedRunner = Runner as jest.MockedClass; +const MockedSchemaCache = SchemaCache as jest.MockedClass; const BASE_OPTIONS = { envSecret: 'a'.repeat(64), @@ -176,6 +179,18 @@ describe('buildInMemoryExecutor', () => { expect(MockedRunner).toHaveBeenCalledWith(expect.objectContaining({ stepTimeoutMs: 30_000 })); }); + it('builds the SchemaCache with the default TTL when schemaCacheTtlMs is not configured', () => { + buildInMemoryExecutor(BASE_OPTIONS); + + expect(MockedSchemaCache).toHaveBeenCalledWith(DEFAULT_SCHEMA_CACHE_TTL_MS); + }); + + it('builds the SchemaCache with a caller-provided schemaCacheTtlMs over the default', () => { + buildInMemoryExecutor({ ...BASE_OPTIONS, schemaCacheTtlMs: 5_000 }); + + expect(MockedSchemaCache).toHaveBeenCalledWith(5_000); + }); + it('passes secrets to Runner config', () => { buildInMemoryExecutor(BASE_OPTIONS); diff --git a/packages/workflow-executor/test/cli.test.ts b/packages/workflow-executor/test/cli.test.ts index f5d10e41f6..a09b44fb39 100644 --- a/packages/workflow-executor/test/cli.test.ts +++ b/packages/workflow-executor/test/cli.test.ts @@ -17,6 +17,7 @@ import { DEFAULT_HTTP_PORT, DEFAULT_MAX_CHAIN_DEPTH, DEFAULT_POLLING_INTERVAL_MS, + DEFAULT_SCHEMA_CACHE_TTL_MS, DEFAULT_STEP_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, } from '../src/defaults'; @@ -306,6 +307,7 @@ describe('printHelp / printVersion', () => { expect(output).toContain(`Default: ${DEFAULT_STOP_TIMEOUT_MS}`); expect(output).toContain(`default: ${DEFAULT_STEP_TIMEOUT_MS}`); expect(output).toContain(`default: ${DEFAULT_MAX_CHAIN_DEPTH}`); + expect(output).toContain(`default: ${DEFAULT_SCHEMA_CACHE_TTL_MS}`); }); it('printVersion prints a version string', () => { From 4f082d68c46fd98da249237cd8eb9f23f2a7ebbc Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 4 Jun 2026 10:08:47 +0200 Subject: [PATCH 3/3] refactor(workflow-executor): clamp schemaCacheTtlMs with positiveOrDefault [PRD-430] Align schemaCacheTtlMs with stepTimeoutMs/aiInvokeTimeoutMs: a 0/negative/ non-finite TTL would silently make the schema cache always-stale. Reuse the existing positiveOrDefault helper instead of `?? default`, and cover the fallback with a test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/workflow-executor/src/build-workflow-executor.ts | 5 ++++- .../workflow-executor/test/build-workflow-executor.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/build-workflow-executor.ts b/packages/workflow-executor/src/build-workflow-executor.ts index 2808c1b8c2..c5f44cd43c 100644 --- a/packages/workflow-executor/src/build-workflow-executor.ts +++ b/packages/workflow-executor/src/build-workflow-executor.ts @@ -94,7 +94,10 @@ function buildCommonDependencies(options: ExecutorOptions) { aiModelPort = new ServerAiAdapter({ forestServerUrl, envSecret: options.envSecret }); } - const schemaCache = new SchemaCache(options.schemaCacheTtlMs ?? DEFAULT_SCHEMA_CACHE_TTL_MS); + // A TTL of 0/negative/non-finite would silently make the cache always-stale, so fall back. + const schemaCache = new SchemaCache( + positiveOrDefault(options.schemaCacheTtlMs, DEFAULT_SCHEMA_CACHE_TTL_MS), + ); const agentPort = new AgentClientAgentPort({ agentUrl: options.agentUrl, diff --git a/packages/workflow-executor/test/build-workflow-executor.test.ts b/packages/workflow-executor/test/build-workflow-executor.test.ts index 66beb37ef5..8666a691a7 100644 --- a/packages/workflow-executor/test/build-workflow-executor.test.ts +++ b/packages/workflow-executor/test/build-workflow-executor.test.ts @@ -191,6 +191,13 @@ describe('buildInMemoryExecutor', () => { expect(MockedSchemaCache).toHaveBeenCalledWith(5_000); }); + it('falls back to the default TTL for a non-positive or non-finite schemaCacheTtlMs', () => { + buildInMemoryExecutor({ ...BASE_OPTIONS, schemaCacheTtlMs: 0 }); + + // A 0/negative/Infinity TTL must not silently make the cache always-stale. + expect(MockedSchemaCache).toHaveBeenCalledWith(DEFAULT_SCHEMA_CACHE_TTL_MS); + }); + it('falls back to the default timeouts for non-positive or non-finite values', () => { buildInMemoryExecutor({ ...BASE_OPTIONS,