From 62a65ca87b1a0dcc4589b46cc812cbaa366980f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:30:11 +0000 Subject: [PATCH] fix(service-automation): trigger-fired-run failure log keeps the envelope in meta, not the message (#6587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activateFlowTrigger's trigger callback spliced AutomationResult.error — which carries a failing node's / driver's text verbatim (#5912) — into the logger.error MESSAGE, the one same-class site PR #6568's residual sweep left on the fired-run path. The message now stays one physical line with the controlled facts (flow name, trigger type, consequence) and the envelope rides the structured meta slot, matching the family shape (#6499/#6568, site-12 envelope treatment). Per-site #4632 verdict: stays `error` on its own reasoning — the fired-run path has no caller holding the result envelope, so the failure would otherwise look normal from the outside; pinned by three new tests in engine-residual-log-cause.test.ts (level + stream + one-line + envelope-in-meta + error-slot layout). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei --- .changeset/spotty-lions-flash.md | 5 ++ .../src/engine-residual-log-cause.test.ts | 90 +++++++++++++++++++ .../services/service-automation/src/engine.ts | 27 +++++- 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 .changeset/spotty-lions-flash.md diff --git a/.changeset/spotty-lions-flash.md b/.changeset/spotty-lions-flash.md new file mode 100644 index 0000000000..ee748364ea --- /dev/null +++ b/.changeset/spotty-lions-flash.md @@ -0,0 +1,5 @@ +--- +'@objectstack/service-automation': patch +--- + +A failed trigger-fired flow run's `error` record now stays on one physical line: the `AutomationResult.error` envelope — which carries a failing node's / driver's text verbatim — moved from the log MESSAGE into the structured meta slot (`error` field), the same #6499/#6568 family shape, applied to the one same-class site that sweep left on the fired-run path. The message keeps its `Trigger-fired run of flow '…' failed` lead phrase and now also names the trigger type and the consequence; anything keyed on the old trailing `: [error text]` splice must read the structured `error` field instead. diff --git a/packages/services/service-automation/src/engine-residual-log-cause.test.ts b/packages/services/service-automation/src/engine-residual-log-cause.test.ts index 9e1180af93..41e2a69557 100644 --- a/packages/services/service-automation/src/engine-residual-log-cause.test.ts +++ b/packages/services/service-automation/src/engine-residual-log-cause.test.ts @@ -602,3 +602,93 @@ describe("#6499 site 14 — validateFlowExpressions' advisory pass: a self-autho assertSilent(streams.stderr, "[flow 'expense_check']", 'stderr'); }); }); + +// ═══ the fired-run envelope seam #6499's sweep left (#6587) ════════════════ + +describe("#6587 — activateFlowTrigger's trigger-fired-run callback: the AutomationResult.error envelope rides meta", () => { + // The same class as site 12 (the envelope carries a failing node's / + // driver's text VERBATIM, #5912) on the FIRED-RUN path instead of the + // subflow path — reported in PR #6568's residual sweep, outside #6499's + // list of 13, fixed here. + + /** A record-change flow whose one work node throws the driver's text. */ + const FIRED_FLOW = { + name: 'rc_fired', + label: 'rc_fired', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'task', triggerType: 'record-after-update' } }, + { id: 'boom', type: 'exploder', label: 'Boom' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'boom' }, + { id: 'e2', source: 'boom', target: 'end' }, + ], + }; + + /** + * Register trigger + flow, then fire the captured callback ONCE — the + * exact path a real trigger plugin drives. The executor THROWS (rather + * than returning `success: false`) so `execute()`'s catch puts the + * driver's text into `AutomationResult.error` verbatim, un-prefixed — + * the #5912-preserved shape this seam re-splices second-hand. + */ + async function fireOnce(engine: AutomationEngine): Promise { + let fire: ((ctx: AutomationContext) => Promise) | undefined; + const trigger: FlowTrigger = { + type: 'record_change', + start(_binding, callback) { fire = callback; }, + stop() {}, + }; + engine.registerTrigger(trigger); + engine.registerNodeExecutor({ + type: 'exploder', + async execute() { throw new Error(MULTILINE_DRIVER); }, + } as NodeExecutor); + engine.registerFlow('rc_fired', FIRED_FLOW); + expect(fire, 'the engine handed the trigger a callback').toBeDefined(); + await fire!({ event: 'record-after-update', object: 'task', record: { id: 't1' } } as AutomationContext); + } + + it("#4632 stays `error` on its own reasoning (no caller holds the envelope): one stderr line, envelope in meta", async () => { + const engine = new AutomationEngine(jsonLogger()); + const streams = await captureBoth(async () => { await fireOnce(engine); }); + + // Behaviour unchanged: the callback resolves (nothing is thrown back + // at the trigger plugin) and the failed run still lands in history. + const runs = await engine.listRuns('rc_fired'); + expect(runs).toHaveLength(1); + expect((runs[0] as { status?: string }).status).toBe('failed'); + + const record = soleRecordWith(streams.stderr, 'Trigger-fired run of flow'); + expectOneLineWithCause(record, 'error'); + expect(record.msg, 'the flow-name correlation').toContain("'rc_fired'"); + expect(record.msg, 'the trigger correlation').toContain("trigger 'record_change'"); + expect(record.msg, 'the consequence, stated out loud').toContain('no caller holds this result'); + assertSilent(streams.stdout, 'Trigger-fired run', 'stdout'); + }); + + it('stays one physical line in `pretty`, with every fact on the greppable line', async () => { + const engine = new AutomationEngine(new ObjectLogger({ level: 'warn', format: 'pretty' })); + const streams = await captureBoth(async () => { await fireOnce(engine); }); + expect(streams.stderr).toHaveLength(1); + expect(streams.stderr[0]).toMatch(RECORD_HEAD); + expect(streams.stderr[0]).toContain('Trigger-fired run of flow'); + expect(streams.stderr[0]).toContain('run `os migrate` for this datasource'); + }); + + it('hands the envelope to error(message, error, meta) — meta THIRD, Error slot empty (#5575)', async () => { + const spy = vi.spyOn(ObjectLogger.prototype, 'error'); + const engine = new AutomationEngine(jsonLogger()); + await captureBoth(async () => { await fireOnce(engine); }); + + const call = spy.mock.calls.find((c) => String(c[0]).includes('Trigger-fired run of flow')); + expect(call).toBeDefined(); + const [message, errorSlot, meta] = call as unknown as [string, unknown, Record]; + expect(message).not.toContain('\n'); + expect(errorSlot).toBeUndefined(); + expect(meta.error).toBe(MULTILINE_DRIVER); + spy.mockRestore(); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index d4adcef0a4..5a51c682b3 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -1721,15 +1721,34 @@ export class AutomationEngine implements IAutomationService { try { // A trigger-fired run's result must not vanish (2026-07-17 eval: // a failing record-change flow produced zero output — the failure - // lived only in the run-history row). Log failures at ERROR: stderr - // survives the CLI's boot-quiet stdout window, and a fired-but-failed - // automation is an operational fault. Condition-skipped runs stay + // lived only in the run-history row). Condition-skipped runs stay // quiet (execute() already debug-logs them — they are high-frequency). + // + // #6587 — `result.error` is the envelope field that carries a + // failing node's / driver's text VERBATIM (#5912 left it that way + // on purpose), so foreign newlines reach this message second-hand; + // it goes to the structured slot — the identical class as + // `bubbleToParent`'s envelope branch, on the fired-run path. See + // `forgetSuspendedRun`'s catch for the full mechanism (#6299). + // + // #4632 verdict: stays `error`, on its own reasoning rather than + // inertia. This is the fire-and-forget path: NO caller holds this + // result envelope, so after the failure the system looks normal + // from the outside — the triggering event was handled and nothing + // retries the run — while the flow's declared effects never + // landed; the only other trace is the passive run-history row. + // That stderr also survives the CLI's boot-quiet stdout window is + // stream mechanics, not the verdict. trigger.start(resolved.binding, (ctx: AutomationContext) => this.execute(flowName, ctx).then((result) => { if (!result.success) { this.logger.error( - `Trigger-fired run of flow '${flowName}' failed: ${result.error ?? 'unknown error'}`, + `Trigger-fired run of flow '${flowName}' failed (trigger '${resolved.triggerType}') — ` + + `no caller holds this result and nothing retries the run; the terminal failure ` + + `is recorded in the flow's run history, and the run's failure envelope is in ` + + `this record's meta.`, + undefined, + { error: result.error ?? 'unknown error' }, ); } }),