Skip to content

Commit f20a491

Browse files
fix(app-shell): a failed flow run reads userMessage and the inner refusal by the record-save rule (#25985)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9WN247kMgZ857N2j9A7tAwf)_ Fixes #25938 Clause-②: no Metadata-protocol: none ## What changes `ui/packages/app-shell/src/utils/flowResponse.ts`, `flowFailureMessage`: a failed flow run is now rendered by the record-save rule (objectstack#24914 ruling B′, premise corrected in the card's comment 6410213338). Both failure arms read the same four rungs, in order: 1. the flow's authored `errorMessage` (translated through the flows bundle, unchanged); 2. the producer-marked `userMessage`, through `declaredUserMessage` from `@object-ui/react`: the envelope's top level on `400 FLOW_FAILED` (`ApiErrorSchema.userMessage`), `AutomationResult.userMessage` on the `200` failed run; 3. the inner refusal the run's write node met (`error.details.innerRefusal` on the 400, `AutomationResult.innerRefusal` on the 200): a permission denial (`isPermissionError`, the record form's own predicate) shows the record form's localized line `form.noPermissionToSave`; anything else shows `innerRefusal.message`, which the spec serves without the node and operation prefixes, so no stripping; 4. the run's `message` (`error.message` / `AutomationResult.error`), so an envelope without the members still says something. The wire `message` is not touched, nothing parses it, no copy is added, and the refusing-`end` path and the FlowRunner layout do not move. **Where the permission line comes from.** `flowResponse.ts` holds no `t()`: its callers hand it a `FlowCopyLocale` (flow name, flows bundle, locale). Threading a translator through `FlowRunner` and both launch hosts would leave the claimed file surface, so the line is read from the resident built-in catalogue at `FlowCopyLocale.locale` (exact tag, then base language, then `en`), the same read `LoadingScreen` and `outboundAgentText` already make. The active language's catalogue is resident whenever the console runs in it. Bound: an app bundle that overrides `form.noPermissionToSave` through i18next is not consulted here, while the record form's `t()` would consult it. ## Tests `ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts`, new describe `a refusal met inside the run, by the record-save rule`. The `400 FLOW_FAILED` fixtures reproduce the door's own constants (`packages/runtime/src/flow-failed-inner-refusal.test.ts`) and are parsed by the spec's `ApiErrorSchema` and `FlowFailureDetailsSchema` before use. Cases: unique refusal shows the inner sentence; validation refusal shows the rule text; permission refusal shows the localized line (en and zh-CN, never the server prose); a marked `userMessage` wins over the inner refusal; the authored `errorMessage` wins over both; no members falls back to `message`; the 200 failed arm follows the same order. - `pnpm exec vitest run` on `flowResponse.test.ts`, `FlowRunner.resumeResult.test.tsx`, `flowLaunch.translation-12075.test.tsx`: Test Files 3 passed, Tests 71 passed. - Ablation, fix committed first, mutation through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, restore proved by blob equal to HEAD and an empty `git diff HEAD`): - inner-refusal rung removed: 4 failed, 54 passed (unique, validation, permission, 200 arm); - `userMessage` rung removed: 2 failed, 56 passed (marked-wins, 200 arm). ## Acceptance notes - `isPermissionError` is reused as-is, so a non-permission code whose inner message matches its text heuristic (row-level security, access denied, permission denied, not permitted) also gets the permission line. That is the record form's behaviour for the same refusal on a save; the code check covers `PERMISSION_DENIED` and `FORBIDDEN`. - The developer console keeps the raw `message` in the sense that the wire is unchanged; this PR adds no `console.warn` beside the substitution (the record form logs one on a permission denial). - Local `pnpm --filter @object-ui/app-shell type-check` needs every ui workspace dependency built first; see the report for what was measured. `Type Check UI` in CI is the verdict. - Commits on this branch before the last one carry no trailer pair; the branch is the platform-assigned card-thread branch, so history is not rewritten. --- _Generated by [Claude Code](https://claude.ai/code/session_01U1cBh9Krc319ezzP6FXDTj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3446d43 commit f20a491

4 files changed

Lines changed: 210 additions & 9 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a failed flow run reads the refusal its write node met the way a record save reads it, instead of the engine's node-prefixed English diagnostic
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **What the user sees.** A flow that fails because a write node was refused (a duplicate, a validation rule, a permission denial) used to show the run's `message` in the dialog and toast: `Node 'create_account' failed: create_record(crm_account) failed: …`, in English under every locale. The Console now shows, in order: the flow's authored `errorMessage`; the producer-marked `userMessage`; the inner refusal's own sentence (a permission denial shows the record form's localized "no permission" line instead of server prose); and only then the run's `message`.
11+
- **Where it applies.** Both failure shapes the flow routes answer: `400 FLOW_FAILED` (`error.userMessage` at the envelope's top level, `error.details.innerRefusal`) and the `200` failed run (`AutomationResult.userMessage`, `AutomationResult.innerRefusal`), for the FlowRunner's resume and both flow-launch hosts.
12+
- **What does not change.** The wire `message` is untouched, a run that met no refusal still shows its `message`, and nothing parses the run's `message`.
13+
14+
What to do: nothing.

‎ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
*/
99

1010
import { describe, it, expect } from 'vitest';
11+
import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales';
12+
import { ApiErrorSchema, FlowFailureDetailsSchema } from '@objectstack/spec/api';
13+
import type { AutomationResult } from '@objectstack/spec/contracts';
1114
import { flowSuccessCopy, interpretFlowResponse, judgeFlowLaunch } from '../flowResponse';
1215

1316
const ok = { ok: true, status: 200 };
@@ -551,3 +554,85 @@ describe("flowSuccessCopy — the action's copy as a template envelope (objectui
551554
expect(out.successToast).toBeUndefined();
552555
});
553556
});
557+
558+
// objectstack#24914 ruling B′ — a refusal a flow's write node met reads like
559+
// the same refusal on a record save: the authored `errorMessage`, then the
560+
// producer-marked `userMessage`, then the inner refusal (permission → the
561+
// form's localized line), then `message`.
562+
describe('interpretFlowResponse — a refusal met inside the run, by the record-save rule (objectstack#24914)', () => {
563+
// Every pack resident, so the `zh` case reads the catalogue a Chinese
564+
// console has loaded.
565+
registerBuiltInLocales();
566+
567+
// The door's own constants (objectstack `packages/runtime/src/flow-failed-inner-refusal.test.ts`).
568+
const INNER_MESSAGE =
569+
"Duplicate record refused on 'crm_account': a unique constraint already holds these values. No record was written.";
570+
const RAW_ERROR = `Node 'create_account' failed: create_record(crm_account) failed: ${INNER_MESSAGE}`;
571+
const USER_TEXT = '该客户名称已被另一位销售代表占用,请联系主管。';
572+
const UNIQUE = { code: 'UNIQUE_VIOLATION', message: INNER_MESSAGE } as const;
573+
const DENIED = { code: 'PERMISSION_DENIED', message: 'insufficient privileges to create crm_account' } as const;
574+
const RULE = { code: 'VALIDATION_FAILED', message: 'Account name must not contain a URL.' } as const;
575+
576+
/** The trigger/resume door's `400 FLOW_FAILED`, proven real by the spec's own schemas. */
577+
function flowFailed(details: Record<string, unknown>, userMessage?: string) {
578+
const error = {
579+
code: 'FLOW_FAILED',
580+
message: RAW_ERROR,
581+
httpStatus: 400,
582+
...(userMessage ? { userMessage } : {}),
583+
details,
584+
};
585+
expect(ApiErrorSchema.safeParse(error).error?.issues).toBeUndefined();
586+
expect(FlowFailureDetailsSchema.safeParse(details).error?.issues).toBeUndefined();
587+
return { success: false, error };
588+
}
589+
const failed400 = (json: unknown, copy?: Parameters<typeof interpretFlowResponse>[3]) =>
590+
interpretFlowResponse({ ok: false, status: 400 }, json, 'Resume', copy);
591+
592+
it('a unique refusal shows the inner sentence, without the node and operation wrapper', () => {
593+
expect(failed400(flowFailed({ innerRefusal: UNIQUE }))).toEqual({
594+
kind: 'failed', error: INNER_MESSAGE, retryable: false,
595+
});
596+
});
597+
598+
it("a validation refusal shows the author's rule text", () => {
599+
expect(failed400(flowFailed({ innerRefusal: RULE }))).toMatchObject({ error: RULE.message });
600+
});
601+
602+
it("a permission refusal shows the record form's localized line, never the server prose", () => {
603+
const en = failed400(flowFailed({ innerRefusal: DENIED }));
604+
expect(en).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave });
605+
606+
const zh = failed400(flowFailed({ innerRefusal: DENIED }), { flowName: 'convert', bundle: undefined, locale: 'zh-CN' });
607+
expect(zh).toMatchObject({ error: builtInLocales.zh.form.noPermissionToSave });
608+
expect((zh as { error: string }).error).not.toContain('crm_account');
609+
});
610+
611+
it('a producer-marked userMessage wins over the inner refusal, verbatim', () => {
612+
expect(failed400(flowFailed({ innerRefusal: UNIQUE }, USER_TEXT))).toMatchObject({ error: USER_TEXT });
613+
expect(failed400(flowFailed({ innerRefusal: DENIED }, USER_TEXT))).toMatchObject({ error: USER_TEXT });
614+
});
615+
616+
it("the flow's authored errorMessage still wins over both", () => {
617+
const json = flowFailed({ errorMessage: 'Conversion failed.', innerRefusal: UNIQUE }, USER_TEXT);
618+
expect(failed400(json)).toMatchObject({ error: 'Conversion failed.' });
619+
});
620+
621+
it('an envelope without the members falls back to message, never to silence', () => {
622+
expect(failed400(flowFailed({}))).toMatchObject({ error: RAW_ERROR });
623+
});
624+
625+
it('the 200 failed arm reads the same members off the run, in the same order', () => {
626+
const run = (extra: Partial<AutomationResult>): AutomationResult => ({
627+
success: false, status: 'failed', error: RAW_ERROR, ...extra,
628+
});
629+
const on200 = (data: AutomationResult) => interpretFlowResponse(ok, { success: true, data }, 'Flow "convert"');
630+
631+
expect(on200(run({ innerRefusal: UNIQUE }))).toMatchObject({ kind: 'failed', error: INNER_MESSAGE, retryable: false });
632+
expect(on200(run({ innerRefusal: DENIED }))).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave });
633+
expect(on200(run({ innerRefusal: UNIQUE, userMessage: USER_TEXT }))).toMatchObject({ error: USER_TEXT });
634+
expect(on200(run({ innerRefusal: UNIQUE, userMessage: USER_TEXT, errorMessage: 'Conversion failed.' })))
635+
.toMatchObject({ error: 'Conversion failed.' });
636+
expect(on200(run({}))).toMatchObject({ error: RAW_ERROR });
637+
});
638+
});

