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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/refused-end-node-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/service-automation': minor
---

Flow `end` nodes honour `outcome: 'refused'` — a terminal `refused` run, distinct from `failed`

`packages/spec` has declared the shape since 17.4.0: an `end` node accepts
`outcome: 'completed' | 'refused'`, a `refused` end requires a `message`,
`ExecutionStatus` carries `refused`, and `ExecutionLog` / `AutomationResult` /
the trigger response carry `refusalMessage`. The engine produced none of it —
it returned on every `end` node without reading its config — so an author who
wrote a refusal shipped a plain completion: the run recorded `completed`, the
caller got the flow's `successMessage`, and the authored reason reached nobody.

The `end` node now honours it:

- **The run terminates `refused`.** A refusal is a *successful evaluation that
says no*, so the result is `success: true, status: 'refused'` with no `error`
and no `errorMessage` — and, deliberately, no `successMessage`: the flow's
completion toast is for a completion. All three terminal producers answer
identically (a triggered run, a resumed screen flow, and an attempt under
`errorHandling.strategy: 'retry'`, where a refusal also stops the ladder
rather than consuming retry budget).
- **The `message` is rendered per record**, through the same interpolation a
`screen` node's `description` gets — one implementation (`interpolateText`),
never a second template engine — so `'Refused: {record.name} is a confirmed
duplicate'` reaches the caller naming the record.
- **Both are persisted on the run.** `sys_automation_run.status` gains
`refused` and a new `refusal_message` column carries the rendered text; the
refusal is never folded into `error`, which would tell every reader the run
broke. `RunRecord` gains `refusalMessage` and `TerminalRunStatus` gains
`refused`, so history rows are written, aged and read back like any other
terminal.
- **A refused run is never resumed.** It writes no continuation, so `resume`
answers `RUN_NOT_FOUND`.

Untouched on purpose: a paused run still returns `silent` with no
`successMessage`, and a plain `end` — or one declaring `outcome: 'completed'` —
completes exactly as before.

An `end` declaring `outcome: 'refused'` **inside a structured region** (a `loop`
body, a `try`/`catch` region) is refused loudly rather than honoured: a refusal
terminates the run and a region body cannot end one. Previously such a node was
a silent no-op like every other `end` in a region, so nothing that ever worked
stops working — put the refusing `end` on the top-level graph and route the
region's exit to it.
14 changes: 10 additions & 4 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,19 @@ type RunLiveness = 'terminal' | 'live';
* `ExecutionStatus.options` and drives every member through the real sweep, so
* the classification and the behaviour cannot drift apart either.
*
* ⛔ Terminality is NOT declared machine-readably anywhere today — `refused`'s
* ⛔ Terminality is NOT declared machine-readably in the SPEC — `refused`'s
* terminality lives in a COMMENT beside the enum member, and a comment is not a
* gate. The `TERMINAL_RUN_STATUSES` exported by `@objectstack/service-automation`
* is a DIFFERENT vocabulary (which terminal states a run may be RECORDED in,
* tied to `sys_automation_run.status`' options) that excludes `refused` on
* purpose, and that package is only a devDependency here. Hence a local total
* map rather than a shared import; see the card for the spec-level proposal.
* tied to `sys_automation_run.status`' options), and that package is only a
* devDependency here. Hence a local total map rather than a shared import; see
* the card for the spec-level proposal.
*
* [#15788] That list used to exclude `refused` on purpose — nothing could write
* the value — and now includes it, because the `end` executor produces it. The
* two vocabularies AGREE about `refused` today; they are still not the same
* question, so this map stays the authority for THIS sweep. ⛔ Do not replace it
* with an import on the strength of one member currently matching.
*
* `completed` is classified terminal alongside the failure states. The approval
* node only writes a request row on the path where it also suspends the run,
Expand Down
14 changes: 8 additions & 6 deletions packages/services/service-automation/src/builtin/screen-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { PluginContext } from '@objectstack/core';
import { defineActionDescriptor, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation';
import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation';
import type { AutomationEngine } from '../engine.js';
import { interpolate } from './template.js';
import { interpolate, interpolateText } from './template.js';
import { parseNodeConfig } from './parse-config.js';
import { judgeHeadlessScreen } from '../screen-input-contract.js';

Expand Down Expand Up @@ -163,11 +163,13 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
// variables here (the engine does NOT pre-interpolate node config) — so
// a step's title/description/field-default/object-form-default can pull
// from prior nodes (e.g. `{lead_record.company}`, `{account_id}`).
const interp = (v: unknown): string | undefined => {
if (v == null) return undefined;
const r = interpolate(v, variables, context);
return r == null ? undefined : String(r);
};
//
// [#15788] The body of this closure now lives in `template.ts` as
// {@link interpolateText}, because a second authored-text slot — the
// refusing `end` node's `message` (#14945 lane 2) — has to render
// through THE SAME implementation, not a copy of it. Same bytes in,
// same bytes out; the only change is where the four lines live.
const interp = (v: unknown): string | undefined => interpolateText(v, variables, context);

// ── Object-form screen (master-detail wizards) ──────────────────────
// When the step names an `objectName`, render that object's FULL
Expand Down
30 changes: 30 additions & 0 deletions packages/services/service-automation/src/builtin/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,36 @@ export function interpolateString(
);
}

/**
* Render an authored TEXT slot — a screen `title` / `description`, an `end`
* node's refusal `message` — through {@link interpolate}, coerced to a string.
*
* [#15788] Hoisted out of `screen-nodes.ts`'s local `interp` closure so the
* refusing `end` node (#14945 lane 2) renders through the SAME implementation
* rather than a second one. The ruling's words are the requirement: the
* refusal message "goes through the same interpolation a screen `description`
* gets" — ⛔ never a second template engine. A second spelling would start
* byte-identical and drift on the first fix that landed in only one of them,
* and the drift would be invisible from either side: both would still
* substitute `{record.name}`.
*
* Absent in, absent out — a slot the author left unset renders nothing rather
* than the string `"undefined"`, and a whole-string token that resolved to
* `null` is the same "nothing" ({@link interpolateString} preserves the raw
* value for a single-token string, so an unresolved `{missing}` arrives here as
* `null`). Every other value is stringified exactly as an embedded
* substitution would be, which is what keeps ONE rendering for both slots.
*/
export function interpolateText(
value: unknown,
variables: VariableMap,
context: AutomationContext,
): string | undefined {
if (value == null) return undefined;
const rendered = interpolate(value, variables, context);
return rendered == null ? undefined : String(rendered);
}

/**
* Recursively interpolate template tokens in arbitrary JSON-like values.
*/
Expand Down
Loading
Loading