diff --git a/.changeset/24914-flow-inner-refusal.md b/.changeset/24914-flow-inner-refusal.md new file mode 100644 index 00000000000..d2ceac6dc16 --- /dev/null +++ b/.changeset/24914-flow-inner-refusal.md @@ -0,0 +1,17 @@ +--- +'@objectstack/service-automation': patch +'@objectstack/runtime': patch +--- + +fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message` + +Clause-②: yes (widening) +Metadata-protocol: none + +Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text. + +- **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text. +- **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built. +- **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union. + +**For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 616639b7651..4a5a149e82b 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -42,6 +42,8 @@ import { relationConditionSites } from '@objectstack/core'; // result, and a second definition of the rule is what let the doors diverge. import { classifyFlowRefusal, + flowFailureDetails, + flowFailureEnvelope, flowIsUnknown, flowNotFoundMessage, FLOW_NOT_FOUND_STATUS, @@ -50,7 +52,7 @@ import { // Who may START a flow by name — the one rule the trigger door, this door and // the declared endpoint share (the maintainer's ruling, letters B and A). import { ELEVATED_START_REFUSAL, refusesElevatedSelfTriggeredStart } from './flow-start-admission.js'; -import type { FlowRunSummary } from '@objectstack/spec/automation'; +import type { FlowFailureDetails } from '@objectstack/spec/api'; // [#5138] The ONE 404 envelope a single-record path answers. Imported rather // than re-spelled so `callData`'s ObjectQL fallback and the protocol service it // falls back FROM cannot disagree about what "this id names no row" looks like. @@ -1044,22 +1046,17 @@ const FLOW_ACTION_REFUSAL_BRAND = Symbol.for('objectstack.runtime.flowActionRefu /** * The run artefacts a failed flow dispatch carries beside its status and code - * — EXACTLY the two fields the trigger door ships in `error.details` on its - * own `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same - * source (`AutomationResult.errorMessage` / `.summary`). The #9585 ruling - * capped this at those two keys; ruling B′ on #24914 (decision batch #379 - * item 5) relaxes the cap by exactly one, `innerRefusal`, and the spec now - * declares the three-key set once for every flow door as - * `FlowFailureDetailsSchema` (`@objectstack/spec/api`). Carrying that third - * key is #24914's emission half and is not done here yet; any key beyond the - * three is a contract widening neither ruling covers. + * — EXACTLY the fields the trigger door ships in `error.details` on its own + * `400 FLOW_FAILED` arm (`domains/automation.ts`), same names, same source, + * built by the same `flowFailureDetails` (`./flow-dispatch-status.ts`). The + * #9585 ruling capped this at two keys (`errorMessage`, `summary`); ruling B′ + * on #24914 (decision batch #379 item 5) relaxes the cap by exactly one, + * `innerRefusal`, and the spec declares the three-key set once for every flow + * door as `FlowFailureDetailsSchema` (`@objectstack/spec/api`). This type IS + * that declaration, so a key beyond the three — a contract widening neither + * ruling covers — fails the build rather than reaching the wire. */ -export interface FlowActionRunDetails { - /** The flow AUTHOR's own failure text (`flow.errorMessage`). */ - errorMessage?: string; - /** The run's per-node accounting — WHICH node failed (#4354). */ - summary?: FlowRunSummary; -} +export type FlowActionRunDetails = FlowFailureDetails; /** * [#9585] The typed refusal carrier for a flow ACTION that ran and failed. @@ -1090,19 +1087,31 @@ export class FlowActionRefusal extends Error { readonly status: number; /** ADR-0112 `error.code` — registered, never minted here. */ readonly code: FlowRefusalCode; - /** The two run artefacts the door carries into `error.details`. */ + /** The run artefacts the door carries into `error.details`. */ readonly runDetails: FlowActionRunDetails; + /** + * [#24914, ruling B′] The producer-marked END-USER text of the refusal the + * run's node met (`AutomationResult.userMessage`), absent when the run + * carries none. A FIELD of the thrown error on purpose: it is the channel + * `declaredUserMessage` (`@objectstack/types`) reads on every boundary, so + * the `/actions` handler relays it to the envelope's top level and a door + * that does not recognise this carrier still reads it off the throw, + * exactly as it reads an app hook's own marking. + */ + readonly userMessage?: string; constructor( message: string, refusal: { status: number; code: FlowRefusalCode }, runDetails: FlowActionRunDetails, + userMessage?: string, ) { super(message); this.name = 'FlowActionRefusal'; this.status = refusal.status; this.code = refusal.code; this.runDetails = runDetails; + if (userMessage !== undefined) this.userMessage = userMessage; (this as Record)[FLOW_ACTION_REFUSAL_BRAND] = true; } } @@ -1290,13 +1299,17 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // there would be this door inventing run evidence for a run that // never started, which is why the two rows below stay plain // throws. + // + // [#24914, ruling B′] Built by the trigger door's own builder + // (`flowFailureDetails`), so the `innerRefusal` a node's data call + // met rides here too, and the producer-marked `userMessage` rides + // the carrier for the handler to put at the envelope's top level. + // The message keeps this door's wording byte for byte. throw new FlowActionRefusal( `Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`, refusal, - { - ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), - ...(result.summary !== undefined ? { summary: result.summary } : {}), - }, + flowFailureDetails(result), + flowFailureEnvelope(result)?.userMessage, ); } const err: any = new Error(refusal.message); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index a81f6e9ddd1..a4f19df0aff 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -309,8 +309,19 @@ export interface DomainHandlerDeps { * `error.code`). `error.code` is the semantic string: pass yours as * `details.code` and it is promoted into the field, otherwise one is derived * from the status. See `./error-envelope.ts`. + * + * `extra` carries the declared SIBLINGS of `code` / `message` a domain may + * put on the envelope — today exactly one, the producer-marked + * `userMessage` (`ApiErrorSchema.userMessage`, `@objectstack/spec/api`): + * end-user text that never replaces `message`. It is the slot ruling B′ on + * #24914 names for the four flow doors, which relay the refusal a flow's + * data node met (`AutomationResult.userMessage`) the way the data door + * relays a record save's. Closed on purpose: a sibling the spec does not + * declare has no slot here, so a domain cannot put an undeclared key on + * the envelope through it. Omit it (or leave `userMessage` absent) and the + * envelope is byte-identical to the three-argument call. */ - error(message: string, httpStatus?: number, details?: any): { status: number; body: any }; + error(message: string, httpStatus?: number, details?: any, extra?: { userMessage?: string }): { status: number; body: any }; /** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */ routeNotFound(route: string): { status: number; body: any }; /** diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a3eed15f335..6d555013359 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -824,9 +824,16 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string // other thrower (#8016 / #9106 — its closed `details` list is // deliberate, not a gap this branch works around). if (actionExec.isFlowActionRefusal(err)) { + // [#24914, ruling B′] `runDetails` now carries the + // `innerRefusal` a node's data call met (the declared third + // key), and the carrier's producer-marked `userMessage` + // goes to the envelope's top level through the deps slot. return { handled: true, - response: deps.error(err.message, err.status, { code: err.code, ...err.runDetails }), + response: deps.error( + err.message, err.status, { code: err.code, ...err.runDetails }, + err.userMessage !== undefined ? { userMessage: err.userMessage } : undefined, + ), }; } throw err; diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 1898b79bd33..63a9318bb64 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -47,6 +47,8 @@ import { capabilityUnavailable } from './unavailable.js'; // [#9446] The ONE #9378 status table, now shared with the `/actions` door. import { classifyFlowRefusal, + flowFailureDetails, + flowFailureEnvelope, flowIsUnknown, flowNotFoundMessage, isPausedRun, @@ -2033,15 +2035,20 @@ async function respondToFlowTrigger( // that happened. A never-dispatched refusal has no author failure text // and no node log to point at, so emitting either there would be this // door inventing run evidence for a run that never started. - const runDetails = refusal.code === 'FLOW_FAILED' - ? { - ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), - ...(result.summary !== undefined ? { summary: result.summary } : {}), - } - : {}; + // + // [#24914, ruling B′] Built by the one shared builder, typed as + // `FlowFailureDetailsSchema`: `errorMessage` / `summary` as before, and + // the `innerRefusal` a node's data call met; the producer-marked + // `userMessage` rides the envelope's top level, beside an unchanged + // `message`. + const failed = refusal.code === 'FLOW_FAILED'; + const runDetails = failed ? flowFailureDetails(result) : {}; return { handled: true, - response: deps.error(refusal.message, refusal.status, { code: refusal.code, ...runDetails }), + response: deps.error( + refusal.message, refusal.status, { code: refusal.code, ...runDetails }, + failed ? flowFailureEnvelope(result) : undefined, + ), }; } // [#9510] THE THIRD STATE, answered deliberately. A run that dispatched and @@ -2252,6 +2259,14 @@ export interface ResumeRefusal { message: string; status: number; details: { code: string } & Record; + /** + * [#24914, ruling B′] The envelope's declared top-level siblings — the + * producer-marked `userMessage` of the refusal the run's node met, set on + * the `FLOW_FAILED` row only and only when the engine result carries one. + * Passed as `DomainHandlerDeps.error`'s `extra` by both doors this table + * serves. + */ + extra?: { userMessage: string }; } /** @@ -2349,15 +2364,21 @@ export async function classifyResumeResult( // code it matched, so the builder promotes it and never derives one. if (code !== undefined && row) return { message: result.error ?? row.fallback, status: row.status, details: { code } }; const verdict = await resumeFailureDetails(deps, automationService, runId, result); + // [#24914, ruling B′] The run's artefacts through the shared builder + // (`FlowFailureDetailsSchema`: `errorMessage` / `summary` / `innerRefusal`), + // the verdict beside them in the same object, and the producer-marked + // `userMessage` at the envelope's top level. The screen-flow leg — a write + // behind a screen, refused after the pause was consumed — answers HERE. + const extra = flowFailureEnvelope(result); return { message: result.error ?? 'Flow run failed', status: 400, details: { code: 'FLOW_FAILED', - ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), - ...(result.summary !== undefined ? { summary: result.summary } : {}), + ...flowFailureDetails(result), ...verdict, }, + ...(extra !== undefined ? { extra } : {}), }; } @@ -3326,7 +3347,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // it answers what it answers, is documented there. const refusal = await classifyResumeResult(deps, automationService, parts[2], result); if (refusal) { - return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details) }; + return { handled: true, response: deps.error(refusal.message, refusal.status, refusal.details, refusal.extra) }; } return { handled: true, response: deps.success(result) }; } diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 5d19bd3b7aa..d7f2df189af 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -808,13 +808,21 @@ function resumeDoorError( message: string, httpStatus: number, details?: Record, + extra?: { userMessage?: string }, ): Error { - const response = deps.error(message, httpStatus, details); - const envelope = (response?.body as { error?: { code?: unknown; message?: unknown; details?: unknown } } | undefined)?.error; + const response = deps.error(message, httpStatus, details, extra); + const envelope = (response?.body as { + error?: { code?: unknown; message?: unknown; details?: unknown; userMessage?: unknown }; + } | undefined)?.error; return Object.assign(new Error(typeof envelope?.message === 'string' ? envelope.message : message), { code: typeof envelope?.code === 'string' ? envelope.code : undefined, status: response?.status ?? httpStatus, ...(envelope?.details !== undefined ? { details: envelope.details } : {}), + // [#24914, ruling B′] The producer-marked end-user text, carried onto + // the thrown error as the `userMessage` field every boundary reads + // through `declaredUserMessage` — exactly what the REST door put at the + // envelope's top level, never folded into `message`. + ...(typeof envelope?.userMessage === 'string' ? { userMessage: envelope.userMessage } : {}), }); } @@ -1001,7 +1009,7 @@ export async function resumeActionRun( if (values !== undefined) signal.variables = values; const result = await automation.resume(runId, signal); const refusal = await classifyResumeResult(deps, automation, runId, result); - if (refusal) throw resumeDoorError(deps, refusal.message, refusal.status, refusal.details); + if (refusal) throw resumeDoorError(deps, refusal.message, refusal.status, refusal.details, refusal.extra); return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null }; } diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 0a9d6036be2..2e203eb14bd 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -56,6 +56,8 @@ import { validationFailure } from './validation-failure.js'; import { buildAutomationContext } from './domains/automation.js'; import { classifyFlowRefusal, + flowFailureDetails, + flowFailureEnvelope, flowIsUnknown, flowNotFoundMessage, FLOW_NOT_FOUND_STATUS, @@ -585,17 +587,21 @@ async function executeFlow( // failure text nor a node log to point at. Byte-identical to the // trigger door's details (`domains/automation.ts`), because #5040 §4 // makes that route's answer this seam's contract. - const runDetails = refusal.code === 'FLOW_FAILED' - ? { - ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), - ...(result.summary !== undefined ? { summary: result.summary } : {}), - } - : {}; + // + // [#24914, ruling B′] The trigger door's own builder, so the + // `innerRefusal` a node's data call met rides here as it does there, + // and the producer-marked `userMessage` takes the envelope's declared + // top-level slot (`extra`, `ApiErrorSchema.userMessage`) — authored + // end-user text, so the message's leak guard does not apply to it. + const failed = refusal.code === 'FLOW_FAILED'; + const runDetails = failed ? flowFailureDetails(result) : {}; + const extra = failed ? flowFailureEnvelope(result) : undefined; return apiErrorResponse({ message: sanitizeMessage(refusal.message, refusal.status), httpStatus: refusal.status, code: refusal.code, ...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}), + ...(extra !== undefined ? { extra } : {}), }); } diff --git a/packages/runtime/src/flow-dispatch-status.ts b/packages/runtime/src/flow-dispatch-status.ts index dd53f01f37b..f692dbe0b0d 100644 --- a/packages/runtime/src/flow-dispatch-status.ts +++ b/packages/runtime/src/flow-dispatch-status.ts @@ -103,6 +103,8 @@ */ import type { AutomationResult } from '@objectstack/spec/contracts'; +import type { FlowFailureDetails } from '@objectstack/spec/api'; +import { declaredUserMessage } from '@objectstack/types'; /** * The codes this table answers with. All four are ADR-0112 registered under @@ -238,3 +240,49 @@ export function classifyFlowRefusal( return undefined; } + +/** + * The `error.details` of a door's `400 FLOW_FAILED` arm, built from the engine + * result — ONE builder for the four doors (trigger, resume, `/actions`, + * declared endpoint), typed as the declaration it emits: + * `FlowFailureDetailsSchema` (`@objectstack/spec/api`, #24996), so a key the + * spec does not declare cannot be added here without failing the build. + * + * Each member is the result's own, copied verbatim and present only when the + * producer set it — the author's `errorMessage`, the run's `summary`, and, + * since ruling B′ on #24914, the `innerRefusal` a node's data call met — so a + * run that produced none of them answers no invented empty. Before this, each + * door spelled the first two by hand (#9414, #9585, #9462); one builder is + * what makes "the four doors emit one shape" a construction rather than four + * copies agreeing. + * + * Read only on the ran-and-failed row: a never-dispatched refusal has no run + * artefacts, and the doors keep that gate where it is. + * + * ⛔ Reads only the producer's fields — never the message text (PD #12). + */ +export function flowFailureDetails(result: AutomationResult): FlowFailureDetails { + return { + ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), + ...(result.summary !== undefined ? { summary: result.summary } : {}), + ...(result.innerRefusal !== undefined ? { innerRefusal: result.innerRefusal } : {}), + }; +} + +/** + * The envelope's top-level `userMessage` for a door's `400 FLOW_FAILED` arm — + * the `extra` argument of `DomainHandlerDeps.error`, or `undefined` when the + * run carries none (ruling B′ on #24914). + * + * `AutomationResult.userMessage` is the producer-marked end-user text of the + * refusal a node met, with `ApiErrorSchema.userMessage`'s semantics: presence + * IS the marking. So it is relayed at the top level of the error, the channel + * a record save's refusal already uses, and ⛔ never as a details key and + * never in place of `message`. Read through `declaredUserMessage` + * (`@objectstack/types`), the one read every boundary applies, so a blank + * value is not a marking here either. + */ +export function flowFailureEnvelope(result: AutomationResult): { userMessage: string } | undefined { + const userMessage = declaredUserMessage(result); + return userMessage !== undefined ? { userMessage } : undefined; +} diff --git a/packages/runtime/src/flow-failed-inner-refusal.test.ts b/packages/runtime/src/flow-failed-inner-refusal.test.ts new file mode 100644 index 00000000000..84d2d991293 --- /dev/null +++ b/packages/runtime/src/flow-failed-inner-refusal.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24914 (ruling B′, the maintainer's 「同意」, decision batch #379 item 5) — + * the four flow doors carry the refusal a flow's data node met, the way the + * data door carries a record save's. + * + * The spec declared the carriers in #24996: `AutomationResult.userMessage` / + * `.innerRefusal`, and ONE `error.details` shape for every door's + * `400 FLOW_FAILED` arm — `FlowFailureDetailsSchema` (`errorMessage?`, + * `summary?`, `innerRefusal?`). The engine emits them (pinned in + * `service-automation`'s `flow-inner-refusal.test.ts`); this file pins the + * DOORS, driven with a scripted engine result so each door is reached by name: + * + * 1. each door — trigger, resume, `/actions`, declared endpoint — answers + * `400 FLOW_FAILED` with `details.innerRefusal` equal to the result's, + * `details` parsing as `FlowFailureDetailsSchema` and the error as + * `ApiErrorSchema`; + * 2. the producer-marked `userMessage` rides the envelope's TOP level + * (`ApiErrorSchema.userMessage`), verbatim — never a details key, never in + * `message`; an unmarked run carries no `userMessage` at all; + * 3. ⛔ `message` is each door's own wording, byte for byte, unchanged; + * 4. one engine result, four doors, ONE `innerRefusal` — compared door + * against door, so a door that drops or reshapes it reddens here even if + * its own row were edited in the same change. + * + * The MCP `resume_run` bridge is pinned door-against-door with the REST resume + * door in `mcp-resume-run.test.ts`; the wire through the REAL engine in + * `@objectstack/verify`'s `automation-flow-inner-refusal.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ApiEndpointSchema, ApiErrorSchema, BaseResponseSchema, FlowFailureDetailsSchema } from '@objectstack/spec/api'; +import type { AutomationResult, ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { buildEndpointExecutionContext, executeEndpointTarget } from './endpoint-executor.js'; + +const FLOW = 'crm_convert_lead_wizard'; +const INNER_MESSAGE = "Duplicate record refused on 'crm_account': a unique constraint already holds these values. No record was written."; +const RAW_ERROR = `Node 'create_account' failed: create_record(crm_account) failed: ${INNER_MESSAGE}`; +const USER_TEXT = '该客户名称已被另一位销售代表占用,请联系主管。'; +/** A summary the spec's `FlowRunSummarySchema` parses — the details are parsed whole below. */ +const SUMMARY: AutomationResult['summary'] = { + selected: 0, acted: 0, skipped: 0, + nodes: [{ nodeId: 'create_account', nodeType: 'create_record', status: 'failure', runs: 1, failures: 1, skipped: 0 }], + gates: [], +}; +const INNER = { code: 'UNIQUE_VIOLATION', message: INNER_MESSAGE } as const; + +/** The engine's ran-and-failed exit carrying the refusal its node met — unmarked. */ +const FAILED_UNMARKED: AutomationResult = { + success: false, status: 'failed', error: RAW_ERROR, durationMs: 9, summary: SUMMARY, innerRefusal: INNER, +}; +/** The same, with the producer's end-user marking (an app hook's `userMessage`). */ +const FAILED_MARKED: AutomationResult = { ...FAILED_UNMARKED, userMessage: USER_TEXT }; + +const flowAction = { name: 'convert_lead', label: 'Convert Lead', objectName: 'crm_lead', type: 'flow', target: FLOW }; + +/** One dispatcher serving trigger, resume and `/actions` from ONE scripted engine. */ +function makeDispatcher(result: AutomationResult) { + const execute = vi.fn(async () => result); + const resume = vi.fn(async () => ({ ...result, status: 'stranded' as const })); + const getFlow = vi.fn(async (name: string) => (name === FLOW ? { name } : null)); + const automation = { execute, resume, getFlow }; + const objectDef = { name: 'crm_lead', actions: [flowAction] }; + const ql: any = { + executeAction: vi.fn(), + getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined), + registry: { getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), getItem: () => undefined }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + return { dispatcher: new HttpDispatcher(kernel), automation }; +} + +const CTX: any = { request: {}, environmentId: 'platform', executionContext: { userId: 'u1', systemPermissions: [] } }; +const EC = { userId: 'u1', positions: [], permissions: [] } as unknown as ExecutionContext; + +async function viaTrigger(result: AutomationResult) { + const { dispatcher } = makeDispatcher(result); + const res: any = await dispatcher.handleAutomation(`/${FLOW}/trigger`, 'POST', {}, CTX); + return { status: res.response.status as number, body: res.response.body }; +} +async function viaResume(result: AutomationResult) { + const { dispatcher } = makeDispatcher(result); + const res: any = await dispatcher.handleAutomation(`/${FLOW}/runs/run_1/resume`, 'POST', {}, CTX); + return { status: res.response.status as number, body: res.response.body }; +} +async function viaAction(result: AutomationResult) { + const { dispatcher } = makeDispatcher(result); + const res: any = await dispatcher.handleActions('/crm_lead/convert_lead', 'POST', {}, CTX); + return { status: res.response.status as number, body: res.response.body }; +} +async function viaEndpoint(result: AutomationResult) { + const { automation } = makeDispatcher(result); + const endpoint = ApiEndpointSchema.parse({ + name: 'convert', path: '/api/v1/apps/crm/convert', method: 'POST', type: 'flow', target: FLOW, + }); + const match: ApiEndpointMatch = { endpoint, params: {} }; + const ctx = buildEndpointExecutionContext({ + request: { method: 'POST', path: '/api/v1/apps/crm/convert', query: {}, headers: {}, body: {} }, + match, + executionContext: EC, + environmentId: 'env-1', + }); + const answer = await executeEndpointTarget(ctx, { callData: vi.fn(), automationService: automation }); + return { status: answer.status, body: answer.body as any }; +} + +const DOORS: Array<[string, (r: AutomationResult) => Promise<{ status: number; body: any }>, string]> = [ + ['trigger', viaTrigger, RAW_ERROR], + ['resume', viaResume, RAW_ERROR], + // `/actions` keeps its own wording since #3962: it names the flow. + ['/actions', viaAction, `Flow '${FLOW}' failed: ${RAW_ERROR}`], + ['endpoint', viaEndpoint, RAW_ERROR], +]; + +describe('#24914 — every flow door carries the inner refusal in its 400 FLOW_FAILED', () => { + for (const [door, via, wording] of DOORS) { + it(`${door}: details.innerRefusal is the result's, the declared shape; message unchanged`, async () => { + const { status, body } = await via(FAILED_UNMARKED); + + expect(status).toBe(400); + expect(body.success).toBe(false); + expect(body.error.code).toBe('FLOW_FAILED'); + expect(body.error.httpStatus).toBe(400); + expect(body.error.details.innerRefusal).toEqual(INNER); + // The declared shape, parsed by the spec's own schemas. + const parsed = FlowFailureDetailsSchema.safeParse(body.error.details); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.innerRefusal).toEqual(INNER); + expect(ApiErrorSchema.safeParse(body.error).error?.issues).toBeUndefined(); + expect(BaseResponseSchema.safeParse(body).error?.issues).toBeUndefined(); + // The diagnostic channel does not move. + expect(body.error.message).toBe(wording); + // Unmarked: no top-level userMessage at all (absent, not empty). + expect('userMessage' in body.error).toBe(false); + }); + + it(`${door}: a producer-marked userMessage rides the envelope's TOP level, verbatim`, async () => { + const { status, body } = await via(FAILED_MARKED); + + expect(status).toBe(400); + expect(body.error.code).toBe('FLOW_FAILED'); + expect(body.error.userMessage).toBe(USER_TEXT); + // Beside `message`, never instead of it or inside it… + expect(body.error.message).toBe(wording); + expect(body.error.message).not.toContain(USER_TEXT); + // …and never a details key: the details ceiling is the three + // declared keys (plus the resume door's verdict). + expect(body.error.details.userMessage).toBeUndefined(); + expect(body.error.details.innerRefusal).toEqual(INNER); + expect(ApiErrorSchema.safeParse(body.error).error?.issues).toBeUndefined(); + }); + } + + it('one engine result, four doors, ONE innerRefusal and ONE userMessage — the drift pin', async () => { + const answers = await Promise.all(DOORS.map(([, via]) => via(FAILED_MARKED))); + + const inner = answers.map((a) => a.body.error.details.innerRefusal); + const marked = answers.map((a) => a.body.error.userMessage); + for (const value of inner) expect(value).toEqual(inner[0]); + for (const value of marked) expect(value).toBe(marked[0]); + // Anchored once, so four doors agreeing on `undefined` cannot pass. + expect(inner[0]).toEqual(INNER); + expect(marked[0]).toBe(USER_TEXT); + }); + + it('a failed run that met no refusal gets no invented member at any door', async () => { + const plain: AutomationResult = { success: false, status: 'failed', error: "Node 'n' failed: downstream 503", durationMs: 2 }; + + for (const [, via] of DOORS) { + const { status, body } = await via(plain); + expect(status).toBe(400); + expect(body.error.code).toBe('FLOW_FAILED'); + expect(body.error.details?.innerRefusal).toBeUndefined(); + expect('userMessage' in body.error).toBe(false); + } + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 8616854c5cd..9391716da06 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -560,7 +560,13 @@ export class HttpDispatcher { : k?.getService?.(name); }, success: (data, meta) => this.success(data, meta), - error: (message, code, details) => this.error(message, code, details), + // [#24914, ruling B′] `extra` is the declared `userMessage` slot only + // (see the deps contract); an absent one passes nothing, so a call + // without it builds exactly the envelope it always built. + error: (message, code, details, extra) => this.error( + message, code, details, undefined, + typeof extra?.userMessage === 'string' ? { userMessage: extra.userMessage } : undefined, + ), routeNotFound: (route) => this.routeNotFound(route), errorFromThrown: (e, fallbackStatus) => this.errorFromThrown(e, fallbackStatus), resolveActiveOrganizationId: (context) => this.resolveActiveOrganizationId(context), diff --git a/packages/runtime/src/mcp-resume-run.test.ts b/packages/runtime/src/mcp-resume-run.test.ts index a58e5bcc031..3c0aaaad81a 100644 --- a/packages/runtime/src/mcp-resume-run.test.ts +++ b/packages/runtime/src/mcp-resume-run.test.ts @@ -491,3 +491,42 @@ describe('MCP resumeRun — (d) the engine\'s answers match the REST resume door }); } }); + +describe('MCP resumeRun — (e) the refusal a flow node met crosses the bridge as the REST door serves it (#24914)', () => { + // Ruling B′ on #24914: the run's `innerRefusal` rides the FLOW_FAILED + // details and its producer-marked `userMessage` the envelope's top level. + // The bridge builds its error through the same `deps.error`, so both must + // reach the thrown error exactly as the REST resume door answers them — + // `details.innerRefusal` in `details`, `userMessage` as the error's own + // `userMessage` field (the channel `declaredUserMessage` reads). + const INNER = { code: 'UNIQUE_VIOLATION', message: "Duplicate record refused on 'crm_task'" }; + const USER_TEXT = 'A follow-up with this subject already exists.'; + + it('innerRefusal and userMessage: same values on both doors, the message unchanged', async () => { + const h = makeHarness(); + const runId = await startPaused(h); + h.engine.resume.mockResolvedValue({ + success: false, status: 'stranded', error: "Node 'mk' failed: create_record(crm_task) failed: boom", + innerRefusal: INNER, userMessage: USER_TEXT, + }); + + let thrown: any; + try { + await h.bridgeFor('u1').resumeRun(runId, { values: { subject: 'x' } }); + } catch (err) { + thrown = err; + } + const rest = await h.dispatcher.handleAutomation( + `schedule_followup/runs/${runId}/resume`, 'POST', { inputs: { subject: 'x' } }, h.ctxFor('u1'), + ); + + expect(thrown?.code).toBe('FLOW_FAILED'); + expect(thrown?.status).toBe(400); + expect(rest.response?.status).toBe(400); + expect(thrown?.details?.innerRefusal).toEqual(INNER); + expect(thrown?.details?.innerRefusal).toEqual(rest.response?.body?.error?.details?.innerRefusal); + expect(thrown?.userMessage).toBe(USER_TEXT); + expect(thrown?.userMessage).toBe(rest.response?.body?.error?.userMessage); + expect(thrown?.message).toBe("Node 'mk' failed: create_record(crm_task) failed: boom"); + }); +}); diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index c77866a6790..04f9726049a 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -36,6 +36,7 @@ import { resolveValueSlotMap } from './value-slot-map.js'; import { refuseNode } from '../guard-refusal.js'; import { parseNodeConfig } from './parse-config.js'; import { resolveRunDataContext, stampSystemInsertOwner } from '../runtime-identity.js'; +import { refusalCarriedBy } from '../inner-refusal.js'; /** * Resolve a node's `filter` to the one the query runs (#19939, Fork R17 → A; @@ -550,7 +551,14 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): metrics: { selected: record ? 1 : 0 }, }; } catch (err) { - return { success: false, error: `get_record(${objectName}) failed: ${(err as Error).message}` }; + // [#24914, ruling B′] The refusal the read met rides out + // structurally beside the unchanged `error` text — see + // `refusalCarriedBy` (`../inner-refusal.ts`). + return { + success: false, + error: `get_record(${objectName}) failed: ${(err as Error).message}`, + ...refusalCarriedBy(err), + }; } }, }); @@ -696,6 +704,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): success: false, error: `create_record(${objectName}) failed: ${(err as Error).message}`, ...(code ? { code } : {}), + // [#24914, ruling B′] …and the refusal itself, for the + // run's CALLER: `{ code, message }` in the wire spelling + // (`UNIQUE_VIOLATION` for the duplicate above, whose + // in-flow `code` stays `DUPLICATE_RECORD`) plus the + // producer-marked `userMessage`. See `refusalCarriedBy`. + ...refusalCarriedBy(err), }; } }, @@ -796,7 +810,13 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): metrics: { acted: writtenRowCount(result) }, }; } catch (err) { - return { success: false, error: `update_record(${objectName}) failed: ${(err as Error).message}` }; + // [#24914, ruling B′] As `create_record`'s arm: the refusal + // rides out structurally beside the unchanged `error` text. + return { + success: false, + error: `update_record(${objectName}) failed: ${(err as Error).message}`, + ...refusalCarriedBy(err), + }; } }, }); @@ -863,7 +883,13 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): metrics: { acted: writtenRowCount(result) }, }; } catch (err) { - return { success: false, error: `delete_record(${objectName}) failed: ${(err as Error).message}` }; + // [#24914, ruling B′] As `create_record`'s arm: the refusal + // rides out structurally beside the unchanged `error` text. + return { + success: false, + error: `delete_record(${objectName}) failed: ${(err as Error).message}`, + ...refusalCarriedBy(err), + }; } }, }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 618c13e08aa..1f4e21922dc 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -11,7 +11,7 @@ import type { FlowRunSummary, } from '@objectstack/spec/automation'; import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult } from '@objectstack/spec/contracts'; -import type { FlowRegistration, FlowRegistrationRefusal } from '@objectstack/spec/api'; +import type { FlowRegistration, FlowRegistrationRefusal, FlowInnerRefusal } from '@objectstack/spec/api'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { createEvalUser, type EvalUserParsed } from '@objectstack/spec/identity'; import { @@ -220,6 +220,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record> = { import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, runTriggerUserId, type SeededRunContext } from './runtime-identity.js'; import { isGuardRefusal, refuseNode } from './guard-refusal.js'; import { readPartialSteps } from './partial-steps.js'; +import { attachCarriedRefusal, carriedRefusalOfResult, readCarriedRefusal, type CarriedRefusal } from './inner-refusal.js'; import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js'; import { summarizeRun, formatRunSummaryLine } from './run-summary.js'; // #5660 — the degrade registration reports a FOREIGN failure (a third-party @@ -384,6 +385,26 @@ export interface NodeExecutionResult { * its own must not inherit an earlier failure's leftover `code`). */ code?: string; + /** + * [#24914, ruling B′] The producer-marked END-USER text of the refusal this + * node's data call met, verbatim (`declaredUserMessage`'s reading of the + * caught error) — absent when the producer marked none. Only meaningful + * when `success` is false. Never read by the flow itself: it is carried to + * the run's caller as `AutomationResult.userMessage`, through the brand + * {@link AutomationEngine.executeNode} puts on the failure it throws + * (`inner-refusal.ts`). `error` keeps its wording; this rides beside it. + */ + userMessage?: string; + /** + * [#24914, ruling B′] The refusal this node's data call met, structured — + * `{ code, message }`, `code` in its WIRE spelling (`UNIQUE_VIOLATION` + * where {@link code} above says `DUPLICATE_RECORD` in-flow). Set by the + * CRUD executors' catch arms, and only for a thrown value that declared a + * 4xx answer (`refusalCarriedBy`, `inner-refusal.ts`). Carried to the + * run's caller as `AutomationResult.innerRefusal`, exactly like + * {@link userMessage}; ⛔ never synthesised from `error`'s text. + */ + innerRefusal?: FlowInnerRefusal; /** * #3863 — why this failed, which decides whether a `fault` edge may route it. * Only meaningful when `success` is false; defaults to `runtime`. @@ -7310,6 +7331,11 @@ export class AutomationEngine implements IAutomationService { // `recordLog`'s own first statement runs, so the two spellings // cannot disagree. Same shape as #15555's. summary: logged?.summary ?? summarizeRun(steps), + // [#24914, ruling B′] The refusal the failing node's data call met, + // beside the unchanged `error`: `userMessage` / `innerRefusal`, read + // off the brand the node's throw carries (`inner-refusal.ts`) — `{}` + // for a throw that met none, so nothing is invented. + ...readCarriedRefusal(err), }; } finally { // Release the re-entrancy guard for this (flow, record). Runs before @@ -8769,6 +8795,10 @@ export class AutomationEngine implements IAutomationService { // that `recordLog`'s own first statement runs, so the two // spellings cannot disagree. summary: logged?.summary ?? summarizeRun(steps), + // [#24914, ruling B′] As `execute()`'s failure exit: the refusal + // the node after the consumed pause met, off the throw's brand. The + // screen-flow leg (a write behind a screen) reaches its caller HERE. + ...readCarriedRefusal(err), }; } } finally { @@ -12165,7 +12195,12 @@ export class AutomationEngine implements IAutomationService { return; } } - throw new Error(`Node '${node.id}' failed: ${errMsg}`); + // [#24914, ruling B′] The text is unchanged — it is the run's + // diagnostic, read by the run log, `failAncestors`, approvals + // and every door's `message`. What the node's data call met is + // carried BESIDE it, on a brand (`inner-refusal.ts`), to the + // terminal exit that builds the run's result. + throw attachCarriedRefusal(new Error(`Node '${node.id}' failed: ${errMsg}`), result); } // Log successful step (#3407: advisory executor warnings ride along @@ -13153,6 +13188,10 @@ export class AutomationEngine implements IAutomationService { } = errorHandling; let lastError = 'Max retries exceeded'; + // [#24914, ruling B′] The refusal the LAST attempt met, kept paired + // with `lastError` so the exhausted exit never reports one attempt's + // text beside another attempt's refusal. + let lastCarried: CarriedRefusal = {}; for (let i = 0; i < maxRetries; i++) { // Calculate delay with exponential backoff let delay = Math.min(baseDelay * Math.pow(multiplier, i), maxDelay); @@ -13188,6 +13227,7 @@ export class AutomationEngine implements IAutomationService { if (result.status === 'paused') return result; if (result.success) return result; lastError = result.error ?? 'Unknown error'; + lastCarried = carriedRefusalOfResult(result); } // [#9378] Same "ran and was rejected" class as `execute()`'s own failure // exit — every attempt above dispatched the flow — so it carries the @@ -13208,6 +13248,7 @@ export class AutomationEngine implements IAutomationService { status: 'failed', errorMessage: flowErrorMessage, flowLabel, + ...lastCarried, }; } @@ -13685,6 +13726,9 @@ export class AutomationEngine implements IAutomationService { // `recordLog`'s own first statement runs, so the two spellings // cannot disagree. Same shape as #15555's. summary: logged?.summary ?? summarizeRun(steps), + // [#24914, ruling B′] As `execute()`'s failure exit, per attempt; + // `retryExecution` reports the LAST attempt's members with its text. + ...readCarriedRefusal(err), }; } } diff --git a/packages/services/service-automation/src/flow-inner-refusal.test.ts b/packages/services/service-automation/src/flow-inner-refusal.test.ts new file mode 100644 index 00000000000..5cc8bc20f85 --- /dev/null +++ b/packages/services/service-automation/src/flow-inner-refusal.test.ts @@ -0,0 +1,361 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24914 (ruling B′, the maintainer's 「同意」, decision batch #379 item 5) — + * the refusal a flow's data node meets reaches the RUN'S RESULT structurally. + * + * The defect: a record save the platform refuses reaches a record form as a + * structured envelope (`code`, `message`, the producer-marked `userMessage`); + * the same refusal met by a flow's data node was flattened into one string — + * the node's catch kept the message alone, the engine prefixed the node id — + * and nothing structured survived to the run's caller. The spec declared the + * carriers in #24996 (`AutomationResult.userMessage` / `.innerRefusal`); this + * file pins the engine's emission of them, on every exit the ruling names: + * + * 1. the four CRUD catch arms carry `{ code, message }` in the WIRE spelling + * — `UNIQUE_VIOLATION` for the engine's duplicate (whose in-flow + * `$error.code` stays `DUPLICATE_RECORD`), `VALIDATION_FAILED`, + * `PERMISSION_DENIED`, and the status-derived member for an app hook's + * unregistered spelling; + * 2. a producer-marked `userMessage` arrives verbatim; an unmarked refusal + * carries NONE (absent, not empty), and a fault carries neither member; + * 3. the terminal exits carry them: `execute()`'s failure exit, the + * `resumeInternal` stranded exit (a write behind a screen — the card's + * measured case) and the retry exits; a `loop` frame the failure unwinds + * through does not lose them; a `fault` edge that handles the failure + * leaves the run with none; + * 4. ⛔ `error` is byte-identical to the composition it always had — the + * diagnostic channel does not move. + * + * The data layer is a fake `IDataEngine` throwing the REAL error classes the + * platform throws (`DuplicateRecordError`, `ValidationError` from + * `@objectstack/objectql`, `PermissionDeniedError` from + * `@objectstack/plugin-security`), so the classification under test is the + * one production meets. The wire half (the four doors) is pinned in + * `@objectstack/runtime` and end to end in `@objectstack/verify`. + */ +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { NodeExecutor } from './engine.js'; +import { registerCrudNodes } from './builtin/crud-nodes.js'; +import { registerLoopNode } from './builtin/loop-node.js'; +import { DuplicateRecordError, ValidationError } from '@objectstack/objectql'; +import { PermissionDeniedError } from '@objectstack/plugin-security'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; + +function makeLogger(): any { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +} + +const ctxWith = (data: any): any => ({ + logger: makeLogger(), + getService: (n: string) => (n === 'data' ? data : undefined), +}); + +/** + * A data engine whose every write and read throws `err` — AFTER accepting the + * call the way `ObjectQL` does (the shared dispatch predicates), so a refusal + * here is the one a real engine would have reached, never a call shape the + * real engine would have rejected first. + */ +function refusingData(err: unknown): any { + return { + async insert() { throw err; }, + async update(_object: string, data: any, options: any) { + assertEngineUpdateDispatch(data, options); + throw err; + }, + async delete(_object: string, options: any) { + assertEngineDeleteDispatch(options); + throw err; + }, + async find() { throw err; }, + async findOne(object: string, query: any) { + assertEngineFindOnePredicate(object, query); + throw err; + }, + }; +} + +const duplicate = () => new DuplicateRecordError('crm_account', new Error('driver: unique violation'), 'name'); +const validation = () => new ValidationError([ + { field: 'amount', code: 'rule_violation', message: 'Amount must be positive' } as any, +]); +const permission = () => new PermissionDeniedError( + "[Security] Access denied: operation 'insert' on object 'crm_account' is not permitted", +); +/** An app hook's refusal: a declared 409 with its OWN (unregistered) code, marked for the end user. */ +const HOOK_USER_TEXT = '该客户名称已被另一位销售代表占用,请联系主管。'; +const markedHook = () => Object.assign(new Error('crm_account.beforeInsert refused: name is reserved'), { + status: 409, + code: 'CRM_ACCOUNT_NAME_RESERVED', + userMessage: HOOK_USER_TEXT, +}); + +/** start → mk (create_record) → end. */ +function createFlow(name = 'convert'): any { + return { + name, label: 'Convert', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'end' }, + ], + }; +} + +function engineOver(err: unknown): AutomationEngine { + const engine = new AutomationEngine(makeLogger()); + registerCrudNodes(engine, ctxWith(refusingData(err))); + return engine; +} + +describe('#24914 — create_record: the refusal reaches AutomationResult, the text does not move', () => { + it('a unique refusal carries { code: UNIQUE_VIOLATION, message } — the wire spelling, not DUPLICATE_RECORD', async () => { + const err = duplicate(); + const engine = engineOver(err); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.success).toBe(false); + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'UNIQUE_VIOLATION', message: err.message }); + // The diagnostic text is the composition it always was, byte for byte. + expect(result.error).toBe(`Node 'mk' failed: create_record(crm_account) failed: ${err.message}`); + // Unmarked: the engine's duplicate carries no producer marking, so NO + // userMessage — absent, never an empty string a runner would render. + expect('userMessage' in result).toBe(false); + }); + + it('a validation refusal carries VALIDATION_FAILED and the author\'s rule text', async () => { + const engine = engineOver(validation()); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' }); + expect(result.error).toBe("Node 'mk' failed: create_record(crm_account) failed: Amount must be positive"); + expect('userMessage' in result).toBe(false); + }); + + it('a permission refusal carries PERMISSION_DENIED', async () => { + const err = permission(); + const engine = engineOver(err); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: err.message }); + expect(result.error).toBe(`Node 'mk' failed: create_record(crm_account) failed: ${err.message}`); + expect('userMessage' in result).toBe(false); + }); + + it('an app hook\'s marked refusal: userMessage arrives VERBATIM, its unregistered code becomes the member its status names', async () => { + const err = markedHook(); + const engine = engineOver(err); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.userMessage).toBe(HOOK_USER_TEXT); + // `innerRefusal.code` is the closed ErrorCode: the hook's own spelling + // is not a member, so the 409 it declared names it — exactly what a + // record save of the same write answers in `error.code`. + expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message }); + // The marking never replaces the diagnostic channel. + expect(result.error).toBe(`Node 'mk' failed: create_record(crm_account) failed: ${err.message}`); + expect(result.error).not.toContain(HOOK_USER_TEXT); + }); + + it('a FAULT (no declared answer) carries neither member — nothing is synthesised from the text', async () => { + const engine = engineOver(new Error('ECONNREFUSED: could not reach the store')); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.error).toBe("Node 'mk' failed: create_record(crm_account) failed: ECONNREFUSED: could not reach the store"); + expect('innerRefusal' in result).toBe(false); + expect('userMessage' in result).toBe(false); + }); + + it('a declared 5xx (a store reporting itself unavailable) is not a refusal either', async () => { + const engine = engineOver(Object.assign(new Error('store unavailable'), { status: 503, code: 'SERVICE_UNAVAILABLE' })); + engine.registerFlow('convert', createFlow()); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect('innerRefusal' in result).toBe(false); + }); +}); + +describe('#24914 — the other three CRUD arms carry the refusal too', () => { + const cases: Array<{ type: string; config: Record; err: () => Error; code: string; op: string }> = [ + { type: 'get_record', config: { objectName: 'crm_account', filter: { id: 'a1' } }, err: permission, code: 'PERMISSION_DENIED', op: 'get_record' }, + { type: 'update_record', config: { objectName: 'crm_account', filter: { id: 'a1' }, fields: { amount: -1 } }, err: validation, code: 'VALIDATION_FAILED', op: 'update_record' }, + { type: 'delete_record', config: { objectName: 'crm_account', filter: { id: 'a1' } }, err: permission, code: 'PERMISSION_DENIED', op: 'delete_record' }, + ]; + for (const c of cases) { + it(`${c.type}: { code: ${c.code}, message } on the result, the text unchanged`, async () => { + const err = c.err(); + const engine = engineOver(err); + engine.registerFlow('one', { + name: 'one', label: 'One', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: c.type, label: 'N', config: c.config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'n' }, + { id: 'e2', source: 'n', target: 'end' }, + ], + } as any); + + const result = await engine.execute('one', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: c.code, message: err.message }); + expect(result.error).toBe(`Node 'n' failed: ${c.op}(crm_account) failed: ${err.message}`); + }); + } +}); + +describe('#24914 — every terminal exit the ruling names carries the members', () => { + it('resumeInternal (stranded): a write behind a pause — the screen-flow leg the card measured', async () => { + const err = markedHook(); + const engine = engineOver(err); + engine.registerNodeExecutor({ + type: 'hold', + descriptor: defineActionDescriptor({ type: 'hold', version: '1.0.0', name: 'hold', supportsPause: true, resumeAuthority: 'any' }), + async execute() { return { success: true, suspend: true }; }, + } as NodeExecutor); + engine.registerFlow('wizard', { + name: 'wizard', label: 'Wizard', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'hold', type: 'hold', label: 'Hold' }, + { id: 'create_account', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'create_account' }, + { id: 'e3', source: 'create_account', target: 'end' }, + ], + } as any); + + const paused = await engine.execute('wizard', { userId: 'u1' }); + expect(paused.status).toBe('paused'); + const result = await engine.resume(paused.runId!, {}); + + expect(result.success).toBe(false); + expect(result.status).toBe('stranded'); + expect(result.userMessage).toBe(HOOK_USER_TEXT); + expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message }); + expect(result.error).toBe(`Node 'create_account' failed: create_record(crm_account) failed: ${err.message}`); + }); + + it('the retry exits: an exhausted retry ladder reports the LAST attempt\'s refusal beside its text', async () => { + const err = duplicate(); + const engine = engineOver(err); + engine.registerFlow('convert', { ...createFlow(), errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 0 } }); + + const result = await engine.execute('convert', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'UNIQUE_VIOLATION', message: err.message }); + expect(result.error).toBe(`Node 'mk' failed: create_record(crm_account) failed: ${err.message}`); + }); + + it('a loop frame the failure unwinds through keeps the members (the brand rides the same error object)', async () => { + const err = validation(); + const engine = new AutomationEngine(makeLogger()); + const ctx = ctxWith(refusingData(err)); + registerCrudNodes(engine, ctx); + registerLoopNode(engine, ctx); + engine.registerNodeExecutor({ + type: 'seed', + async execute(_node, variables) { + variables.set('rows', [{ id: 'r1' }]); + return { success: true }; + }, + } as NodeExecutor); + engine.registerFlow('sweep', { + name: 'sweep', label: 'Sweep', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'query', type: 'seed', label: 'Seed' }, + { + id: 'each', type: 'loop', label: 'Each', + config: { + collection: { dialect: 'cel', source: 'rows' }, + iteratorVariable: 'row', + body: { + nodes: [ + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } }, + ], + edges: [], + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'query' }, + { id: 'e2', source: 'query', target: 'each' }, + { id: 'e3', source: 'each', target: 'end' }, + ], + } as any); + + const result = await engine.execute('sweep', { userId: 'u1' }); + + expect(result.status).toBe('failed'); + expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' }); + }); + + it('a fault edge that HANDLES the failure leaves the run with neither member', async () => { + const engine = engineOver(duplicate()); + engine.registerNodeExecutor({ + type: 'handled', + async execute() { return { success: true }; }, + } as NodeExecutor); + engine.registerFlow('recovers', { + name: 'recovers', label: 'Recovers', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } }, + { id: 'handler', type: 'handled', label: 'Handler' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mk' }, + { id: 'e2', source: 'mk', target: 'end' }, + { id: 'ef', source: 'mk', target: 'handler', type: 'fault' }, + { id: 'e3', source: 'handler', target: 'end' }, + ], + } as any); + + const result = await engine.execute('recovers', { userId: 'u1' }); + + expect(result.success).toBe(true); + expect('innerRefusal' in result).toBe(false); + expect('userMessage' in result).toBe(false); + }); +}); diff --git a/packages/services/service-automation/src/inner-refusal.ts b/packages/services/service-automation/src/inner-refusal.ts new file mode 100644 index 00000000000..df57944e6c4 --- /dev/null +++ b/packages/services/service-automation/src/inner-refusal.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ErrorCode, type FlowInnerRefusal } from '@objectstack/spec/api'; +import type { AutomationResult } from '@objectstack/spec/contracts'; +import { + declaredUserMessage, + isEngineDuplicateRecordEnvelope, + resolveThrownHttpError, + structuredCodeAnswer, +} from '@objectstack/types'; + +/** + * The refusal a flow node's data call met, carried STRUCTURALLY from the node + * to the run's result (ruling B′ on #24914, the maintainer's 「同意」, decision + * batch #379 item 5; the contract half is #24996). + * + * ## The defect this closes + * + * A record save the platform refuses — a duplicate, a validation rule, a + * permission, an app hook's refusal — reaches a record form as a structured + * envelope: `code`, `message`, and the producer-marked `userMessage`. The same + * refusal met by a flow's data node was flattened into ONE string at three + * hops: the node's catch kept the message alone (`create_record(OBJECT) + * failed: …`), the engine prefixed the node id (`Node 'ID' failed: …`), and + * each flow door served that string as the `FLOW_FAILED` message. A flow + * runner had nothing structured left to render, so an end user read developer + * prose naming a node id and an object API name. + * + * Both members are DECLARED on `AutomationResult` (`userMessage`, + * `innerRefusal`) and on every door's `FLOW_FAILED` details + * (`FlowFailureDetailsSchema`, `@objectstack/spec/api`); this module is the + * producer side of that declaration, and nothing here moves the diagnostic + * text: the node's `error`, the engine's prefix, the stored run and every + * door's `message` keep their wording byte for byte. + */ +export interface CarriedRefusal { + /** + * The producer-marked END-USER text, verbatim — present exactly when the + * thrown value declared one, read through `declaredUserMessage` + * (`@objectstack/types`), the one read every boundary applies. Absent for + * everything unmarked, so a runner keeps its own substitution there. + */ + userMessage?: string; + /** + * `{ code, message }` — `code` in its WIRE spelling, the ADR-0112 member a + * record save of the same write answers in `error.code`; `message` the + * refusal's own text, without the node and operation prefixes. Present only + * when the thrown value is a REFUSAL (see {@link refusalCarriedBy}). + */ + innerRefusal?: FlowInnerRefusal; +} + +/** + * Read what a CAUGHT data-call error carries for the run's caller — the one + * call each data node's catch arm makes (`builtin/crud-nodes.ts`), so the four + * arms cannot drift on what counts as a refusal or how its code is spelled. + * + * ## What counts as a refusal + * + * The rule `resolveThrownHttpError` (`@objectstack/types`) already states for + * every HTTP door: a throw that DECLARES its own HTTP answer is a refusal, a + * throw that declares none is a fault. `declaredStatus` is that declaration + * (`.status`, `.statusCode`, or the 400 the validation shape declares), and + * only a 4xx one counts here: the caller asked for a write the platform will + * not do (`DuplicateRecordError` 409, `ValidationError` 400, + * `PermissionDeniedError` 403, an app hook's declared refusal). An undeclared + * throw (a driver crash, an unreachable store) and a declared 5xx (a service + * reporting itself unavailable) carry no `innerRefusal`: the spec says the + * member is absent on every failure that met no refusal, and ⛔ it is never + * synthesised from message text. + * + * ## Which code + * + * The code the data door's `errorFromThrown` (`runtime/src/http-dispatcher.ts`) + * answers for the same throw, decided the same way: the resolver's code (a + * registered `.code`, `VALIDATION_FAILED` for the validation shape, else the + * standard member the status derives — so an app hook's unregistered spelling + * becomes the member its status names, as `error.code` does on that door), + * except the engine's unique-constraint envelope, which crosses every boundary + * as the one ruled wire spelling (the 2026-09-03 ruling, commit 65846bc46). + * That spelling is read off the shared classification's arm + * (`structuredCodeAnswer`), never written here, and the in-flow `$error.code` + * keeps the engine's in-process `DUPLICATE_RECORD` (the `create_record` arm's + * own `code`, untouched). + */ +export function refusalCarriedBy(err: unknown): CarriedRefusal { + const userMessage = declaredUserMessage(err); + const thrown = resolveThrownHttpError(err); + const declared = thrown.declaredStatus; + const isRefusal = declared !== undefined && declared >= 400 && declared < 500; + let innerRefusal: FlowInnerRefusal | undefined; + if (isRefusal) { + const armCode = isEngineDuplicateRecordEnvelope(err) ? structuredCodeAnswer(err)?.body.code : undefined; + const wireCode = typeof armCode === 'string' && ErrorCode.safeParse(armCode).success + ? (armCode as ErrorCode) + : thrown.code; + innerRefusal = { code: wireCode, message: thrown.message }; + } + return { + ...(userMessage !== undefined ? { userMessage } : {}), + ...(innerRefusal !== undefined ? { innerRefusal } : {}), + }; +} + +/** + * The "this thrown node failure carries a structured refusal" brand — the + * engine's half of the carriage, between a node's returned failure and the + * run's terminal exit. + * + * The engine turns a node's returned failure into a THROW + * (`Node 'ID' failed: …`) that unwinds through every enclosing frame to + * `execute()`, `resumeInternal()` or `executeWithoutRetry()`. The structured + * members ride that throw as a non-enumerable symbol-keyed brand — the idiom + * `partial-steps.ts` and `guard-refusal.ts` already use on the same throw + * path — so nothing about the error changes: same identity, same `message`, + * same `instanceof`. A `loop` or `parallel` frame the failure travels out + * through rethrows the SAME object, so the brand reaches the terminal exit + * unchanged. + * + * A container that re-words a child failure into a NEW returned failure of + * its own (`try_catch` with no catch region, `subflow`) builds a new error, + * and the brand does not cross it; the run's result then carries no member, + * which the spec's "present only when a refusal was met" allows and which + * never invents one. + */ +export const CARRIED_REFUSAL: unique symbol = Symbol.for( + 'objectstack.automation.carriedRefusal', +) as never; + +/** + * Brand `err` with the members a node's returned failure carried. A failure + * that carried neither leaves `err` unbranded, so a reader cannot tell it from + * any other throw — which is the honest answer. + */ +export function attachCarriedRefusal(err: E, carried: CarriedRefusal): E { + const value = pickCarried(carried); + if (value.userMessage === undefined && value.innerRefusal === undefined) return err; + Object.defineProperty(err, CARRIED_REFUSAL, { + value, + enumerable: false, + configurable: true, + writable: true, + }); + return err; +} + +/** + * The members a thrown failure carries, ready to spread onto an + * `AutomationResult` — `{}` when it carries none, so a terminal exit spreads + * nothing for every throw that met no refusal. + */ +export function readCarriedRefusal(err: unknown): CarriedRefusal { + if (!err || typeof err !== 'object') return {}; + const value = (err as Record)[CARRIED_REFUSAL]; + return value && typeof value === 'object' ? pickCarried(value as CarriedRefusal) : {}; +} + +/** + * The members an attempt's RESULT carries — for `retryExecution`, whose + * exhausted exit reports the last attempt's failure and must report that + * attempt's refusal beside it, never an earlier attempt's. + */ +export function carriedRefusalOfResult(result: AutomationResult): CarriedRefusal { + return pickCarried(result); +} + +function pickCarried(source: CarriedRefusal): CarriedRefusal { + return { + ...(typeof source.userMessage === 'string' ? { userMessage: source.userMessage } : {}), + ...(source.innerRefusal !== undefined ? { innerRefusal: source.innerRefusal } : {}), + }; +} diff --git a/packages/verify/src/automation-flow-inner-refusal.test.ts b/packages/verify/src/automation-flow-inner-refusal.test.ts new file mode 100644 index 00000000000..e764a1c9141 --- /dev/null +++ b/packages/verify/src/automation-flow-inner-refusal.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24914 (ruling B′, the maintainer's 「同意」, decision batch #379 item 5) — + * a screen flow whose write node is refused reaches THE WIRE with the refusal + * structured, the way a record save of the same write does. + * + * The card's measured case: HotCRM's lead conversion, a `create_record` on + * `crm_account` behind a screen, refused by the per-organization unique name. + * The end user read the engine's node text — `Node 'create_account' failed: + * create_record(crm_account) failed: …` — because the refusal was flattened + * into that one string before it left the engine. The spec now declares the + * carriers (#24996) and the engine and the doors emit them (this card); this + * file drives a REAL `AutomationEngine` with the REAL CRUD executors through + * the REAL `HttpDispatcher`, with a data layer throwing the REAL error classes + * the platform throws, and reads the response body: + * + * - a unique, a validation and a permission refusal each reach the trigger + * door — and, behind a pause, the resume door (the screen-flow leg) — as + * `400 FLOW_FAILED` with `details.innerRefusal = { code, message }`; + * - ⭐ the inner `code` is the one the DATA door answers for the very same + * thrown value (`errorFromThrown`, the record save's exit): parity is + * compared door against door, never against a literal alone; + * - a producer-marked `userMessage` (an app hook's refusal) arrives verbatim + * at the envelope's top level; an unmarked refusal carries none; + * - `message` is the run's diagnostic, unchanged. + * + * ⚠️ Resolves `@objectstack/runtime` and `@objectstack/service-automation` + * through their BUILT `dist/`: rebuild both before trusting a run — and above + * all before trusting an ablated one. + */ + +import { describe, it, expect } from 'vitest'; + +import { HttpDispatcher } from '@objectstack/runtime'; +import { AutomationEngine, InMemorySuspendedRunStore, registerCrudNodes } from '@objectstack/service-automation'; +import { DuplicateRecordError, ValidationError } from '@objectstack/objectql'; +import { PermissionDeniedError } from '@objectstack/plugin-security'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { ApiErrorSchema, FlowFailureDetailsSchema } from '@objectstack/spec/api'; + +const CTX = { request: {}, executionContext: { userId: 'user_1' } } as never; + +function createTestLogger(): never { + const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => logger }; + return logger as never; +} + +const HOOK_USER_TEXT = '该客户名称已被另一位销售代表占用,请联系主管。'; + +/** The refusals a `create_record` on `crm_account` meets in production. */ +const REFUSALS: Array<{ label: string; make: () => Error; code: string; marked?: string }> = [ + { + label: 'unique (the card\'s measured case)', + make: () => new DuplicateRecordError('crm_account', new Error('driver: unique violation'), 'name'), + code: 'UNIQUE_VIOLATION', + }, + { + label: 'validation rule', + make: () => new ValidationError([{ field: 'name', code: 'rule_violation', message: 'Account name is required' } as never]), + code: 'VALIDATION_FAILED', + }, + { + label: 'permission', + make: () => new PermissionDeniedError("[Security] Access denied: operation 'insert' on object 'crm_account' is not permitted"), + code: 'PERMISSION_DENIED', + }, + { + label: 'an app hook\'s marked refusal', + make: () => Object.assign(new Error('crm_account.beforeInsert refused: name is reserved'), { + status: 409, code: 'CRM_ACCOUNT_NAME_RESERVED', userMessage: HOOK_USER_TEXT, + }), + code: 'RESOURCE_CONFLICT', + marked: HOOK_USER_TEXT, + }, +]; + +const holdDescriptor = defineActionDescriptor({ + type: 'hold', version: '1.0.0', name: 'hold', supportsPause: true, resumeAuthority: 'any', +}); + +/** + * start → [hold →] create_account → end, over a data layer whose insert throws + * `err` — the card's lead-conversion shape (`behindPause`: the write runs after + * the screen, through the resume door). + */ +function boot(err: Error, behindPause: boolean): HttpDispatcher { + const engine = new AutomationEngine(createTestLogger(), new InMemorySuspendedRunStore()); + const data = { insert: async () => { throw err; } }; + registerCrudNodes(engine, { logger: createTestLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as never); + engine.registerNodeExecutor({ + type: 'hold', + descriptor: holdDescriptor, + async execute() { return { success: true, suspend: true }; }, + } as never); + const write = { + id: 'create_account', type: 'create_record', label: 'Create account', + config: { objectName: 'crm_account', fields: { name: 'Acme' } }, + }; + engine.registerFlow('lead_conversion', { + name: 'lead_conversion', label: 'Convert Lead', type: 'autolaunched', + nodes: behindPause + ? [{ id: 'start', type: 'start', label: 'Start' }, { id: 'hold', type: 'hold', label: 'Screen' }, write, { id: 'end', type: 'end', label: 'End' }] + : [{ id: 'start', type: 'start', label: 'Start' }, write, { id: 'end', type: 'end', label: 'End' }], + edges: behindPause + ? [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'create_account' }, + { id: 'e3', source: 'create_account', target: 'end' }, + ] + : [ + { id: 'e1', source: 'start', target: 'create_account' }, + { id: 'e2', source: 'create_account', target: 'end' }, + ], + } as never); + const services: Record = { automation: engine }; + const resolve = (name: string): unknown => services[name]; + return new HttpDispatcher({ + getService: resolve, + getServiceAsync: async (name: string): Promise => resolve(name), + context: { getService: resolve }, + } as never); +} + +/** The code the DATA door's thrown-error exit answers for `err` — the record save's `error.code`. */ +function dataDoorCode(dispatcher: HttpDispatcher, err: Error): string { + return (dispatcher as any).domainDeps.errorFromThrown(err).body.error.code; +} + +describe('#24914 — a refused write inside a flow reaches the wire structured, at the trigger and resume doors', () => { + for (const r of REFUSALS) { + it(`trigger door — ${r.label}: 400 FLOW_FAILED, details.innerRefusal { code: ${r.code} }, message unchanged`, async () => { + const err = r.make(); + const dispatcher = boot(err, false); + + const res = await dispatcher.handleAutomation('/lead_conversion/trigger', 'POST', {}, CTX); + + expect(res.response?.status).toBe(400); + const error = res.response?.body?.error; + expect(error?.code).toBe('FLOW_FAILED'); + expect(error?.details?.innerRefusal).toEqual({ code: r.code, message: err.message }); + // ⭐ The record save's code for the SAME throw — door against door. + expect(error?.details?.innerRefusal?.code).toBe(dataDoorCode(dispatcher, err)); + expect(FlowFailureDetailsSchema.safeParse(error?.details).success).toBe(true); + expect(ApiErrorSchema.safeParse(error).success).toBe(true); + // The diagnostic channel: the run's text, byte for byte. + expect(error?.message).toBe(`Node 'create_account' failed: create_record(crm_account) failed: ${err.message}`); + if (r.marked !== undefined) { + expect(error?.userMessage).toBe(r.marked); + expect(error?.message).not.toContain(r.marked); + } else { + expect(error && 'userMessage' in error).toBe(false); + } + }); + + it(`resume door (a write behind a screen) — ${r.label}: the same structure on the stranded 400`, async () => { + const err = r.make(); + const dispatcher = boot(err, true); + const started = await dispatcher.handleAutomation('/lead_conversion/trigger', 'POST', {}, CTX); + expect(started.response?.status).toBe(200); + const runId = started.response?.body?.data?.runId as string; + + const res = await dispatcher.handleAutomation(`/lead_conversion/runs/${runId}/resume`, 'POST', {}, CTX); + + expect(res.response?.status).toBe(400); + const error = res.response?.body?.error; + expect(error?.code).toBe('FLOW_FAILED'); + expect(error?.details).toMatchObject({ runId, status: 'stranded', repairable: true }); + expect(error?.details?.innerRefusal).toEqual({ code: r.code, message: err.message }); + expect(error?.details?.innerRefusal?.code).toBe(dataDoorCode(dispatcher, err)); + expect(error?.message).toBe(`Node 'create_account' failed: create_record(crm_account) failed: ${err.message}`); + if (r.marked !== undefined) expect(error?.userMessage).toBe(r.marked); + else expect(error && 'userMessage' in error).toBe(false); + }); + } + + it('a write that met a FAULT, not a refusal, carries no inner refusal and no userMessage', async () => { + const dispatcher = boot(new Error('ECONNREFUSED: could not reach the store'), false); + + const res = await dispatcher.handleAutomation('/lead_conversion/trigger', 'POST', {}, CTX); + + expect(res.response?.status).toBe(400); + expect(res.response?.body?.error?.code).toBe('FLOW_FAILED'); + expect(res.response?.body?.error?.details?.innerRefusal).toBeUndefined(); + expect('userMessage' in (res.response?.body?.error ?? {})).toBe(false); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index fc8482acbf6..3c66782a1fb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4386,6 +4386,21 @@ "verb": "update", "pinned": 4 }, + { + "file": "packages/services/service-automation/src/flow-inner-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-automation/src/flow-inner-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-automation/src/flow-inner-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/record-lookup-expand.integration.test.ts", "verb": "findOne",