‎ui/packages/app-shell/src/utils/flowResponse.ts‎

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@
112112
* beats N dialects). If it is absent the message degrades to the envelope's own
113113
* `error.message`, never to silence.
114114
*
115+
* ## A refusal met inside the run reads like the same refusal on a save (objectstack#24914)
116+
*
117+
* `message` names node ids and object API names, in English: a duplicate met
118+
* by a `create_record` node read `Node 'create_account' failed:
119+
* create_record(crm_account) failed: …`. Ruling B′ gave the run the record
120+
* save's two carriers — the producer-marked `userMessage` (the envelope's top
121+
* level on a 400, `AutomationResult.userMessage` on a 200) and `innerRefusal`
122+
* (`error.details` on a 400, the run's own member on a 200: the node's refusal
123+
* in its wire `code`, `message` already without the prefixes). Both failure
124+
* arms read them through {@link flowFailureMessage}, between the authored
125+
* `errorMessage` and `message`, and render the inner refusal by the record
126+
* form's rule. `message` itself is untouched on the wire.
127+
*
115128
* ## A LAUNCH is judged here too, not just interpreted (objectui#9973)
116129
*
117130
* Classifying the response was never the whole rule. The two launch handlers
@@ -188,6 +201,8 @@
188201

189202
import { actionCopySource, actionErrorDetail } from '@object-ui/core';
190203
import type { ActionResult } from '@object-ui/core';
204+
import { getLoadedBuiltInLocales } from '@object-ui/i18n';
205+
import { declaredUserMessage, isPermissionError } from '@object-ui/react';
191206
import { errorCodeIs } from '@object-ui/types';
192207
import type { AutomationResult } from '@objectstack/spec/contracts';
193208
import { translateFlow, type TranslationBundle } from '@objectstack/spec/system';
@@ -289,14 +304,72 @@ export type FlowResponseOutcome<S = unknown> =
289304
| ({ kind: 'done'; data: FlowRunResult | undefined; successMessage?: string } & Pick<AutomationResult, 'flowLabel'>);
290305

