Skip to content

Commit f7ff693

Browse files
fix(app-shell): a flow-backed api action reads the run's inner refusal, not the node chain (#27630)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:`api` 类型的动作(记录页 ⋯ 菜单、列表行和工具栏)调用流程端点失败时,不再把引擎的节点堆栈原样弹给用户,改为显示内层拒绝的白话提示(权限拒绝就显示和记录保存一样的本地化权限提示)。 - 为什么改:成员点「Recalculate Estimate」看到的是 `Node 'recalc_each' failed: map … subflow … update_record(showcase_task) failed: …` 这种开发者文字;流程界面早已有提取规则,api 动作入口没有接上。 - 风险与回滚:只影响 api 动作收到 `400 FLOW_FAILED` 的分支,其他错误照旧显示原消息;回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/utils/flowResponse.ts`(新导出 `apiActionFailureMessage`)、`views/RecordDetailView.tsx`、`hooks/useConsoleActionRuntime.tsx` 的 `!res.ok` 分支。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/utils/__tests__/flowResponse.test.ts`,预期全绿,其中 objectstack#27487 一组 5 例。 - 需要你做的:无,席位落地。showcase 那条 map → subflow 链要等服务端卡 #27601 落地后才会显示白话提示。 Refs #27487 (item 1: the console action door) Clause-②: no Metadata-protocol: none ## What changes A `type: 'api'` action whose target is a declared `type: 'flow'` endpoint (showcase "Recalculate Estimate" → `/api/v1/apps/showcase/recalc`) gets the trigger door's `400 FLOW_FAILED` when the run fails, `error.details.innerRefusal` and the top-level `userMessage` included (runtime `endpoint-executor.ts` builds it with the trigger door's own `flowFailureDetails`). Both api handlers answered that envelope's `message`, the engine's node chain: - `RecordDetailView`'s `apiHandler` (the record header ⋯ menu, the card's repro door) read `error.message`; - `useConsoleActionRuntime`'s `apiHandler` (list rows, toolbar, shell) read `actionErrorDetail`. Neither path goes through `flowLaunch` / `interpretFlowResponse`, so #25938's extraction never ran there. `flowResponse.ts` now exports `apiActionFailureMessage(res, json, fallback, locale)`. On `400 FLOW_FAILED` it reads the envelope through the failed arm's existing rule (`flowFailureMessage`: authored `errorMessage`, then `userMessage`, then `innerRefusal` rendered as the record form renders it, then `message`). Every other answer reads `actionErrorDetail`, as before. The 400 arm of `interpretFlowResponse` and the new export share one helper (`flowFailedEnvelopeMessage`), and `flowFailureMessage` takes the permission line's locale explicitly, since the api door knows no flow to build a `FlowCopyLocale` for; it passes the host's active `language`. No new message map. Both handlers call it on a non-2xx. In `RecordDetailView.tsx` that is the call-site line plus its import and the `language` entry in the handler's dependency list (declared in the report). ## Tests - `utils/__tests__/flowResponse.test.ts`: new `apiActionFailureMessage` group. One-level and nested map → subflow envelopes (each proven real by `ApiErrorSchema` and `FlowFailureDetailsSchema`) with `innerRefusal: PERMISSION_DENIED` read the form's permission line in `en` and `zh-CN`; a validation refusal reads its sentence; `userMessage` and the authored `errorMessage` outrank it; an envelope with no carriers reads its message; a 403, a non-`FLOW_FAILED` 400 and a bodiless 502 read as before. - `hooks/__tests__/useConsoleActionRuntime.apiFlowRefusal-27487.test.tsx`: the hook's `apiHandler` against the nested envelope answers the `zh` permission line from the host's language; control: a 403 reads its message. - `views/RecordDetailView.apiFlowRefusal-27487.test.tsx`: the handler set the record page hands its `ActionProvider` sends `recordId` to the endpoint and answers the permission line; control: no carriers → the message. <details><summary>Local runs (head 3219ed5)</summary> - vitest (6 files: the three above plus `apiNewTabUrl-26747`, `headerApiInterpolation`, `flowLaunch.translation-12075`): Test Files 6 passed, Tests 85 passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - Ablation (`scripts/ablation-replace.mjs`, marker `!'ABLATED_27487'` short-circuiting the `FLOW_FAILED` test in `apiActionFailureMessage`): 5 tests red (the 3 new flowResponse cases that read carriers, both door cases); every control stayed green; restore verified `git diff HEAD` empty. - Derived gates (`dispatch-gates --commands`): 34 derived, 34 run, all exit 0; `--ran` reconciliation: 0 NOT-MEASURED, 0 UNRUN (full lines in the report comment). - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> ## Acceptance notes - This PR is item 1 only. The showcase repro (map item → subflow → `update_record`) still shows the chain after it lands: `service-automation`'s `map-node.ts` and `subflow-node.ts` return a new failure with the child's `error` text and drop the child run's `innerRefusal` / `userMessage`, so the endpoint's envelope carries no `innerRefusal` for that chain. Server half filed as #27601. Once it lands, this door shows the permission line with no further UI change (pinned by the nested-envelope tests). - The card's other expectation, not offering the action to someone who cannot perform its write, is not addressed here: the triage scoped this card to the toast copy (the refusal itself is correct). - Observed, not changed: on a `200` whose outer envelope is `success: true` but whose `data.success` is `false`, both api handlers report success. The endpoint door keeps that `200` only for an unclassified failure (its own comment says so); no reach measured, so noted only. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Ca8en1EvoQ3voL2nWm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 875d007 commit f7ff693

7 files changed

Lines changed: 497 additions & 19 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): a flow-backed api action shows the run's refusal, not the engine's node chain
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A `type: 'api'` action whose target is a declared `type: 'flow'` endpoint gets the trigger door's `400 FLOW_FAILED` when the run fails. The record page's action menu and the list and toolbar actions showed that envelope's message, so a member refused on the flow's write read text like `Node 'recalc_each' failed: map … subflow … update_record(showcase_task) failed: …`. Both api handlers now read the answer by the rule a screen flow already uses: the flow's own `errorMessage`, then the producer-marked `userMessage`, then the refusal the write met (`innerRefusal`), rendered as the record form renders it (a permission denial reads the form's permission line in the console's language), and only then the message. Any other error answer reads as before.
11+
12+
A failure inside a `map` item or a `subflow` reaches this door with no `innerRefusal` until the engine carries it out of those nodes, so that chain still shows the message.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27487 — a `type: 'api'` action whose target is a declared
11+
* `type: 'flow'` endpoint shows the run's inner refusal, not the node chain.
12+
*
13+
* The endpoint answers a failed run with the trigger door's `400 FLOW_FAILED`
14+
* (`error.details.innerRefusal` included). The shared console runtime's
15+
* `apiHandler` used to toast that envelope's `message`, so a member refused on
16+
* a flow's `update_record` read `Node '…' failed: update_record(…) failed: …`.
17+
* It now reads the envelope through `apiActionFailureMessage`, in the host's
18+
* active language.
19+
*
20+
* CONTROL: a 4xx that is not `FLOW_FAILED` still reads its own message.
21+
*/
22+
23+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
24+
import { renderHook, act } from '@testing-library/react';
25+
import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales';
26+
27+
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
28+
29+
const authFetchSpy = vi.fn();
30+
vi.mock('@object-ui/auth', async (importOriginal) => ({
31+
...(await importOriginal<Record<string, unknown>>()),
32+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
33+
createAuthenticatedFetch: () => authFetchSpy,
34+
}));
35+
36+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
useObjectLabel: () => ({
39+
fieldLabel: (_o: any, _n: any, l: any) => l,
40+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
41+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
42+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
43+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
44+
}),
45+
useObjectTranslation: () => ({
46+
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
47+
language: 'zh-CN',
48+
i18n: undefined,
49+
}),
50+
}));
51+
52+
vi.mock('../useActionModal', () => ({
53+
useActionModal: () => ({
54+
modalHandler: vi.fn(async () => ({ success: true })),
55+
modalElement: null,
56+
closeModal: () => {},
57+
resolveModalTarget: vi.fn(async () => null),
58+
}),
59+
}));
60+
61+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
62+
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
63+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
64+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
65+
66+
vi.mock('sonner', () => {
67+
const fn: any = vi.fn();
68+
fn.error = vi.fn();
69+
fn.success = vi.fn();
70+
return { toast: fn };
71+
});
72+
73+
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';
74+
75+
// Every pack resident, so the `zh` line is the catalogue a Chinese console has.
76+
registerBuiltInLocales();
77+
78+
const REFUSAL = 'You do not have permission to perform this action. Contact your administrator if you need access.';
79+
const NESTED = "Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow 'showcase_recalc_task_estimate') failed: "
80+
+ `Node 'write_estimate' failed: update_record(showcase_task) failed: ${REFUSAL}`;
81+
82+
const RECALC = {
83+
name: 'showcase_recalc_estimate',
84+
label: 'Recalculate Estimate',
85+
type: 'api',
86+
target: '/api/v1/apps/showcase/recalc',
87+
recordIdParam: 'recordId',
88+
params: { _rowRecord: { id: 'task-1' } },
89+
};
90+
91+
function answer(status: number, body: unknown) {
92+
authFetchSpy.mockImplementation(async () =>
93+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }),
94+
);
95+
}
96+
97+
async function runRecalc() {
98+
const { result } = renderHook(() =>
99+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'showcase_task' }),
100+
);
101+
let out: any;
102+
await act(async () => {
103+
out = await result.current.apiHandler({ ...RECALC } as any);
104+
});
105+
return out;
106+
}
107+
108+
beforeEach(() => {
109+
authFetchSpy.mockReset();
110+
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
111+
});
112+
113+
afterEach(() => {
114+
vi.unstubAllGlobals();
115+
vi.restoreAllMocks();
116+
});
117+
118+
describe('objectstack#27487 — console runtime: a flow-backed api action reads the run refusal', () => {
119+
it("a refused nested map → subflow write shows the record form's permission line, in the host's language", async () => {
120+
answer(400, {
121+
success: false,
122+
error: {
123+
code: 'FLOW_FAILED',
124+
message: NESTED,
125+
httpStatus: 400,
126+
details: { innerRefusal: { code: 'PERMISSION_DENIED', message: REFUSAL } },
127+
},
128+
});
129+
130+
const out = await runRecalc();
131+
132+
expect(authFetchSpy).toHaveBeenCalledTimes(1);
133+
expect(out).toEqual({ success: false, error: builtInLocales.zh.form.noPermissionToSave });
134+
});
135+
136+
it('CONTROL: a 4xx that is not FLOW_FAILED reads its own message', async () => {
137+
answer(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'Forbidden', httpStatus: 403 } });
138+
139+
expect(await runRecalc()).toEqual({ success: false, error: 'Forbidden' });
140+
});
141+
});

