From a186e4fb642f63ed4633c5b56682ac637954b02e Mon Sep 17 00:00:00 2001 From: SecurID Date: Fri, 10 Jul 2026 16:01:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(routines):=20hot-enable=20on=20LLM=20key=20?= =?UTF-8?q?save=20=E2=80=94=20resolve=20chat=20agent=20live=20per=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routines was the last feature needing a manual restart on first-run: the kernel wired it at boot behind `if (graphPool && orchestrator)`, capturing the orchestrator by reference, so a key saved via the Setup Wizard never reached it (the boot log literally said "then restart to enable routines"). Adopt chat's live-resolution pattern (issue #473): - initRoutines/RoutineRunner take `getOrchestrator` (live resolver over the ServiceRegistry) instead of a captured `orchestrator`; the boot gate is graphPool-only. The next cron fire after key save just works — no trigger code, no re-init; a key rotation swaps to the new bundle too. - Keyless fires record an `error` run naming the missing key, checked before the sender lookup — pre-key the channel senders are dark too and would otherwise mask the cause with 'no proactive sender'. The pause re-read now runs first so a raced fire on a paused row stays a skip. - POST /:id/trigger returns a synchronous 503 `routines.chat_unavailable` instead of a 202 whose background run is guaranteed to fail; the web-ui maps the code to a catalog message (EN/DE) naming the fix. 7 new tests: keyless error run + check ordering, hot-enable flip, key rotation across instances, trigger 503→202 flip (router-level analog of chatSessionsRouterGraceful). Fixes #473 Co-Authored-By: Claude Fable 5 --- middleware/src/index.ts | 28 +-- .../src/plugins/routines/initRoutines.ts | 22 ++- .../src/plugins/routines/routineRunner.ts | 73 ++++++-- middleware/src/routes/routines.ts | 16 +- .../hrRoutineTemplate.integration.test.ts | 5 +- middleware/test/routineRunner.test.ts | 99 ++++++++++- middleware/test/routinesTriggerRoute.test.ts | 160 ++++++++++++++++++ .../routines/_components/RoutineActions.tsx | 11 ++ web-ui/messages/de.json | 1 + web-ui/messages/en.json | 1 + 10 files changed, 372 insertions(+), 44 deletions(-) create mode 100644 middleware/test/routinesTriggerRoute.test.ts diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 2113ab93..e9e5be33 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -1645,9 +1645,11 @@ async function main(): Promise { // so chat goes live the moment the key is saved — no restart needed. // // The boot-only wiring guarded on `orchestrator` below (domain-tool - // hydration of per-Agent orchestrators, the routines feature) re-applies on - // the next restart for advanced stacks (sub-agents / routines). The default - // out-of-the-box stack has no domain tools, so chat is fully functional hot. + // hydration of per-Agent orchestrators) re-applies on the next restart for + // advanced stacks (sub-agents). The default out-of-the-box stack has no + // domain tools, so chat is fully functional hot. Routines follows the same + // live-resolution pattern as chat (issue #473): its runner resolves + // chatAgent per run, so it hot-enables on key save too. const chatAgentBundle = serviceRegistry.get('chatAgent'); const orchestrator = chatAgentBundle?.raw; if (!chatAgentBundle) { @@ -1924,8 +1926,12 @@ async function main(): Promise { // Routines feature (OB-NEW): persistent user-created scheduled agent // invocations. Requires Postgres for persistence; skipped in zero-config - // dev (in-memory KG backend, no DATABASE_URL). Channel adapters that want - // proactive delivery register their `ProactiveSender` into + // dev (in-memory KG backend, no DATABASE_URL). The chat agent is NOT + // required at wiring time — the runner resolves chatAgent@1 live per run + // (same pattern as the chat routes above), so routines hot-enable the + // moment the Setup Wizard key save publishes it; keyless fires record an + // `error` run naming the missing key (issue #473). Channel adapters that + // want proactive delivery register their `ProactiveSender` into // `routinesHandle.senderRegistry` after this call (Teams: wrap a // long-lived `CloudAdapter.continueConversationAsync` via // `createProactiveSender('teams', sendFn)`). Channel adapters MUST also @@ -1934,11 +1940,11 @@ async function main(): Promise { // `manage_routine` tool's `create`/`list` actions return a // model-friendly error string and the model degrades gracefully. let routinesHandle: RoutinesHandle | undefined; - if (graphPool && orchestrator) { + if (graphPool) { routinesHandle = await initRoutines({ pool: graphPool, scheduler: jobScheduler, - orchestrator, + getOrchestrator: () => getChatAgentBundle()?.raw, registerNativeTool: (name, handler, options) => nativeToolRegistry.register(name, { handler, @@ -1973,15 +1979,11 @@ async function main(): Promise { }), ); console.log( - '[middleware] routines feature ready (manage_routine tool registered, routinesIntegration published)', - ); - } else if (!graphPool) { - console.log( - '[middleware] routines feature SKIPPED — no graphPool (in-memory KG backend; set DATABASE_URL to enable)', + '[middleware] routines feature ready (manage_routine tool registered, routinesIntegration published, chat agent resolved live per run)', ); } else { console.log( - '[middleware] routines feature SKIPPED — chatAgent not active (set ANTHROPIC_API_KEY via the Setup Wizard, then restart to enable routines)', + '[middleware] routines feature SKIPPED — no graphPool (in-memory KG backend; set DATABASE_URL to enable)', ); } diff --git a/middleware/src/plugins/routines/initRoutines.ts b/middleware/src/plugins/routines/initRoutines.ts index 8fb31317..38a6e2f6 100644 --- a/middleware/src/plugins/routines/initRoutines.ts +++ b/middleware/src/plugins/routines/initRoutines.ts @@ -21,9 +21,11 @@ import { routineTurnContext } from './routineTurnContext.js'; /** * Single-call wiring for the routines feature. The kernel calls this once - * after the Postgres pool, the JobScheduler, and the ChatAgent are - * available; everything below the surface is created here so `index.ts` - * stays thin. + * after the Postgres pool and the JobScheduler are available — the chat + * agent is NOT required at wiring time: the runner resolves it live per + * run via `getOrchestrator`, so routines hot-enable the moment the Setup + * Wizard key save publishes chatAgent@1 (issue #473). Everything below + * the surface is created here so `index.ts` stays thin. * * Lifecycle: * - Runs DB migrations (idempotent — `_routine_migrations` tracks). @@ -47,13 +49,15 @@ export interface InitRoutinesOptions { pool: Pool; scheduler: JobScheduler; /** - * Real `Orchestrator` (typically `chatAgentBundle.raw`). The runner - * needs the lower-level `runTurn` (not the higher-level `chat`) so it - * can persist the per-turn `runTrace` for the call-stack viewer. The - * structural type keeps this layer free of a hard import on the + * Live resolver for the real `Orchestrator` (typically + * `() => chatAgentBundle?.raw` over the service registry). Resolved per + * run, never captured — see `RoutineRunnerOptions.getOrchestrator`. The + * runner needs the lower-level `runTurn` (not the higher-level `chat`) + * so it can persist the per-turn `runTrace` for the call-stack viewer. + * The structural type keeps this layer free of a hard import on the * orchestrator package. */ - orchestrator: OrchestratorLike; + getOrchestrator: () => OrchestratorLike | undefined; /** * Native-tool registration surface. Production passes the kernel-owned * `nativeToolRegistry`. The shape is the minimum used here so a stub @@ -106,7 +110,7 @@ export async function initRoutines( store, runsStore, scheduler: opts.scheduler, - orchestrator: opts.orchestrator, + getOrchestrator: opts.getOrchestrator, senderRegistry, log, maxActivePerUser: opts.maxActivePerUser, diff --git a/middleware/src/plugins/routines/routineRunner.ts b/middleware/src/plugins/routines/routineRunner.ts index a2d88162..a78d0636 100644 --- a/middleware/src/plugins/routines/routineRunner.ts +++ b/middleware/src/plugins/routines/routineRunner.ts @@ -88,6 +88,15 @@ export class UnknownChannelError extends Error { } } +export class ChatAgentUnavailableError extends Error { + constructor() { + super( + 'chat agent unavailable — configure the LLM API key in Settings to enable routine runs', + ); + this.name = 'ChatAgentUnavailableError'; + } +} + export interface RoutineRunnerOptions { store: RoutineStore; runsStore: RoutineRunsStore; @@ -98,13 +107,17 @@ export interface RoutineRunnerOptions { */ scheduler: JobSchedulerLike; /** - * Production wiring passes `chatAgentBundle.raw` (the real - * `Orchestrator`); tests pass a stub that returns a synthetic - * `ChatTurnResult`. The runner needs `runTurn` (not the higher-level - * `chat`) so it can persist the per-turn `runTrace` for the - * call-stack viewer. + * Live resolver for the plugin-published chat agent — production wiring + * passes a closure over `serviceRegistry.get('chatAgent')?.raw`; tests + * pass `() => stub`. Resolved per run (never captured) so routines + * hot-enable the moment the Setup Wizard key save republishes + * chatAgent@1, and a key rotation swaps instances without a restart. + * A run that fires while it returns `undefined` records an `error` + * run via `ChatAgentUnavailableError`. The runner needs `runTurn` + * (not the higher-level `chat`) so it can persist the per-turn + * `runTrace` for the call-stack viewer. */ - orchestrator: OrchestratorLike; + getOrchestrator: () => OrchestratorLike | undefined; senderRegistry: ProactiveSenderRegistry; log?: (msg: string) => void; /** Override the per-user active-routine cap. */ @@ -133,7 +146,7 @@ export class RoutineRunner { private readonly store: RoutineStore; private readonly runsStore: RoutineRunsStore; private readonly scheduler: JobSchedulerLike; - private readonly orchestrator: OrchestratorLike; + private readonly getOrchestrator: () => OrchestratorLike | undefined; private readonly senders: ProactiveSenderRegistry; private readonly log: (msg: string) => void; private readonly maxActivePerUser: number; @@ -145,7 +158,7 @@ export class RoutineRunner { this.store = opts.store; this.runsStore = opts.runsStore; this.scheduler = opts.scheduler; - this.orchestrator = opts.orchestrator; + this.getOrchestrator = opts.getOrchestrator; this.senders = opts.senderRegistry; this.log = opts.log ?? ((msg) => console.log(msg)); this.maxActivePerUser = @@ -261,6 +274,17 @@ export class RoutineRunner { return row ?? null; } + /** + * Whether a chat agent currently resolves. The trigger endpoint checks + * this to return a synchronous 503 instead of accepting a manual run + * that is guaranteed to record `error` — cron fires still go through + * `runOnce` and record the failure, keeping the misconfiguration + * visible in run history. + */ + chatAgentAvailable(): boolean { + return this.getOrchestrator() !== undefined; + } + /** * Dispose every registered routine. Called on graceful shutdown. * In-flight runs receive their AbortSignal via `stopForPlugin`. @@ -392,20 +416,31 @@ export class RoutineRunner { let userId = routine.userId; try { - const sender = this.senders.get(routine.channel); - if (!sender) { - throw new UnknownChannelError(routine.channel); - } - // Re-read the row before invoking. A pause/delete that landed // between schedule and trigger should be honoured (the dispose was - // best-effort, not transactional). + // best-effort, not transactional). Checked first so a raced fire on + // a paused row never records a spurious availability error. const fresh = await this.store.get(routine.id); if (!fresh || fresh.status !== 'active') return; prompt = fresh.prompt; tenant = fresh.tenant; userId = fresh.userId; + // Resolve the chat agent per fire (issue #473) and BEFORE the + // sender lookup: pre-key, the channel plugins (which require + // chatAgent@^1) are dark too, so a sender-first check would record + // the misleading 'no proactive sender' error when the actual fix + // is configuring the key. + const orchestrator = this.getOrchestrator(); + if (!orchestrator) { + throw new ChatAgentUnavailableError(); + } + + const sender = this.senders.get(routine.channel); + if (!sender) { + throw new UnknownChannelError(routine.channel); + } + if (signal.aborted) { status = 'timeout'; errorMessage = signal.reason instanceof Error @@ -424,7 +459,7 @@ export class RoutineRunner { // data sections directly from the tool handler's output instead // of letting the LLM author markdown rows. Non-templated routines // skip the wrap entirely — byte-identical to pre-C.2 behaviour. - const turn = await this.runTurnWithOptionalCapture(fresh); + const turn = await this.runTurnWithOptionalCapture(fresh, orchestrator); result = turn.result; cardBody = turn.cardBody; @@ -530,12 +565,16 @@ export class RoutineRunner { */ private async runTurnWithOptionalCapture( routine: Routine, + // The instance `runOnce` resolved for this fire — passed down (not + // re-resolved) so one run never straddles two orchestrator instances + // across a mid-run key rotation. + orchestrator: OrchestratorLike, ): Promise<{ readonly result: ChatTurnResult; readonly cardBody?: readonly unknown[]; }> { if (routine.outputTemplate === null) { - const result = await this.orchestrator.runTurn({ + const result = await orchestrator.runTurn({ userMessage: routine.prompt, userId: routine.userId, sessionScope: `routine:${routine.id}`, @@ -570,7 +609,7 @@ export class RoutineRunner { captureRawToolResult, }, () => - this.orchestrator.runTurn({ + orchestrator.runTurn({ userMessage: augmentedPrompt, userId: routine.userId, sessionScope: `routine:${routine.id}`, diff --git a/middleware/src/routes/routines.ts b/middleware/src/routes/routines.ts index 1c0f269b..c3f06dd5 100644 --- a/middleware/src/routes/routines.ts +++ b/middleware/src/routes/routines.ts @@ -169,7 +169,8 @@ function toDto(r: Routine): RoutineDto { * Routes are mounted under `/api/v1/routines` and gated by `requireAuth`. * GET / → list all routines (cross-tenant) * PATCH /:id/status → body `{status: 'active' | 'paused'}` - * POST /:id/trigger → fire one manual run (records as `manual` in routine_runs) + * POST /:id/trigger → fire one manual run (records as `manual` in routine_runs); + * 503 `routines.chat_unavailable` while no chat agent is published * GET /:id/runs → list per-routine run history (last 50 by default) * GET /:id/runs/:runId → single run with full agentic trace (call-stack viewer) * DELETE /:id → permanent (cascades into routine_runs) @@ -254,6 +255,19 @@ export function createRoutinesRouter(deps: RoutinesRouterDeps): Router { .json({ code: 'routines.not_found', message: `routine '${id}' not found` }); return; } + // Interactive path gets a synchronous 503 while no chat agent is + // published (LLM key not configured) instead of a 202 whose run is + // guaranteed to fail in the background — the web-ui maps this code + // to a "set the key in Settings" message. Cron fires keep recording + // `error` runs so the misconfiguration stays visible in history. + if (!deps.runner.chatAgentAvailable()) { + res.status(503).json({ + code: 'routines.chat_unavailable', + message: + 'chat agent unavailable — configure the LLM API key in Settings, then trigger again', + }); + return; + } // Fire-and-forget: the trigger run takes seconds (LLM call + // tool roundtrip + validator retry + Teams send). Holding the // HTTP connection open the whole way means web-ui proxies time diff --git a/middleware/test/hrRoutineTemplate.integration.test.ts b/middleware/test/hrRoutineTemplate.integration.test.ts index 93d07d32..6fe15d07 100644 --- a/middleware/test/hrRoutineTemplate.integration.test.ts +++ b/middleware/test/hrRoutineTemplate.integration.test.ts @@ -287,11 +287,12 @@ async function makeRunner(template: RoutineOutputTemplate): Promise<{ const sender = new StubSender(); const senderRegistry = new InMemoryProactiveSenderRegistry(); senderRegistry.register(sender); + const hrOrchestrator = makeHrOrchestrator(); const runner = new RoutineRunner({ store: store as unknown as RoutineStore, runsStore: runsStore as unknown as RoutineRunsStore, scheduler, - orchestrator: makeHrOrchestrator(), + getOrchestrator: () => hrOrchestrator, senderRegistry, log: () => {}, }); @@ -462,7 +463,7 @@ describe('Phase C.8 — HR routine end-to-end with reference template', () => { store: store as unknown as RoutineStore, runsStore: runsStore as unknown as RoutineRunsStore, scheduler, - orchestrator, + getOrchestrator: () => orchestrator, senderRegistry, log: () => {}, }); diff --git a/middleware/test/routineRunner.test.ts b/middleware/test/routineRunner.test.ts index 2c5accf1..db939a84 100644 --- a/middleware/test/routineRunner.test.ts +++ b/middleware/test/routineRunner.test.ts @@ -276,6 +276,9 @@ interface Harness { interface MakeHarnessOptions { orchestrator?: OrchestratorLike; + /** Live resolver seam (issue #473). Takes precedence over `orchestrator`; + * lets tests flip the chat agent between fires (hot-enable / rotation). */ + getOrchestrator?: () => OrchestratorLike | undefined; agentCallsRef?: Array<{ userMessage: string; userId?: string; @@ -311,7 +314,7 @@ function makeHarness(options: MakeHarnessOptions = {}): Harness { store: store as unknown as RoutineStore, runsStore: runsStore as unknown as RoutineRunsStore, scheduler, - orchestrator: stubOrch.orchestrator, + getOrchestrator: options.getOrchestrator ?? (() => stubOrch.orchestrator), senderRegistry, log: () => {}, maxActivePerUser: options.maxActivePerUser, @@ -847,7 +850,7 @@ describe('RoutineRunner — Phase C.5 templated end-to-end pipeline', () => { store: store as unknown as RoutineStore, runsStore: runsStore as unknown as RoutineRunsStore, scheduler, - orchestrator, + getOrchestrator: () => orchestrator, senderRegistry, log: (msg) => { logLines.push(msg); @@ -1075,3 +1078,95 @@ describe('RoutineRunner — Phase C.6 adaptive-card path', () => { assert.equal(h.sender.calls[0]!.message.text, 'Hi.'); }); }); + +/** + * Issue #473 — the runner resolves the chat agent LIVE per fire (never + * captured at construction), so routines hot-enable the moment the Setup + * Wizard key save publishes chatAgent@1, and a key rotation swaps + * orchestrator instances without a restart. Keyless fires record an + * `error` run naming the missing key. + */ +describe('live chat-agent resolution (issue #473)', () => { + it('records a chat-unavailable error run when no orchestrator resolves — before the sender lookup', async () => { + // Pre-key BOTH the chat agent and the channel senders are dark + // (channel plugins require chatAgent@^1). The recorded error must + // name the missing key, not the missing sender — so the routine is + // registered without a sender and the chat-agent check has to win. + const h = makeHarness({ + getOrchestrator: () => undefined, + registerSender: false, + }); + // createRoutine guards on a registered sender; seed the row directly + // and let resumeRoutine do the scheduler registration (it only warns + // on a missing sender). + const row = await h.store.create(baseInput); + await h.runner.resumeRoutine(row.id); + + await h.scheduler.fire(row.id); + + assert.equal(h.runsStore.inserts.length, 1); + assert.equal(h.runsStore.inserts[0]!.status, 'error'); + assert.match( + h.runsStore.inserts[0]!.errorMessage ?? '', + /chat agent unavailable.*LLM API key/i, + ); + assert.doesNotMatch( + h.runsStore.inserts[0]!.errorMessage ?? '', + /no proactive sender/, + ); + assert.equal(h.store.recordRunCalls.length, 1); + assert.equal(h.store.recordRunCalls[0]!.status, 'error'); + }); + + it('hot-enables: a fire after the resolver starts returning an orchestrator succeeds without re-registration', async () => { + const stub = makeStubOrchestrator({ answerText: 'routines are live' }); + let current: OrchestratorLike | undefined; + const h = makeHarness({ getOrchestrator: () => current }); + const routine = await h.runner.createRoutine(baseInput); + + // Keyless fire → error run, orchestrator never invoked. + await h.scheduler.fire(routine.id); + assert.equal(h.runsStore.inserts[0]!.status, 'error'); + assert.equal(h.sender.calls.length, 0); + + // Key saved → chatAgent@1 published. Same scheduler entry, next fire + // just works — no restart, no re-init. + current = stub.orchestrator; + await h.scheduler.fire(routine.id); + + assert.equal(h.runsStore.inserts.length, 2); + assert.equal(h.runsStore.inserts[1]!.status, 'ok'); + assert.equal(stub.calls.length, 1); + assert.equal(h.sender.calls.length, 1); + assert.equal(h.sender.calls[0]!.message.text, 'routines are live'); + }); + + it('key rotation: each fire uses the orchestrator instance current at fire time', async () => { + const a = makeStubOrchestrator({ answerText: 'from A' }); + const b = makeStubOrchestrator({ answerText: 'from B' }); + let current: OrchestratorLike | undefined = a.orchestrator; + const h = makeHarness({ getOrchestrator: () => current }); + const routine = await h.runner.createRoutine(baseInput); + + await h.scheduler.fire(routine.id); + current = b.orchestrator; // reactivate published a NEW bundle + await h.scheduler.fire(routine.id); + + assert.equal(a.calls.length, 1); + assert.equal(b.calls.length, 1); + assert.equal(h.sender.calls[0]!.message.text, 'from A'); + assert.equal(h.sender.calls[1]!.message.text, 'from B'); + assert.deepEqual( + h.runsStore.inserts.map((r) => r.status), + ['ok', 'ok'], + ); + }); + + it('chatAgentAvailable() mirrors the resolver', async () => { + let current: OrchestratorLike | undefined; + const h = makeHarness({ getOrchestrator: () => current }); + assert.equal(h.runner.chatAgentAvailable(), false); + current = makeStubOrchestrator().orchestrator; + assert.equal(h.runner.chatAgentAvailable(), true); + }); +}); diff --git a/middleware/test/routinesTriggerRoute.test.ts b/middleware/test/routinesTriggerRoute.test.ts new file mode 100644 index 00000000..02751108 --- /dev/null +++ b/middleware/test/routinesTriggerRoute.test.ts @@ -0,0 +1,160 @@ +import { strict as assert } from 'node:assert'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import express from 'express'; +import type { Express } from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { createRoutinesRouter } from '../src/routes/routines.js'; +import type { RoutineRunner } from '../src/plugins/routines/routineRunner.js'; +import type { RoutineRunsStore } from '../src/plugins/routines/routineRunsStore.js'; +import type { + Routine, + RoutineStore, +} from '../src/plugins/routines/routineStore.js'; + +/** + * Issue #473 — POST /:id/trigger returns a synchronous 503 + * `routines.chat_unavailable` while no chat agent is published (LLM key + * not configured), instead of a 202 whose background run is guaranteed + * to record `error`. The moment the resolver reports an agent (Setup + * Wizard key save), the same route flips to 202 without a restart — + * the router-level analog of `chatSessionsRouterGraceful.test.ts`. + */ + +function makeRoutine(id: string): Routine { + const now = new Date(); + return { + id, + tenant: 'tenant-A', + userId: 'user-1', + name: 'demo', + cron: '*/30 * * * *', + prompt: 'Hi.', + channel: 'teams', + conversationRef: {}, + status: 'active', + timeoutMs: 600_000, + createdAt: now, + updatedAt: now, + lastRunAt: null, + lastRunStatus: null, + lastRunError: null, + outputTemplate: null, + }; +} + +class StubRunner { + public readonly rows = new Map(); + public chatAvailable = false; + public triggerCalls: string[] = []; + + seed(routine: Routine): void { + this.rows.set(routine.id, routine); + } + + async peekRoutine(id: string): Promise { + return this.rows.get(id) ?? null; + } + + chatAgentAvailable(): boolean { + return this.chatAvailable; + } + + async triggerRoutineNow(id: string): Promise { + this.triggerCalls.push(id); + const row = this.rows.get(id); + if (!row) throw new Error(`routine '${id}' not found`); + return row; + } +} + +interface Harness { + server: Server; + baseUrl: string; + runner: StubRunner; + close(): Promise; +} + +async function makeHarness(): Promise { + const runner = new StubRunner(); + const app: Express = express(); + app.use(express.json()); + app.use( + '/v1/routines', + createRoutinesRouter({ + store: {} as RoutineStore, + runsStore: {} as RoutineRunsStore, + runner: runner as unknown as RoutineRunner, + log: () => {}, + }), + ); + + return new Promise((resolve) => { + const server = app.listen(0, () => { + const addr = server.address() as AddressInfo; + resolve({ + server, + baseUrl: `http://127.0.0.1:${addr.port}`, + runner, + close: () => + new Promise((r) => { + server.close(() => { + r(); + }); + }), + }); + }); + }); +} + +describe('POST /v1/routines/:id/trigger — chat-agent availability (issue #473)', () => { + let h: Harness; + + beforeEach(async () => { + h = await makeHarness(); + h.runner.seed(makeRoutine('routine-1')); + }); + + afterEach(async () => { + await h.close(); + }); + + it('returns 503 routines.chat_unavailable while no chat agent is published — without dispatching a run', async () => { + const res = await fetch(`${h.baseUrl}/v1/routines/routine-1/trigger`, { + method: 'POST', + }); + assert.equal(res.status, 503); + const body = (await res.json()) as { code: string; message: string }; + assert.equal(body.code, 'routines.chat_unavailable'); + assert.match(body.message, /LLM API key/i); + assert.equal(h.runner.triggerCalls.length, 0); + }); + + it('404 still wins over 503 for unknown routines', async () => { + const res = await fetch(`${h.baseUrl}/v1/routines/nope/trigger`, { + method: 'POST', + }); + assert.equal(res.status, 404); + const body = (await res.json()) as { code: string }; + assert.equal(body.code, 'routines.not_found'); + }); + + it('flips to 202 the moment the chat agent resolves — no restart, same router instance', async () => { + const first = await fetch(`${h.baseUrl}/v1/routines/routine-1/trigger`, { + method: 'POST', + }); + assert.equal(first.status, 503); + + // Setup Wizard key save → reactivate → chatAgent@1 published. + h.runner.chatAvailable = true; + + const second = await fetch(`${h.baseUrl}/v1/routines/routine-1/trigger`, { + method: 'POST', + }); + assert.equal(second.status, 202); + const body = (await second.json()) as { routine: { id: string } }; + assert.equal(body.routine.id, 'routine-1'); + assert.deepEqual(h.runner.triggerCalls, ['routine-1']); + }); +}); diff --git a/web-ui/app/routines/_components/RoutineActions.tsx b/web-ui/app/routines/_components/RoutineActions.tsx index 8d7b1cb4..9ab2ea12 100644 --- a/web-ui/app/routines/_components/RoutineActions.tsx +++ b/web-ui/app/routines/_components/RoutineActions.tsx @@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/app/_components/ui/Button'; import { + ApiError, deleteRoutine, setRoutineStatus, triggerRoutineNow, @@ -64,6 +65,16 @@ export function RoutineActions({ routine }: Props): React.ReactElement { }, 45000); router.refresh(); } catch (err) { + // Issue #473: pre-key the middleware answers 503 + // routines.chat_unavailable instead of accepting a run that is + // guaranteed to fail — name the fix instead of the raw error. + if ( + err instanceof ApiError && + err.body.includes('routines.chat_unavailable') + ) { + setError(t('triggerChatUnavailable')); + return; + } setError(err instanceof Error ? err.message : String(err)); } }); diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index f34ed2cd..cedaf61d 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -3420,6 +3420,7 @@ "triggerButton": "Jetzt", "triggerTitle": "Routine jetzt manuell auslösen — feuert einen Agent-Run und liefert das Ergebnis ins Channel.", "triggerNotice": "Gestartet — Ergebnis erscheint in ~30 Sekunden im Channel.", + "triggerChatUnavailable": "Kein LLM-API-Key konfiguriert — bitte in Admin → Einstellungen hinterlegen und erneut auslösen.", "deleteConfirm": "Routine ''{name}'' löschen? Das kann nicht rückgängig gemacht werden." }, "templateEditor": { diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index ee1c1376..a798a3f1 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -3420,6 +3420,7 @@ "triggerButton": "Now", "triggerTitle": "Trigger the routine manually now — fires an agent run and delivers the result to the channel.", "triggerNotice": "Started — the result will appear in the channel in ~30 seconds.", + "triggerChatUnavailable": "No LLM API key configured — add it in Admin → Settings, then trigger again.", "deleteConfirm": "Delete routine ''{name}''? This cannot be undone." }, "templateEditor": {