291306
/**
292-
* A flow declares a friendly `errorMessage`; prefer it — translated, when the
293-
* bundle carries `flows.FLOW.errorMessage` (objectui#12075) — over the raw
294-
* `error`, then fall back to the label. `actionErrorDetail` guarantees a string.
307+
* What a failed run's message is read from. On a 200 every member is the run's
308+
* own (`AutomationResult`); on a 400 `FLOW_FAILED` `errorMessage` and
309+
* `innerRefusal` are `error.details`' (`FlowFailureDetailsSchema`) and
310+
* `userMessage` is the envelope's top level (`ApiErrorSchema.userMessage`).
311+
*/
312+
interface FlowFailureCarriers {
313+
errorMessage?: unknown;
314+
/** Already read by `declaredUserMessage`, so non-empty or `null`. */
315+
userMessage: string | null;
316+
innerRefusal?: unknown;
317+
}
318+
319+
/** The one subtree of a resident catalogue {@link permissionDeniedLine} reads. */
320+
type PermissionLinePack = { form?: { noPermissionToSave?: unknown } } | undefined;
321+
322+
/**
323+
* The record form's permission line (`form.noPermissionToSave`) in `locale`,
324+
* read from the RESIDENT built-in catalogue — exact tag first, then its base
325+
* language, the registry's own order (the same read `LoadingScreen` makes).
326+
* The active language's catalogue is resident whenever the console runs in
327+
* it; anything else answers the resident `en` catalogue, which always is.
328+
* The literal is the last resort only for a registry that answers neither.
329+
*
330+
* Each read is one chain off `getLoadedBuiltInLocales()`, never a local
331+
* binding of its result, so `scripts/check-i18n-dead-keys.mjs` derives the
332+
* `en` read as a read of `form.noPermissionToSave` and the active-language
333+
* reads as runtime-indexed; its header bullet for this file says which leg
334+
* sees what.
335+
*/
336+
function permissionDeniedLine(locale: string | undefined): string {
337+
const usable = (line: unknown): string | undefined =>
338+
typeof line === 'string' && line.trim() ? line : undefined;
339+
const base = locale ? locale.toLowerCase().split('-')[0] : undefined;
340+
return (
341+
(locale ? usable((getLoadedBuiltInLocales()[locale] as PermissionLinePack)?.form?.noPermissionToSave) : undefined)
342+
?? (base ? usable((getLoadedBuiltInLocales()[base] as PermissionLinePack)?.form?.noPermissionToSave) : undefined)
343+
?? usable((getLoadedBuiltInLocales().en as PermissionLinePack)?.form?.noPermissionToSave)
344+
?? "You don't have permission to save this record."
345+
);
346+
}
347+
348+
/**
349+
* A failed run's message, by the record-save rule (objectstack#24914 ruling
350+
* B′): the flow's authored `errorMessage` — translated when the bundle carries
351+
* `flows.FLOW.errorMessage` (objectui#12075) — then the producer-marked
352+
* `userMessage`, then the refusal the run's write node met (`innerRefusal`,
353+
* whose `message` the spec serves without the node and operation prefixes),
354+
* then the run's own `message`.
355+
*
356+
* The inner refusal is rendered exactly as the record form renders the same
357+
* refusal on a save: a permission denial (`isPermissionError`) becomes the
358+
* form's localized permission line instead of server prose (objectstack#3821),
359+
* anything else reads its own sentence. `message` stays the last rung, so a
360+
* run that met no refusal still says something, never nothing.
295361
*/
296-
function flowFailureMessage(data: FlowRunResult, fallback: string, copy: FlowCopyLocale | undefined): string {
297-
const friendly = data.errorMessage;
362+
function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: FlowCopyLocale | undefined): string {
363+
const friendly = failure.errorMessage;
298364
if (typeof friendly === 'string' && friendly.length > 0) return translatedFlowMessage('errorMessage', friendly, copy);
299-
return actionErrorDetail(data, fallback);
365+
if (failure.userMessage) return failure.userMessage;
366+
const inner = failure.innerRefusal as { code?: unknown; message?: unknown } | null | undefined;
367+
if (inner && typeof inner === 'object' && typeof inner.code === 'string') {
368+
const refusal = { code: inner.code, message: typeof inner.message === 'string' ? inner.message : '' };
369+
if (isPermissionError(refusal)) return permissionDeniedLine(copy?.locale);
370+
if (refusal.message.trim()) return refusal.message;
371+
}
372+
return message;
300373
}
301374

