Skip to content

Commit 27e4d65

Browse files
fix(core): the console runs a stored action that leaves type to its default as a script action (#26964)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb6NNyYYEoRck5PnHDXNdfpc)_ Fixes #26879 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台动作运行器(`@object-ui/core` 的 `ActionRunner`)在选派发路由的唯一一处,把「没写 `type`」的已存动作读成 spec 的默认值 `script`,于是它走已注册的 `script` 派发,打平台动作路由 `POST /api/v1/actions/{object}/{action}`。 - **为什么改**:spec 的 `ActionSchema.type` 默认是 `script`,但控制台跑的是存储里的原样行、不是解析后的行。Studio「新建」骨架和旧版 Studio 存下的无 `type` 动作,路由被解析成动作自己的名字,匹配不到执行器,被报「not executable」,点了没反应。 - **风险与回滚**:低。只影响「无 `type`、无 `actionType`、有名字、名下没注册处理器、也没写运行器自有键(navigate/api/endpoint/onClick/redirect/显式 reload、close)」的动作:原来必然被拒,现在走 script。未知 `type`、空动作仍拒绝;按名注册的处理器仍优先。回滚即还原 `resolveDispatchType` 一个函数。 - **先看**:`ui/packages/core/src/actions/ActionRunner.ts` 的 `resolveDispatchType` 与 `declaresRunnerBehavior`(新增)以及 `execute` 里改用它的那一行;`ActionRunner.specDefaultType-26879.test.ts`(8 个用例)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/core/src/actions/__tests__/ActionRunner.specDefaultType-26879.test.ts`,预期 8 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One place picks the route.** `execute` used to resolve the dispatch key as `type || actionType || name`. It now calls `resolveDispatchType`, which keeps the first three legs (an authored `type`, the `action:*` block's `actionType`, the `name` when a handler is registered under it or the action carries a runner-own key) and, for a named action that has none of those, answers `ActionSchema.type`'s default, `script`. Nothing unnamed takes the default, so `{}` still lands in `executeActionSchema` and is refused. - **The same key is asked twice, as before:** by `refusalBeforeInput` (the dispatch's early refusal, so a typeless script action meets the script dispatch's row guard) and by `dispatchCollected`. - `declaresRunnerBehavior` names the runner-own keys once; `executeActionSchema`'s guard now reads it instead of its own copy (same keys: `navigate` never reaches that guard because `dispatchCollected` runs it first). - **Fork taken: the runner, not the serve path.** The card offered either the console reading a typeless action as `script` at route selection, or the serve path returning the parsed row. The runner is in this lane's file surface and covers every unparsed source at once (stored rows from any Studio build or by hand), with no server change. No writer adds `type`, and no stored row is rewritten. - `@object-ui/core`'s exports are unchanged: both helpers and the constant are module-private. - One root changeset, `@object-ui/core` patch. ## Tests New `ui/packages/core/src/actions/__tests__/ActionRunner.specDefaultType-26879.test.ts`, 8 cases: - A typeless `{ name, body }` and a typeless `{ name, operation: 'update', patch }` both POST `/api/v1/actions/tasks/NAME` through the real `createServerActionHandler`. - A typeless action is asked the `script` dispatch's `refuseBeforeInput`. - **The default is the spec's:** the stored row is parsed with `@objectstack/spec`'s `ActionSchema`, and with a spy handler under every runnable type the typeless action reaches exactly the handler named by the parsed `type`. If the spec's default ever moves, this case goes red. - Not swallowed by the default: an unknown `type` is refused, `{}` is refused as "not executable", a handler registered under the action's name runs ahead of `script`, and a typeless named `redirect` action still redirects. The typeless update shape already reached the route through the `operation` check that runs ahead of `type` (pinned in `ActionRunner.updateOperation.test.ts`); it is pinned again here at the new resolution. ## Acceptance notes - The card's two shapes run on the script route; an unknown `type` and an empty schema are still refused (cases above). - The New skeleton in `ObjectActionsPanel.tsx` still leaves `type` off, as its comment intends; with this change that row runs. Its pinned Type control reading "—" until the first write is untouched (outside this claim's file surface). ## Verification All at head `e7e7e357`, a worktree off `origin/main` `8074feb6` (main has moved 27 commits since, none in `ui/packages/core/`; `git merge-tree` against it is clean). <details><summary>Tests, ablation and type-check</summary> - `pnpm --dir ui exec vitest run packages/core/src/actions/` under `scripts/pm/os-verify-lock.sh`: `Test Files 45 passed (45)`, `Tests 660 passed (660)`; the new file alone, verbose: 8/8. - Every ui test file that names `ActionRunner`, `ActionProvider`, `useActionRunner` or `useConsoleActionRuntime` outside `packages/core/src/actions/` (383 files: app-shell, plugin-grid, components, react, plugin-detail, plugin-view, console and others), `--maxWorkers=2` in four locked batches: 47 + 45 + 145 + 146 files passed, 630 + 452 + 1656 + 1310 tests passed, 0 failed. - **Ablation** through `scripts/ablation-replace.mjs` on the committed head: `return SPEC_DEFAULT_ACTION_TYPE;` replaced by `return name;` (anchor 1 -> 0, blob `ecfc5fa6` -> `9f29d4cd`). The new file went `3 failed | 5 passed (8)`: the typeless body action, the early-refusal case and the spec-default case. The typeless update case stays green, as expected: `operation` is read ahead of `type`. Restored: blob back to `ecfc5fa6` = HEAD, `git diff HEAD` empty. - `pnpm exec turbo run type-check --filter=@object-ui/core --concurrency=2` under the lock: `VERDICT command-exit 0`, 4/4 tasks, core's `type-check` a cache miss (it runs `tsconfig.test.json` too, so the new test file is compiled). </details> <details><summary>Derived dispatch gates (34/34)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands; every one exited 0, the PR-body gates (`check-closing-keyword-parity`, `check-adr-0087-registration`, `check-changeset-no-major`) against this body. - `pnpm --dir ui exec vitest run scripts/` under the lock: `Test Files 178 passed | 2 skipped (180)`, `Tests 5065 passed | 2 skipped (5067)`. - `pnpm check:type-check-debt` under the lock: `0 raw tsc error(s) total, none above its recorded number`. - `dispatch-gates --ran`: `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED`. - `pnpm check:new-line-citations` (from `ui/`): `0 new citation(s)`. Control-byte self-scan of the three files: no hit. </details> No dogfood battery: no `!` in the title and no `(narrowing)` arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUfE6TDKDHQvod289V)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6853b1 commit 27e4d65

3 files changed

Lines changed: 258 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/core': patch
3+
---
4+
5+
fix(core): the console runs a stored action that leaves `type` to its default as a script action
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`ActionSchema.type` defaults to `script`, but the console runs a stored action row as it was stored, not as the spec parses it. An action saved without `type`, such as the blank New skeleton in Studio or a row an older Studio build wrote, had its route resolved to its own name, matched no executor and was refused as "not executable".
11+
12+
- **The action runner now reads a missing `type` as the spec's default** at the one place it picks the dispatch route. A typeless `{ name, body }` action and a typeless `{ name, operation: 'update', patch }` action both reach the registered `script` dispatch, which posts the platform action route.
13+
- **Unchanged:** an unknown `type` and a completely empty schema are still refused; a handler registered under the action's name still runs first; a typeless action that declares a runner key of its own (`navigate`, `api`, `endpoint`, `onClick`, `redirect`, an explicit `reload` or `close`) still runs on that key.
14+
- No writer adds `type`, and no stored row changes.

‎ui/packages/core/src/actions/ActionRunner.ts‎

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,61 @@ function crossesObjectBoundary(viewName: string, contextObject: string | undefin
10751075
return !viewName.startsWith(`${contextObject}.`);
10761076
}
10771077

1078+
/**
1079+
* Does the action declare a behaviour the runner performs itself, without a
1080+
* dispatch route — a `navigate`, an `api` / `endpoint` call, an `onClick`, a
1081+
* `redirect`, or an EXPLICIT `reload` / `close`?
1082+
*
1083+
* These are runner keys, not `ActionSchema` keys: the legacy fallbacks of
1084+
* `dispatchCollected` and `executeActionSchema` run them. `reload` / `close`
1085+
* default to true downstream, so only an explicit `true` counts as declared
1086+
* intent — otherwise every empty schema would look like it asked for one.
1087+
*/
1088+
function declaresRunnerBehavior(action: ActionDef): boolean {
1089+
return !!(action.navigate || action.api || action.endpoint || action.onClick || action.redirect)
1090+
|| action.reload === true || action.close === true;
1091+
}
1092+
1093+
/**
1094+
* The route an action is dispatched on — the key `dispatchCollected` looks up
1095+
* a registered handler and a built-in executor under. This is the ONE place
1096+
* the runner picks it:
1097+
*
1098+
* 1. The authored `type`, else the `action:*` block's `actionType`.
1099+
* 2. Neither: the action's `name`, when a handler is registered under it — the
1100+
* by-name dispatch hosts wire custom actions through, and the leg an
1101+
* `action:button` that declares no `actionType` lands on — or when the
1102+
* action declares a runner behaviour of its own
1103+
* ({@link declaresRunnerBehavior}), which the legacy fallbacks run.
1104+
* 3. Otherwise a named action is a declaration that left `type` to its
1105+
* default: `@objectstack/spec`'s `ActionSchema.type` is
1106+
* `.default('script')`. A stored row reaches the console UNPARSED
1107+
* (objectstack-ai/objectstack#3903), so nothing has materialized that
1108+
* default; it is applied here, where the route is read, and ⛔ never by
1109+
* writing `type` into each producer. Without it the action fell back to its
1110+
* NAME, matched no executor, and was refused as "not executable" — a
1111+
* spec-valid script action, dead in the console.
1112+
* 4. Nothing named: `''`, which `executeActionSchema` refuses (objectui#2960).
1113+
*
1114+
* An unknown `type` is step 1's answer and stays unknown: it is never read as
1115+
* the default, so it is refused as before.
1116+
*/
1117+
function resolveDispatchType(action: ActionDef, hasHandler: (name: string) => boolean): string {
1118+
const declared = action.type || action.actionType;
1119+
if (declared) return declared;
1120+
const name = action.name || '';
1121+
if (!name || hasHandler(name) || declaresRunnerBehavior(action)) return name;
1122+
return SPEC_DEFAULT_ACTION_TYPE;
1123+
}
1124+
1125+
/**
1126+
* `ActionSchema.type`'s default in `@objectstack/spec` — the platform action
1127+
* route. Read by {@link resolveDispatchType} alone;
1128+
* `ActionRunner.specDefaultType-26879.test.ts` holds it equal to the type the
1129+
* spec materializes on a typeless action.
1130+
*/
1131+
const SPEC_DEFAULT_ACTION_TYPE: RunnableActionType = 'script';
1132+
10781133
/**
10791134
* Is this the declarative single-record field write?
10801135
*
@@ -1452,8 +1507,9 @@ export class ActionRunner {
14521507
// two apart. Dev-only, warn-once, changes nothing.
14531508
warnOnDeprecatedObjectParams(action);
14541509

1455-
// Resolve the action type
1456-
const actionType = action.type || action.actionType || action.name || '';
1510+
// Resolve the dispatch route — a typeless declaration takes the spec's
1511+
// default `type` (see `resolveDispatchType`).
1512+
const actionType = resolveDispatchType(action, (name) => this.handlers.has(name));
14571513

14581514
// Conditional execution. Ask "is a `condition` gate DECLARED?" — not "is
14591515
// the raw value truthy?" (objectui#3872). Truthiness cannot answer that
@@ -2560,13 +2616,9 @@ export class ActionRunner {
25602616
*/
25612617
private async executeActionSchema(action: ActionDef): Promise<ActionResult> {
25622618
const hasApi = !!(action.api || action.endpoint);
2563-
// `reload`/`close` default to true downstream, so only an EXPLICIT `true`
2564-
// counts as declared intent — otherwise every empty schema would look like
2565-
// it asked for a reload and slip back through this guard.
2566-
const declaresBehavior =
2567-
hasApi || !!action.onClick || !!action.redirect
2568-
|| action.reload === true || action.close === true;
2569-
if (!declaresBehavior) {
2619+
// Only an EXPLICIT `reload`/`close` counts (see `declaresRunnerBehavior`),
2620+
// or every empty schema would slip back through this guard.
2621+
if (!declaresRunnerBehavior(action)) {
25702622
const label = action.name || action.type || action.actionType || 'action';
25712623
return {
25722624
success: false,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* A stored action without `type` runs on the spec's default route
6+
* (objectstack-ai/objectstack#26879).
7+
*
8+
* `@objectstack/spec`'s `ActionSchema.type` is `.default('script')`, but the
9+
* console runs the stored row, not its parse: a row rehydrated UNPARSED
10+
* carries no `type` at all. The runner used to resolve such an action's route
11+
* to its NAME, match no executor, and refuse it as "not executable" — so a
12+
* spec-valid script action that Studio's New skeleton or an older Studio build
13+
* stored without `type` was dead in the console. The runner now reads the
14+
* missing `type` as the spec's default, at the one place it picks the route
15+
* (`resolveDispatchType`).
16+
*
17+
* Pinned here:
18+
* 1. A typeless `{ name, body }` and a typeless `{ name, operation: 'update',
19+
* patch }` both reach the registered `script` dispatch — the REAL
20+
* `createServerActionHandler`, so the URL that leaves the client is what is
21+
* asserted.
22+
* 2. The default is the SPEC's: a typeless action takes the route named by
23+
* the `type` that `ActionSchema` materializes on it.
24+
* 3. What the default must not swallow: an unknown `type` and a completely
25+
* empty schema are still refused, a handler registered under the action's
26+
* name still outranks the default, and a runner-own key (`redirect`) still
27+
* runs on its legacy leg.
28+
*/
29+
30+
import { describe, it, expect, vi } from 'vitest';
31+
import { ActionSchema } from '@objectstack/spec/ui';
32+
import { ActionRunner, type ActionDef, type ActionRunnerHandler } from '../ActionRunner';
33+
import { createServerActionHandler, type ServerActionFetch } from '../serverActionHandler';
34+
35+
/** A fetch stub answering the route's success envelope. */
36+
function okFetch() {
37+
return vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: { ok: true } }) }));
38+
}
39+
40+
/** A runner wired as a console host wires one: the server-action dispatch under `script`. */
41+
function wireRunner(fetch: ReturnType<typeof okFetch>) {
42+
const runner = new ActionRunner({});
43+
runner.registerHandler(
44+
'script',
45+
createServerActionHandler({ fetch: fetch as unknown as ServerActionFetch }),
46+
);
47+
return runner;
48+
}
49+
50+
/** A stored script action as New's skeleton or an older Studio build left it: no `type`. */
51+
const TYPELESS_BODY_ACTION: ActionDef = {
52+
name: 'run_script',
53+
label: 'Run script',
54+
objectName: 'tasks',
55+
body: { language: 'expression', source: 'true' },
56+
};
57+
58+
/** A stored declarative update with no `type`. */
59+
const TYPELESS_UPDATE_ACTION: ActionDef = {
60+
name: 'mark_done',
61+
label: 'Mark Done',
62+
objectName: 'tasks',
63+
operation: 'update',
64+
patch: { status: 'done' },
65+
};
66+
67+
describe('a stored action without `type` runs on the script route', () => {
68+
it('runs a typeless `{ name, body }` action on the platform action route', async () => {
69+
const fetch = okFetch();
70+
const runner = wireRunner(fetch);
71+
72+
const result = await runner.execute({ ...TYPELESS_BODY_ACTION, params: { recordId: 'rec_1' } as never });
73+
74+
expect(result.success).toBe(true);
75+
expect(fetch).toHaveBeenCalledTimes(1);
76+
const [url, init] = (fetch as any).mock.calls[0];
77+
expect(url).toBe('/api/v1/actions/tasks/run_script');
78+
expect(init.method).toBe('POST');
79+
});
80+
81+
it("runs a typeless `{ name, operation: 'update', patch }` action on the platform action route", async () => {
82+
const fetch = okFetch();
83+
const runner = wireRunner(fetch);
84+
85+
const result = await runner.execute({ ...TYPELESS_UPDATE_ACTION, params: { recordId: 'rec_2' } as never });
86+
87+
expect(result.success).toBe(true);
88+
expect(fetch).toHaveBeenCalledTimes(1);
89+
expect((fetch as any).mock.calls[0][0]).toBe('/api/v1/actions/tasks/mark_done');
90+
});
91+
92+
it("asks the script dispatch's early refusal for a typeless action, as for a typed one", async () => {
93+
// The route picked once is the route asked before any input
94+
// (`refusalBeforeInput`) and the route dispatched on.
95+
const dispatch = vi.fn(async () => ({ success: true })) as unknown as ActionRunnerHandler;
96+
const refuseBeforeInput = vi.fn(() => ({ error: 'Select one record' }));
97+
dispatch.refuseBeforeInput = refuseBeforeInput;
98+
const runner = new ActionRunner({});
99+
runner.registerHandler('script', dispatch);
100+
101+
const result = await runner.execute({ ...TYPELESS_BODY_ACTION });
102+
103+
expect(result.success).toBe(false);
104+
expect(result.error).toBe('Select one record');
105+
expect(refuseBeforeInput).toHaveBeenCalledTimes(1);
106+
expect(dispatch).not.toHaveBeenCalled();
107+
});
108+
});
109+
110+
describe("the default is the spec's", () => {
111+
it('takes the route named by the `type` ActionSchema materializes on a typeless action', async () => {
112+
const parsed = ActionSchema.safeParse(TYPELESS_BODY_ACTION);
113+
expect(parsed.error).toBeUndefined();
114+
const specType = parsed.success ? parsed.data.type : undefined;
115+
expect(typeof specType).toBe('string');
116+
117+
const runner = new ActionRunner({});
118+
const reached: string[] = [];
119+
for (const type of ['script', 'url', 'modal', 'flow', 'api', 'form', 'navigation']) {
120+
runner.registerHandler(type, async () => {
121+
reached.push(type);
122+
return { success: true };
123+
});
124+
}
125+
126+
const result = await runner.execute({ ...TYPELESS_BODY_ACTION });
127+
128+
expect(result.success).toBe(true);
129+
expect(reached).toEqual([specType]);
130+
});
131+
});
132+
133+
describe('what the default does not swallow', () => {
134+
it('still refuses an action whose `type` names no route', async () => {
135+
const script = vi.fn(async () => ({ success: true }));
136+
const runner = new ActionRunner({});
137+
runner.registerHandler('script', script);
138+
139+
const result = await runner.execute({ type: 'convert_lead', name: 'convert_lead' });
140+
141+
expect(result.success).toBe(false);
142+
expect(result.error).toContain('convert_lead');
143+
expect(script).not.toHaveBeenCalled();
144+
});
145+
146+
it('still refuses a completely empty schema', async () => {
147+
const script = vi.fn(async () => ({ success: true }));
148+
const runner = new ActionRunner({});
149+
runner.registerHandler('script', script);
150+
151+
const result = await runner.execute({});
152+
153+
expect(result.success).toBe(false);
154+
expect(result.error).toContain('not executable');
155+
expect(script).not.toHaveBeenCalled();
156+
});
157+
158+
it('runs a handler registered under the action name ahead of the default', async () => {
159+
const script = vi.fn(async () => ({ success: true }));
160+
const byName = vi.fn(async () => ({ success: true }));
161+
const runner = new ActionRunner({});
162+
runner.registerHandler('script', script);
163+
runner.registerHandler('run_script', byName);
164+
165+
const result = await runner.execute({ ...TYPELESS_BODY_ACTION });
166+
167+
expect(result.success).toBe(true);
168+
expect(byName).toHaveBeenCalledTimes(1);
169+
expect(script).not.toHaveBeenCalled();
170+
});
171+
172+
it('runs a typeless named action that declares a `redirect` on its legacy leg', async () => {
173+
const script = vi.fn(async () => ({ success: true }));
174+
const runner = new ActionRunner({});
175+
runner.registerHandler('script', script);
176+
177+
const result = await runner.execute({ name: 'go_home', redirect: '/home' });
178+
179+
expect(result.success).toBe(true);
180+
expect(result.redirect).toBe('/home');
181+
expect(script).not.toHaveBeenCalled();
182+
});
183+
});

0 commit comments

Comments
 (0)