Skip to content

Commit 83e3520

Browse files
os-zhuangclaude
andauthored
fix(console): a flow or action that failed under HTTP 200 stops reporting success (#2958) (#2995)
Business failures come back HTTP 200 with the failure on the INNER envelope (objectstack#3913), and a failed flow launch carries neither `status` nor `screen`. Three call sites read that response by hand and two read it incompletely, so a failed run was indistinguishable from a completed one: no dialog, a green "completed successfully" toast, a refresh, and the real error swallowed. - `utils/flowResponse.ts` (new) — the flow trigger/resume rule, once: transport failure / flow failure / screen pause / terminal success, with `error` guaranteed to be a STRING. Failure is classified before `paused`, which is safe because the engine always stamps `success: true` alongside `status: 'paused'` (service-automation engine.ts). - Both flow-launch handlers (`useConsoleActionRuntime`, `RecordDetailView`) now go through it, so a launch that failed returns `success: false` and no longer refreshes. RecordDetailView additionally stops passing `json.error` through raw — the nested `{code, message}` shape reaches `toast.error()` as a React child and crashes the page (React #31). - `FlowRunner`'s resume shares the same interpreter, keeping its retryable (transport) vs terminal (flow) close behaviour and gaining the coercion. - `apiHandler` classifies an HTTP-200 `success: false` body as a failure before refreshing on it. The `script` handler was already fixed via `interpretActionResponse`; this is the same lesson on the automation route, with a matching ratchet test so the rule cannot be hand-rolled back into three copies. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f59f2c1 commit 83e3520

7 files changed

Lines changed: 604 additions & 37 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* #2958 ratchet — every flow `trigger` / `resume` caller goes through
6+
* `interpretFlowResponse`.
7+
*
8+
* The sibling of `actions-envelope.ratchet.test.ts`, for the same reason. The
9+
* rule for reading an `AutomationResult` lived in THREE hand-rolled copies —
10+
* the flow handlers in `useConsoleActionRuntime` and `RecordDetailView`, and
11+
* `FlowRunner`'s resume — and only the resume copy was complete. The two launch
12+
* copies validated the transport envelope and then treated everything else as
13+
* terminal success, so a run that failed on its first node was reported as a
14+
* completed one: no dialog, a green "completed successfully" toast, a refresh,
15+
* and the real error swallowed. One copy also passed the error value through
16+
* raw, where a nested `{code, message}` reaches `toast.error()` as a React child
17+
* and crashes the page (React #31).
18+
*
19+
* If this fails: don't hand-roll the check. Import `interpretFlowResponse` from
20+
* `utils/flowResponse` — it classifies transport failure / flow failure /
21+
* screen pause / terminal success, and guarantees a string error.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
import { readdirSync, readFileSync, statSync } from 'node:fs';
26+
import path from 'node:path';
27+
import { fileURLToPath } from 'node:url';
28+
29+
const here = path.dirname(fileURLToPath(import.meta.url));
30+
const appShellSrc = here;
31+
32+
/** The helper that owns the rule — exempt from its own guard. */
33+
const OWNER = path.join(appShellSrc, 'utils', 'flowResponse.ts');
34+
35+
function walk(dir: string, out: string[] = []): string[] {
36+
for (const entry of readdirSync(dir)) {
37+
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') continue;
38+
const full = path.join(dir, entry);
39+
if (statSync(full).isDirectory()) walk(full, out);
40+
else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
41+
}
42+
return out;
43+
}
44+
45+
/**
46+
* Comments name routes freely while explaining them. Only CODE counts.
47+
*/
48+
function stripComments(src: string): string {
49+
return src
50+
.replace(/\/\*[\s\S]*?\*\//g, '')
51+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
52+
}
53+
54+
/**
55+
* A file that LAUNCHES or RESUMES a flow run — i.e. one that receives an
56+
* `AutomationResult` to interpret.
57+
*
58+
* Deliberately narrower than "names the automation route": most automation
59+
* callers READ the registry (`/connectors`, `/actions`, `/_status`, `/runs` for
60+
* the observability panel) and get back a list, not a run result. Those have no
61+
* envelope rule to get wrong.
62+
*/
63+
function runsAFlow(src: string): boolean {
64+
const code = stripComments(src);
65+
if (!/\/api\/v1\/automation\//.test(code)) return false;
66+
return /\/trigger[`'"]/.test(code) || /\/resume[`'"]/.test(code);
67+
}
68+
69+
describe('#2958 ratchet — flow trigger/resume callers use interpretFlowResponse', () => {
70+
const callers = walk(appShellSrc)
71+
.filter((f) => f !== OWNER && runsAFlow(readFileSync(f, 'utf8')));
72+
73+
const offenders = callers
74+
.filter((f) => !readFileSync(f, 'utf8').includes('interpretFlowResponse'))
75+
.map((f) => path.relative(appShellSrc, f));
76+
77+
it('no app-shell file interprets a flow run result by hand', () => {
78+
expect(offenders).toEqual([]);
79+
});
80+
81+
it('still guards something — the three known callers are present', () => {
82+
// A ratchet that matches nothing passes vacuously forever. Pin that the
83+
// copies it exists for are actually being scanned.
84+
expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual(
85+
expect.arrayContaining([
86+
path.join('hooks', 'useConsoleActionRuntime.tsx'),
87+
path.join('views', 'RecordDetailView.tsx'),
88+
path.join('views', 'FlowRunner.tsx'),
89+
]),
90+
);
91+
});
92+
});

packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,159 @@ describe('flowHandler — list_toolbar selection fallback', () => {
662662
});
663663
});
664664

665+
// #2958 — a business failure comes back HTTP 200 with the failure on the INNER
666+
// envelope (`data.success === false`), and a failed flow launch carries neither
667+
// `status` nor `screen`. Both used to land in the terminal-success return: the
668+
// user saw a green "completed successfully" toast, the view refreshed, and the
669+
// real error was swallowed. These pin the failure paths AND that the success
670+
// paths they sit next to still work.
671+
describe('#2958 — a failure reported under HTTP 200 is a failure, not success', () => {
672+
it('flowHandler reports a failed launch (no status, no screen) instead of terminal success', async () => {
673+
// The reported repro: a screen flow whose first CRUD node fails. Outer
674+
// envelope says success; only `data.success` shows the truth.
675+
authFetchSpy.mockResolvedValue({
676+
ok: true,
677+
json: async () => ({
678+
success: true,
679+
data: { success: false, error: "Node 'apply' failed: Update requires an ID" },
680+
}),
681+
});
682+
const refreshSpy = vi.fn();
683+
const { result } = renderHook(() =>
684+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv', onRefresh: refreshSpy }),
685+
);
686+
687+
let res: any;
688+
await act(async () => {
689+
res = await result.current.flowHandler({ type: 'flow', name: 'convert_lead', target: 'convert_lead' } as any);
690+
});
691+
692+
expect(res.success).toBe(false);
693+
expect(res.error).toBe("Node 'apply' failed: Update requires an ID");
694+
// A failed run changed nothing — refreshing implies it did.
695+
expect(refreshSpy).not.toHaveBeenCalled();
696+
// The ActionRunner's post-execution hook owns the error toast; the handler
697+
// must not fire a second one.
698+
expect((toast as any).error).not.toHaveBeenCalled();
699+
});
700+
701+
it('flowHandler prefers the flow-declared errorMessage over the raw engine error', async () => {
702+
authFetchSpy.mockResolvedValue({
703+
ok: true,
704+
json: async () => ({
705+
success: true,
706+
data: {
707+
success: false,
708+
status: 'failed',
709+
error: "Node 'apply' failed: constraint violation on lead.status",
710+
errorMessage: 'This lead has already been converted.',
711+
},
712+
}),
713+
});
714+
const { result } = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [] }));
715+
716+
let res: any;
717+
await act(async () => {
718+
res = await result.current.flowHandler({ type: 'flow', name: 'convert_lead', target: 'convert_lead' } as any);
719+
});
720+
721+
expect(res).toMatchObject({ success: false, error: 'This lead has already been converted.' });
722+
});
723+
724+
it('flowHandler still OPENS the wizard on a screen pause (success path intact)', async () => {
725+
// The engine stamps `success: true` alongside `status: 'paused'`, which is
726+
// why classifying failure first cannot swallow a wizard. `silent: true` is
727+
// the marker that the action only opened the runner.
728+
authFetchSpy.mockResolvedValue({
729+
ok: true,
730+
json: async () => ({
731+
success: true,
732+
data: {
733+
success: true,
734+
status: 'paused',
735+
runId: 'run-7',
736+
screen: { nodeId: 'collect', title: 'New Assignee', fields: [] },
737+
},
738+
}),
739+
});
740+
const refreshSpy = vi.fn();
741+
const { result } = renderHook(() =>
742+
useConsoleActionRuntime({ dataSource: {}, objects: [], onRefresh: refreshSpy }),
743+
);
744+
745+
let res: any;
746+
await act(async () => {
747+
res = await result.current.flowHandler({ type: 'flow', name: 'f', target: 'f' } as any);
748+
});
749+
750+
expect(res).toMatchObject({ success: true, silent: true });
751+
// The run hasn't completed — the runner refreshes on completion, not now.
752+
expect(refreshSpy).not.toHaveBeenCalled();
753+
});
754+
755+
it('flowHandler coerces a nested {code, message} error to a string (React #31)', async () => {
756+
// Handing the object through as `error` reaches `toast.error()` as a React
757+
// child and crashes the page.
758+
authFetchSpy.mockResolvedValue({
759+
ok: false,
760+
status: 403,
761+
json: async () => ({
762+
success: false,
763+
error: { message: 'Run is parked on a service-owned node', code: 'PERMISSION_DENIED' },
764+
}),
765+
});
766+
const { result } = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [] }));
767+
768+
let res: any;
769+
await act(async () => {
770+
res = await result.current.flowHandler({ type: 'flow', name: 'f', target: 'f' } as any);
771+
});
772+
773+
expect(typeof res.error).toBe('string');
774+
expect(res.error).toBe('Run is parked on a service-owned node');
775+
});
776+
777+
it('apiHandler reports an HTTP-200 success:false body instead of refreshing on it', async () => {
778+
// The `log_call`-style repro on the api transport: 200, `success: false`.
779+
authFetchSpy.mockResolvedValue({
780+
ok: true,
781+
json: async () => ({ success: false, error: "Action 'log_call' on object '*' not found" }),
782+
});
783+
const refreshSpy = vi.fn();
784+
const { result } = renderHook(() =>
785+
useConsoleActionRuntime({ dataSource: {}, objects: [], onRefresh: refreshSpy }),
786+
);
787+
788+
let res: any;
789+
await act(async () => {
790+
res = await result.current.apiHandler({ type: 'api', name: 'log_call', target: '/api/v1/x' } as any);
791+
});
792+
793+
expect(res.success).toBe(false);
794+
expect(res.error).toBe("Action 'log_call' on object '*' not found");
795+
expect(refreshSpy).not.toHaveBeenCalled();
796+
});
797+
798+
it('apiHandler leaves a payload that merely CONTAINS a success key alone', async () => {
799+
// Only the envelope's own `success: false` is a failure. A handler value
800+
// that happens to carry `success` is data, and a `success: true` envelope
801+
// is of course fine — neither may be misread as a rejection.
802+
authFetchSpy.mockResolvedValue({
803+
ok: true,
804+
json: async () => ({ success: true, data: { success: false, rows: 0, note: 'partial' } }),
805+
});
806+
const { result } = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [] }));
807+
808+
let res: any;
809+
await act(async () => {
810+
res = await result.current.apiHandler({ type: 'api', name: 'x', target: '/api/v1/x' } as any);
811+
});
812+
813+
expect(res.success).toBe(true);
814+
expect(res.data).toEqual({ success: false, rows: 0, note: 'partial' });
815+
});
816+
});
817+
665818
describe('serverActionHandler — list_toolbar selection fallback', () => {
666819
it('uses the single selected row from the runner context as recordId', async () => {
667820
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ success: true, data: {} }) });

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ import { useActionModal } from './useActionModal';
4343
import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog';
4444
import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog';
4545
import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog';
46-
import { FlowRunner, type ScreenFlowState } from '../views/FlowRunner';
46+
import { FlowRunner, type ScreenFlowState, type ScreenSpec } from '../views/FlowRunner';
4747
import { resolveActionParams } from '../utils/resolveActionParams';
4848
import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../environment/EnvironmentEntitlementDialog';
4949
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements';
5050
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
5151
import { actionErrorDetail } from '../utils/actionErrorDetail';
5252
import { interpretActionResponse } from '../utils/actionResponse';
53+
import { interpretFlowResponse } from '../utils/flowResponse';
5354

