Skip to content

Commit 5aadce3

Browse files
fix(service-automation): a triggered run carries the flow author's successMessage / errorMessage — execute() and both retry exits (#9414) (#9514)
* fix(service-automation): execute() carries the flow author's terminal messages (#9414) `AutomationResult` declares `successMessage` / `errorMessage` as a general terminal-result feature, but `resumeInternal` was the only producer. A flow dispatched through `POST /api/v1/automation/:name/trigger` carried neither, so the author's own text reached a caller only when the run happened to pause and be resumed — and the console consumer that reads `error.details.errorMessage` (#9413, objectui `flowResponse.ts`) got nothing on every non-screen flow. Four terminal exits now produce the pair: `execute()`'s success and failure returns, both of `executeWithoutRetry`'s, and `retryExecution`'s exhausted exit — a different exit from `execute()`'s own, reached by exactly the runs that failed repeatedly. Paused, skipped and never-dispatched exits keep carrying neither, and that boundary is pinned rather than only described. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza * test(verify): pin the trigger route's terminal messages end-to-end at the documented wire path (#9414) `content/docs/automation/flows.mdx` promises that a 400 from `POST /api/v1/automation/:name/trigger` carries the flow author's own `errorMessage` at `error.details.errorMessage`. That sentence was false before the engine repair — the field was always absent at the source on this route — so the docs described the declaration while the implementation never honoured it. The engine pins assert `AutomationResult`; the route pins drive a scripted result. Neither proves the documented sentence. `@objectstack/verify` is the one package depending on both `@objectstack/runtime` and `@objectstack/service-automation`, so a real engine is driven through a real `HttpDispatcher` here and the response body is read at the doc's own path and key — on both exits, plus the "when the flow declares one" half. One case drives a deliberately hostile string (braces, padding, `&`, quotes) and asserts byte identity: `flows.mdx` documents both fields as plain strings with `{var}` explicitly NOT interpolated, so templating, trimming or escaping on the way out would contradict the shipped contract. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86ea8df commit 5aadce3

4 files changed

Lines changed: 593 additions & 4 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): a triggered run carries the flow author's `successMessage` / `errorMessage``execute()` and both retry exits, symmetric with `resume()` (#9414)
6+
7+
`AutomationResult` declares `successMessage` / `errorMessage` as a general
8+
terminal-result feature — *"Friendly terminal messages copied from the flow
9+
definition (`flow.successMessage` / `flow.errorMessage`) … `successMessage` is
10+
set on terminal success, `errorMessage` on failure."* One producer honoured it.
11+
`resumeInternal` set both on its terminal returns; `execute()` set neither, on
12+
either exit, and neither did `executeWithoutRetry` or `retryExecution`.
13+
14+
So a flow's own words reached a caller **only if the run happened to pause and
15+
be resumed**. A flow dispatched straight through
16+
`POST /api/v1/automation/:name/trigger` — or the legacy `trigger/:name` that
17+
`client.automation.trigger()` calls — carried nothing, though the flow declared
18+
the text and the contract said it was set. One declaration, two behaviours
19+
decided by *route* rather than by authoring.
20+
21+
**The consumer was already there and already reading.** The trigger route
22+
carries `errorMessage` into `error.details.errorMessage` (#9413), which is the
23+
one place the console reads it from (objectui `flowResponse.ts`, #4899) — and on
24+
the trigger path it was **always absent at the source**, so every non-screen flow
25+
showed the raw node error instead of the sentence its author wrote.
26+
27+
Four terminal exits now produce the pair, which is every exit a triggered run can
28+
leave through:
29+
30+
- `execute()` terminal success → `successMessage`; terminal failure →
31+
`errorMessage`, **beside** the raw `error` rather than instead of it (the
32+
transport folds `error` into the ADR-0112 message and carries the author's text
33+
in `details`).
34+
- `executeWithoutRetry()` — both exits. `retryExecution` returns this result
35+
verbatim when a later attempt succeeds, so without it `successMessage` would
36+
depend on *which attempt* happened to work.
37+
- `retryExecution()`'s **exhausted** exit, which is a different exit from
38+
`execute()`'s own failure return — a flow under `errorHandling.strategy:
39+
'retry'` never reaches that one. A repair stopping at `execute()` would have
40+
left the message missing for exactly the runs most likely to need it.
41+
42+
**Nothing else gained a message, deliberately.** The paused return is not
43+
terminal; the skip exits (`condition_not_met`, `reentrancy_loop_guard`) return
44+
`success: true` for a run that executed no node; the never-dispatched exits
45+
(flow not found / disabled / no start node) have no lifecycle verdict at all and
46+
must not acquire a second channel implying one. Those boundaries are pinned, not
47+
just described.
48+
49+
No new keys and no contract edit — the pair was declared, documented and
50+
consumed already; this is the production half catching up (ADR-0049
51+
enforce-or-remove, restoration direction).

packages/services/service-automation/src/engine.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3239,6 +3239,28 @@ export class AutomationEngine implements IAutomationService {
32393239
success: true,
32403240
output,
32413241
durationMs,
3242+
// [#9414] The flow author's own completion text, on the exit
3243+
// every TRIGGERED run leaves through. `AutomationResult`
3244+
// declares this pair as a general terminal-result feature —
3245+
// "`successMessage` is set on terminal success, `errorMessage`
3246+
// on failure" — but `resumeInternal` was the only producer, so
3247+
// the author's words reached a caller only when the run
3248+
// happened to pause and be resumed. A flow dispatched straight
3249+
// through `POST /api/v1/automation/:name/trigger` (or the
3250+
// legacy `trigger/:name` the SDK calls) carried nothing. Same
3251+
// declaration, two behaviours decided by ROUTE rather than by
3252+
// authoring — the shape ADR-0049's enforce-or-remove exists to
3253+
// stop, and this is the restoration half of it, not a new key.
3254+
//
3255+
// ⚠️ TERMINAL exits only, which is the whole boundary. The
3256+
// paused return below carries neither (the run has not
3257+
// finished; `resumeInternal` stamps the message when it later
3258+
// ends). Neither do the SKIP exits above — `condition_not_met`
3259+
// and `reentrancy_loop_guard` return `success: true` for a run
3260+
// that executed no node, so stamping "Opportunity created!" on
3261+
// one would be a toast about work nobody did. They carry no
3262+
// `summary` for exactly the same reason.
3263+
successMessage: flow.successMessage,
32423264
// #4354 — hand the counts back synchronously so a caller
32433265
// (a `subflow` roll-up, a runtime test asserting the sweep wrote
32443266
// something) never has to re-read the run to learn what it did.
@@ -3309,7 +3331,7 @@ export class AutomationEngine implements IAutomationService {
33093331

33103332
// Error handling strategy
33113333
if (flow.errorHandling?.strategy === 'retry') {
3312-
return this.retryExecution(flowName, context, startTime, flow.errorHandling);
3334+
return this.retryExecution(flowName, context, startTime, flow.errorHandling, flow.errorMessage);
33133335
}
33143336
return {
33153337
success: false,
@@ -3350,6 +3372,16 @@ export class AutomationEngine implements IAutomationService {
33503372
// documented as the run's lifecycle verdict, and already the
33513373
// value recorded in the log.
33523374
status: 'failed',
3375+
// [#9414] The author's failure text, BESIDE the raw `error`
3376+
// rather than instead of it. The transport folds `error` into
3377+
// the ADR-0112 message and carries this one in
3378+
// `error.details.errorMessage` — the single place the console
3379+
// reads it from (objectui `flowResponse.ts`, PR #4899). The
3380+
// envelope has no `data`, so a producer that leaves this absent
3381+
// does not degrade to a plainer toast: the consumer falls back
3382+
// to the raw node error text, which is what every non-screen
3383+
// flow showed until now.
3384+
errorMessage: flow.errorMessage,
33533385
// A failed run's counts matter MORE, not less: they say how far
33543386
// it got before dying — how many rows it had already written.
33553387
summary: logged.summary,
@@ -6117,12 +6149,20 @@ export class AutomationEngine implements IAutomationService {
61176149
* the parameter as the parsed shape is what keeps a second set of defaults
61186150
* from growing back here: a knob the spec stops defaulting becomes a
61196151
* compile error, not a silent engine-side guess.
6152+
*
6153+
* `flowErrorMessage` is the author's terminal failure text
6154+
* (`flow.errorMessage`), passed for the same reason `errorHandling` is
6155+
* passed rather than re-read: `execute()` already holds the parsed flow,
6156+
* and the exhausted exit below must report the definition THIS dispatch
6157+
* started under — not whatever a hot-reload re-registered under the same
6158+
* name while the loop slept between attempts (#9414).
61206159
*/
61216160
private async retryExecution(
61226161
flowName: string,
61236162
context: AutomationContext | undefined,
61246163
startTime: number,
61256164
errorHandling: NonNullable<FlowParsed['errorHandling']>,
6165+
flowErrorMessage: string | undefined,
61266166
): Promise<AutomationResult> {
61276167
// `maxRetries >= 1` is guaranteed under `strategy: 'retry'` — the schema
61286168
// refuses the zero-attempt spelling of "retry" (#4247), so reaching this
@@ -6159,7 +6199,20 @@ export class AutomationEngine implements IAutomationService {
61596199
// same lifecycle verdict, and a transport answers it the same way.
61606200
// Without it, a flow whose author chose `errorHandling.strategy:
61616201
// 'retry'` would be the ONE failure shape that still rode HTTP 200.
6162-
return { success: false, error: lastError, durationMs: Date.now() - startTime, status: 'failed' };
6202+
//
6203+
// [#9414] `errorMessage` for the same structural reason: this is a
6204+
// DIFFERENT terminal exit from `execute()`'s own failure return — a
6205+
// flow under `strategy: 'retry'` is handed off above and never reaches
6206+
// that one — so a repair that stopped at `execute()` would leave the
6207+
// author's message missing for precisely the runs most likely to need
6208+
// it, the ones that failed over and over.
6209+
return {
6210+
success: false,
6211+
error: lastError,
6212+
durationMs: Date.now() - startTime,
6213+
status: 'failed',
6214+
errorMessage: flowErrorMessage,
6215+
};
61636216
}
61646217

61656218
/**
@@ -6290,7 +6343,13 @@ export class AutomationEngine implements IAutomationService {
62906343

62916344
// #4354 — a retried run reports its own attempt's counts, not the
62926345
// failed one's: `retryExecution` returns THIS result on success.
6293-
return { success: true, output, durationMs, summary: logged.summary };
6346+
//
6347+
// [#9414] …and because `retryExecution` returns THIS result, this
6348+
// is the exit a run that succeeded on attempt 2+ leaves through.
6349+
// The author's completion text has to be produced here as well, or
6350+
// `successMessage` would be a function of which attempt happened to
6351+
// work — the same route-dependent shape the fix is removing.
6352+
return { success: true, output, durationMs, successMessage: flow.successMessage, summary: logged.summary };
62946353
} catch (err: unknown) {
62956354
const errorMessage = err instanceof Error ? err.message : String(err);
62966355
const durationMs = Date.now() - startTime;
@@ -6311,7 +6370,19 @@ export class AutomationEngine implements IAutomationService {
63116370
// same ran-and-failed exit as the two above and is classified the
63126371
// same: a selective classification is the one a later reader
63136372
// mistakes for a rule.
6314-
return { success: false, error: errorMessage, durationMs, status: 'failed', summary: logged.summary };
6373+
//
6374+
// [#9414] `errorMessage: flow.errorMessage` rides along on the same
6375+
// argument, and note the two names are NOT the same thing here:
6376+
// the local `errorMessage` is the raw thrown text, `flow.errorMessage`
6377+
// is what the author wrote for a human to read.
6378+
return {
6379+
success: false,
6380+
error: errorMessage,
6381+
durationMs,
6382+
status: 'failed',
6383+
errorMessage: flow.errorMessage,
6384+
summary: logged.summary,
6385+
};
63156386
}
63166387
}
63176388
}

0 commit comments

Comments
 (0)