302375
/**
@@ -343,10 +416,15 @@ export function interpretFlowResponse<S = unknown>(
343416
// authorable `errorMessage` stays preferred for it; the envelope's own
344417
// `message` is the fallback. See the header note.
345418
if (res.status === 400 && errorCodeIs(json?.error, 'FLOW_FAILED')) {
419+
const details = errorEnvelopeDetails(json);
346420
return {
347421
kind: 'failed',
348422
error: flowFailureMessage(
349-
errorEnvelopeDetails(json),
423+
{
424+
errorMessage: details.errorMessage,
425+
userMessage: declaredUserMessage(json?.error),
426+
innerRefusal: details.innerRefusal,
427+
},
350428
actionErrorDetail(json, `${label} failed`),
351429
copy,
352430
),
@@ -390,7 +468,15 @@ export function interpretFlowResponse<S = unknown>(
390468
if (data.success === false || data.status === 'failed') {
391469
return {
392470
kind: 'failed',
393-
error: flowFailureMessage(data, `${label} failed`, copy),
471+
error: flowFailureMessage(
472+
{
473+
errorMessage: data.errorMessage,
474+
userMessage: declaredUserMessage(data),
475+
innerRefusal: data.innerRefusal,
476+
},
477+
actionErrorDetail(data, `${label} failed`),
478+
copy,
479+
),
394480
retryable: false,
395481
};
396482
}

‎ui/scripts/check-i18n-dead-keys.mjs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@
211211
* produce it. Derived and read are pinned TO EACH OTHER instead:
212212
* `ANALYSED_PACK_OBJECT_IMPORTERS` below lists exactly the paths these bullets
213213
* analyse, and `scripts/__tests__/check-i18n-dead-keys.test.ts` fails when the
214-
* derived non-test set and that list disagree in either direction. A sixth
214+
* derived non-test set and that list disagree in either direction. An unlisted
215215
* importer therefore turns a test red instead of joining the silence, which is
216216
* the failure this section had: not a wrong number, an UNCLASSIFIED READER.
217217
*
@@ -267,6 +267,21 @@
267267
* default away from its call site, and both go dark to every leg while a
268268
* shipping component still renders them — and that edit would read as a
269269
* tidy-up.
270+
* - `packages/app-shell/src/utils/flowResponse.ts` — reads ONE key, the
271+
* record form's permission line, for a failed flow run whose write node was
272+
* refused, through `getLoadedBuiltInLocales()` and never a static pack
273+
* import. Three chains, each written straight off the call so the
274+
* derivation can follow them: the active tag and its base language are
275+
* RUNTIME-indexed (opaque rows, class 1 below, like `outboundAgentText`),
276+
* and the `en` fallback is spelled in full and resolves to that leaf. It is
277+
* a TWO-segment key, under the property-chain leg's three-segment floor,
278+
* so that leg is blind to it; the full-key probe sees it only because the
279+
* same dotted key is spelled in this file's own comments. ⇒ covered BY
280+
* COINCIDENCE, the same tier as the two bullets above. Nothing is at risk
281+
* today for a second, unrelated reason: the record form reads the same key
282+
* as a literal `t()` argument, so the AST pass keeps it live. Drop both the
283+
* comment spelling and the form's call and this read goes dark while a
284+
* failed flow still shows it.
270285
*
271286
* The rest of the matches are TEST-only importers, deliberately not listed one
272287
* by one. A key read only by a test is not a key a user can see, so a test
@@ -743,6 +758,7 @@ export const ANALYSED_PACK_OBJECT_IMPORTERS = Object.freeze([
743758
'packages/plugin-grid/demo/main.tsx',
744759
'packages/plugin-grid/demo/bulk-actions.tsx',
745760
'packages/react/src/utils/nonGridRowCeiling.tsx',
761+
'packages/app-shell/src/utils/flowResponse.ts',
746762
]);
747763

748764
/** A path is a TEST importer when it lives in a `__tests__` directory or is

0 commit comments

Comments
 (0)