Skip to content

Commit 5363e2d

Browse files
feat(spec,service-automation): a flow run's result carries the flow's authored label as flowLabel (#20633)
Part of #20318 — stage 1 of 2 (the served authored label). Stage 2, the objectui reader of `flows.FLOW.label` in the runner header and completion toast, stays open on the card; this PR does not flip the ledger row. Clause-②: yes (widening: `AutomationResult` gains `flowLabel`) ## What changes `AutomationResult` (`@objectstack/spec/contracts`) gains one optional member, `flowLabel?: string`: the flow definition's authored `label`, copied verbatim by `service-automation` at the same exits that copy `successMessage` / `errorMessage`. `TriggerFlowResponseSchema.data` (`@objectstack/spec/api`) mirrors it, which its compile-time parity guard with `AutomationResult` requires. The generated automation API reference is regenerated. `translateFlow`'s docblock now says where the fallback label comes from and that the translation key is still unread. This is option (b) of seat 4's fork, taken by the claim from standing text: the result already carries flow-level strings copied from the definition for a runner's benefit. ## The served shape (for the stage-2 sub-issue) - **Field:** `flowLabel`, type `string`, optional, on `AutomationResult` and on `TriggerFlowResponse.data`. - **Set** on every result of an evaluation of a registered flow: - `status: 'paused'` (first attempt, a retry attempt, a resume that pauses again, a subflow chain that pauses again); - terminal success with no `status`, including the two skip exits (`condition_not_met`, `reentrancy_loop_guard`); - `'failed'` (the trigger exit and the exhausted-retry exit), `'stranded'`, `'refused'`; - a resumed parent whose delegated subflow child failed (no `status`, `success: false`). - **Absent** on every refusal that carries a `code` (`FLOW_DISABLED`, `FLOW_NO_START_NODE`, `FLOW_INPUT_SCHEMA_INVALID` and all six resume refusals, a nested child's screen refusal included), and when the flow is not registered. - **Subflow rule:** always the label of the run the caller addressed, which is the parent. The child that supplied the screen never lends its label. Decided from what the runner displays: `ScreenFlowState.flowName` is the launched flow, and the delegated pause answers with the parent's `runId`. - **Verbatim:** `FlowSchema` requires `label`, so the member is always present on the set above and is always what the author wrote, an empty string included. It is never replaced by the API name. - **At the wire:** both runner doors relay the result verbatim on a `200`, so `data.flowLabel` arrives on `POST /api/v1/automation/NAME/trigger` (paused, finished, refused or skipped launch) and on `POST /api/v1/automation/NAME/runs/RUN_ID/resume` (a further pause, the completion, a refusal). A `400 FLOW_FAILED` answer does not carry it: the trigger door and `classifyResumeResult` build `error.details` from their own key set (`errorMessage`, `summary`, and on resume the stranded verdict), in `packages/runtime`, and this PR leaves that set alone. See the open question below. ## Premise, verified before the first edit (on `origin/main` c1d8051) 1. Every `successMessage` / `errorMessage` copy site is in `engine.ts` and reads the local parsed `flow`, whose `label` is a required string. The one exception, `retryExecution`'s exhausted exit, receives `flow.errorMessage` as a parameter from `execute()`, which holds `flow`; the label is now passed beside it. The hits in `subflow-node.ts` / `map-node.ts` are comments; node executors return a `NodeExecutionResult`, never an `AutomationResult`. 2. `respondToFlowTrigger` answers the paused arm and the terminal arm with `deps.success(result)`, the resume door answers every `success !== false` result the same way, and the dispatcher's `success()` is `{ success: true, data, meta }` with nothing projected. The `400 FLOW_FAILED` arms project `details` from their own key set (above). ## Verification (at `01cd34c7e4`) - `service-automation`: new `flow-label-on-result.test.ts`, 16 tests (every PRESENT arm, the subflow chain, verbatim `''`, and the ABSENT refusals). Full package `vitest run`: 152 files, 1867 tests passed. `typecheck`: exit 0, test layer 0 debt. - Ablation, three legs through `scripts/ablation-replace.mjs` (anchor must hit, restore proved by blob == HEAD): blanking the 13 direct stamps turned 10 tests red and left 6 green (the refused, retry-exhausted and 4 absent tests), as predicted; blanking the refused chokepoint turned exactly the refused test red; blanking the retry-exhausted exit turned exactly that test red. - Reverse type check: `flowLabel: 42` at the refused chokepoint makes `service-automation`'s `tsc --noEmit` red with TS2322 at `engine.ts:9060`, so it reads the rebuilt spec `.d.ts`. Renaming the mirror member in `automation-api.zod.ts` makes spec's test typecheck red with 3 errors in `automation-api.zod.test.ts`, so the mirror is load-bearing. - `spec`: the touched test files (3 files, 340 tests) pass, `typecheck` exit 0, `check:generated --fix` regenerated only `content/docs/references/api/automation-api.mdx`. - Wire, `@objectstack/verify` (the one package depending on both the doors and the engine): new `automation-trigger-flow-label.test.ts` plus the four sibling automation suites, 5 files, 17 tests passed. The existing paused-run parity test still deep-compares the two routes' bodies, now with `flowLabel` on both. - Pin sweep: no test deep-compares an engine `AutomationResult` outside this package. Consumer suites run green: `plugin-approvals` (791), `trigger-record-change` (101), `trigger-schedule` (150). - Gates: `dispatch-gates --commands` derived 111; `--ran` reconciles 109 run and 2 NOT MEASURED. `check:liveness` exit 0. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: its prerequisite is every package built (33 had no dist), which is CI-owned. - NOT MEASURED: `check:type-check-debt`, reason: `--re-measure` re-runs tsc for every debt-ledger entry repo-wide, none of which is a package this diff touches, and a 300s ceiling fired. `check:type-check-coverage` ran green. ## Acceptance notes - **Surface beyond the claim's list, declared:** `packages/spec/src/api/automation-api.zod.ts` (+ its test and the regenerated reference page) is the trigger transport's declared response schema, bound to `AutomationResult` by `TriggerFlowDataMatchesContract`, and its `z.object` strips undeclared keys, so a client parsing the response would drop the label. `packages/verify/src/automation-trigger-flow-label.test.ts` is the route pin the dispatch asked for. It lives in `verify` because nothing else depends on both packages. - **Two stamped exits no test observes:** `reentrancy_loop_guard` (needs a same-record re-entrant dispatch) and `executeWithoutRetry`'s failed exit (the retry loop consumes it and answers from the exhausted exit). Both are stamped, and the first ablation leg blanked them. - **The wire file was not ablated at `dist` level.** Its PRESENT assertions are `toBe(label)`, which an absent member cannot satisfy, and the engine-side legs above already show the mutation reds the equivalent pins. - `seedRunVariables` still writes `$flowLabel` as `flow.label ?? flowName`. The `??` arm is unreachable because `label` is required. Noted only. - Read, not measured: the resumed-parent exit for a delegated child failure carries no `errorMessage`. The contract says `errorMessage` is set on failure, so a runner would show the raw subflow error there instead of the parent author's text. The probe at the resume door never got the shared verify lock, so this stays a note and no card is filed. Carrier: none. - The `translations.mdx` bullet, the ledger flip and the `authorWarn` drop ride the stage-2 reader, per the claim. `packages/spec/liveness/translation.json` is unchanged: no note names a missing producer, and nothing in it became false. ## Open question for the seat Should the `400 FLOW_FAILED` details carry `flowLabel`? My recommendation is no. No runner surface names the flow on a failure: the failure toast shows the error text, and the `Flow "NAME" failed` fallback is built client-side before the request. Widening an ADR-0112 details set in `packages/runtime` with no reader is outside this claim. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d04b6b commit 5363e2d

9 files changed

Lines changed: 585 additions & 8 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
feat(spec,service-automation): a flow run's result carries the flow's authored label as `flowLabel` (#20318)
7+
8+
Clause-②: yes (widening)
9+
10+
**The widening.** `AutomationResult` (`@objectstack/spec/contracts`) gains one
11+
optional member, `flowLabel?: string`, and `TriggerFlowResponseSchema`
12+
(`@objectstack/spec/api`) mirrors it on `data`. The automation engine sets it to
13+
the flow definition's `label`, copied verbatim, the same way it copies
14+
`successMessage` and `errorMessage`. Nothing is removed or renamed, and no
15+
existing member changes meaning.
16+
17+
**Why.** A flow runner names the flow it is running, in its header and in its
18+
completion toast, and translates that name against the `flows.<flow>.label`
19+
translation key, falling back to the authored label. The runner only held the
20+
flow's API name, so there was no authored label to fall back to. The console's
21+
reader of the translation key is a separate change.
22+
23+
**Which results carry it.**
24+
25+
- **Set** on every result of an evaluation of a registered flow: `status: 'paused'`
26+
(first attempt, retry attempt, a resume that pauses again), a terminal success
27+
(including the two skip exits), `'failed'` (including an exhausted retry budget),
28+
`'stranded'`, `'refused'`, and a resumed parent whose delegated child failed.
29+
- **Absent** on every refusal that carries a `code` (the run never dispatched, or a
30+
resume never continued it) and when the flow is not registered.
31+
- **Subflow chains** answer with the label of the run the caller addressed, which
32+
is the parent. The child that supplied the screen does not lend its label.
33+
- **Never the API name.** `FlowSchema` requires `label`, so the value is always
34+
what the author wrote, an empty string included.
35+
36+
**At the wire.** Both runner doors relay the result verbatim on a `200`, so
37+
`data.flowLabel` arrives on `POST /api/v1/automation/:name/trigger` (a paused or
38+
finished launch) and on `POST /api/v1/automation/:name/runs/:runId/resume` (a
39+
further pause or the completion). A `400 FLOW_FAILED` answer is unchanged: its
40+
`error.details` keep their fixed set (`errorMessage`, `summary` and, on resume,
41+
the stranded verdict), with no `flowLabel`.
42+
43+
**For a consumer.** A client that parses the trigger response with
44+
`TriggerFlowResponseSchema` now keeps `data.flowLabel`, where an undeclared key
45+
would have been stripped. A caller that deep-compares a whole `AutomationResult`
46+
from `execute()` or `resume()` sees one more key on the results listed above.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ const result = AutomationApiErrorCode.parse(data);
642642
| **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. |
643643
| **successMessage** | `string` | optional | Friendly terminal message copied from the flow definition on terminal success, so a screen-flow runner can show a meaningful toast |
644644
| **errorMessage** | `string` | optional | Friendly terminal message copied from the flow definition on failure |
645+
| **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 |
645646
| **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 |
646647
| **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). |
647648

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5364,7 +5364,10 @@ export class AutomationEngine implements IAutomationService {
53645364
typeof startCondition === 'string' ? { dialect: 'cel', source: startCondition } : startCondition;
53655365
if (!this.evaluateCondition(condExpr, variables)) {
53665366
this.logger.debug(`Flow '${flowName}' skipped: start condition not met`);
5367-
return { success: true, output: { skipped: true, reason: 'condition_not_met' } };
5367+
// `flowLabel` rides even here, unlike `successMessage` /
5368+
// `summary`: it names the flow, it claims no work done, and
5369+
// this answer reaches a runner as a 200 like any other.
5370+
return { success: true, output: { skipped: true, reason: 'condition_not_met' }, flowLabel: flow.label };
53685371
}
53695372
}
53705373

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

@@ -5780,7 +5788,9 @@ export class AutomationEngine implements IAutomationService {
57805788
// `persistSuspendedRun` stored the continuation under — so it is
57815789
// the only id `resume()` can be called with.
57825790
if (flow.errorHandling?.strategy === 'retry') {
5783-
return this.retryExecution(flowName, context, startTime, flow.errorHandling, flow.errorMessage);
5791+
return this.retryExecution(
5792+
flowName, context, startTime, flow.errorHandling, flow.errorMessage, flow.label,
5793+
);
57845794
}
57855795
return {
57865796
success: false,
@@ -5833,6 +5843,7 @@ export class AutomationEngine implements IAutomationService {
58335843
// to the raw node error text, which is what every non-screen
58345844
// flow showed until now.
58355845
errorMessage: flow.errorMessage,
5846+
flowLabel: flow.label,
58365847
// A failed run's counts matter MORE, not less: they say how far
58375848
// it got before dying — how many rows it had already written.
58385849
// [#17562] Recomputed when the guard above had to abandon
@@ -6532,6 +6543,10 @@ export class AutomationEngine implements IAutomationService {
65326543
runId,
65336544
durationMs: Date.now() - run.startTime,
65346545
screen: childRes.screen,
6546+
// THIS run's flow, not the child's: the caller
6547+
// addressed this run id and its runner names the
6548+
// flow it launched. The child only lends a screen.
6549+
flowLabel: flow.label,
65356550
};
65366551
}
65376552
// [#14379] A child REFUSAL is not a child failure. The
@@ -6593,7 +6608,7 @@ export class AutomationEngine implements IAutomationService {
65936608
error,
65946609
this.consumedSuspensions.has(childRunId) ? childRunId : undefined,
65956610
);
6596-
return { success: false, error, durationMs: Date.now() - run.startTime };
6611+
return { success: false, error, durationMs: Date.now() - run.startTime, flowLabel: flow.label };
65976612
}
65986613
// [#18714] DELEGATED-LEG REFUSAL. The child ran to a
65996614
// refusing terminal — an `end` declaring
@@ -7007,6 +7022,7 @@ export class AutomationEngine implements IAutomationService {
70077022
output,
70087023
durationMs,
70097024
successMessage: flow.successMessage,
7025+
flowLabel: flow.label,
70107026
summary,
70117027
};
70127028
} catch (err: unknown) {
@@ -7097,7 +7113,7 @@ export class AutomationEngine implements IAutomationService {
70977113
steps,
70987114
variables: variablesSnapshot,
70997115
}, context);
7100-
return { success: true, status: 'paused', runId, durationMs, screen: err.screen };
7116+
return { success: true, status: 'paused', runId, durationMs, screen: err.screen, flowLabel: flow.label };
71017117
}
71027118

