Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-lions-flash.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
let fire: ((ctx: AutomationContext) => Promise<void>) | 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<string, unknown>];
expect(message).not.toContain('\n');
expect(errorSlot).toBeUndefined();
expect(meta.error).toBe(MULTILINE_DRIVER);
spy.mockRestore();
});
});
27 changes: 23 additions & 4 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
);
}
}),
Expand Down
Loading