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
46 changes: 46 additions & 0 deletions .changeset/20318-automation-result-flow-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

feat(spec,service-automation): a flow run's result carries the flow's authored label as `flowLabel` (#20318)

Clause-②: yes (widening)

**The widening.** `AutomationResult` (`@objectstack/spec/contracts`) gains one
optional member, `flowLabel?: string`, and `TriggerFlowResponseSchema`
(`@objectstack/spec/api`) mirrors it on `data`. The automation engine sets it to
the flow definition's `label`, copied verbatim, the same way it copies
`successMessage` and `errorMessage`. Nothing is removed or renamed, and no
existing member changes meaning.

**Why.** A flow runner names the flow it is running, in its header and in its
completion toast, and translates that name against the `flows.<flow>.label`
translation key, falling back to the authored label. The runner only held the
flow's API name, so there was no authored label to fall back to. The console's
reader of the translation key is a separate change.

**Which results carry it.**

- **Set** on every result of an evaluation of a registered flow: `status: 'paused'`
(first attempt, retry attempt, a resume that pauses again), a terminal success
(including the two skip exits), `'failed'` (including an exhausted retry budget),
`'stranded'`, `'refused'`, and a resumed parent whose delegated child failed.
- **Absent** on every refusal that carries a `code` (the run never dispatched, or a
resume never continued it) and when the flow is not registered.
- **Subflow chains** answer with the label of the run the caller addressed, which
is the parent. The child that supplied the screen does not lend its label.
- **Never the API name.** `FlowSchema` requires `label`, so the value is always
what the author wrote, an empty string included.

**At the wire.** Both runner doors relay the result verbatim on a `200`, so
`data.flowLabel` arrives on `POST /api/v1/automation/:name/trigger` (a paused or
finished launch) and on `POST /api/v1/automation/:name/runs/:runId/resume` (a
further pause or the completion). A `400 FLOW_FAILED` answer is unchanged: its
`error.details` keep their fixed set (`errorMessage`, `summary` and, on resume,
the stranded verdict), with no `flowLabel`.

**For a consumer.** A client that parses the trigger response with
`TriggerFlowResponseSchema` now keeps `data.flowLabel`, where an undeclared key
would have been stripped. A caller that deep-compares a whole `AutomationResult`
from `execute()` or `resume()` sees one more key on the results listed above.
1 change: 1 addition & 0 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ const result = AutomationApiErrorCode.parse(data);
| **screen** | `{ nodeId: string; title?: string; description?: string; fields: object[]; … }` | optional | The screen to render - set when the run paused at a `screen` node awaiting user input. The client collects values for `screen.fields` and resumes the run with them. |
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
| **flowLabel** | `string` | optional | The flow definition's authored `label`, copied verbatim so a runner can name the flow (header, completion toast) and translate it against `flows.<flow>.label`. Set on every result of an evaluation of a registered flow (paused and terminal alike); absent on a refusal carrying `code`. For a subflow chain it is the addressed (parent) run's flow. Never defaulted to the API name |
| **refusalMessage** | `string` | optional | Rendered refusal, set when `status` is `refused` - the `end` node's `message` template interpolated against the run's variables, so it names the record. Authored per-record text (not a flow-level copy like the two above); absent on every other status. A runner shows it with Close only |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |

Expand Down
39 changes: 32 additions & 7 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5364,7 +5364,10 @@ export class AutomationEngine implements IAutomationService {
typeof startCondition === 'string' ? { dialect: 'cel', source: startCondition } : startCondition;
if (!this.evaluateCondition(condExpr, variables)) {
this.logger.debug(`Flow '${flowName}' skipped: start condition not met`);
return { success: true, output: { skipped: true, reason: 'condition_not_met' } };
// `flowLabel` rides even here, unlike `successMessage` /
// `summary`: it names the flow, it claims no work done, and
// this answer reaches a runner as a 200 like any other.
return { success: true, output: { skipped: true, reason: 'condition_not_met' }, flowLabel: flow.label };
}
}

Expand Down Expand Up @@ -5411,7 +5414,7 @@ export class AutomationEngine implements IAutomationService {
`note booleans persist as 0/1 on SQLite/libsql and CEL \`1 != true\` is true.`,
{ recordId: String(guardRecordId) },
);
return { success: true, output: { skipped: true, reason: 'reentrancy_loop_guard' } };
return { success: true, output: { skipped: true, reason: 'reentrancy_loop_guard' }, flowLabel: flow.label };
}
if (reentryKey) {
this.activeRecordFlows.add(reentryKey);
Expand Down Expand Up @@ -5568,6 +5571,10 @@ export class AutomationEngine implements IAutomationService {
// one would be a toast about work nobody did. They carry no
// `summary` for exactly the same reason.
successMessage: flow.successMessage,
// The authored flow name for the runner header and the
// completion toast — on EVERY evaluation's result, paused and
// terminal alike (see `AutomationResult.flowLabel`).
flowLabel: flow.label,
// #4354 — hand the counts back synchronously so a caller
// (a `subflow` roll-up, a runtime test asserting the sweep wrote
// something) never has to re-read the run to learn what it did.
Expand Down Expand Up @@ -5631,6 +5638,7 @@ export class AutomationEngine implements IAutomationService {
runId,
durationMs,
screen: err.screen,
flowLabel: flow.label,
};
}

Expand Down Expand Up @@ -5780,7 +5788,9 @@ export class AutomationEngine implements IAutomationService {
// `persistSuspendedRun` stored the continuation under — so it is
// the only id `resume()` can be called with.
if (flow.errorHandling?.strategy === 'retry') {
return this.retryExecution(flowName, context, startTime, flow.errorHandling, flow.errorMessage);
return this.retryExecution(
flowName, context, startTime, flow.errorHandling, flow.errorMessage, flow.label,
);
}
return {
success: false,
Expand Down Expand Up @@ -5833,6 +5843,7 @@ export class AutomationEngine implements IAutomationService {
// to the raw node error text, which is what every non-screen
// flow showed until now.
errorMessage: flow.errorMessage,
flowLabel: flow.label,
// A failed run's counts matter MORE, not less: they say how far
// it got before dying — how many rows it had already written.
// [#17562] Recomputed when the guard above had to abandon
Expand Down Expand Up @@ -6532,6 +6543,10 @@ export class AutomationEngine implements IAutomationService {
runId,
durationMs: Date.now() - run.startTime,
screen: childRes.screen,
// THIS run's flow, not the child's: the caller
// addressed this run id and its runner names the
// flow it launched. The child only lends a screen.
flowLabel: flow.label,
};
}
// [#14379] A child REFUSAL is not a child failure. The
Expand Down Expand Up @@ -6593,7 +6608,7 @@ export class AutomationEngine implements IAutomationService {
error,
this.consumedSuspensions.has(childRunId) ? childRunId : undefined,
);
return { success: false, error, durationMs: Date.now() - run.startTime };
return { success: false, error, durationMs: Date.now() - run.startTime, flowLabel: flow.label };
}
// [#18714] DELEGATED-LEG REFUSAL. The child ran to a
// refusing terminal — an `end` declaring
Expand Down Expand Up @@ -7007,6 +7022,7 @@ export class AutomationEngine implements IAutomationService {
output,
durationMs,
successMessage: flow.successMessage,
flowLabel: flow.label,
summary,
};
} catch (err: unknown) {
Expand Down Expand Up @@ -7097,7 +7113,7 @@ export class AutomationEngine implements IAutomationService {
steps,
variables: variablesSnapshot,
}, context);
return { success: true, status: 'paused', runId, durationMs, screen: err.screen };
return { success: true, status: 'paused', runId, durationMs, screen: err.screen, flowLabel: flow.label };
}