‎ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../en
5252
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements.js';
5353
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
5454
import { launchConsoleFlow } from '../utils/flowLaunch.js';
55+
import { apiActionFailureMessage } from '../utils/flowResponse.js';
5556
import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js';
5657
import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js';
5758
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
@@ -510,7 +511,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
510511
openEntitlementDialog(entitlementSpec);
511512
return { success: false };
512513
}
513-
return { success: false, error: errorDetail(body, `HTTP ${res.status}`) };
514+
// A flow-backed endpoint's refused run reads its inner refusal, not
515+
// the node chain (objectstack#27487).
516+
return { success: false, error: apiActionFailureMessage(res, body, `HTTP ${res.status}`, language) };
514517
}
515518
const json = await res.json().catch(() => ({}));
516519
// A business rejection can arrive as HTTP 200 with `success: false`
@@ -630,7 +633,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
630633
} catch (error) {
631634
return { success: false, error: (error as Error).message };
632635
}
633-
}, [dataSource, objApiName, objects, metadata, authFetch, activeOrganization, refresh, openEntitlementDialog, t]);
636+
}, [dataSource, objApiName, objects, metadata, authFetch, activeOrganization, refresh, openEntitlementDialog, t, language]);
634637

635638
/**
636639
* Flow action handler — starts the flow through one of two doors

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ActionRunner } from '@object-ui/core';
1212
import { builtInLocales, registerBuiltInLocales } from '@object-ui/i18n/locales';
1313
import { ApiErrorSchema, FlowFailureDetailsSchema } from '@objectstack/spec/api';
1414
import type { AutomationResult } from '@objectstack/spec/contracts';
15-
import { flowSuccessCopy, interpretFlowResponse, judgeFlowLaunch } from '../flowResponse';
15+
import { apiActionFailureMessage, flowSuccessCopy, interpretFlowResponse, judgeFlowLaunch } from '../flowResponse';
1616

1717
const ok = { ok: true, status: 200 };
1818

@@ -743,3 +743,63 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record-
743743
expect(on200(run({}))).toMatchObject({ error: RAW_ERROR });
744744
});
745745
});
746+
747+
describe('apiActionFailureMessage — a flow-backed api action reads the run refusal, not the node chain (objectstack#27487)', () => {
748+
registerBuiltInLocales();
749+
750+
// The card's toast: a map item's subflow met the refusal on its update.
751+
const REFUSAL = 'You do not have permission to perform this action. Contact your administrator if you need access.';
752+
const ONE_LEVEL = `Node 'write_estimate' failed: update_record(showcase_task) failed: ${REFUSAL}`;
753+
const NESTED = "Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow 'showcase_recalc_task_estimate') failed: "
754+
+ ONE_LEVEL;
755+
const DENIED = { code: 'PERMISSION_DENIED', message: REFUSAL } as const;
756+
const RULE = { code: 'VALIDATION_FAILED', message: 'Estimate must be a positive number.' } as const;
757+
758+
/** A declared `type: 'flow'` endpoint's `400 FLOW_FAILED`, proven real by the spec's own schemas. */
759+
function endpointFailed(message: string, details: Record<string, unknown>, userMessage?: string) {
760+
const error = {
761+
code: 'FLOW_FAILED',
762+
message,
763+
httpStatus: 400,
764+
...(userMessage ? { userMessage } : {}),
765+
details,
766+
};
767+
expect(ApiErrorSchema.safeParse(error).error?.issues).toBeUndefined();
768+
expect(FlowFailureDetailsSchema.safeParse(details).error?.issues).toBeUndefined();
769+
return { success: false, error };
770+
}
771+
const on400 = (json: unknown, locale?: string) => apiActionFailureMessage({ status: 400 }, json, 'HTTP 400', locale);
772+
773+
it("a one-level permission refusal reads the record form's line, in the host's language", () => {
774+
expect(on400(endpointFailed(ONE_LEVEL, { innerRefusal: DENIED }))).toBe(builtInLocales.en.form.noPermissionToSave);
775+
expect(on400(endpointFailed(ONE_LEVEL, { innerRefusal: DENIED }), 'zh-CN')).toBe(builtInLocales.zh.form.noPermissionToSave);
776+
});
777+
778+
it('the nested map → subflow chain reads the same line once the inner refusal is carried', () => {
779+
const shown = on400(endpointFailed(NESTED, { innerRefusal: DENIED }));
780+
expect(shown).toBe(builtInLocales.en.form.noPermissionToSave);
781+
expect(shown).not.toContain("Node '");
782+
expect(shown).not.toContain('subflow');
783+
});
784+
785+
it("a non-permission refusal reads its own sentence; a userMessage and the flow's errorMessage outrank it", () => {
786+
expect(on400(endpointFailed(NESTED, { innerRefusal: RULE }))).toBe(RULE.message);
787+
expect(on400(endpointFailed(NESTED, { innerRefusal: DENIED }, 'Ask your lead to recalculate.')))
788+
.toBe('Ask your lead to recalculate.');
789+
expect(on400(endpointFailed(NESTED, { errorMessage: 'Recalculation failed.', innerRefusal: DENIED })))
790+
.toBe('Recalculation failed.');
791+
});
792+
793+
it('an envelope without the members falls back to its message, never to silence', () => {
794+
expect(on400(endpointFailed(NESTED, {}))).toBe(NESTED);
795+
});
796+
797+
it('every other answer reads the envelope as the door always has', () => {
798+
const denied = { success: false, error: { code: 'PERMISSION_DENIED', message: 'Forbidden', details: { innerRefusal: RULE } } };
799+
expect(apiActionFailureMessage({ status: 403 }, denied, 'HTTP 403', 'en')).toBe('Forbidden');
800+
// A 400 that is not FLOW_FAILED does not read flow carriers either.
801+
const invalid = { success: false, error: { code: 'VALIDATION_FAILED', message: 'Bad body', details: { innerRefusal: RULE } } };
802+
expect(apiActionFailureMessage({ status: 400 }, invalid, 'HTTP 400', 'en')).toBe('Bad body');
803+
expect(apiActionFailureMessage({ status: 502 }, null, 'HTTP 502', 'en')).toBe('HTTP 502');
804+
});
805+
});

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

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@
125125
* `errorMessage` and `message`, and render the inner refusal by the record
126126
* form's rule. `message` itself is untouched on the wire.
127127
*
128+
* The `type: 'api'` action door reads the same carriers (objectstack#27487).
129+
* A declared `type: 'flow'` endpoint answers a run that failed with the
130+
* trigger door's `400 FLOW_FAILED`, details included, and a flow-backed api
131+
* action reached the user as that envelope's `message`, the node chain.
132+
* {@link apiActionFailureMessage} is that door's reading: both api handlers
133+
* (`useConsoleActionRuntime`'s and `RecordDetailView`'s) call it on a non-2xx.
134+
*
128135
* ## A LAUNCH is judged here too, not just interpreted (objectui#9973)
129136
*
130137
* Classifying the response was never the whole rule. The two launch handlers
@@ -363,7 +370,9 @@ function permissionDeniedLine(locale: string | undefined): string {
363370
* `flows.FLOW.errorMessage` (objectui#12075) — then the producer-marked
364371
* `userMessage`, then the refusal the run's write node met (`innerRefusal`,
365372
* whose `message` the spec serves without the node and operation prefixes),
366-
* then the run's own `message`.
373+
* then the run's own `message`. `locale` is the language the permission line
374+
* is read in: the flow's own (`copy.locale`) on a launch or resume, the
375+
* host's active language on the api action door, which knows no flow.
367376
*
368377
* The inner refusal is rendered exactly as the record form renders the same
369378
* refusal on a save: a permission denial (`isPermissionError`) becomes the
@@ -373,7 +382,12 @@ function permissionDeniedLine(locale: string | undefined): string {
373382
* permission check exactly as the form asks it (objectstack#26059, Q3). `message` stays the last rung, so a
374383
* run that met no refusal still says something, never nothing.
375384
*/
376-
function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: FlowCopyLocale | undefined): string {
385+
function flowFailureMessage(
386+
failure: FlowFailureCarriers,
387+
message: string,
388+
copy: FlowCopyLocale | undefined,
389+
locale: string | undefined,
390+
): string {
377391
const friendly = failure.errorMessage;
378392
if (typeof friendly === 'string' && friendly.length > 0) return translatedFlowMessage('errorMessage', friendly, copy);
379393
if (failure.userMessage) return failure.userMessage;
@@ -382,7 +396,7 @@ function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy:
382396
const refusal = { code: inner.code, message: typeof inner.message === 'string' ? inner.message : '' };
383397
const scopeReason = delegationScopeMessage(refusal);
384398
if (scopeReason) return scopeReason;
385-
if (isPermissionError(refusal)) return permissionDeniedLine(copy?.locale);
399+
if (isPermissionError(refusal)) return permissionDeniedLine(locale);
386400
if (refusal.message.trim()) return refusal.message;
387401
}
388402
return message;
@@ -411,6 +425,50 @@ function errorEnvelopeDetails(json: unknown): FlowRunResult {
411425
return details as FlowRunResult;
412426
}
413427

428+
/**
429+
* The message of a `400 FLOW_FAILED` error envelope, by
430+
* {@link flowFailureMessage}: the carriers are `error.details`' `errorMessage`
431+
* and `innerRefusal` and the envelope's top-level `userMessage`, and the last
432+
* rung is the envelope's own message (`fallback` when it has none).
433+
*/
434+
function flowFailedEnvelopeMessage(
435+
json: unknown,
436+
fallback: string,
437+
copy: FlowCopyLocale | undefined,
438+
locale: string | undefined,
439+
): string {
440+
const details = errorEnvelopeDetails(json);
441+
return flowFailureMessage(
442+
{
443+
errorMessage: details.errorMessage,
444+
userMessage: declaredUserMessage((json as { error?: unknown } | null | undefined)?.error),
445+
innerRefusal: details.innerRefusal,
446+
},
447+
actionErrorDetail(json, fallback),
448+
copy,
449+
locale,
450+
);
451+
}
452+
453+
/**
454+
* The error a `type: 'api'` action shows for a non-2xx answer (objectstack#27487
455+
* — see the header). A `400 FLOW_FAILED` is a flow the endpoint ran, and reads
456+
* the run's refusal by the failed arm's rule; `locale` is the host's active
457+
* language, since the door knows no flow to translate an `errorMessage` for.
458+
* Every other answer reads `actionErrorDetail`, as the door always has.
459+
*/
460+
export function apiActionFailureMessage(
461+
res: { status: number },
462+
json: unknown,
463+
fallback: string,
464+
locale: string | undefined,
465+
): string {
466+
if (res.status === 400 && errorCodeIs((json as { error?: unknown } | null | undefined)?.error, 'FLOW_FAILED')) {
467+
return flowFailedEnvelopeMessage(json, fallback, undefined, locale);
468+
}
469+
return actionErrorDetail(json, fallback);
470+
}
471+
414472
/**
415473
* Classify a `POST /api/v1/automation/{flow}/trigger` or
416474
* `.../runs/{runId}/resume` response.
@@ -432,18 +490,9 @@ export function interpretFlowResponse<S = unknown>(
432490
// authorable `errorMessage` stays preferred for it; the envelope's own
433491
// `message` is the fallback. See the header note.
434492
if (res.status === 400 && errorCodeIs(json?.error, 'FLOW_FAILED')) {
435-
const details = errorEnvelopeDetails(json);
436493
return {
437494
kind: 'failed',
438-
error: flowFailureMessage(
439-
{
440-
errorMessage: details.errorMessage,
441-
userMessage: declaredUserMessage(json?.error),
442-
innerRefusal: details.innerRefusal,
443-
},
444-
actionErrorDetail(json, `${label} failed`),
445-
copy,
446-
),
495+
error: flowFailedEnvelopeMessage(json, `${label} failed`, copy, copy?.locale),
447496
retryable: false,
448497
};
449498
}
@@ -492,6 +541,7 @@ export function interpretFlowResponse<S = unknown>(
492541
},
493542
actionErrorDetail(data, `${label} failed`),
494543
copy,
544+
copy?.locale,
495545
),
496546
retryable: false,
497547
};

0 commit comments

Comments
 (0)