5455
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
5556

@@ -350,6 +351,16 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
350351
return { success: false, error: errorDetail(body, `HTTP ${res.status}`) };
351352
}
352353
const json = await res.json().catch(() => ({}));
354+
// A business rejection can arrive as HTTP 200 with `success: false`
355+
// (objectstack#3913). `res.ok` alone misses it, so the call reported
356+
// success, toasted green and refreshed while the error was swallowed
357+
// (#2958). Classified BEFORE the refresh below — a rejected call
358+
// changed nothing to re-fetch.
359+
if (json && typeof json === 'object' && (json as { success?: unknown }).success === false) {
360+
// `name` is not guaranteed on an api action (it can be target-only),
361+
// so fall back to the endpoint rather than interpolating `undefined`.
362+
return { success: false, error: errorDetail(json, `Action "${action.name || targetStr}" failed`) };
363+
}
353364
if (action.refreshAfter !== false) refresh();
354365
// Unwrap the ObjectStack `{ success, data }` envelope so `result.data`
355366
// is the inner payload — the contract every `result.data` consumer
@@ -470,21 +481,28 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
470481
},
471482
);
472483
const json = await res.json().catch(() => null);
473-
if (!res.ok || (json && json.success === false)) {
474-
return { success: false, error: errorDetail(json, `Flow "${flowName}" failed (HTTP ${res.status})`) };
484+
// Single source for the flow-response rule — shared with
485+
// RecordDetailView's copy of this handler and with FlowRunner's resume.
486+
// A launch that FAILED (HTTP 200, `data.success === false`, no `status`
487+
// and no `screen`) used to be indistinguishable from a completed run and
488+
// fell into the terminal-success return below: no dialog, a green toast,
489+
// and a refresh (#2958). See utils/flowResponse.
490+
const outcome = interpretFlowResponse<ScreenSpec>(res, json, `Flow "${flowName}"`);
491+
if (outcome.kind === 'failed') {
492+
// The ActionRunner's post-execution hook surfaces `error` as a toast.
493+
return { success: false, error: outcome.error };
475494
}
476495
// Screen-flow runtime: paused at a `screen` node awaiting input — open
477496
// the FlowRunner to render the form + resume. Refresh happens on complete.
478-
const data = json?.data ?? {};
479-
if (data.status === 'paused' && data.screen) {
480-
setScreenFlow({ flowName, runId: data.runId, screen: data.screen });
497+
if (outcome.kind === 'paused') {
498+
setScreenFlow({ flowName, runId: outcome.runId ?? '', screen: outcome.screen });
481499
// The action only OPENED the wizard — it hasn't completed. Suppress the
482500
// action-level success toast; the flow-runner owns completion messaging.
483501
return { success: true, silent: true };
484502
}
485503
const shouldRefresh = action.refreshAfter !== false;
486504
if (shouldRefresh) refresh();
487-
return { success: true, data: json?.data, reload: shouldRefresh };
505+
return { success: true, data: outcome.data, reload: shouldRefresh };
488506
} catch (error) {
489507
return { success: false, error: (error as Error).message };
490508
}

0 commit comments

Comments
 (0)