const errorMessage = err instanceof Error ? err.message : String(err);
Expand Down Expand Up @@ -7256,6 +7272,7 @@ export class AutomationEngine implements IAutomationService {
// worse) condition, which this stamp must not claim.
status: 'stranded',
errorMessage: flow.errorMessage,
flowLabel: flow.label,
// [#15555] Recomputed when the guard above had to abandon
// `recordLog`: the same pure function of the same steps
// that `recordLog`'s own first statement runs, so the two
Expand Down Expand Up @@ -8970,6 +8987,8 @@ export class AutomationEngine implements IAutomationService {
* `'paused'`, so callers can resume it", and a refused run is never
* resumed — handing one back would advertise a verb that answers
* `RUN_NOT_FOUND`.
* - `flowLabel` — the refused run's own flow, as on every evaluation's
* result: the refusal notice is still shown under the flow's name.
*
* The `recordLog` call is guarded exactly as the completion sites are
* (#16274 / #15555): a history write must never break the run that
Expand Down Expand Up @@ -9038,6 +9057,7 @@ export class AutomationEngine implements IAutomationService {
success: true,
status: 'refused',
refusalMessage: args.refusalMessage,
flowLabel: args.flow.label,
output,
durationMs: args.durationMs,
summary: logged?.summary ?? summarizeRun(args.steps),
Expand Down Expand Up @@ -11252,14 +11272,16 @@ export class AutomationEngine implements IAutomationService {
* passed rather than re-read: `execute()` already holds the parsed flow,
* and the exhausted exit below must report the definition THIS dispatch
* started under — not whatever a hot-reload re-registered under the same
* name while the loop slept between attempts (#9414).
* name while the loop slept between attempts (#9414). `flowLabel` is
* passed for the same reason.
*/
private async retryExecution(
flowName: string,
context: AutomationContext | undefined,
startTime: number,
errorHandling: NonNullable<FlowParsed['errorHandling']>,
flowErrorMessage: string | undefined,
flowLabel: string,
): Promise<AutomationResult> {
// `maxRetries >= 1` is guaranteed under `strategy: 'retry'` — the schema
// refuses the zero-attempt spelling of "retry" (#4247), so reaching this
Expand Down Expand Up @@ -11332,6 +11354,7 @@ export class AutomationEngine implements IAutomationService {
durationMs: Date.now() - startTime,
status: 'failed',
errorMessage: flowErrorMessage,
flowLabel,
};
}

Expand Down Expand Up @@ -11627,7 +11650,7 @@ export class AutomationEngine implements IAutomationService {
// The author's completion text has to be produced here as well, or
// `successMessage` would be a function of which attempt happened to
// work — the same route-dependent shape the fix is removing.
return { success: true, output, durationMs, successMessage: flow.successMessage, summary };
return { success: true, output, durationMs, successMessage: flow.successMessage, flowLabel: flow.label, summary };
} catch (err: unknown) {
// [#15788] The THIRD producer: an attempt that reached a refusing
// `end`. A flow under `errorHandling.strategy: 'retry'` is handed
Expand Down Expand Up @@ -11730,6 +11753,7 @@ export class AutomationEngine implements IAutomationService {
runId,
durationMs,
screen: err.screen,
flowLabel: flow.label,
};
}

Expand Down Expand Up @@ -11802,6 +11826,7 @@ export class AutomationEngine implements IAutomationService {
durationMs,
status: 'failed',
errorMessage: flow.errorMessage,
flowLabel: flow.label,
// [#17562] Recomputed when the guard above had to abandon
// `recordLog`: the same pure function of the same steps that
// `recordLog`'s own first statement runs, so the two spellings
Expand Down
Loading
Loading