diff --git a/.changeset/28054-flow-message-template.md b/.changeset/28054-flow-message-template.md new file mode 100644 index 00000000000..2466d557013 --- /dev/null +++ b/.changeset/28054-flow-message-template.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/lint': minor +'@object-ui/app-shell': minor +--- + +feat(automation): a flow's `successMessage` / `errorMessage` are `{{ }}` text slots over the run's variables + +Clause-②: no (narrowing) — `registerFlow` and `objectstack validate` (compile, the publish gate) now refuse a flow whose `successMessage` / `errorMessage` carries a single-brace `{token}` or a `{{ }}` hole that does not compile, where they accepted any string before. A `{{ }}` hole in either key now renders instead of being copied, which changes what a stored message means and widens no accept set. + +Metadata-protocol: narrowed (`FlowSchema.successMessage` / `errorMessage`, and the `flows.FLOW.successMessage` / `.errorMessage` translation leaves, are refused at the flow doors and the translation parse when they carry a single-brace token or an uncompilable hole; the meaning shift of a `{{ }}` hole, from literal text to a rendered slot, is the semantic half and ranks below it) + + + +A screen flow can now finish on "3 contacts enrolled": `successMessage: '{{ enrolledCount }} contacts enrolled'` renders the count the run bound. Until now the engine copied both messages onto the run result as written, so a `{{ }}` hole reached the user raw. + +- **Spec (`@objectstack/spec`).** `FlowSchema.successMessage` and `errorMessage` join the flow text slots (`FLOW_TEXT_SLOTS`, `flowTextSlotSources` in `automation/flow-text-slot-template.ts`): `{{ path }}` and `{{ path | formatter }}` holes over the run's variables, the grammar a refusing `end` node's `message` reads. The `flows..successMessage` / `.errorMessage` translations are the same text slot as their source, so a single-brace `{token}` in one is refused, and `flowTerminalMessageKey(flow, key)` spells the key the engine reads. +- **Engine (`@objectstack/service-automation`).** Every terminal exit renders the message instead of copying it: `successMessage` over the variables the run finished with (first run, a retried attempt, a resume), `errorMessage` over the variables at the failure point (`{{ $error.message }}` included; the retry-exhausted exit reports the last attempt's rendering). The translated template is picked in the run's locale before the holes are filled, exactly as for a screen's heading. A hole over a variable the run never bound renders nothing, as in every text slot, and a message with no hole is byte-identical. A message that cannot render (a `{{ x | currency }}` hole with no tenant default currency) is left off the result with a warning; the run's verdict stands. +- **Doors (`registerFlow`, `objectstack validate`, the publish gate).** Both keys are judged as a node's text slot is: a single-brace `{var}` token is refused with its `{{ var }}` spelling, and a hole that does not compile (`{{ a + b }}`) is refused with the compile step's words. +- **Console (`@object-ui/app-shell`).** The flow's toasts are drawn as served. The client overlay of `flows..successMessage` / `.errorMessage` is removed, because the engine now serves them translated and filled, and an overlay would put the bundle's template, holes raw, over the filled sentence. `launchConsoleFlow` takes the host's `language` in place of `translation: { i18n, language }`, and `interpretFlowResponse` takes the locale string in place of `FlowCopyLocale`. + +**BREAKING** accept-set narrowing at the flow doors (`@objectstack/spec`, `@objectstack/service-automation`, `@objectstack/lint`), shipped as `minor`. + +- **Before.** `successMessage: 'Enrolled {count} contacts'` registered, `objectstack validate` exited 0, and the run's result carried the string as written, braces and all. A `{{ enrolledCount }}` hole was copied the same way. +- **Now.** `registerFlow` refuses that flow at `successMessage` with the text-slot refusal an `end` node's `message` gets, spelling `{{ count }}`; `objectstack validate` / compile / the publish gate report the issue at `flows[i].successMessage` and exit 1. A hole that does not compile (`{{ a + b }}`) is refused with the compile step's words. A translated `flows.FLOW.successMessage` / `.errorMessage` with a single-brace token is refused by the translation parse. A well-formed `{{ enrolledCount }}` renders over the run's variables at the terminal exit. +- **Unchanged.** A message with no brace renders byte-identical. An absent message stays absent. A hole over a variable the run never bound renders blank, as in every text slot. The run's own verdict, status and history row do not depend on the message. +- **Who sees it.** An author whose flow message carries a closed single-brace token, a hole that does not compile, or a hole over a `$` root the engine does not bind: 0 first-party flows do. + +**The remedy.** Write `{{ var }}` holes over the run's variables, spelled as the refusal prints them. For a `{word}` meant as literal text, rephrase it so no closed single-brace token remains (drop the braces, or use other brackets); a lone `{` that closes nothing is not judged. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 5e796eeb7d9..38091144e20 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -79,8 +79,8 @@ const approvalFlow = { | `nodes` | `FlowNode[]` | ✅ | Flow nodes | | `edges` | `FlowEdge[]` | ✅ | Connections between nodes | | `errorHandling` | `object` | optional | Error handling strategy | -| `successMessage` | `string` | optional | Toast a screen-flow runner shows on terminal success (plain string — `{var}` is **not** interpolated) | -| `errorMessage` | `string` | optional | Toast a screen-flow runner shows on failure, instead of the raw error | +| `successMessage` | `string` | optional | Toast a screen-flow runner shows on terminal success — a `{{ }}` template over the run's variables (`'{{ enrolledCount }} contacts enrolled'`), rendered when the run completes | +| `errorMessage` | `string` | optional | Toast a screen-flow runner shows on failure, instead of the raw error — a `{{ }}` template rendered over the variables at the failure point (`{{ $error.message }}` included) | ### Flow Types diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index af076bdc954..5c9cd00b0d6 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -169,8 +169,8 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | @@ -297,8 +297,8 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | @@ -382,7 +382,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **errorMessage** | `string` | optional | The flow author's own failure text (`flow.errorMessage`), copied verbatim from `AutomationResult.errorMessage`; absent when the author wrote none | +| **errorMessage** | `string` | optional | The flow author's own failure text (`flow.errorMessage`, rendered by the engine), copied verbatim from `AutomationResult.errorMessage`; absent when the author wrote none | | **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | The run's per-node accounting (`AutomationResult.summary`) - which node failed; absent when the engine measured none | | **innerRefusal** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string }` | optional | The refusal a node's write met inside the run (`AutomationResult.innerRefusal`): `{ code, message }`, `code` in its wire spelling (e.g. `UNIQUE_VIOLATION`). Absent when the failure met no refusal | @@ -839,8 +839,8 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | @@ -1122,8 +1122,8 @@ const result = AutomationApiErrorCode.parse(data); | **status** | `Enum<'completed' \| 'paused' \| 'failed' \| 'stranded' \| 'refused'>` | optional | Lifecycle status. `paused` means the run suspended at a node and can be continued with the resume route. Absent or `completed`/`failed`/`stranded`/`refused` means the run reached a terminal state. `refused` is a first-class refusal: the flow reached an `end` node declaring `outcome: 'refused'` — a successful evaluation that said no, so `success` is true, `successMessage` is absent and the per-record reason is on `refusalMessage`; a runner shows it with Close only. `stranded` is the terminally-failed-but-repairable run: a resume consumed the suspension and a downstream node threw, so the run is recorded as failed and can be re-armed only by an explicit operator verb - never by the resume route, which answers RUN_NOT_FOUND for it. | | **runId** | `string` | optional | Run id - set when `status` is `paused`, so callers can resume it | | **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 | +| **successMessage** | `string` | optional | Friendly terminal message rendered from the flow definition on terminal success (its `{{ }}` holes filled over the run's variables, in the run's locale), so a screen-flow runner can show a meaningful toast | +| **errorMessage** | `string` | optional | Friendly terminal message rendered from the flow definition on failure (its `{{ }}` holes filled over the variables at the failure point, in the run's locale) | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim, of the refusal a node met inside the run - present exactly when that refusal's producer opted in at throw time (`ApiErrorSchema.userMessage` semantics). Never replaces `error` | | **innerRefusal** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; message: string }` | optional | The refusal a node's write met inside the run, structured: `{ code, message }`, `code` in its wire spelling. Set on a failed run only when a refusal was met; `code` above and `error` are unchanged by it | | **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..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 | @@ -1149,8 +1149,8 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | @@ -1214,8 +1214,8 @@ const result = AutomationApiErrorCode.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 7cfead5157c..2d52edb61a9 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -31,8 +31,8 @@ const result = FlowSchema.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | @@ -253,8 +253,8 @@ const result = FlowSchema.parse(data); | **name** | `string` | ✅ | Machine name | | **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | -| **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | -| **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | +| **successMessage** | `string` | optional | Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run's variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes. | +| **errorMessage** | `string` | optional | Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run's variables, rendered over the variables at the failure point. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index b18e87f2c45..e21e860cec8 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -280,8 +280,8 @@ Translation data for a single object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | -| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **successMessage** | `string` | optional | Translated completion toast — a `{{ }}` template like the flow's own `successMessage`, picked by the engine in the run's locale and rendered in its place over the run's variables; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — a `{{ }}` template like the flow's own `errorMessage`, picked by the engine in the run's locale and rendered in its place over the variables at the failure point; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | @@ -455,8 +455,8 @@ Translation data for a single object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | -| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **successMessage** | `string` | optional | Translated completion toast — a `{{ }}` template like the flow's own `successMessage`, picked by the engine in the run's locale and rendered in its place over the run's variables; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — a `{{ }}` template like the flow's own `errorMessage`, picked by the engine in the run's locale and rendered in its place over the variables at the failure point; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | @@ -692,8 +692,8 @@ Translation data for a single object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | -| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **successMessage** | `string` | optional | Translated completion toast — a `{{ }}` template like the flow's own `successMessage`, picked by the engine in the run's locale and rendered in its place over the run's variables; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — a `{{ }}` template like the flow's own `errorMessage`, picked by the engine in the run's locale and rendered in its place over the variables at the failure point; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | @@ -899,8 +899,8 @@ Translation data for a single object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | -| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | -| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **successMessage** | `string` | optional | Translated completion toast — a `{{ }}` template like the flow's own `successMessage`, picked by the engine in the run's locale and rendered in its place over the run's variables; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — a `{{ }}` template like the flow's own `errorMessage`, picked by the engine in the run's locale and rendered in its place over the variables at the failure point; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 6cbddc8107e..a2881fa6023 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -422,12 +422,14 @@ Honest limits worth knowing before you plan around them: is deliberately small.** The keys are addressed the way the runner resolves them — flow name, screen node id, screen field name — and every part renders in the active locale: - - a screen's `title` and `description` are `{{ }}` templates the server - renders for each run, so the server translates them: it picks the - translated template in the locale of the person who started the run, then - fills its holes, and the console draws the screen as served. A translation - keeps the holes of the text it translates (`'任务“{{ subject }}”创建成功!'`), - and `description` is translated only where the screen authors one; + - a screen's `title` and `description`, and the flow's own `successMessage` + and `errorMessage`, are `{{ }}` templates the server renders for each run, + so the server translates them: it picks the translated template in the + locale of the person who started the run, then fills its holes, and the + console draws the screen and the toast as served. A translation keeps the + holes of the text it translates (`'任务“{{ subject }}”创建成功!'`), and + `description`, `successMessage` and `errorMessage` are translated only + where the flow authors them; - each field's `label`, `placeholder` and `inlineHelpText` is applied by the console's screen-flow runner; - the flow's own `label` names the flow in the runner's header, above the @@ -435,11 +437,10 @@ Honest limits worth knowing before you plan around them: cover shows the label authored on the flow, and a backend that serves no label shows the flow's API name. - Two strings stay outside the group. A `successMessage` authored on the flow - replaces the completion sentence and renders as authored, so the translated - label appears in the toast only when the flow declares none. The runner's own - chrome — the Cancel and Submit buttons — belongs to the console's message - catalog rather than your app's bundle. + A `successMessage` authored on the flow replaces the completion sentence, so + the translated label appears in the toast only when the flow declares none. + The runner's own chrome — the Cancel and Submit buttons — belongs to the + console's message catalog rather than your app's bundle. **The tooling asks for these keys like any other group's.** `os lint` reports a missing `flows.*` key against `supportedLocales`, and `os i18n extract` diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index c0ac47ab84e..4038c9570ec 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -5349,6 +5349,49 @@ describe('the http text slots, body and literals are refused with their remedies }); }); +describe('a flow\'s own text slots — `successMessage` / `errorMessage` (#28054)', () => { + const stackWith = (messages: Record, endMessage?: string) => ({ + flows: [{ + name: 'enrol', + ...messages, + nodes: [ + { id: 'start', type: 'start' }, + ...(endMessage !== undefined ? [{ id: 'finish', type: 'end', config: { outcome: 'refused', message: endMessage } }] : []), + ], + edges: [], + }], + }); + const errorsOf = (stack: unknown) => + validateStackExpressions(stack as never).filter((i) => (i.severity ?? 'error') === 'error'); + + it.each(['successMessage', 'errorMessage'] as const)('%s: a single-brace token is refused at `error`, as in a node text slot', (key) => { + const text = '{enrolledCount} contacts enrolled'; + const found = errorsOf(stackWith({ [key]: text })); + expect(found).toHaveLength(1); + expect(found[0]!.where).toBe(`flow 'enrol' ${key}`); + expect(found[0]!.path).toBe(`flows[0].${key}`); + expect(found[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(found[0]!.message).toContain('`{{ enrolledCount }} contacts enrolled`'); + // The node text slot carrying the same text is refused with the same words. + const node = errorsOf(stackWith({}, text)); + expect(node).toHaveLength(1); + expect(node[0]!.message).toBe(found[0]!.message); + }); + + it('a hole that does not compile is refused with the compile step\'s words, as in a node text slot', () => { + const text = 'Enrolled {{ enrolledCount + 1 }} contacts'; + const found = errorsOf(stackWith({ successMessage: text })); + const node = errorsOf(stackWith({}, text)); + expect(found).toHaveLength(1); + expect(node).toHaveLength(1); + expect(found[0]!.message).toBe(node[0]!.message); + }); + + it('a well-formed template and a plain sentence are clean', () => { + expect(errorsOf(stackWith({ successMessage: '{{ enrolledCount }} contacts enrolled', errorMessage: 'Enrolment failed.' }))).toEqual([]); + }); +}); + describe('node config an executor requires (#20316)', () => { const stackWith = (node: Record) => ({ flows: [{ name: 'config_flow', nodes: [{ id: 'start', type: 'start' }, { id: 'n', ...node }], edges: [] }], diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 07fac05f121..5d244c3d346 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -103,6 +103,7 @@ import { resolveFlowNodeExpressions, flowNodeValueTemplateRefusals, flowNodeTextSlotSources, + flowTextSlotSources, textSlotTemplateRefusal, structuralConditionRefusal, closedScopeTemplateRefusal, @@ -2715,6 +2716,25 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression return { refused: false }; }; + // [#28054] The flow's OWN text slots — `successMessage` / `errorMessage`, + // which the engine renders at the run's terminal exit — judged by the same + // two checks `registerFlow` runs on them, the node text slots' pair below: + // the spec's one single-brace judge, then the `template` compile. A + // finding carries the flow key's path (`flows[0].successMessage`). + for (const slot of flowTextSlotSources(flow)) { + // `flow 'NAME' successMessage` — the key, not the label, so the word `flow` is said once. + const slotWhere = `flow '${flowName}' ${slot.path}`; + const path = flowIndex >= 0 ? { path: `flows[${flowIndex}].${slot.path}` } : {}; + const tokenRefusal = textSlotTemplateRefusal(slot.source); + if (tokenRefusal !== undefined) { + issues.push({ where: slotWhere, message: tokenRefusal, source: slot.source, severity: 'error', ...path }); + continue; + } + for (const e of validateExpression('template', slot.source).errors) { + issues.push({ where: slotWhere, message: e.message, source: e.source, severity: 'error', ...path }); + } + } + for (const graph of graphs) { const at = graph.scope ? `flow '${flowName}' · ${graph.scope}` : `flow '${flowName}'`; // `recordsOf`, not `as unknown as AnyRec[]` (#15793). A NESTED region's diff --git a/packages/services/service-automation/src/dag-traversal.ts b/packages/services/service-automation/src/dag-traversal.ts index 76db37d7021..14c4714ae09 100644 --- a/packages/services/service-automation/src/dag-traversal.ts +++ b/packages/services/service-automation/src/dag-traversal.ts @@ -140,6 +140,7 @@ export interface DagTraversalHost { readonly finishRefusedRun: AutomationEngine['finishRefusedRun']; readonly persistSuspendedRun: AutomationEngine['persistSuspendedRun']; readonly flowChecksumOf: AutomationEngine['flowChecksumOf']; + readonly renderFlowTerminalMessage: AutomationEngine['renderFlowTerminalMessage']; } /** The body of {@link AutomationEngine.executeNode}, moved unedited; that method's docblock is its contract. */ @@ -1123,9 +1124,10 @@ export function seedRunVariables( /** The body of {@link AutomationEngine.executeWithoutRetry}, moved unedited; that method's docblock is its contract. */ export async function executeWithoutRetry( - this: Pick, + this: Pick, flowName: string, context?: AutomationContext, + dispatched?: { errorMessage: string | undefined }, ): Promise { const startTime = Date.now(); const flow = this.flows.get(flowName); @@ -1283,7 +1285,12 @@ export async function executeWithoutRetry( // 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, flowLabel: flow.label, summary }; + // + // [#28054] Rendered over this attempt's variables, in the run's locale. + const successMessage = this.renderFlowTerminalMessage( + flowName, 'successMessage', flow.successMessage, variables, runContext, + ); + return { success: true, output, durationMs, 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 @@ -1454,12 +1461,18 @@ export async function executeWithoutRetry( // argument, and note the two names are NOT the same thing here: // the local `errorMessage` is the raw thrown text, `flow.errorMessage` // is what the author wrote for a human to read. + // + // [#28054] Rendered over this attempt's variables at the failure + // point. Under `retryExecution` the template is the dispatched + // definition's (`dispatched`), as the exhausted exit reports it. return { success: false, error: errorMessage, durationMs, status: 'failed', - errorMessage: flow.errorMessage, + errorMessage: this.renderFlowTerminalMessage( + flowName, 'errorMessage', dispatched ? dispatched.errorMessage : flow.errorMessage, variables, runContext, + ), flowLabel: flow.label, // [#17562] Recomputed when the guard above had to abandon // `recordLog`: the same pure function of the same steps that diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index b0d510c36e9..9a56172d2e0 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -75,7 +75,7 @@ import { defaultEnvCredentialResolver, type CredentialResolver } from './credent // [#22450] The one bundle-locale negotiation rule (exact → case-insensitive → // base language → variant expansion), asked about the locales the i18n service // holds — the same question objectql's `negotiatedMessageLocale` asks it. -import { resolveBundleLocale } from '@objectstack/spec/system'; +import { resolveBundleLocale, type FlowTerminalMessageKey } from '@objectstack/spec/system'; // [#25222] The registration-time checks' bodies, and the declarations only // they read. Each `AutomationEngine` method of the same name calls its body. import { @@ -243,6 +243,7 @@ import { warnIfNodeTypeVocabularyNeverSealed, setNodeError, renderRefusalMessage, + renderFlowTerminalMessage, renderFlowTextSlot, translatedFlowTemplate, } from './node-text-rendering.js'; @@ -6275,7 +6276,12 @@ export class AutomationEngine implements IAutomationService { // that executed no node, so stamping "Opportunity created!" on // one would be a toast about work nobody did. They carry no // `summary` for exactly the same reason. - successMessage: flow.successMessage, + // + // [#28054] Rendered, not copied: a `{{ }}` template over the + // variables the run finished with, in the run's locale. + successMessage: this.renderFlowTerminalMessage( + flowName, 'successMessage', flow.successMessage, variables, runContext, + ), // The authored flow name for the runner header and the // completion toast — on EVERY evaluation's result, paused and // terminal alike (see `AutomationResult.flowLabel`). @@ -6548,7 +6554,12 @@ export class AutomationEngine implements IAutomationService { // does not degrade to a plainer toast: the consumer falls back // to the raw node error text, which is what every non-screen // flow showed until now. - errorMessage: flow.errorMessage, + // + // [#28054] Rendered over the variables at the failure point + // (`$error` included), in the run's locale. + errorMessage: this.renderFlowTerminalMessage( + flowName, 'errorMessage', flow.errorMessage, variables, runContext, + ), 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. @@ -7211,6 +7222,7 @@ export class AutomationEngine implements IAutomationService { finishRefusedRun: (...a) => this.finishRefusedRun(...a), journalConsumedSuspension: (...a) => this.journalConsumedSuspension(...a), failAncestors: (...a) => this.failAncestors(...a), + renderFlowTerminalMessage: (...a) => this.renderFlowTerminalMessage(...a), }; return resumeInternal.call(host, runId, signal, skipBubble, childSummary, childRefusal); } @@ -8931,6 +8943,39 @@ export class AutomationEngine implements IAutomationService { return renderRefusalMessage.call(host, flowName, nodeId, authored, variables, context); } + /** + * [#28054] A flow's terminal text — `successMessage` or `errorMessage` — + * rendered in the run's language, the way {@link renderRefusalMessage} + * renders a refusing `end` node's message: the translated TEMPLATE at + * `flowTerminalMessageKey` (`@objectstack/spec/system`) is picked in the + * run's locale, then its `{{ }}` holes are filled over `variables` through + * {@link renderFlowTextSlot}, the one text renderer. Every terminal exit + * calls it: a success exit over the variables the run finished with, a + * failure exit over the variables at the failure point. A hole over a + * variable the run never bound renders nothing, as in every text slot, and + * a message with no hole renders as written. + * + * `undefined` when the flow authors none, when the text renders empty, and + * when the authored template refuses to render (the doors compile it, so + * only a `{{ amount | currency }}` hole meeting no tenant default currency + * gets there): the run has already ended, so its verdict stands and the + * caller shows its own sentence, never raw holes; the warning names why. + */ + private renderFlowTerminalMessage( + flowName: string, + key: FlowTerminalMessageKey, + authored: string | undefined, + variables: Map, + context: AutomationContext, + ): string | undefined { + // The body lives in ./node-text-rendering.ts. + const host: ThisParameterType = { + renderFlowTextSlot: (...a) => this.renderFlowTextSlot(...a), + logger: this.logger, + }; + return renderFlowTerminalMessage.call(host, flowName, key, authored, variables, context); + } + /** * [#22450, #22507] Render one user-read flow TEXT slot in the run's * language: pick the translated TEMPLATE at `slot.key`, then fill its @@ -9470,6 +9515,15 @@ export class AutomationEngine implements IAutomationService { * started under — not whatever a hot-reload re-registered under the same * name while the loop slept between attempts (#9414). `flowLabel` is * passed for the same reason. + * + * [#28054] `flowErrorMessage` is a `{{ }}` template, rendered over the + * variables at the failure point — and those are the LAST attempt's, which + * only that attempt holds. So each attempt is handed this template and + * renders it at its own failure exit, and the exhausted exit reports the + * last attempt's rendering: still the definition this dispatch started + * under, filled from the run that failed last. An attempt that never + * dispatched (its flow vanished or was disabled between attempts) bound no + * variable, so the template renders over none. */ private async retryExecution( flowName: string, @@ -9500,6 +9554,9 @@ export class AutomationEngine implements IAutomationService { // with `lastError` so the exhausted exit never reports one attempt's // text beside another attempt's refusal. let lastCarried: CarriedRefusal = {}; + // [#28054] The last attempt's rendering of `flowErrorMessage`, paired + // with `lastError` the same way. + let lastErrorMessage: string | undefined; for (let i = 0; i < maxRetries; i++) { // Calculate delay with exponential backoff let delay = Math.min(baseDelay * Math.pow(multiplier, i), maxDelay); @@ -9509,7 +9566,7 @@ export class AutomationEngine implements IAutomationService { await new Promise(r => setTimeout(r, delay)); // Execute directly without recursion into retryExecution again - const result = await this.executeWithoutRetry(flowName, context); + const result = await this.executeWithoutRetry(flowName, context, { errorMessage: flowErrorMessage }); // [#9510] THE THIRD STATE, read deliberately and BEFORE `success`. // Since the ADR-0019 suspend arm was restored to // `executeWithoutRetry`, an attempt can end NON-TERMINAL: the run @@ -9536,6 +9593,9 @@ export class AutomationEngine implements IAutomationService { if (result.success) return result; lastError = result.error ?? 'Unknown error'; lastCarried = carriedRefusalOfResult(result); + lastErrorMessage = result.status === 'failed' + ? result.errorMessage + : this.renderFlowTerminalMessage(flowName, 'errorMessage', flowErrorMessage, new Map(), context ?? {}); } // [#9378] Same "ran and was rejected" class as `execute()`'s own failure // exit — every attempt above dispatched the flow — so it carries the @@ -9554,7 +9614,7 @@ export class AutomationEngine implements IAutomationService { error: lastError, durationMs: Date.now() - startTime, status: 'failed', - errorMessage: flowErrorMessage, + errorMessage: lastErrorMessage, flowLabel, ...lastCarried, }; @@ -9674,10 +9734,16 @@ export class AutomationEngine implements IAutomationService { * attempt, because it is the SAME event happening on a later attempt. Two * answers for one user-visible situation is what this method's missing * suspend arm produced. + * + * [#28054] `dispatched` carries the `errorMessage` template of the + * definition the retried dispatch started under; the failure exit renders + * it over this attempt's variables in place of the re-read flow's own (see + * {@link retryExecution}). */ private executeWithoutRetry( flowName: string, context?: AutomationContext, + dispatched?: { errorMessage: string | undefined }, ): Promise { // The body lives in ./dag-traversal.ts (#25222). const host: ThisParameterType = { @@ -9694,7 +9760,8 @@ export class AutomationEngine implements IAutomationService { finishRefusedRun: (...a) => this.finishRefusedRun(...a), persistSuspendedRun: (...a) => this.persistSuspendedRun(...a), flowChecksumOf: (...a) => this.flowChecksumOf(...a), + renderFlowTerminalMessage: (...a) => this.renderFlowTerminalMessage(...a), }; - return executeWithoutRetry.call(host, flowName, context); + return executeWithoutRetry.call(host, flowName, context, dispatched); } } diff --git a/packages/services/service-automation/src/flow-registration-checks.ts b/packages/services/service-automation/src/flow-registration-checks.ts index e10c1e8e9d5..0b7c7e44f48 100644 --- a/packages/services/service-automation/src/flow-registration-checks.ts +++ b/packages/services/service-automation/src/flow-registration-checks.ts @@ -24,7 +24,7 @@ import { flowNodeValueTemplateRefusals } from '@objectstack/spec/automation'; // [#22110] The flow TEXT slots read ADR-0032 §3's `{{ }}` holes — the one judge // of the single-brace tokens they may still carry, and the one list of where // those slots are, both shared with `objectstack validate`. -import { flowNodeTextSlotSources, textSlotTemplateRefusal } from '@objectstack/spec/automation'; +import { flowNodeTextSlotSources, flowTextSlotSources, textSlotTemplateRefusal } from '@objectstack/spec/automation'; // [#21982] Which node types the spec judges an undeclared config key for — the // ONE judge of a builtin's undeclared key, met here through `FlowSchema.parse`. // `validateNodeConfigKeys` asks it and stands aside for every type it names, @@ -566,6 +566,25 @@ export function validateFlowExpressions( check(path, where, raw); }; + // [#28054] The flow's OWN text slots — `successMessage` / `errorMessage`, + // rendered at the run's terminal exit — judged in the same two steps as a + // node's text slots below, with the same judge and the same compile, so a + // hole the engine would refuse to render stops the flow here. + for (const slot of flowTextSlotSources(flow)) { + // The label already names the key (`flow successMessage`), and a flow-level + // slot's path IS that key, so no `at …` clause follows it (a node slot's + // `at config.…` names a path its label does not). + const slotWhere = slot.label; + const tokenRefusal = textSlotTemplateRefusal(slot.source); + if (tokenRefusal !== undefined) { + refuse(slot.path, slotWhere, tokenRefusal, slot.source); + continue; + } + for (const e of validateExpression('template', slot.source).errors) { + refuse(slot.path, slotWhere, e.message, slot.source); + } + } + // #4347 — every graph in the flow, not just the top-level arrays. An // ADR-0031 container keeps a whole sub-graph in its `config`, so // iterating `flow.nodes`/`flow.edges` checked PART of the flow while diff --git a/packages/services/service-automation/src/flow-terminal-message-template.test.ts b/packages/services/service-automation/src/flow-terminal-message-template.test.ts new file mode 100644 index 00000000000..e4cd1bc6fbc --- /dev/null +++ b/packages/services/service-automation/src/flow-terminal-message-template.test.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #28054 — a flow's `successMessage` / `errorMessage` are TEXT slots: `{{ }}` + * holes over the run's variables, rendered at every terminal exit. + * + * Until this change every terminal exit copied `flow.successMessage` onto the + * result as written, so `'{{enrolledCount}} contacts enrolled'` reached the + * caller with its hole raw (the card's probe). The precedent followed for + * every choice is a refusing `end` node's `message`: the same renderer + * (`renderTextSlot`), the same translation pick (`renderFlowTextSlot`, in the + * run's locale, before the holes are filled), and the same doors + * (`registerFlow` refuses a single-brace token and a hole that does not + * compile with the very words a node's text slot gets). + * + * ⚠️ Direction, predicted before running: every "rendered" pin is RED on the + * unfixed engine (it answered the template verbatim), and so are the door + * pins (nothing judged the two keys). The no-hole controls are green on both + * sides on purpose — they fence the change: a message without `{{` is + * byte-identical. + */ + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; +import { validateExpression } from '@objectstack/formula'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { textSlotTemplateRefusal } from '@objectstack/spec/automation'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { flowTerminalMessageKey } from '@objectstack/spec/system'; + +import { AutomationEngine } from './engine.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import { i18nServiceReader } from './plugin.js'; +import { renderTextSlot } from './builtin/template.js'; +import { withLoaderSetFromPull } from './loader-set.test-support.js'; + +interface LogLine { level: string; message: string } + +function createTestLogger(lines: LogLine[] = []): any { + const at = (level: string) => (message: unknown) => { lines.push({ level, message: String(message) }); }; + return { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug'), child: () => createTestLogger(lines) }; +} + +/** The card's probe template. */ +const ENROLLED = '{{enrolledCount}} contacts enrolled'; + +/** + * `start → enrol → end`. The `enrol` node binds `enrolledCount = 3` and + * `attempted = 3`, then succeeds or fails as `fails` says. `pause` puts a + * pausing gate before it, so the counting leg runs on the RESUME. + */ +function enrolFlow(opts: { successMessage?: string; errorMessage?: string; pause?: boolean; errorHandling?: unknown } = {}) { + return { + name: 'enrol', label: 'Enrol', type: 'autolaunched', + ...(opts.successMessage !== undefined ? { successMessage: opts.successMessage } : {}), + ...(opts.errorMessage !== undefined ? { errorMessage: opts.errorMessage } : {}), + ...(opts.errorHandling ? { errorHandling: opts.errorHandling } : {}), + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + ...(opts.pause ? [{ id: 'gate', type: 'approval_pause', label: 'Gate' }] : []), + { id: 'enrol', type: 'enrol_contacts', label: 'Enrol' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: opts.pause + ? [ + { id: 'e0', source: 'start', target: 'gate' }, + { id: 'e1', source: 'gate', target: 'enrol' }, + { id: 'e2', source: 'enrol', target: 'end' }, + ] + : [ + { id: 'e0', source: 'start', target: 'enrol' }, + { id: 'e1', source: 'enrol', target: 'end' }, + ], + }; +} + +function engineWith(opts: { fails?: boolean; i18n?: unknown; lines?: LogLine[] } = {}) { + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger(opts.lines), new InMemorySuspendedRunStore())); + const runs = { count: 0 }; + engine.registerNodeExecutor({ + type: 'enrol_contacts', + async execute(_node: unknown, variables: Map) { + runs.count++; + variables.set('enrolledCount', 3); + variables.set('attempt', runs.count); + return opts.fails ? { success: false, error: 'mailing service down' } : { success: true }; + }, + } as never); + engine.registerNodeExecutor({ + type: 'approval_pause', + descriptor: defineActionDescriptor({ + type: 'approval_pause', version: '1.0.0', name: 'approval_pause', supportsPause: true, resumeAuthority: 'any', + }), + async execute() { + return { success: true, suspend: true, correlation: 'approval:req' }; + }, + } as never); + if (opts.i18n) { + engine.setI18nServiceSource(i18nServiceReader({ + getService: ((name: string) => { + if (name === 'i18n') return opts.i18n as T; + throw new Error(`service '${name}' not registered`); + }) as never, + })); + } + return { engine, runs }; +} + +/** The message `registerFlow` threw, or `undefined` when it registered. */ +function registrationRefusal(engine: AutomationEngine, flow: unknown): string | undefined { + try { + engine.registerFlow('enrol', flow as never); + return undefined; + } catch (err) { + return (err as Error).message; + } +} + +/** A refusing `end` node carrying `message` — the node slot the flow-level pair is judged alike with. */ +function refusingEndFlow(message: string) { + return { + name: 'gatekeeper', label: 'Gatekeeper', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'finish', type: 'end', label: 'Finish', config: { outcome: 'refused', message } }, + ], + edges: [{ id: 'e0', source: 'start', target: 'finish' }], + }; +} + +describe('#28054 — the card\'s probe: the hole is filled on the result', () => { + it('the first-run path answers "3 contacts enrolled"', async () => { + const { engine } = engineWith(); + engine.registerFlow('enrol', enrolFlow({ successMessage: ENROLLED }) as never); + + const result = await engine.execute('enrol'); + + expect(result.success).toBe(true); + expect(result.successMessage).toBe('3 contacts enrolled'); + }); + + it('the resume path answers "3 contacts enrolled" — the count is bound on the resumed leg', async () => { + const { engine } = engineWith(); + engine.registerFlow('enrol', enrolFlow({ successMessage: ENROLLED, pause: true }) as never); + + const paused = await engine.execute('enrol'); + expect(paused.status).toBe('paused'); + expect(paused.successMessage).toBeUndefined(); + + const done = await engine.resume(paused.runId as string, { output: { verdict: 'approved' } } as never); + + expect(done.success).toBe(true); + expect(done.successMessage).toBe('3 contacts enrolled'); + }); + + it('a run that succeeds on a later attempt renders over that attempt\'s variables', async () => { + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); + let count = 0; + engine.registerNodeExecutor({ + type: 'enrol_contacts', + async execute(_node: unknown, variables: Map) { + count++; + variables.set('attempt', count); + return count === 1 ? { success: false, error: 'flaky' } : { success: true }; + }, + } as never); + engine.registerFlow('enrol', enrolFlow({ + successMessage: 'Enrolled on attempt {{ attempt }}', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 0 }, + }) as never); + + const result = await engine.execute('enrol'); + + expect(result.success).toBe(true); + expect(result.successMessage).toBe('Enrolled on attempt 2'); + }); +}); + +describe('#28054 — errorMessage renders over the variables at the failure point', () => { + it('a failing run answers the filled errorMessage beside the raw error', async () => { + const { engine } = engineWith({ fails: true }); + engine.registerFlow('enrol', enrolFlow({ errorMessage: 'Could not finish enrolling {{ enrolledCount }} contacts.' }) as never); + + const result = await engine.execute('enrol'); + + expect(result.success).toBe(false); + expect(result.status).toBe('failed'); + expect(result.errorMessage).toBe('Could not finish enrolling 3 contacts.'); + expect(result.error).toContain('mailing service down'); + }); + + it('the engine\'s own `$error` is a root the errorMessage can name', async () => { + const { engine } = engineWith({ fails: true }); + engine.registerFlow('enrol', enrolFlow({ errorMessage: 'Enrolment failed: {{ $error.message }}' }) as never); + + const result = await engine.execute('enrol'); + + expect(result.errorMessage).toContain('Enrolment failed: '); + expect(result.errorMessage).toContain('mailing service down'); + expect(result.errorMessage).not.toContain('{{'); + }); + + it('the retry-exhausted exit answers the LAST attempt\'s rendering', async () => { + const { engine, runs } = engineWith({ fails: true }); + engine.registerFlow('enrol', enrolFlow({ + errorMessage: 'Gave up after attempt {{ attempt }}.', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 0 }, + }) as never); + + const result = await engine.execute('enrol'); + + expect(runs.count).toBe(2); + expect(result.status).toBe('failed'); + expect(result.errorMessage).toBe('Gave up after attempt 2.'); + }); +}); + +describe('#28054 — a hole over a variable the run never bound behaves as in a node text slot', () => { + it('renders the blank fragment `renderTextSlot` renders for any text slot', async () => { + const { engine } = engineWith(); + const template = '{{ nobodyBoundThis }} contacts enrolled'; + engine.registerFlow('enrol', enrolFlow({ successMessage: template }) as never); + + const result = await engine.execute('enrol'); + + expect(result.success).toBe(true); + expect(result.successMessage).toBe(' contacts enrolled'); + // The node slots' one renderer answers the same for the same template. + expect(result.successMessage).toBe(renderTextSlot(template, new Map())); + }); +}); + +describe('#28054 — the doors judge the pair as they judge a node text slot', () => { + it('registerFlow refuses a single-brace token with the node slot\'s refusal and its `{{ }}` spelling', () => { + const { engine } = engineWith(); + const text = '{enrolledCount} contacts enrolled'; + + const flowLevel = registrationRefusal(engine, enrolFlow({ successMessage: text })); + const nodeLevel = registrationRefusal(engine, refusingEndFlow(text)); + + const refusal = textSlotTemplateRefusal(text)!; + expect(refusal).toContain('`{{ enrolledCount }} contacts enrolled`'); + // The row names the key once: `flow successMessage: …`, no `flow … flow` or `at successMessage`. + expect(flowLevel).toContain(`• flow successMessage: ${refusal}`); + expect(nodeLevel).toContain(refusal); + }); + + it('registerFlow refuses a hole that does not compile with the compile step\'s own words', () => { + const { engine } = engineWith(); + const text = 'Enrolled {{ enrolledCount + 1 }} contacts'; + const compiled = validateExpression('template', text).errors[0]!.message; + + const flowLevel = registrationRefusal(engine, enrolFlow({ errorMessage: text })); + const nodeLevel = registrationRefusal(engine, refusingEndFlow(text)); + + expect(flowLevel).toContain(`• flow errorMessage: ${compiled}`); + expect(nodeLevel).toContain(compiled); + }); + + it('registers the well-formed template and the plain sentence', () => { + const { engine } = engineWith(); + expect(registrationRefusal(engine, enrolFlow({ successMessage: ENROLLED, errorMessage: 'Enrolment failed.' }))).toBeUndefined(); + }); +}); + +describe('#28054 — the controls: a message with no hole is byte-identical', () => { + it('success and failure text without `{{` reach the result as written', async () => { + const plain = 'Enrolment complete — 100% done: see the "Contacts" tab.'; + const { engine } = engineWith(); + engine.registerFlow('enrol', enrolFlow({ successMessage: plain }) as never); + expect((await engine.execute('enrol')).successMessage).toBe(plain); + + const failing = engineWith({ fails: true }); + failing.engine.registerFlow('enrol', enrolFlow({ errorMessage: 'Enrolment did not finish.' }) as never); + expect((await failing.engine.execute('enrol')).errorMessage).toBe('Enrolment did not finish.'); + }); + + it('a flow that declares neither gets neither', async () => { + const { engine } = engineWith(); + engine.registerFlow('enrol', enrolFlow() as never); + const result = await engine.execute('enrol'); + expect(result.successMessage).toBeUndefined(); + expect(result.errorMessage).toBeUndefined(); + }); +}); + +describe('#28054 — translated where it is rendered, in the run\'s locale', () => { + const ZH = { flows: { enrol: { successMessage: '已登记 {{ enrolledCount }} 位联系人', errorMessage: '登记失败:{{ enrolledCount }}' } } }; + function i18nWith(bundles: Record>) { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(bundles)) i18n.loadTranslations(locale, data); + return i18n; + } + const zhRun = { event: 'manual', locale: 'zh-CN' } as unknown as AutomationContext; + + it('is keyed `flows..successMessage` / `.errorMessage`', () => { + expect(flowTerminalMessageKey('enrol', 'successMessage')).toBe('flows.enrol.successMessage'); + expect(flowTerminalMessageKey('enrol', 'errorMessage')).toBe('flows.enrol.errorMessage'); + }); + + it('a zh-CN run answers the translated template with its hole filled', async () => { + const { engine } = engineWith({ i18n: i18nWith({ 'zh-CN': ZH }) }); + engine.registerFlow('enrol', enrolFlow({ successMessage: ENROLLED }) as never); + + const result = await engine.execute('enrol', zhRun); + + expect(result.successMessage).toBe('已登记 3 位联系人'); + }); + + it('a zh-CN failing run answers the translated errorMessage filled at the failure point', async () => { + const { engine } = engineWith({ fails: true, i18n: i18nWith({ 'zh-CN': ZH }) }); + engine.registerFlow('enrol', enrolFlow({ errorMessage: 'Could not enrol {{ enrolledCount }}' }) as never); + + const result = await engine.execute('enrol', zhRun); + + expect(result.errorMessage).toBe('登记失败:3'); + }); + + it('a run with no locale, or a locale the bundle lacks, renders the authored template', async () => { + const { engine } = engineWith({ i18n: i18nWith({ 'zh-CN': ZH }) }); + engine.registerFlow('enrol', enrolFlow({ successMessage: ENROLLED }) as never); + + expect((await engine.execute('enrol')).successMessage).toBe('3 contacts enrolled'); + expect((await engine.execute('enrol', { event: 'manual', locale: 'ja-JP' } as never)).successMessage) + .toBe('3 contacts enrolled'); + }); +}); + +describe('#28054 — a terminal text that cannot render leaves the verdict standing', () => { + it('a `{{ x | currency }}` hole with no tenant default currency: success stands, no message, one warning', async () => { + const lines: LogLine[] = []; + const { engine } = engineWith({ lines }); + engine.registerFlow('enrol', enrolFlow({ successMessage: 'Charged {{ enrolledCount | currency }}' }) as never); + + const result = await engine.execute('enrol'); + + expect(result.success).toBe(true); + expect(result.successMessage).toBeUndefined(); + expect(lines.filter((l) => l.level === 'warn' && l.message.includes("flow 'enrol': its successMessage did not render"))).toHaveLength(1); + }); +}); diff --git a/packages/services/service-automation/src/node-text-rendering.ts b/packages/services/service-automation/src/node-text-rendering.ts index aa29e575234..f261afa20a7 100644 --- a/packages/services/service-automation/src/node-text-rendering.ts +++ b/packages/services/service-automation/src/node-text-rendering.ts @@ -3,7 +3,8 @@ /** * The node-level helpers of {@link AutomationEngine}'s DAG traversal: the * node-type vocabulary checks a run makes, a failed node's error variable, and - * the rendering of refusal messages and flow text slots. + * the rendering of refusal messages, a flow's terminal text (#28054) and flow + * text slots. * * [#25222] Slice 7 of the `engine.ts` split, move-only. Each function here is * the body of the `AutomationEngine` method of the same name, moved unedited. @@ -22,7 +23,7 @@ import { collectFlowGraphs } from '@objectstack/spec/automation'; // `../guard-refusal.js` and package-external contracts, so nothing it pulls in // reaches back here. import { renderTextSlot, FlowTextTemplateError } from './builtin/template.js'; -import { flowRefusalMessageKey } from '@objectstack/spec/system'; +import { flowRefusalMessageKey, flowTerminalMessageKey, type FlowTerminalMessageKey } from '@objectstack/spec/system'; // The value below (`negotiatedServiceLocale`) is read only inside the function // bodies, at call time, never while a module is evaluating, so the import cycle // with ./engine.js is safe under ESM live bindings; the tsup bundle is one file @@ -113,6 +114,40 @@ export function renderRefusalMessage( ); } +/** The body of {@link AutomationEngine.renderFlowTerminalMessage}; that method's docblock is its contract. */ +export function renderFlowTerminalMessage( + this: Pick, + flowName: string, + key: FlowTerminalMessageKey, + authored: string | undefined, + variables: Map, + context: AutomationContext, +): string | undefined { + if (authored === undefined) return undefined; + try { + return this.renderFlowTextSlot( + { + key: flowTerminalMessageKey(flowName, key), + authored, + subject: `flow '${flowName}' ${key}`, + shownAs: `the ${key}`, + }, + variables, + context, + ); + } catch (err: unknown) { + if (!(err instanceof FlowTextTemplateError)) throw err; + // The run has already ended, and its verdict and history row stand: a + // terminal text that cannot render is left off the result, so the + // caller shows its own sentence, never the template's raw holes. + this.logger.warn( + `[automation] flow '${flowName}': its ${key} did not render (${err.message}), so the run's result ` + + `carries no ${key} and the caller shows its own sentence. The run's own outcome is unaffected.`, + ); + return undefined; + } +} + /** The body of {@link AutomationEngine.renderFlowTextSlot}; that method's docblock is its contract. */ export function renderFlowTextSlot( this: Pick, diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index 3db6ab0c5a1..d14a476b100 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -112,6 +112,7 @@ export interface RunResumeHost { readonly recoverStoredFlow: AutomationEngine['recoverStoredFlow']; readonly heldFlowVersion: AutomationEngine['heldFlowVersion']; readonly resolveResumeTarget: AutomationEngine['resolveResumeTarget']; + readonly renderFlowTerminalMessage: AutomationEngine['renderFlowTerminalMessage']; } /** The body of {@link AutomationEngine.resolveRunContext}; that method's docblock is its contract. */ @@ -1182,7 +1183,7 @@ export function takeSubflowParentStrand(this: Pick, + this: Pick, runId: string, // Never `undefined` past the public door: `resume` normalises an // absent caller signal to `{}` (commit 7307191db), and the engine's own @@ -1875,7 +1876,11 @@ export async function resumeInternal( success: true, output, durationMs, - successMessage: flow.successMessage, + // [#28054] Rendered over the variables the run finished with, + // in the locale the run was started in (the persisted context). + successMessage: this.renderFlowTerminalMessage( + run.flowName, 'successMessage', flow.successMessage, variables, context, + ), flowLabel: flow.label, summary, }; @@ -2136,7 +2141,10 @@ export async function resumeInternal( // nothing, so no verb can re-arm them. A different (and // worse) condition, which this stamp must not claim. status: 'stranded', - errorMessage: flow.errorMessage, + // [#28054] Rendered over the variables at the failure point. + errorMessage: this.renderFlowTerminalMessage( + run.flowName, 'errorMessage', flow.errorMessage, variables, context, + ), flowLabel: flow.label, // [#15555] Recomputed when the guard above had to abandon // `recordLog`: the same pure function of the same steps diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index cf0aed10900..79e726eb004 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -135,6 +135,7 @@ "FLOW_RESERVED_NODE_TYPES (const)", "FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES (const)", + "FLOW_TEXT_SLOTS (const)", "FLOW_TRIGGER_KINDS (const)", "Flow (type)", "FlowCollectionSlot (type)", @@ -189,6 +190,7 @@ "FlowSchema (const)", "FlowSlotRefusalCode (type)", "FlowSlotRefusalParams (interface)", + "FlowTextSlot (interface)", "FlowTriggerKind (type)", "FlowValueSlot (type)", "FlowValueSlotParsed (type)", @@ -328,6 +330,7 @@ "flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey (function)", + "flowTextSlotSources (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", "getFlowNodeSiblingBlockJsonSchemas (function)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 977addc3343..f1577e04256 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -832,6 +832,7 @@ "emailTemplateForm (const)", "flowRefusalMessageKey (function)", "flowScreenCopyKey (function)", + "flowTerminalMessageKey (function)", "gcsStorageExample (const)", "globalFilterKey (function)", "hasMovedFileColumns (function)", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 64a08ba5420..24d965bb924 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 247, - "droppedRefinementSites": 951, + "droppedRefinementSites": 973, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -125,9 +125,11 @@ "manifest.reports.element.runtimeFilter.lazy", "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", + "manifest.translations.element.valueType.flows.valueType.errorMessage", "manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", "manifest.translations.element.valueType.flows.valueType.screens.valueType.description", "manifest.translations.element.valueType.flows.valueType.screens.valueType.title", + "manifest.translations.element.valueType.flows.valueType.successMessage", "manifest.views.element.form", "manifest.views.element.form.sections.element", "manifest.views.element.form.submitBehavior.options[1].url", @@ -265,6 +267,8 @@ "data.options[1].manifest.reports.element.runtimeFilter.lazy", "data.options[1].manifest.sharingRules.element.sharedWith", "data.options[1].manifest.skills.element.triggerConditions.element", + "data.options[1].manifest.translations.element.valueType.flows.valueType.errorMessage", + "data.options[1].manifest.translations.element.valueType.flows.valueType.successMessage", "data.options[1].manifest.views.element.form", "data.options[1].manifest.views.element.form.sections.element", "data.options[1].manifest.views.element.form.submitBehavior.options[1].url", @@ -294,9 +298,11 @@ }, "api/GetTranslationsResponse": { "sites": [ + "translations.flows.valueType.errorMessage", "translations.flows.valueType.refusals.valueType.message", "translations.flows.valueType.screens.valueType.description", - "translations.flows.valueType.screens.valueType.title" + "translations.flows.valueType.screens.valueType.title", + "translations.flows.valueType.successMessage" ] }, "api/GetUiViewResponse": { @@ -398,9 +404,11 @@ "options[1].manifest.reports.element.runtimeFilter.lazy", "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", + "options[1].manifest.translations.element.valueType.flows.valueType.errorMessage", "options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", "options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.description", "options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.title", + "options[1].manifest.translations.element.valueType.flows.valueType.successMessage", "options[1].manifest.views.element.form", "options[1].manifest.views.element.form.sections.element", "options[1].manifest.views.element.form.submitBehavior.options[1].url", @@ -473,6 +481,8 @@ "data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy", "data.packages.element.options[1].manifest.sharingRules.element.sharedWith", "data.packages.element.options[1].manifest.skills.element.triggerConditions.element", + "data.packages.element.options[1].manifest.translations.element.valueType.flows.valueType.errorMessage", + "data.packages.element.options[1].manifest.translations.element.valueType.flows.valueType.successMessage", "data.packages.element.options[1].manifest.views.element.form", "data.packages.element.options[1].manifest.views.element.form.sections.element", "data.packages.element.options[1].manifest.views.element.form.submitBehavior.options[1].url", @@ -1411,16 +1421,20 @@ }, "system/PlatformTranslationBundle": { "sites": [ + "valueType.flows.valueType.errorMessage", "valueType.flows.valueType.refusals.valueType.message", "valueType.flows.valueType.screens.valueType.description", - "valueType.flows.valueType.screens.valueType.title" + "valueType.flows.valueType.screens.valueType.title", + "valueType.flows.valueType.successMessage" ] }, "system/PlatformTranslationData": { "sites": [ + "flows.valueType.errorMessage", "flows.valueType.refusals.valueType.message", "flows.valueType.screens.valueType.description", - "flows.valueType.screens.valueType.title" + "flows.valueType.screens.valueType.title", + "flows.valueType.successMessage" ] }, "system/Schedule": { @@ -1430,9 +1444,11 @@ }, "system/ServedTranslationData": { "sites": [ + "flows.valueType.errorMessage", "flows.valueType.refusals.valueType.message", "flows.valueType.screens.valueType.description", - "flows.valueType.screens.valueType.title" + "flows.valueType.screens.valueType.title", + "flows.valueType.successMessage" ] }, "system/ServerRateLimitConfig": { @@ -1472,23 +1488,29 @@ }, "system/TranslationBundle": { "sites": [ + "valueType.flows.valueType.errorMessage", "valueType.flows.valueType.refusals.valueType.message", "valueType.flows.valueType.screens.valueType.description", - "valueType.flows.valueType.screens.valueType.title" + "valueType.flows.valueType.screens.valueType.title", + "valueType.flows.valueType.successMessage" ] }, "system/TranslationData": { "sites": [ + "flows.valueType.errorMessage", "flows.valueType.refusals.valueType.message", "flows.valueType.screens.valueType.description", - "flows.valueType.screens.valueType.title" + "flows.valueType.screens.valueType.title", + "flows.valueType.successMessage" ] }, "system/TranslationItem": { "sites": [ + "flows.valueType.errorMessage", "flows.valueType.refusals.valueType.message", "flows.valueType.screens.valueType.description", - "flows.valueType.screens.valueType.title" + "flows.valueType.screens.valueType.title", + "flows.valueType.successMessage" ] }, "ui/Action": { diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 2946bb76c5f..18a9eaa2d90 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -130,6 +130,7 @@ "FLOW_RESERVED_NODE_TYPES": "src/automation/flow-reserved-node-types.ts#FLOW_RESERVED_NODE_TYPES (const)", "FLOW_SLOT_REFUSAL_CODES": "src/automation/flow-node-expression-paths.ts#FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_STRUCTURAL_NODE_TYPES (const)", + "FLOW_TEXT_SLOTS": "src/automation/flow-text-slot-template.ts#FLOW_TEXT_SLOTS (const)", "FLOW_TRIGGER_KINDS": "src/automation/flow-trigger-kind.ts#FLOW_TRIGGER_KINDS (const)", "Flow": "src/automation/flow.zod.ts#Flow (type)", "FlowCollectionSlot": "src/automation/builtin-node-config.zod.ts#FlowCollectionSlot (type)", @@ -183,6 +184,7 @@ "FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)", "FlowSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalCode (type)", "FlowSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalParams (interface)", + "FlowTextSlot": "src/automation/flow-text-slot-template.ts#FlowTextSlot (interface)", "FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)", "FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)", "FlowValueSlotParsed": "src/automation/builtin-node-config.zod.ts#FlowValueSlotParsed (type)", @@ -321,6 +323,7 @@ "flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey": "src/automation/flow-screen-option-key.ts#flowScreenFieldOptionKey (function)", + "flowTextSlotSources": "src/automation/flow-text-slot-template.ts#flowTextSlotSources (function)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)", "getFlowNodeSiblingBlockJsonSchemas": "src/automation/flow.zod.ts#getFlowNodeSiblingBlockJsonSchemas (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 8126c8fa2f5..eb405d5229b 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -793,6 +793,7 @@ "emailTemplateForm": "src/system/email-template.form.ts#emailTemplateForm (const)", "flowRefusalMessageKey": "src/system/i18n-resolver.ts#flowRefusalMessageKey (function)", "flowScreenCopyKey": "src/system/i18n-resolver.ts#flowScreenCopyKey (function)", + "flowTerminalMessageKey": "src/system/i18n-resolver.ts#flowTerminalMessageKey (function)", "gcsStorageExample": "src/system/object-storage.zod.ts#gcsStorageExample (const)", "globalFilterKey": "src/system/i18n-resolver.ts#globalFilterKey (function)", "hasMovedFileColumns": "src/system/migration.zod.ts#hasMovedFileColumns (function)", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 7a6c82fc4fd..91eab047736 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -168,19 +168,19 @@ }, "successMessage": { "status": "live", - "verifiedAt": "2026-10-16", + "verifiedAt": "2026-11-01", "evidenceScope": "in-repo", - "evidence": "packages/spec/src/system/i18n-resolver.ts#translateFlow (overlays `flows..successMessage` down the locale chain, only where the flow authors a `successMessage`); packages/spec/src/system/i18n-resolver.ts#FLOW_TERMINAL_MESSAGE_KEYS (the key list the CLI skeleton writer and `translation.test.ts` share); packages/lint/src/validate-translation-references.ts#validateTranslationReferences (refuses a translated `successMessage` over a flow that declares none); ui/packages/app-shell/src/utils/flowResponse.ts#translatedFlowMessage (asks the spec's `translateFlow` for `flows..successMessage` under the run's flow name and locale, and keeps the served string when the bundle has none); ui/packages/app-shell/src/utils/flowResponse.ts#interpretFlowResponse (the terminal-success arm hands the served `successMessage` to `translatedFlowMessage`); ui/packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (hands `interpretFlowResponse` this run's flow name and bundle, so the resume and both launch hosts through `judgeFlowLaunch` answer the same words)", - "producer": "packages/spec/src/automation/flow.zod.ts#FlowSchema (`successMessage`, the flow's own completion text); packages/services/service-automation/src/engine.ts#AutomationEngine (copies `flow.successMessage` verbatim onto the terminal result, declared as `AutomationResult.successMessage` in packages/spec/src/contracts/automation-service.ts)", - "note": "FLIPPED planned → live 2026-10-16 (#23710). The console reads the translated `successMessage`: objectui's `translatedFlowMessage` asks the spec's `translateFlow` for `flows..successMessage` over the string the engine serves verbatim, and falls back to that string when the bundle carries none. The reader landed in objectui 42d8e8db6 (objectui#12075), and the `.objectui-sha` pin daeb77fc84dd carries it (`git merge-base --is-ancestor 42d8e8db6 daeb77fc84dd` answers yes), so every objectui pointer here was read at the pin this repo builds against. Re-read when the pin moved f96f771140f1 → daeb77fc84dd (2026-10-16): the reader files are byte-identical at both. It was `planned` (#22507, verified 2026-10-09) as the spec half of a contract-first split, the shape `screens` and `label` took: the face declared the key and `translateFlow` applied it while the console still drew the served string untranslated. No `authorWarn`, then or now: a child row's warn reaches no file-authored bundle (the group is a record keyed by flow name; see the CLI's `authorWarnedTranslationGroups` note)." + "evidence": "packages/services/service-automation/src/engine.ts#renderFlowTerminalMessage (called with `flow.successMessage` at every terminal completion exit — `execute()`, a retried attempt's in `dag-traversal.ts`, the resume's in `run-resume.ts` — over the variables the run finished with; its body, in `node-text-rendering.ts`, asks for `flows..successMessage` as spelled by `flowTerminalMessageKey`, reads it through the `i18n` service in the run's `AutomationContext.locale` via `translatedFlowTemplate` — a miss echoes the key and picks nothing, so the authored template renders — and renders the picked template with `renderFlowTextSlot`); packages/spec/src/system/i18n-resolver.ts#FLOW_TERMINAL_MESSAGE_KEYS (the key list the CLI skeleton writer and `translation.test.ts` share); packages/lint/src/validate-translation-references.ts#validateTranslationReferences (refuses a translated `successMessage` over a flow that declares none)", + "producer": "packages/spec/src/automation/flow.zod.ts#FlowSchema (`successMessage`, the flow's own completion text, a `{{ }}` template); packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service)", + "note": "[#28054] REPOINTED from the console to the engine, still `live`. `FlowSchema.successMessage` became a `{{ }}` text slot the engine renders at the run's terminal exit, so the translation moved to where the string is rendered, as a screen's `title` / `description` did under maintainer ruling A on #22507: the engine picks the translated TEMPLATE in the run's locale before it fills the holes, and serves the result translated and filled. The console reader this row cited (objectui#12075, `translatedFlowMessage` in `flowResponse.ts`) is removed in the same change, because an overlay on the served string would replace the filled sentence with the bundle's template, its holes drawn raw. The translation leaf is `flowTextSlotTranslation`, the one the screen and refusal templates use, so a single-brace token in it is refused. History: FLIPPED planned → live 2026-10-16 (#23710), client-side. The console reads the translated `successMessage`: objectui's `translatedFlowMessage` asks the spec's `translateFlow` for `flows..successMessage` over the string the engine serves verbatim, and falls back to that string when the bundle carries none. The reader landed in objectui 42d8e8db6 (objectui#12075), and the `.objectui-sha` pin daeb77fc84dd carries it (`git merge-base --is-ancestor 42d8e8db6 daeb77fc84dd` answers yes), so every objectui pointer here was read at the pin this repo builds against. Re-read when the pin moved f96f771140f1 → daeb77fc84dd (2026-10-16): the reader files are byte-identical at both." }, "errorMessage": { "status": "live", - "verifiedAt": "2026-10-16", + "verifiedAt": "2026-11-01", "evidenceScope": "in-repo", - "evidence": "packages/spec/src/system/i18n-resolver.ts#translateFlow (overlays `flows..errorMessage` down the locale chain, only where the flow authors a `errorMessage`); packages/spec/src/system/i18n-resolver.ts#FLOW_TERMINAL_MESSAGE_KEYS (the key list the CLI skeleton writer and `translation.test.ts` share); packages/lint/src/validate-translation-references.ts#validateTranslationReferences (refuses a translated `errorMessage` over a flow that declares none); ui/packages/app-shell/src/utils/flowResponse.ts#translatedFlowMessage (asks the spec's `translateFlow` for `flows..errorMessage` under the run's flow name and locale, and keeps the served string when the bundle has none); ui/packages/app-shell/src/utils/flowResponse.ts#flowFailureMessage (prefers the served `errorMessage`, handed to `translatedFlowMessage`, over the raw `error`); ui/packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (hands `interpretFlowResponse` this run's flow name and bundle, so the resume and both launch hosts through `judgeFlowLaunch` answer the same words)", - "producer": "packages/spec/src/automation/flow.zod.ts#FlowSchema (`errorMessage`, the flow's own failure text); packages/services/service-automation/src/engine.ts#AutomationEngine (copies `flow.errorMessage` verbatim onto the terminal result, declared as `AutomationResult.errorMessage` in packages/spec/src/contracts/automation-service.ts)", - "note": "FLIPPED planned → live 2026-10-16 (#23710). The console reads the translated `errorMessage`: objectui's `translatedFlowMessage` asks the spec's `translateFlow` for `flows..errorMessage` over the string the engine serves verbatim, and falls back to that string when the bundle carries none. The reader landed in objectui 42d8e8db6 (objectui#12075), and the `.objectui-sha` pin daeb77fc84dd carries it (`git merge-base --is-ancestor 42d8e8db6 daeb77fc84dd` answers yes), so every objectui pointer here was read at the pin this repo builds against. Re-read when the pin moved f96f771140f1 → daeb77fc84dd (2026-10-16): the reader files are byte-identical at both. It was `planned` (#22507, verified 2026-10-09) as the spec half of a contract-first split, the shape `screens` and `label` took: the face declared the key and `translateFlow` applied it while the console still drew the served string untranslated. No `authorWarn`, then or now: a child row's warn reaches no file-authored bundle (the group is a record keyed by flow name; see the CLI's `authorWarnedTranslationGroups` note)." + "evidence": "packages/services/service-automation/src/engine.ts#renderFlowTerminalMessage (called with `flow.errorMessage` at every terminal failure exit — `execute()`, a retried attempt's in `dag-traversal.ts`, the resume's in `run-resume.ts` — over the variables at the failure point; its body, in `node-text-rendering.ts`, asks for `flows..errorMessage` as spelled by `flowTerminalMessageKey`, reads it through the `i18n` service in the run's `AutomationContext.locale` via `translatedFlowTemplate` — a miss echoes the key and picks nothing, so the authored template renders — and renders the picked template with `renderFlowTextSlot`); packages/spec/src/system/i18n-resolver.ts#FLOW_TERMINAL_MESSAGE_KEYS (the key list the CLI skeleton writer and `translation.test.ts` share); packages/lint/src/validate-translation-references.ts#validateTranslationReferences (refuses a translated `errorMessage` over a flow that declares none)", + "producer": "packages/spec/src/automation/flow.zod.ts#FlowSchema (`errorMessage`, the flow's own failure text, a `{{ }}` template); packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service)", + "note": "[#28054] REPOINTED from the console to the engine, still `live`. `FlowSchema.errorMessage` became a `{{ }}` text slot the engine renders at the run's terminal exit, so the translation moved to where the string is rendered, as a screen's `title` / `description` did under maintainer ruling A on #22507: the engine picks the translated TEMPLATE in the run's locale before it fills the holes, and serves the result translated and filled. The console reader this row cited (objectui#12075, `translatedFlowMessage` in `flowResponse.ts`) is removed in the same change, because an overlay on the served string would replace the filled sentence with the bundle's template, its holes drawn raw. The translation leaf is `flowTextSlotTranslation`, the one the screen and refusal templates use, so a single-brace token in it is refused. History: FLIPPED planned → live 2026-10-16 (#23710), client-side. The console reads the translated `errorMessage`: objectui's `translatedFlowMessage` asks the spec's `translateFlow` for `flows..errorMessage` over the string the engine serves verbatim, and falls back to that string when the bundle carries none. The reader landed in objectui 42d8e8db6 (objectui#12075), and the `.objectui-sha` pin daeb77fc84dd carries it (`git merge-base --is-ancestor 42d8e8db6 daeb77fc84dd` answers yes), so every objectui pointer here was read at the pin this repo builds against. Re-read when the pin moved f96f771140f1 → daeb77fc84dd (2026-10-16): the reader files are byte-identical at both." }, "screens": { "status": "live", diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index d1923dd18bb..74e32f159e5 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -446,11 +446,13 @@ export const TriggerFlowResponseSchema = lazySchema(() => BaseResponseSchema.ext + 'and resumes the run with them.', ), successMessage: z.string().optional().describe( - 'Friendly terminal message copied from the flow definition on terminal ' - + 'success, so a screen-flow runner can show a meaningful toast', + 'Friendly terminal message rendered from the flow definition on terminal ' + + 'success (its `{{ }}` holes filled over the run\'s variables, in the run\'s locale), ' + + 'so a screen-flow runner can show a meaningful toast', ), errorMessage: z.string().optional().describe( - 'Friendly terminal message copied from the flow definition on failure', + 'Friendly terminal message rendered from the flow definition on failure (its `{{ }}` ' + + 'holes filled over the variables at the failure point, in the run\'s locale)', ), userMessage: z.string().optional().describe( 'Producer-marked user-facing refusal text, verbatim, of the refusal a node met inside ' @@ -611,8 +613,8 @@ export type ResumeFailureDetailsParsed = z.infer z.object({ errorMessage: z.string().optional().describe( - 'The flow author\'s own failure text (`flow.errorMessage`), copied verbatim from ' - + '`AutomationResult.errorMessage`; absent when the author wrote none', + 'The flow author\'s own failure text (`flow.errorMessage`, rendered by the engine), copied ' + + 'verbatim from `AutomationResult.errorMessage`; absent when the author wrote none', ), summary: FlowRunSummarySchema.optional().describe( 'The run\'s per-node accounting (`AutomationResult.summary`) - which node failed; ' diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index 79323e8a84b..452d6923184 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -14,9 +14,11 @@ import { describe, expect, it } from 'vitest'; import { FLOW_NODE_TEXT_SLOTS, + FLOW_TEXT_SLOTS, TEXT_SLOT_TEMPLATE_REFUSAL, closedScopeTemplateRefusal, flowNodeTextSlotSources, + flowTextSlotSources, templateSlotSource, textSlotTemplateRefusal, } from './flow-text-slot-template'; @@ -24,6 +26,7 @@ import { valueSlotTemplateRefusals } from './flow-value-slot-template'; import { EndConfigSchema, ScreenConfigSchema } from './builtin-node-config.zod'; import { NotifyConfigSchema } from './io-node-config.zod'; import { tmpl } from '../shared/expression.zod'; +import { FLOW_TERMINAL_MESSAGE_KEYS } from '../system/i18n-resolver'; describe('FLOW_NODE_TEXT_SLOTS — where the `{{ }}` text slots are', () => { it('names exactly the eight slots whose rendered value is only ever text', () => { @@ -47,6 +50,32 @@ describe('FLOW_NODE_TEXT_SLOTS — where the `{{ }}` text slots are', () => { }); }); +describe('FLOW_TEXT_SLOTS — the flow\'s own text slots (#28054)', () => { + it('names the two terminal messages, under the keys the `flows` translation face spells them', () => { + expect(FLOW_TEXT_SLOTS.map((s) => s.key)).toEqual(['successMessage', 'errorMessage']); + // The engine picks each slot's translation at `flows..`, so the + // slot list and the face's key list are one list. + expect(FLOW_TEXT_SLOTS.map((s) => s.key)).toEqual([...FLOW_TERMINAL_MESSAGE_KEYS]); + }); + + it('flowTextSlotSources yields each authored string at its flow key, and skips the rest', () => { + expect(flowTextSlotSources({ name: 'f', successMessage: '{{ n }} done', errorMessage: 'Failed.' })).toEqual([ + { path: 'successMessage', label: 'flow successMessage', source: '{{ n }} done' }, + { path: 'errorMessage', label: 'flow errorMessage', source: 'Failed.' }, + ]); + expect(flowTextSlotSources({ name: 'f' })).toEqual([]); + expect(flowTextSlotSources({ name: 'f', successMessage: 42 })).toEqual([]); + expect(flowTextSlotSources(null)).toEqual([]); + }); + + it('judges a flow-level slot with the node slots\' one judge', () => { + const [slot] = flowTextSlotSources({ name: 'f', successMessage: '{enrolledCount} contacts enrolled' }); + const refusal = textSlotTemplateRefusal(slot!.source)!; + expect(refusal.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(refusal).toContain('`{{ enrolledCount }} contacts enrolled`'); + }); +}); + describe('textSlotTemplateRefusal — the one judge of the single brace in a text slot', () => { it('passes text with no single-brace token: plain text, `{{ }}` holes, a hole over an engine-bound `$` variable, a formatter', () => { for (const text of [ diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index e12c4385173..9bd83f23c52 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -22,6 +22,13 @@ * variables (`{{ record.name }}`, `{{ $error.message }}`, * `{{ amount | currency }}`), never logic. * + * Since #28054 the flow itself carries two text slots besides its nodes' + * ({@link FLOW_TEXT_SLOTS}): `successMessage` and `errorMessage`, the terminal + * text the run hands its caller. The engine renders each at the run's terminal + * exit, over the variables as the run left them, through the renderer the + * refusing `end` node's `message` uses — so the doors judge them exactly as + * they judge a node's text slot ({@link flowTextSlotSources}). + * * This module is the ONE judge of what the slots still carry from the old * dialect: a single-brace `{…}` token is refused, and the refusal names the * `{{ }}` spelling of each token or, for a token no hole can spell, where to @@ -30,7 +37,7 @@ * `AutomationEngine.registerFlow` and the `validateStackExpressions` rule * (`objectstack validate`, `objectstack compile`, the runtime publish gate of * an `active` metadata save) call it on every slot - * {@link flowNodeTextSlotSources} finds — ⛔ never a second reading of the + * {@link flowNodeTextSlotSources} and {@link flowTextSlotSources} find — ⛔ never a second reading of the * dialect anywhere. Those doors then compile each slot's `{{ }}` holes * (`validateExpression('template', …)`), which the spec cannot do itself: the * template engine is a runtime. @@ -50,7 +57,10 @@ * validate`'s `expression-invalid` finding with the parse's issue list, and * would fail the row in the stored pass (`os migrate meta --stored`), which * lists a text-slot refusal made after the parse as a TODO under - * `registerFlow` and leaves its verdict unmoved. + * `registerFlow` and leaves its verdict unmoved. The flow's own two slots + * ({@link FLOW_TEXT_SLOTS}) are left to the doors for the same reasons: + * `registerFlow` reads them straight off the parsed flow, so unlike an `end` + * node they never lack a door after the parse. * * ## Refused, not converted (ADR-0087 D2) * @@ -483,9 +493,38 @@ export function textSlotTemplateRefusal(text: string): string | undefined { return refusals.length === 0 ? undefined : refusals.join(' '); } -/** One text slot present in a node's config, with the text it carries. */ +/** + * One text slot of the FLOW itself (#28054) — a top-level `FlowSchema` key + * whose value the engine renders to text. + */ +export interface FlowTextSlot { + /** The flow key (`flow.`). */ + readonly key: 'successMessage' | 'errorMessage'; + /** Author-facing label for diagnostics, e.g. `flow successMessage`. */ + readonly label: string; +} + +/** + * Every text slot of the flow itself — the terminal text the run hands its + * caller (#28054). Measured from the engine, as {@link FLOW_NODE_TEXT_SLOTS} + * is from the executors: `successMessage` is rendered at every terminal + * success exit (`execute()`, a retried attempt's, the resume's) over the + * variables the run finished with, and `errorMessage` at every terminal + * failure exit over the variables at the failure point, both through the + * renderer the refusing `end` node's `message` goes through. The keys are the + * flow-level keys of the `flows` translation face (`FLOW_TERMINAL_MESSAGE_KEYS` + * in `system/i18n-resolver.ts`), which the engine picks in the run's locale + * before it fills the holes. Until #28054 both were copied onto the result + * verbatim, so a `{{ }}` hole reached the user as written. + */ +export const FLOW_TEXT_SLOTS: readonly FlowTextSlot[] = [ + { key: 'successMessage', label: 'flow successMessage' }, + { key: 'errorMessage', label: 'flow errorMessage' }, +]; + +/** One text slot present in a node's config — or, from {@link flowTextSlotSources}, on the flow — with the text it carries. */ export interface FlowNodeTextSlotSource { - /** Path into `node.config` — the slot's key. */ + /** Path into `node.config` — the slot's key; from {@link flowTextSlotSources}, the flow key. */ readonly path: string; /** The slot, as a door names it — `notify title`. */ readonly label: string; @@ -521,6 +560,23 @@ export function flowNodeTextSlotSources(nodeType: string, config: unknown): Flow return out; } +/** + * The text slots the FLOW itself carries ({@link FLOW_TEXT_SLOTS}), with their + * template text — the flow-level sibling of {@link flowNodeTextSlotSources}, + * judged by the same doors in the same two steps: {@link textSlotTemplateRefusal}, + * then the `template` compile. A slot that is absent or not a string is + * skipped (`FlowSchema` refuses any other shape). + */ +export function flowTextSlotSources(flow: unknown): FlowNodeTextSlotSource[] { + if (flow === null || typeof flow !== 'object' || Array.isArray(flow)) return []; + const out: FlowNodeTextSlotSource[] = []; + for (const slot of FLOW_TEXT_SLOTS) { + const value = (flow as Record)[slot.key]; + if (typeof value === 'string') out.push({ path: slot.key, label: slot.label, source: value }); + } + return out; +} + // ─── A CLOSED scope (#24798) ──────────────────────────────────────────────── /** diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index d34b8bd97de..0af50adefa5 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1073,11 +1073,23 @@ export const FlowSchema = lazySchema(() => strictObject( * screen-flow-only was the alternative considered and rejected at #9414's * triage — narrowing the text would delete a declared, documented, * console-consumed capability to make a bug disappear — so treat the - * screen-flow toast as one consumer, not the whole contract. Plain - * strings; `{var}` is NOT interpolated here. + * screen-flow toast as one consumer, not the whole contract. + * + * Both are flow TEXT slots (#28054, `FLOW_TEXT_SLOTS` in + * `flow-text-slot-template.ts`): `{{ }}` holes over the run's variables + * (`'{{ enrolledCount }} contacts enrolled'`), rendered by the engine at the + * terminal exit exactly like a refusing `end` node's `message` — + * `successMessage` over the variables the run finished with, `errorMessage` + * over the variables at the failure point — after it picks the translated + * template (`flows..successMessage` / `.errorMessage`) in the run's + * locale. A hole over a variable the run never bound renders nothing, as in + * every text slot. The doors judge them as they judge a node's text slot: + * `registerFlow` and `objectstack validate` refuse a single-brace `{var}` + * token (no placeholder, it would be sent as literal text) with its `{{ }}` + * spelling, and a hole that does not compile. */ - successMessage: z.string().optional().describe('Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done".'), - errorMessage: z.string().optional().describe('Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error.'), + successMessage: z.string().optional().describe('Text for a terminal success, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". A `{{ }}` template over the run\'s variables (`{{ enrolledCount }} contacts enrolled`), rendered when the run completes.'), + errorMessage: z.string().optional().describe('Text for a terminal failure, carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. A `{{ }}` template over the run\'s variables, rendered over the variables at the failure point.'), /** Metadata & Versioning */ version: z.number().int().default(1).describe('Version number'), diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 65ca8973998..58d137813dd 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -674,10 +674,12 @@ export interface AutomationResult { */ screen?: ScreenSpec; /** - * Friendly terminal messages copied from the flow definition + * Friendly terminal messages rendered from the flow definition * (`flow.successMessage` / `flow.errorMessage`) so a screen-flow runner can * show a meaningful toast instead of a generic "Done" / the raw error. * `successMessage` is set on terminal success, `errorMessage` on failure. + * Each is the flow's `{{ }}` template already rendered over the run's + * variables, in the run's locale (#28054), so a runner draws it as served. */ successMessage?: string; errorMessage?: string; diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index dca0053a454..5bca9474ede 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3887,6 +3887,25 @@ export function flowScreenCopyKey(flowName: string, nodeId: string, key: FlowScr return `flows.${flowName}.screens.${nodeId}.${key}`; } +/** + * Dot-notation i18n key for a flow's terminal text — + * `flows..successMessage` / `flows..errorMessage` (#28054). + * + * The sibling of {@link flowScreenCopyKey}, for the same reason and with the + * same reader. Both are `{{ }}` templates the engine renders at the run's + * terminal exit (`FLOW_TEXT_SLOTS` in `automation/flow-text-slot-template.ts`), + * so it picks the translated TEMPLATE in the run's locale through the `i18n` + * service and only then fills the holes — the rule of the maintainer's ruling + * on #22507: a user-read flow string the server renders per run is translated + * where it is rendered, and a client overlay never touches a server-rendered + * slot. + * + * `flowName` is `Flow.name` and `key` one of {@link FLOW_TERMINAL_MESSAGE_KEYS}. + */ +export function flowTerminalMessageKey(flowName: string, key: FlowTerminalMessageKey): string { + return `flows.${flowName}.${key}`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, @@ -5245,9 +5264,9 @@ export interface FlowLike { /** `Flow.name` — the machine name the `flows` group is keyed by. */ name: string; label?: string; - /** The completion toast (`Flow.successMessage`, served verbatim as `AutomationResult.successMessage`). */ + /** The completion toast (`Flow.successMessage`, a `{{ }}` template the engine renders into `AutomationResult.successMessage`, #28054). */ successMessage?: string; - /** The failure toast (`Flow.errorMessage`, served verbatim as `AutomationResult.errorMessage`). */ + /** The failure toast (`Flow.errorMessage`, a `{{ }}` template the engine renders into `AutomationResult.errorMessage`, #28054). */ errorMessage?: string; nodes?: FlowNodeLike[]; [key: string]: any; @@ -5340,12 +5359,17 @@ export type FlowScreenFieldCopyKey = typeof FLOW_SCREEN_FIELD_COPY_KEYS[number]; * `flows..successMessage` and `flows..errorMessage`, each spelled * as `FlowSchema` spells the string it overlays. * - * The engine carries both VERBATIM on the terminal `AutomationResult` (plain - * strings, never interpolated), so a runner translates the served string by - * handing it to {@link translateFlow} under its own key, the way it already - * resolves the served `flowLabel`. Exported for the same one-list-both-sides - * reason as {@link FLOW_SCREEN_COPY_KEYS}: the CLI's skeleton writer offers - * exactly these keys, and `translation.test.ts` pins the schema to them. + * Since #28054 both are `{{ }}` templates the engine renders at the run's + * terminal exit, so the ENGINE picks each translation, in the run's locale, + * before it fills the holes ({@link flowTerminalMessageKey}), and the served + * `AutomationResult.successMessage` / `.errorMessage` arrive translated and + * filled: a runner draws them as served and overlays neither (maintainer + * ruling A on #22507, which moved a screen's heading and body text the same + * way). Until then the engine carried both verbatim and the runner handed the + * served string to {@link translateFlow}, which still applies these keys to a + * flow DOCUMENT. Exported for the same one-list-both-sides reason as + * {@link FLOW_SCREEN_COPY_KEYS}: the CLI's skeleton writer offers exactly these + * keys, and `translation.test.ts` pins the schema to them. */ export const FLOW_TERMINAL_MESSAGE_KEYS = ['successMessage', 'errorMessage'] as const; @@ -5605,16 +5629,16 @@ export function resolveFlowScreenTitle( * label the engine serves on every run result as `AutomationResult.flowLabel`, * and draws the answer (the API name when no label was served) in its header * and its completion toast. Both `flows` rows in the ledger are `live`, and - * cite these reads. The terminal toasts and the option - * labels (#22507) take the same client side — the runner hands the served - * `successMessage` / `errorMessage` to this function as it does the label, - * and overlays each served field's options through - * {@link resolveFlowScreenFieldOptions} — in a downstream objectui change; - * their ledger rows are `planned` until it lands. A screen's heading and body - * text do NOT take the client side any more (maintainer ruling A on #22507): - * the server renders both per run, so the engine picks their translated - * templates in the run's locale ({@link flowScreenCopyKey}), and the runner - * stops overlaying the served heading. + * cite these reads. The option labels (#22507) take the same client side — + * the runner overlays each served field's options through + * {@link resolveFlowScreenFieldOptions}. A screen's heading and body text do + * NOT take the client side any more (maintainer ruling A on #22507): the + * server renders both per run, so the engine picks their translated templates + * in the run's locale ({@link flowScreenCopyKey}), and the runner stops + * overlaying the served heading. The terminal toasts followed them (#28054): + * the engine renders `successMessage` / `errorMessage` as `{{ }}` templates, + * so it picks their translations ({@link flowTerminalMessageKey}) and the + * runner draws the served strings as they arrive. */ export function translateFlow( flow: T, diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 81afea834b0..9733d3675be 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, flowRefusalMessageKey, flowScreenCopyKey } from './i18n-resolver'; +import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, FLOW_TERMINAL_MESSAGE_KEYS, flowRefusalMessageKey, flowScreenCopyKey, flowTerminalMessageKey } from './i18n-resolver'; import { TEXT_SLOT_TEMPLATE_REFUSAL } from '../automation/flow-text-slot-template'; import { FlowSchema } from '../automation/flow.zod'; import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; @@ -1337,6 +1337,30 @@ describe('translation unknown-key strictness', () => { } }); + // #28054 — the engine renders the flow's terminal text as a `{{ }}` + // template and picks its translation before it fills the holes, so the + // translation is the same text slot as the source. + it('keys the terminal toasts as translated templates that keep the `{{ }}` holes (#28054)', () => { + const result = parse({ enrol: { successMessage: '已登记 {{ enrolledCount }} 位联系人', errorMessage: '登记失败' } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(flowTerminalMessageKey('enrol', 'successMessage')).toBe('flows.enrol.successMessage'); + expect(flowTerminalMessageKey('enrol', 'errorMessage')).toBe('flows.enrol.errorMessage'); + }); + + it('refuses a single-brace `{token}` in a translated toast, through the one text-slot judge (#28054)', () => { + for (const key of FLOW_TERMINAL_MESSAGE_KEYS) { + const result = parse({ enrol: { [key]: '已登记 {enrolledCount} 位联系人' } }); + expect(result.success, key).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['flows', 'enrol', key]); + expect(issue.message.slice(0, issue.message.indexOf('. ') + 1)).toBe(TEXT_SLOT_TEMPLATE_REFUSAL); + expect(issue.message).toContain('{{ enrolledCount }}'); + } + // An empty string is the untranslated skeleton slot. + expect(parse({ enrol: { successMessage: '' } }).success).toBe(true); + }); + it('keys a screen\'s `description` — a translated template that keeps the `{{ }}` holes (#22507)', () => { // Ruling A on #22507: the engine picks this template in the run's locale // before it fills the holes, so a translation carries the holes of the diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index babf8e8747e..4cc4050aac6 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -88,16 +88,18 @@ const BULK_PARAM_NO_OPTIONS = /** * A translated flow TEXT SLOT — the one leaf every `{{ }}` template the flow * engine picks from the `flows` group is spelled with: a screen's `title` and - * `description`, and a refusing `end` node's `message`. + * `description`, a refusing `end` node's `message`, and (#28054) the flow's + * own `successMessage` and `errorMessage`. * * The engine picks each of them in the run's locale and renders it through * `renderTextSlot` (maintainer ruling A on #22507), so a translation is the * same text slot as the template it translates, and the one text-slot judge * (`textSlotTemplateRefusal`) refuses here exactly what `ScreenConfigSchema` - * and `EndConfigSchema` refuse on the source: a single-brace `{token}`, which - * would render as literal text, and a hole over a `$` root the engine does not - * bind. One leaf for the three, so a slot cannot be judged in one place and - * drawn literally in the slot beside it (#22627). + * and `EndConfigSchema` refuse on the source, and what `registerFlow` refuses + * on the flow's own two slots: a single-brace `{token}`, which would render as + * literal text, and a hole over a `$` root the engine does not bind. One leaf + * for all of them, so a slot cannot be judged in one place and drawn literally + * in the slot beside it (#22627). * * No `.min(1)`, like every sibling leaf: an empty string is the untranslated * slot `os i18n extract` writes into a skeleton, the judge finds no token in @@ -1648,15 +1650,21 @@ const appTranslationDataShape = () => ({ * flows..errorMessage * * The flow's own completion and failure text (`FlowSchema.successMessage` / - * `.errorMessage`). The engine carries each VERBATIM on the terminal - * `AutomationResult` — a plain string, never interpolated — and the console - * draws it as the toast at the end of a run, so the text needs no pick - * before a render and the overlay is client-side, exactly as for the flow - * `label` the engine serves as `AutomationResult.flowLabel`. Translated - * only where the flow authors the message: a bundle cannot ADD a toast to a - * flow that declares none (`translateFlow`; `objectstack validate` refuses - * such a key). The toast's chrome sentence (`Flow "…" completed`, shown when - * the flow declares no `successMessage`) stays the console's own words. + * `.errorMessage`). Since #28054 each is a `{{ }}` template the engine + * renders at the run's terminal exit, so it is picked like a screen's + * heading: the engine reads `flowTerminalMessageKey(flow, key)` + * (`system/i18n-resolver.ts`) through the `i18n` service, in the run's + * `AutomationContext.locale`, renders the translation in place of the + * authored template, and serves the result filled; the console draws it as + * served (maintainer ruling A on #22507). With no locale, or no entry, the + * authored template renders. A translation is the same text slot as its + * source (`flowTextSlotTranslation`): it keeps the holes + * (`'已登记 {{ enrolledCount }} 位联系人'`), and a single-brace `{token}` is + * refused. Translated only where the flow authors the message: a bundle + * cannot ADD a toast to a flow that declares none (`objectstack validate` + * refuses such a key). The toast's chrome sentence (`Flow "…" completed`, + * shown when the flow declares no `successMessage`) stays the console's own + * words. * * ## `options.` — a screen field's option labels (#22507) * @@ -1725,13 +1733,18 @@ const appTranslationDataShape = () => ({ }, }, { label: z.string().optional().describe('Translated flow label'), - successMessage: z.string().optional().describe( - "Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries " - + 'verbatim on the terminal result; only a flow that authors one is translated', + // [#28054] Picked by the ENGINE in the run's locale and rendered in place + // of the flow's own template at the terminal exit, so judged as the text + // slot it translates, like a screen's `title` and `description`. + successMessage: flowTextSlotTranslation().optional().describe( + "Translated completion toast — a `{{ }}` template like the flow's own `successMessage`, picked by the engine " + + "in the run's locale and rendered in its place over the run's variables; only a flow that authors one is " + + 'translated', ), - errorMessage: z.string().optional().describe( - "Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries " - + 'verbatim on the terminal result; only a flow that authors one is translated', + errorMessage: flowTextSlotTranslation().optional().describe( + "Translated failure toast — a `{{ }}` template like the flow's own `errorMessage`, picked by the engine in " + + "the run's locale and rendered in its place over the variables at the failure point; only a flow that " + + 'authors one is translated', ), screens: z.record(z.string(), strictObject({ surface: 'this flow screen translation', diff --git a/packages/verify/src/automation-trigger-terminal-messages.test.ts b/packages/verify/src/automation-trigger-terminal-messages.test.ts index afde252e1c1..37f8ac48b22 100644 --- a/packages/verify/src/automation-trigger-terminal-messages.test.ts +++ b/packages/verify/src/automation-trigger-terminal-messages.test.ts @@ -31,11 +31,16 @@ * against the engine's internal result object: a pin that stops at * `AutomationResult` cannot fail when the wire mapping is the half that breaks. * - * **Verbatim is contract too.** `flows.mdx` documents both fields as plain - * author-declared strings with `{var}` explicitly NOT interpolated. Anything this - * path did to them on the way out — templating, trimming, HTML-escaping — would - * contradict the shipped contract, so one case drives a deliberately - * hostile string and asserts byte identity rather than a substring match. + * **Rendered, and otherwise byte for byte (#28054).** `flows.mdx` documents both + * fields as flow text slots: `{{ }}` holes over the run's variables, filled by the + * engine at the terminal exit, and a single-brace `{var}` refused when the flow + * registers. Everything else in the string is the author's: this path does no + * trimming and no HTML-escaping on the way out. So three cases pin it on the + * wire: a brace-free hostile string (padding, quotes, `&`, `>`) arrives byte for + * byte; the same string with a `{{ amount }}` hole arrives with the trigger param + * filled in and every other byte kept; and the single-brace `{amount}` spelling + * never reaches the wire, because `registerFlow` refuses the flow on the trigger + * path with its `{{ amount }}` spelling. * * ⚠️ This suite resolves both packages through their BUILT `dist/`, as every * dependent of theirs in this workspace does. Rebuild `@objectstack/service-automation` @@ -56,11 +61,22 @@ const ERROR_TEXT = 'We could not create the opportunity — check the amount and const RAW_FAILURE = 'downstream 503'; /** - * Braces (a templating probe), padding (a trimming probe), and `&`/quotes/`>` - * (an escaping probe) in one string. `{amount}` is supplied as a real trigger - * param below, so an interpolating producer would visibly substitute it. + * Padding (a trimming probe) and `&`/quotes/`>` (an escaping probe), with no + * hole and no closed single-brace token: a message with nothing to fill reaches + * the wire byte for byte. */ -const HOSTILE = ' {amount} items — "R&D" & 5 > 3, kept verbatim '; +const PLAIN_HOSTILE = ' Items — "R&D" & 5 > 3, kept verbatim '; + +/** + * A `{{ amount }}` hole (the render probe), padding (a trimming probe), and + * `&`/quotes/`>` (an escaping probe) in one string. `amount` is a declared input + * supplied as a real trigger param below, so the engine fills the hole with it. + */ +const HOSTILE = ' {{ amount }} items — "R&D" & 5 > 3, kept verbatim '; +/** HOSTILE as the engine renders it with `amount = 42`: the hole filled, every other byte kept. */ +const HOSTILE_RENDERED = ' 42 items — "R&D" & 5 > 3, kept verbatim '; +/** The single-brace spelling the flow text slots refuse at registration (#28054). */ +const SINGLE_BRACE = ' {amount} items — "R&D" & 5 > 3, kept verbatim '; const CTX = { request: {}, executionContext: { userId: 'user_1' } } as never; @@ -70,7 +86,7 @@ function createTestLogger(): never { } /** A one-node flow whose single `script` node passes or fails as asked. */ -function bootFlow(opts: { fails: boolean; successMessage?: string; errorMessage?: string }): HttpDispatcher { +function bootFlow(opts: { fails: boolean; successMessage?: string; errorMessage?: string; amountInput?: boolean }): HttpDispatcher { const engine = new AutomationEngine(createTestLogger()); engine.registerNodeExecutor({ type: 'script', @@ -84,6 +100,7 @@ function bootFlow(opts: { fails: boolean; successMessage?: string; errorMessage? type: 'autolaunched', ...(opts.successMessage !== undefined ? { successMessage: opts.successMessage } : {}), ...(opts.errorMessage !== undefined ? { errorMessage: opts.errorMessage } : {}), + ...(opts.amountInput ? { variables: [{ name: 'amount', type: 'number', isInput: true }] } : {}), nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'work', type: 'script', label: 'Work', config: { function: 'noop' } }, @@ -141,25 +158,50 @@ describe('#9414 — a triggered run reaches the wire with the author\'s terminal expect(result.response?.body?.data?.successMessage).toBe(SUCCESS_TEXT); }); - it('carries both VERBATIM — no templating, no trimming, no escaping', async () => { - // `flows.mdx` documents these as plain strings with `{var}` explicitly - // NOT interpolated. `amount` is a real trigger param here, so an - // interpolating producer would substitute it and this would fail loudly. - const failing = bootFlow({ fails: true, errorMessage: HOSTILE }); - const passing = bootFlow({ fails: false, successMessage: HOSTILE }); + it('carries a message with no hole byte for byte — no trimming, no escaping', async () => { + // A brace-free hostile string: nothing to render, so the engine's render + // must hand back every byte the author wrote, padding and `& " >` included. + const failing = bootFlow({ fails: true, errorMessage: PLAIN_HOSTILE }); + const passing = bootFlow({ fails: false, successMessage: PLAIN_HOSTILE }); + + const failed = await trigger(failing, { amount: 42 }); + const passed = await trigger(passing, { amount: 42 }); + + expect(failed.response?.body?.error?.details?.errorMessage).toBe(PLAIN_HOSTILE); + expect(passed.response?.body?.data?.successMessage).toBe(PLAIN_HOSTILE); + expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/^ {2}Items — "R&D" & 5 > 3/); + expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/verbatim {2}$/); + }); + + it('fills the `{{ }}` hole with the run\'s variable and keeps every other byte — no trimming, no escaping (#28054)', async () => { + // `flows.mdx` documents these as flow text slots. `amount` is a declared + // input supplied as a real trigger param, so the engine fills the hole + // at the terminal exit; the padding, quotes, `&` and `>` are the author's. + const failing = bootFlow({ fails: true, errorMessage: HOSTILE, amountInput: true }); + const passing = bootFlow({ fails: false, successMessage: HOSTILE, amountInput: true }); const failed = await trigger(failing, { amount: 42 }); const passed = await trigger(passing, { amount: 42 }); - expect(failed.response?.body?.error?.details?.errorMessage).toBe(HOSTILE); - expect(passed.response?.body?.data?.successMessage).toBe(HOSTILE); + expect(failed.response?.body?.error?.details?.errorMessage).toBe(HOSTILE_RENDERED); + expect(passed.response?.body?.data?.successMessage).toBe(HOSTILE_RENDERED); // Spelled out, because `toBe` on a constant can be read as tautological: - // the padding survives, and the brace was never a template. - expect(failed.response?.body?.error?.details?.errorMessage).not.toContain('42'); - expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/^ {2}\{amount\}/); + // the hole is gone, the value is there, and the padding survives. + expect(failed.response?.body?.error?.details?.errorMessage).not.toContain('{{'); + expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/^ {2}42 items/); expect(failed.response?.body?.error?.details?.errorMessage).toMatch(/verbatim {2}$/); }); + it('a single-brace `{amount}` never reaches the wire: registerFlow refuses it with its `{{ }}` spelling (#28054)', () => { + for (const key of ['successMessage', 'errorMessage'] as const) { + // `toThrow(string)` matches a substring: the door, the key and the judge's own lead. + expect(() => bootFlow({ fails: false, [key]: SINGLE_BRACE, amountInput: true })).toThrow( + `flow ${key}: A flow text slot reads`, + ); + expect(() => bootFlow({ fails: false, [key]: SINGLE_BRACE, amountInput: true })).toThrow(/\{\{ ?amount ?\}\}/); + } + }); + it('a flow declaring NO messages gets no invented key — the doc\'s "when the flow declares one" half', async () => { // ⚠️ Green before and after the repair, deliberately: it fences the fix // rather than demonstrating it. The route omits the key entirely when diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 3ef60365488..57c57abeda2 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -174,9 +174,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // Entitlement 403s render as a dialog, not a toast — its copy is localized // here rather than taken from the server (objectui#2458 / cloud#959). // `language` also resolves inline per-locale action-param labels below, and - // with `i18n` it is what a flow launch translates the flow's own toasts in - // (objectui#12075). - const { t, language, i18n } = useObjectTranslation(); + // is the language a flow launch reads a refused write's permission line in. + const { t, language } = useObjectTranslation(); /** * The console's own metadata store. @@ -769,7 +768,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons recordId, triggerParams: params, objects: withKnownObjects(objects, metadata.objects), - translation: { i18n, language }, + language, }); const judged = launch.judged; // Paused at a `screen` node: FlowRunner renders the form + resumes, and @@ -795,7 +794,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } catch (error) { return { success: false, error: (error as Error).message }; } - }, [authFetch, objApiName, objects, metadata, refresh, i18n, language, t]); + }, [authFetch, objApiName, objects, metadata, refresh, language, t]); // Server-side action handler — POST /api/v1/actions/{object}/{action}, built // from @object-ui/core's `createServerActionHandler` via the shared console diff --git a/ui/packages/app-shell/src/utils/__tests__/flowLaunch.translation-12075.test.tsx b/ui/packages/app-shell/src/utils/__tests__/flowLaunch.translation-12075.test.tsx index 3f532e8d6b5..02c4c1f60f8 100644 --- a/ui/packages/app-shell/src/utils/__tests__/flowLaunch.translation-12075.test.tsx +++ b/ui/packages/app-shell/src/utils/__tests__/flowLaunch.translation-12075.test.tsx @@ -2,11 +2,14 @@ * ObjectUI * Copyright (c) 2024-present ObjectStack Inc. * - * A flow LAUNCH answers the flow's own toasts in the host's language - * (objectui#12075): `launchConsoleFlow` reads the `flows` bundle for the flow - * the run belongs to out of the host's i18next instance, and hands it to - * `interpretFlowResponse`. The instance here is a real `createI18n` one, loaded - * through `transformSpecTranslations` the way the console loads an app bundle. + * A flow LAUNCH speaks the flow's own toasts as the engine served them + * (objectstack#28054). The engine renders `successMessage` / `errorMessage` as + * `{{ }}` templates and picks their `flows.FLOW.*` translations in the run's + * locale, so the served strings arrive translated and filled; the launch no + * longer overlays the host's bundle (objectui#12075 did), because the bundle's + * entry is the TEMPLATE and would put raw holes over the filled sentence. A + * launch is handed only the host's language now, for a refused write's + * permission line, so no bundle can reach the toast. * * ⚠️ A `.tsx` on purpose, though it renders nothing: the `.test.ts` suffix * puts a file in the `unit` project, which runs with `isolate: false` (one @@ -20,24 +23,11 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createI18n, transformSpecTranslations } from '@object-ui/i18n'; -import type { TranslationData } from '@objectstack/spec/system'; import { launchConsoleFlow } from '../flowLaunch'; const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); vi.mock('sonner', () => ({ toast: toasts })); -const ZH_CN: TranslationData = { - objects: { crm_case: { label: '工单', fields: { name: { label: '名称' } } } }, - flows: { case_escalate: { successMessage: '工单已升级', errorMessage: '升级失败' } }, -}; - -function zhInstance() { - const instance = createI18n({ defaultLanguage: 'zh-CN', detectBrowserLanguage: false }); - instance.addResourceBundle('zh-CN', 'translation', transformSpecTranslations(ZH_CN as Record), true, true); - return instance; -} - function answering(body: unknown) { return vi.fn(async () => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })); } @@ -51,7 +41,7 @@ function launch(fetch: ReturnType, language = 'zh-CN') { recordId: 'c1', triggerParams: {}, objects: [], - translation: { i18n: zhInstance(), language }, + language, }); } @@ -60,24 +50,24 @@ beforeEach(() => { toasts.success.mockClear(); }); -describe('launchConsoleFlow — the flow\'s own toasts in the host\'s language (objectui#12075)', () => { - it('speaks the translated successMessage of a straight-through run', async () => { - const out = await launch(answering({ success: true, data: { success: true, status: 'completed', successMessage: 'Case escalated' } })); +describe("launchConsoleFlow — the flow's own toasts as served (objectstack#28054)", () => { + it('speaks the served successMessage of a straight-through run', async () => { + const out = await launch(answering({ success: true, data: { success: true, status: 'completed', successMessage: '已升级 3 张工单' } })); expect(out.door).toBe('trigger'); - expect(out.judged.successToast).toBe('工单已升级'); - expect(toasts.success).toHaveBeenCalledWith('工单已升级'); + expect(out.judged.successToast).toBe('已升级 3 张工单'); + expect(toasts.success).toHaveBeenCalledWith('已升级 3 张工单'); }); - it('hands the ActionRunner the translated errorMessage of a failed run', async () => { - const out = await launch(answering({ success: true, data: { success: false, error: 'boom', errorMessage: 'Escalation failed' } })); - expect(out.judged.result).toEqual({ success: false, error: '升级失败' }); + it('hands the ActionRunner the served errorMessage of a failed run', async () => { + const out = await launch(answering({ success: true, data: { success: false, error: 'boom', errorMessage: '3 张工单升级失败' } })); + expect(out.judged.result).toEqual({ success: false, error: '3 张工单升级失败' }); }); - it('keeps the served strings in a language the bundle does not carry', async () => { + it('keeps the served strings in every language', async () => { const out = await launch( - answering({ success: true, data: { success: false, error: 'boom', errorMessage: 'Escalation failed' } }), + answering({ success: true, data: { success: false, error: 'boom', errorMessage: 'Escalation failed for 3 cases' } }), 'en', ); - expect(out.judged.result).toEqual({ success: false, error: 'Escalation failed' }); + expect(out.judged.result).toEqual({ success: false, error: 'Escalation failed for 3 cases' }); }); }); diff --git a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index fdfed0fba58..bd45b95b47d 100644 --- a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -562,59 +562,47 @@ describe("judgeFlowLaunch — a straight-through run's success copy (objectui#12 }); }); -describe("interpretFlowResponse — the flow's own toasts in the active language (objectui#12075)", () => { - // `flows.FLOW.successMessage` / `errorMessage` (objectstack#22507). The - // bundle shape is the spec's `TranslationBundle`, keyed by locale. - const copy = { - flowName: 'case_escalate', - locale: 'zh-CN', - bundle: { - 'zh-CN': { - flows: { case_escalate: { successMessage: '工单已升级', errorMessage: '升级失败' } }, - }, - }, - }; - - it('translates the served successMessage on a done run, and the launch speaks the translation', () => { - const json = { success: true, data: { success: true, status: 'completed', successMessage: 'Case escalated' } }; - const out = interpretFlowResponse(ok, json, 'Flow "case_escalate"', copy); - expect(out).toMatchObject({ kind: 'done', successMessage: '工单已升级' }); - expect(judgeFlowLaunch(out, undefined).successToast).toBe('工单已升级'); - }); - - it('translates the authored errorMessage on a 200 flow failure', () => { +describe("interpretFlowResponse — the flow's own toasts are drawn as served (objectstack#28054)", () => { + // The engine renders `successMessage` / `errorMessage` as `{{ }}` templates + // and picks their `flows.FLOW.*` translations in the run's locale before + // it fills the holes, so the served strings arrive translated and filled. + // A client overlay never touches a server-rendered slot (ruling A on + // objectstack#22507): it would put the bundle's template, holes raw, in + // place of the filled sentence. + const SERVED_DONE = '已登记 3 位联系人'; + const SERVED_FAILED = '登记失败:3'; + + it('answers the served successMessage on a done run, and the launch speaks it as served', () => { + const json = { success: true, data: { success: true, status: 'completed', successMessage: SERVED_DONE } }; + const out = interpretFlowResponse(ok, json, 'Flow "enrol"', 'zh-CN'); + expect(out).toMatchObject({ kind: 'done', successMessage: SERVED_DONE }); + expect(judgeFlowLaunch(out, undefined).successToast).toBe(SERVED_DONE); + }); + + it('answers the served errorMessage on a 200 flow failure', () => { const out = interpretFlowResponse(ok, { success: true, - data: { success: false, error: 'boom', errorMessage: 'Escalation failed' }, - }, 'Resume', copy); - expect(out).toMatchObject({ kind: 'failed', retryable: false, error: '升级失败' }); + data: { success: false, error: 'boom', errorMessage: SERVED_FAILED }, + }, 'Resume', 'zh-CN'); + expect(out).toMatchObject({ kind: 'failed', retryable: false, error: SERVED_FAILED }); }); - it('translates the authored errorMessage carried in a 400 FLOW_FAILED envelope', () => { + it('answers the served errorMessage carried in a 400 FLOW_FAILED envelope', () => { const out = interpretFlowResponse( { ok: false, status: 400 }, - errorEnvelope(400, 'FLOW_FAILED', 'engine prose', { errorMessage: 'Escalation failed' }), + errorEnvelope(400, 'FLOW_FAILED', 'engine prose', { errorMessage: SERVED_FAILED }), 'Resume', - copy, + 'zh-CN', ); - expect(out).toMatchObject({ kind: 'failed', retryable: false, error: '升级失败' }); + expect(out).toMatchObject({ kind: 'failed', retryable: false, error: SERVED_FAILED }); }); - it('adds no toast the flow does not author, and leaves the engine prose untranslated', () => { - const done = interpretFlowResponse(ok, { success: true, data: { success: true } }, 'Flow "x"', copy); + it('adds no toast the flow does not author, and leaves the engine prose as it is', () => { + const done = interpretFlowResponse(ok, { success: true, data: { success: true } }, 'Flow "x"', 'zh-CN'); expect(done).toMatchObject({ kind: 'done', successMessage: undefined }); - const failed = interpretFlowResponse(ok, { success: true, data: { success: false, error: 'boom' } }, 'Resume', copy); + const failed = interpretFlowResponse(ok, { success: true, data: { success: false, error: 'boom' } }, 'Resume', 'zh-CN'); expect(failed).toMatchObject({ kind: 'failed', error: 'boom' }); }); - - it('answers the served strings where the bundle carries no key, or there is no bundle', () => { - const json = { success: true, data: { success: true, status: 'completed', successMessage: 'Case escalated' } }; - const otherFlow = { ...copy, flowName: 'other_flow' }; - expect(interpretFlowResponse(ok, json, 'Flow "other_flow"', otherFlow)).toMatchObject({ successMessage: 'Case escalated' }); - expect(interpretFlowResponse(ok, json, 'Flow "case_escalate"', { ...copy, bundle: undefined })) - .toMatchObject({ successMessage: 'Case escalated' }); - expect(interpretFlowResponse(ok, json, 'Flow "case_escalate"')).toMatchObject({ successMessage: 'Case escalated' }); - }); }); // objectui#13367 — the invoking action's `successMessage` may arrive as the @@ -674,8 +662,8 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record- expect(FlowFailureDetailsSchema.safeParse(details).error?.issues).toBeUndefined(); return { success: false, error }; } - const failed400 = (json: unknown, copy?: Parameters[3]) => - interpretFlowResponse({ ok: false, status: 400 }, json, 'Resume', copy); + const failed400 = (json: unknown, locale?: string) => + interpretFlowResponse({ ok: false, status: 400 }, json, 'Resume', locale); it('a unique refusal shows the inner sentence, without the node and operation wrapper', () => { expect(failed400(flowFailed({ innerRefusal: UNIQUE }))).toEqual({ @@ -691,7 +679,7 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record- const en = failed400(flowFailed({ innerRefusal: DENIED })); expect(en).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave }); - const zh = failed400(flowFailed({ innerRefusal: DENIED }), { flowName: 'convert', bundle: undefined, locale: 'zh-CN' }); + const zh = failed400(flowFailed({ innerRefusal: DENIED }), 'zh-CN'); expect(zh).toMatchObject({ error: builtInLocales.zh.form.noPermissionToSave }); expect((zh as { error: string }).error).not.toContain('crm_account'); }); diff --git a/ui/packages/app-shell/src/utils/flowLaunch.ts b/ui/packages/app-shell/src/utils/flowLaunch.ts index 5a268282e88..48b9cb4d5c8 100644 --- a/ui/packages/app-shell/src/utils/flowLaunch.ts +++ b/ui/packages/app-shell/src/utils/flowLaunch.ts @@ -76,22 +76,19 @@ * `FlowRunner` speaks a paused run's closing copy. Which copy wins is not * decided here: see `flowSuccessCopy` in `utils/flowResponse`. * - * ## The flow's own toasts reach the user in their language (objectui#12075) + * ## The flow's own toasts reach the user in their language (objectstack#28054) * - * A launch is handed the host's i18next instance and active language, reads - * the `flows` bundle for the flow the run belongs to (`activeFlowsBundle`, the - * same reader `FlowRunner` uses), and hands it to `interpretFlowResponse`, - * which translates the flow's `successMessage` and `errorMessage` against - * `flows.FLOW.successMessage` / `flows.FLOW.errorMessage`. So the toast a - * straight-through run closes on, and the error a failed launch hands the - * ActionRunner, are the words a paused run's runner shows. + * The engine renders the flow's `successMessage` and `errorMessage` and picks + * their `flows.FLOW.*` translations in the run's locale, so a launch speaks + * them as served, exactly as a paused run's runner does. It is handed the + * host's active language only for the permission line a refused write reads + * (`interpretFlowResponse`). */ import { toast } from 'sonner'; import { createServerActionHandler } from '@object-ui/core'; import type { ActionDef, ActionResult, ServerActionFetch } from '@object-ui/core'; -import { interpretFlowResponse, judgeFlowLaunch, type FlowCopyLocale, type FlowLaunchJudgement } from './flowResponse.js'; -import { activeFlowsBundle, type TranslationResourceReader } from './flowsBundle.js'; +import { interpretFlowResponse, judgeFlowLaunch, type FlowLaunchJudgement } from './flowResponse.js'; /** The door a flow click went through. */ export type ConsoleFlowLaunchDoor = 'action' | 'trigger'; @@ -144,17 +141,8 @@ export interface ConsoleFlowLaunchInput { triggerParams: Record; /** The object metadata the host already holds. */ objects: readonly unknown[] | undefined; - /** - * The host's i18next instance and active language, which the flow's own - * toasts are translated in (objectui#12075 — see the header). - */ - translation: { i18n: TranslationResourceReader | undefined; language: string }; -} - -/** The translation `interpretFlowResponse` applies to `flowName`'s own toasts. */ -function flowCopyLocale(input: ConsoleFlowLaunchInput, flowName: string): FlowCopyLocale { - const { i18n, language } = input.translation; - return { flowName, bundle: activeFlowsBundle(i18n, language, flowName), locale: language }; + /** The host's active language, which a refused write's permission line is read in. */ + language: string; } export interface ConsoleFlowLaunch { @@ -210,7 +198,7 @@ export async function launchConsoleFlow( door: 'action', flowName, judged: judgeFlowLaunch( - interpretFlowResponse(answered.res, answered.json, `Flow "${flowName}"`, flowCopyLocale(input, flowName)), + interpretFlowResponse(answered.res, answered.json, `Flow "${flowName}"`, input.language), action.refreshAfter, action.successMessage, ), @@ -230,7 +218,7 @@ export async function launchConsoleFlow( door: 'trigger', flowName: clickedFlow, judged: judgeFlowLaunch( - interpretFlowResponse(res, json, `Flow "${clickedFlow}"`, flowCopyLocale(input, clickedFlow)), + interpretFlowResponse(res, json, `Flow "${clickedFlow}"`, input.language), action.refreshAfter, action.successMessage, ), diff --git a/ui/packages/app-shell/src/utils/flowResponse.ts b/ui/packages/app-shell/src/utils/flowResponse.ts index 7ba78e30e84..2192b15d0f6 100644 --- a/ui/packages/app-shell/src/utils/flowResponse.ts +++ b/ui/packages/app-shell/src/utils/flowResponse.ts @@ -198,24 +198,17 @@ * whose copy is the flow's own is `silent` as well. So {@link judgeFlowLaunch} * marks those two `handedOff`, the member the runner reads to skip the hop. * - * ## The flow's own toasts are translated here, once (objectui#12075) - * - * The engine serves the flow's `successMessage` and `errorMessage` verbatim, - * in the author's language, and an app's bundle translates them under - * `flows.FLOW.successMessage` / `flows.FLOW.errorMessage` (objectstack#22507, - * `FLOW_TERMINAL_MESSAGE_KEYS`). {@link interpretFlowResponse} is the one - * place both are lifted off the wire, so it is the one place they are - * translated: handed a {@link FlowCopyLocale} — the flow the run belongs to, - * the active `flows` bundle and its locale — it answers the `done` arm's - * `successMessage` and the `failed` arm's authored `errorMessage` through the - * spec's `translateFlow`, each falling back to the served string. That reaches - * `FlowRunner`'s resume and both launch hosts (through `launchConsoleFlow`) - * alike, so a straight-through run and a paused one close on the same words. - * - * Only what the flow AUTHORS is translated: `translateFlow` overlays a toast - * only where the served result carries one, so a bundle never turns a - * caller's default sentence into copy the author never wrote, and the engine's - * own failure prose (`error`) is passed through untouched — it has no key. + * ## The flow's own toasts are drawn as served (objectstack#28054) + * + * The flow's `successMessage` and `errorMessage` are `{{ }}` templates the + * engine renders over the run's variables at the run's terminal exit, so the + * engine also translates them: it picks `flows.FLOW.successMessage` / + * `flows.FLOW.errorMessage` in the run's locale before it fills the holes, and + * serves the result already translated and filled. A client overlay never + * touches a server-rendered slot (maintainer ruling A on objectstack#22507, + * the rule a screen's heading follows), so {@link interpretFlowResponse} + * answers both strings exactly as served; an overlay here would replace the + * filled sentence with the bundle's template, its holes drawn raw. */ import { actionCopySource, actionErrorDetail, refusalFieldErrors } from '@object-ui/core'; @@ -224,36 +217,6 @@ import { getLoadedBuiltInLocales } from '@object-ui/i18n'; import { declaredUserMessage, delegationScopeMessage, isPermissionError } from '@object-ui/react'; import { errorCodeIs } from '@object-ui/types'; import type { AutomationResult } from '@objectstack/spec/contracts'; -import { translateFlow, type TranslationBundle } from '@objectstack/spec/system'; - -/** - * What {@link interpretFlowResponse} needs to translate the flow's own toasts - * (objectui#12075 — see the header): the flow the run belongs to, the active - * language's `flows` bundle (`activeFlowsBundle` in `utils/flowsBundle`), and - * that language. - */ -export interface FlowCopyLocale { - flowName: string; - bundle: TranslationBundle | undefined; - locale: string; -} - -/** - * One of the flow's terminal toasts as the user should read it: the bundle's - * `flows.FLOW.KEY` where it carries one, the served string otherwise. Asked - * of the spec's `translateFlow`, which overlays only a string the flow serves. - */ -function translatedFlowMessage( - key: 'successMessage' | 'errorMessage', - served: string, - copy: FlowCopyLocale | undefined, -): string { - if (!copy) return served; - const answered = translateFlow({ name: copy.flowName, [key]: served }, copy.bundle, { locale: copy.locale })[key]; - // `translateFlow` answers the served string back when the bundle has no - // key; the type says optional only because `FlowLike` declares it so. - return typeof answered === 'string' ? answered : served; -} /** * The `AutomationResult` fields the console reads. Deliberately loose: this is @@ -366,13 +329,12 @@ function permissionDeniedLine(locale: string | undefined): string { /** * A failed run's message, by the record-save rule (objectstack#24914 ruling - * B′): the flow's authored `errorMessage` — translated when the bundle carries - * `flows.FLOW.errorMessage` (objectui#12075) — then the producer-marked + * B′): the flow's authored `errorMessage`, as the engine served it (rendered + * and translated, objectstack#28054) — then the producer-marked * `userMessage`, then the refusal the run's write node met (`innerRefusal`, * whose `message` the spec serves without the node and operation prefixes), - * then the run's own `message`. `locale` is the language the permission line - * is read in: the flow's own (`copy.locale`) on a launch or resume, the - * host's active language on the api action door, which knows no flow. + * then the run's own `message`. `locale` is the host's active language, the + * one the permission line is read in. * * The inner refusal is rendered exactly as the record form renders the same * refusal on a save: a permission denial (`isPermissionError`) becomes the @@ -385,11 +347,10 @@ function permissionDeniedLine(locale: string | undefined): string { function flowFailureMessage( failure: FlowFailureCarriers, message: string, - copy: FlowCopyLocale | undefined, locale: string | undefined, ): string { const friendly = failure.errorMessage; - if (typeof friendly === 'string' && friendly.length > 0) return translatedFlowMessage('errorMessage', friendly, copy); + if (typeof friendly === 'string' && friendly.length > 0) return friendly; if (failure.userMessage) return failure.userMessage; const inner = failure.innerRefusal as { code?: unknown; message?: unknown } | null | undefined; if (inner && typeof inner === 'object' && typeof inner.code === 'string') { @@ -434,7 +395,6 @@ function errorEnvelopeDetails(json: unknown): FlowRunResult { function flowFailedEnvelopeMessage( json: unknown, fallback: string, - copy: FlowCopyLocale | undefined, locale: string | undefined, ): string { const details = errorEnvelopeDetails(json); @@ -445,7 +405,6 @@ function flowFailedEnvelopeMessage( innerRefusal: details.innerRefusal, }, actionErrorDetail(json, fallback), - copy, locale, ); } @@ -454,7 +413,7 @@ function flowFailedEnvelopeMessage( * The error a `type: 'api'` action shows for a non-2xx answer (objectstack#27487 * — see the header). A `400 FLOW_FAILED` is a flow the endpoint ran, and reads * the run's refusal by the failed arm's rule; `locale` is the host's active - * language, since the door knows no flow to translate an `errorMessage` for. + * language. * Every other answer reads `actionErrorDetail`, as the door always has. */ export function apiActionFailureMessage( @@ -464,7 +423,7 @@ export function apiActionFailureMessage( locale: string | undefined, ): string { if (res.status === 400 && errorCodeIs((json as { error?: unknown } | null | undefined)?.error, 'FLOW_FAILED')) { - return flowFailedEnvelopeMessage(json, fallback, undefined, locale); + return flowFailedEnvelopeMessage(json, fallback, locale); } return actionErrorDetail(json, fallback); } @@ -497,15 +456,16 @@ export function apiActionRefusalResult( * `.../runs/{runId}/resume` response. * * `label` names the flow for fallback messages (e.g. `Flow "convert_lead"`). - * `json` is the parsed body, or `null` when it could not be parsed. `copy` - * translates the flow's own `successMessage` / `errorMessage` (objectui#12075 - * — see the header); without it both are answered as served. + * `json` is the parsed body, or `null` when it could not be parsed. `locale` + * is the host's active language, which a permission refusal's line is read + * in. The flow's own `successMessage` / `errorMessage` are answered as served + * — the engine renders and translates them (objectstack#28054, see the header). */ export function interpretFlowResponse( res: { ok: boolean; status: number }, json: any, label: string, - copy?: FlowCopyLocale, + locale?: string, ): FlowResponseOutcome { if (!res.ok) { // The flow RAN and failed — same event as the inner-envelope failure @@ -515,7 +475,7 @@ export function interpretFlowResponse( if (res.status === 400 && errorCodeIs(json?.error, 'FLOW_FAILED')) { return { kind: 'failed', - error: flowFailedEnvelopeMessage(json, `${label} failed`, copy, copy?.locale), + error: flowFailedEnvelopeMessage(json, `${label} failed`, locale), retryable: false, }; } @@ -563,8 +523,7 @@ export function interpretFlowResponse( innerRefusal: data.innerRefusal, }, actionErrorDetail(data, `${label} failed`), - copy, - copy?.locale, + locale, ), retryable: false, }; @@ -595,10 +554,7 @@ export function interpretFlowResponse( return { kind: 'done', data: json?.data as FlowRunResult | undefined, - successMessage: - typeof data.successMessage === 'string' - ? translatedFlowMessage('successMessage', data.successMessage, copy) - : undefined, + successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined, flowLabel: servedFlowLabel(data), }; } diff --git a/ui/packages/app-shell/src/views/FlowRunner.tsx b/ui/packages/app-shell/src/views/FlowRunner.tsx index 6c27e23d574..df6f0d2d42c 100644 --- a/ui/packages/app-shell/src/views/FlowRunner.tsx +++ b/ui/packages/app-shell/src/views/FlowRunner.tsx @@ -124,7 +124,7 @@ * (objectui#12274). This file adds only its own default, the catalog's * sentence. * - * ## The AUTHOR's copy is localized; the chrome is not (objectui#5920, objectui#12075) + * ## The AUTHOR's copy is localized; the chrome is not (objectui#5920, objectstack#28054) * * A screen's heading, its body text, each field's copy and each option's * label, and the flow's own completion and failure toasts are the flow @@ -150,10 +150,12 @@ * spec's `resolveFlowScreenFieldOptions`, each option addressed by its value * read as text. Every key, and every option, falls back to the authored * string on its own. - * - **The flow's own toasts** — `FlowSchema.successMessage` and - * `errorMessage`, served verbatim on the run result — are translated by - * `interpretFlowResponse` (`utils/flowResponse`), handed this run's flow and - * bundle, so the resume and both launch hosts answer the same words. + * - **The flow's own toasts are served already translated, too.** + * `FlowSchema.successMessage` and `errorMessage` are `{{ }}` templates the + * engine renders at the run's terminal exit (objectstack#28054), so it picks + * their translations as it picks the heading's, and `interpretFlowResponse` + * (`utils/flowResponse`) answers them as served, for the resume and both + * launch hosts alike. * * WHERE the bundle comes from is the channel the console already has: * `activeFlowsBundle` (`utils/flowsBundle`) reads the `flows` group back out @@ -595,10 +597,10 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat // STRING: handing the nested `{code, message}` object to `toast.error()` // renders nothing at best and crashes the page as a React child (React // #31). See utils/flowResponse. - // Handed this run's flow and bundle, it also answers the flow's own - // `successMessage` / `errorMessage` in the active language (objectui#12075 - // — see the header). - const outcome = interpretFlowResponse(res, json, 'Resume', { flowName, bundle, locale: language }); + // The flow's own `successMessage` / `errorMessage` arrive rendered and + // translated by the engine (see the header); `language` is the one a + // refused write's permission line is read in. + const outcome = interpretFlowResponse(res, json, 'Resume', language); if (outcome.kind === 'failed') { // Two carriers on purpose (`c40f3b8ca`): the toast is the console's // viewport-fixed failure idiom; the inline Alert stays in the dialog, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 72200d7f485..994134b50a1 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -796,9 +796,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri [pageReturn.stepper, originFromState?.label], ); useRecordStepKeys(!embedded && !!pageStepper, pageStepper, { arrows: false }); - // `i18n` with `language`: what a flow launch translates the flow's own - // toasts in (objectui#12075). - const { t, language, i18n } = useObjectTranslation(); + // `language` is also the language a flow launch reads a refused write's + // permission line in. + const { t, language } = useObjectTranslation(); // The DISPLAY locale the audit-history dates format with (objectui#10442). // `language` above stays for what it is: the key into per-locale LABEL maps. const displayLocale = useDisplayLocale(); @@ -1813,7 +1813,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri recordId, triggerParams: flowParams, objects, - translation: { i18n, language }, + language, }); const judged = launch.judged; // Paused at a `screen` node: FlowRunner renders the form + resumes, and @@ -1839,7 +1839,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } catch (error) { return { success: false, error: (error as Error).message }; } - }, [authFetch, pureRecordId, objectName, objects, i18n, language]); + }, [authFetch, pureRecordId, objectName, objects, language]); // Server-side action handler — POST /api/v1/actions/{object}/{action}, // built from @object-ui/core's `createServerActionHandler` via the shared diff --git a/ui/packages/app-shell/src/views/__tests__/FlowRunner.flowsTranslation-12075.test.tsx b/ui/packages/app-shell/src/views/__tests__/FlowRunner.flowsTranslation-12075.test.tsx index 1648572dfbc..849e67fc679 100644 --- a/ui/packages/app-shell/src/views/__tests__/FlowRunner.flowsTranslation-12075.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/FlowRunner.flowsTranslation-12075.test.tsx @@ -9,6 +9,12 @@ * - `flows.FLOW.successMessage` — the flow's own completion toast; * - `flows.FLOW.errorMessage` — the flow's own failure toast. * + * Since objectstack#28054 the two toasts are `{{ }}` templates the ENGINE + * picks in the run's locale and renders over the run's variables, so the + * runner draws them as served and never overlays the bundle's copy (the + * objectstack#22507 ruling A, as for the screen heading). The option labels + * stay a client overlay. + * * The bundle reaches the runner the way every other translated console * surface gets it — the server's `TranslationData` payload through * `transformSpecTranslations`, added with the provider's `addResourceBundle` @@ -65,8 +71,10 @@ const ZH_CN: TranslationData = { flows: { case_escalate: { label: '升级工单', - successMessage: '工单已升级', - errorMessage: '升级失败,请联系管理员', + // Templates the engine renders before it serves the toast + // (objectstack#28054); the runner must not draw them over the served one. + successMessage: '已升级 {{ escalated }} 个工单', + errorMessage: '升级失败:{{ $error.message }}', screens: { pick: { // The engine serves the heading already translated (objectstack#22507 @@ -151,16 +159,16 @@ describe('FlowRunner — the served screen heading is drawn as served (objectsta }); }); -describe('FlowRunner — the flow\'s own toasts from `flows.FLOW.successMessage` / `errorMessage` (objectui#12075)', () => { - it('toasts the translated successMessage when the run completes', async () => { +describe('FlowRunner — the flow\'s own toasts are drawn as served (objectstack#28054)', () => { + it('toasts the successMessage the engine served, never the bundle template over it', async () => { const authFetch = vi.fn(async () => - jsonResponse({ success: true, data: { success: true, status: 'completed', successMessage: 'Case escalated' } }), + jsonResponse({ success: true, data: { success: true, status: 'completed', successMessage: '已升级 3 个工单' } }), ); const dialog = renderRunner(ESCALATION, i18nWith('zh-CN', ZH_CN), authFetch); fireEvent.click(dialog.getAllByRole('button').find((b) => b.textContent && /提交|Submit/.test(b.textContent))!); await waitFor(() => expect(toasts.success).toHaveBeenCalledTimes(1)); - expect(toasts.success).toHaveBeenCalledWith('工单已升级'); + expect(toasts.success).toHaveBeenCalledWith('已升级 3 个工单'); }); it('adds no completion copy the flow does not author: the catalog sentence names the flow', async () => { @@ -173,23 +181,24 @@ describe('FlowRunner — the flow\'s own toasts from `flows.FLOW.successMessage` await waitFor(() => expect(toasts.success).toHaveBeenCalledTimes(1)); const message = String(toasts.success.mock.calls[0][0]); - expect(message).not.toBe('工单已升级'); + expect(message).not.toContain('{{'); expect(message).toContain('升级工单'); }); - it('toasts and shows the translated errorMessage when the run fails', async () => { + it('toasts and shows the errorMessage the engine served when the run fails', async () => { const authFetch = vi.fn(async () => jsonResponse({ success: true, - data: { success: false, status: 'failed', error: 'boom', errorMessage: 'Escalation failed, contact an admin' }, + data: { success: false, status: 'failed', error: 'boom', errorMessage: '升级失败:boom' }, }), ); const dialog = renderRunner(ESCALATION, i18nWith('zh-CN', ZH_CN), authFetch); fireEvent.click(dialog.getAllByRole('button').find((b) => b.textContent && /提交|Submit/.test(b.textContent))!); await waitFor(() => expect(toasts.error).toHaveBeenCalledTimes(1)); - expect(toasts.error).toHaveBeenCalledWith('升级失败,请联系管理员'); - expect(within(screen.getByRole('alert')).getByText('升级失败,请联系管理员')).toBeInTheDocument(); + expect(toasts.error).toHaveBeenCalledWith('升级失败:boom'); + expect(within(screen.getByRole('alert')).getByText('升级失败:boom')).toBeInTheDocument(); + expect(screen.queryByText('升级失败:{{ $error.message }}')).not.toBeInTheDocument(); }); it('keeps the served toasts verbatim in a language the bundle does not carry', async () => {