71037119
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -7256,6 +7272,7 @@ export class AutomationEngine implements IAutomationService {
72567272
// worse) condition, which this stamp must not claim.
72577273
status: 'stranded',
72587274
errorMessage: flow.errorMessage,
7275+
flowLabel: flow.label,
72597276
// [#15555] Recomputed when the guard above had to abandon
72607277
// `recordLog`: the same pure function of the same steps
72617278
// that `recordLog`'s own first statement runs, so the two
@@ -8970,6 +8987,8 @@ export class AutomationEngine implements IAutomationService {
89708987
* `'paused'`, so callers can resume it", and a refused run is never
89718988
* resumed — handing one back would advertise a verb that answers
89728989
* `RUN_NOT_FOUND`.
8990+
* - `flowLabel` — the refused run's own flow, as on every evaluation's
8991+
* result: the refusal notice is still shown under the flow's name.
89738992
*
89748993
* The `recordLog` call is guarded exactly as the completion sites are
89758994
* (#16274 / #15555): a history write must never break the run that
@@ -9038,6 +9057,7 @@ export class AutomationEngine implements IAutomationService {
90389057
success: true,
90399058
status: 'refused',
90409059
refusalMessage: args.refusalMessage,
9060+
flowLabel: args.flow.label,
90419061
output,
90429062
durationMs: args.durationMs,
90439063
summary: logged?.summary ?? summarizeRun(args.steps),
@@ -11252,14 +11272,16 @@ export class AutomationEngine implements IAutomationService {
1125211272
* passed rather than re-read: `execute()` already holds the parsed flow,
1125311273
* and the exhausted exit below must report the definition THIS dispatch
1125411274
* started under — not whatever a hot-reload re-registered under the same
11255-
* name while the loop slept between attempts (#9414).
11275+
* name while the loop slept between attempts (#9414). `flowLabel` is
11276+
* passed for the same reason.
1125611277
*/
1125711278
private async retryExecution(
1125811279
flowName: string,
1125911280
context: AutomationContext | undefined,
1126011281
startTime: number,
1126111282
errorHandling: NonNullable<FlowParsed['errorHandling']>,
1126211283
flowErrorMessage: string | undefined,
11284+
flowLabel: string,
1126311285
): Promise<AutomationResult> {
1126411286
// `maxRetries >= 1` is guaranteed under `strategy: 'retry'` — the schema
1126511287
// refuses the zero-attempt spelling of "retry" (#4247), so reaching this
@@ -11332,6 +11354,7 @@ export class AutomationEngine implements IAutomationService {
1133211354
durationMs: Date.now() - startTime,
1133311355
status: 'failed',
1133411356
errorMessage: flowErrorMessage,
11357+
flowLabel,
1133511358
};
1133611359
}
1133711360

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

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

0 commit comments

Comments
 (0)