Skip to content

Commit 3ba8d77

Browse files
authored
fix(actions): dispatch on the declared action type over REST (#3915) (#3919)
The REST `/actions/:object/:action` route had no action-type branching — every action went to the script-handler registry, while the MCP `run_action` bridge implemented the `flow` branch. A spec-faithful caller invoking a `type: 'flow'` action got `Action '' on object '*' not found`. Both headless surfaces now share one dispatch: `flow` runs on the automation service (via the extracted `dispatchFlowAction`, with the caller's identity forwarded so `runAs: 'user'` enforces RLS as the invoker), `script` keeps the registry path, and `url`/`modal`/`form`/`api` return a 400 naming the type and what to call instead. The route also resolves standalone `defineAction` artifacts and Studio-authored `action` rows, which additionally closes a declared-but-unenforced ADR-0066 D4 gate on that path.
1 parent 48724d6 commit 3ba8d77

7 files changed

Lines changed: 652 additions & 42 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(actions): dispatch on the declared action `type` over REST — flow actions are no longer MCP-only (#3915)
6+
7+
`POST /api/v1/actions/:object/:action` had **no action-type branching at all**.
8+
Whatever an action declared, the route went straight to `ql.executeAction` — the
9+
script-handler registry — while the MCP `run_action` bridge had implemented the
10+
`flow` branch since #2849. The spec is unambiguous that every non-`script` type
11+
dispatches on `target` (`packages/spec/src/ui/action.zod.ts`), so a REST/SDK
12+
caller who followed it and invoked a `type: 'flow'` action got
13+
14+
```
15+
Action '' on object '*' not found
16+
```
17+
18+
and had to know, out of band, to call `POST /api/v1/automation/:target/trigger`
19+
itself. Worse for the Studio-authored case: `resyncAuthoredActions` deliberately
20+
registers **no** handler for a flow-typed action ("no body (target/flow/url
21+
action)"), so there was never anything for the registry to find.
22+
23+
The two headless surfaces now share one dispatch:
24+
25+
- **`flow`**`automation.execute(action.target, …)` via the new
26+
`dispatchFlowAction`, which the MCP path now calls too. The caller's identity
27+
(`userId` / `positions` / `permissions` / `tenantId`) is forwarded, so a
28+
`runAs: 'user'` flow enforces RLS as the invoker instead of falling into the
29+
user-less UNSCOPED path (ADR-0049). A flow action on a kernel with no
30+
automation service reports **503**, not a `{ success: false }` body.
31+
- **`script`** → the handler registry, unchanged. An action with no resolvable
32+
declaration is handler-only by definition and keeps that path.
33+
- **`url` / `modal` / `form` / `api`****400** naming the type and the
34+
prescription (for `api`, the `target` endpoint to call directly) instead of a
35+
registry miss that reads like the action does not exist.
36+
37+
The route also resolves **standalone declarations** now — `defineAction`
38+
artifacts in the ObjectQL registry and Studio-authored `action` metadata rows,
39+
neither of which appears inside any object's `actions[]`. They were invisible
40+
to this route before, which is why a flow-typed one could not be dispatched —
41+
and, separately, why its `requiredPermissions` were declared-but-unenforced on
42+
REST while MCP honoured them. The ADR-0066 D4 gate still runs **before** the
43+
type check, so an unauthorized caller learns nothing about how an action
44+
dispatches.
45+
46+
**Migration:** a caller invoking a `url`/`modal`/`form`/`api` action through
47+
this endpoint used to receive `{ success: false, error: "Action '' on object
48+
'*' not found" }` (HTTP 200) and now receives a 400 that says what to call
49+
instead. No spec-faithful action changes behavior.

content/docs/ui/actions.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ curl -b cookies.txt -X POST \
233233
Global (object-less) actions post to `/api/v1/actions/global/:action`. For
234234
credentials, see [API Authentication](/docs/api#authentication).
235235

236+
The endpoint dispatches on the **declared `type`**, exactly like the MCP
237+
`run_action` tool — so the same URL invokes a script handler or a flow:
238+
239+
| `type` | Over REST |
240+
|:---|:---|
241+
| `script` | Runs the registered handler / inline body. |
242+
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Equivalent to `POST /api/v1/automation/:target/trigger`, without having to know the flow name. |
243+
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
244+
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |
245+
236246
## Expose it to AI (MCP)
237247

238248
Actions are **not** AI-visible by default. Opting in takes two fields — and
@@ -256,6 +266,7 @@ and [Connect an MCP Client](/docs/ai/connect-mcp).
256266
|:---|:---|
257267
| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (e.g. a bare, unprefixed field name) and fail-closed hides it; or the user fails `requiredPermissions` |
258268
| Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) |
269+
| REST → 400 naming the action's `type` | The type has no server dispatch (`url`/`modal`/`form`/`api`) — open its `target` in the client, or call that endpoint directly |
259270
| Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable |
260271
| Runs but the list looks stale | Add `refreshAfter: true` |
261272

packages/runtime/src/action-execution.ts

Lines changed: 176 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,114 @@ export function isHeadlessInvokableAction(deps: ActionExecutionDeps, action: any
260260
return false;
261261
}
262262

263+
/**
264+
* The action types a headless caller can actually invoke through a server
265+
* dispatch — the two {@link isHeadlessInvokableAction} accepts. Kept next to
266+
* it so the predicate and the explanation below can never drift apart.
267+
*/
268+
const SERVER_DISPATCHED_ACTION_TYPES: ReadonlySet<string> = new Set(['script', 'flow']);
269+
270+
/**
271+
* Explain why a declared action has NO server-side dispatch — `null` when it
272+
* does have one (`script` / `flow`).
273+
*
274+
* The spec is explicit (`packages/spec/src/ui/action.zod.ts`): every
275+
* non-`script` type dispatches on `target`, and only `flow` has a server-side
276+
* runner (the automation engine). `url` / `modal` / `form` are renderer
277+
* navigation and `api` names a *different* endpoint the client calls itself —
278+
* none of them is the script-handler registry. Pre-#3915 the REST route had
279+
* no type branching at all, so every one of them fell through to
280+
* `executeAction` and came back as the misleading
281+
* `Action '' on object '*' not found`. Naming the type and the prescription
282+
* turns that dead end into an actionable 400.
283+
*/
284+
export function headlessActionTypeError(deps: ActionExecutionDeps, action: any, objectName?: string): string | null {
285+
const type: string = action?.type ?? 'script';
286+
if (SERVER_DISPATCHED_ACTION_TYPES.has(type)) return null;
287+
const name: string = action?.name ?? 'unknown';
288+
const on = objectName ? ` on '${objectName}'` : '';
289+
const target: string | undefined = typeof action?.target === 'string' ? action.target : undefined;
290+
if (type === 'api') {
291+
return (
292+
`Action '${name}'${on} is \`type: 'api'\` — it dispatches on \`target\`, ` +
293+
`not through the action registry. Call ${target ? `\`${target}\`` : 'its `target` endpoint'} directly` +
294+
`${typeof action?.method === 'string' ? ` (${action.method})` : ''}.`
295+
);
296+
}
297+
return (
298+
`Action '${name}'${on} is \`type: '${type}'\` — a client-side action with no server dispatch. ` +
299+
`The renderer opens its \`target\`${target ? ` ('${target}')` : ''}; there is nothing for the server to run.`
300+
);
301+
}
302+
303+
/**
304+
* The automation service when this kernel has a usable one, else `null` —
305+
* the single availability probe behind `type: 'flow'` dispatch (both the
306+
* headless-invokability filter and the two invoke paths ask through it).
307+
*/
308+
export async function resolveAutomationService(deps: ActionExecutionDeps, envId?: string): Promise<any | null> {
309+
try {
310+
const svc: any = await deps.resolveService('automation', envId);
311+
return svc && typeof svc.execute === 'function' ? svc : null;
312+
} catch {
313+
return null; // no automation service on this kernel
314+
}
315+
}
316+
317+
/** Message for a flow action on a kernel with no automation service (a 503-shaped condition). */
318+
export function flowActionUnavailableError(action: any): string {
319+
return `Action '${action?.name ?? 'unknown'}' is a flow but no automation service is available`;
320+
}
321+
322+
/**
323+
* Dispatch a `type: 'flow'` action through the automation service.
324+
*
325+
* The ONE implementation both headless surfaces share — the MCP `run_action`
326+
* tool and the REST `/actions/:object/:action` route (#3915, which is exactly
327+
* the asymmetry that let this branch exist on only one of them). Throws on a
328+
* missing automation service and converts a `{ success: false }` engine result
329+
* into a throw so both callers report failure the same way; returns the raw
330+
* automation result otherwise.
331+
*
332+
* Forwarding the caller's identity (rather than just executing the flow) is
333+
* what lets a `runAs: 'user'` flow enforce RLS as the invoker instead of
334+
* falling into the user-less UNSCOPED path (#2849, ADR-0049 / #1888; mirrors
335+
* the record-change trigger's context shape).
336+
*/
337+
export async function dispatchFlowAction(deps: ActionExecutionDeps,
338+
action: any,
339+
wiring: {
340+
objectName: string;
341+
record: Record<string, unknown>;
342+
params: Record<string, unknown>;
343+
ec: any;
344+
envId?: string;
345+
},
346+
): Promise<any> {
347+
const { objectName, record, params, ec, envId } = wiring;
348+
const automation = await resolveAutomationService(deps, envId);
349+
if (!automation) {
350+
throw new Error(flowActionUnavailableError(action));
351+
}
352+
// Pass a proper AutomationContext (the engine never read the former
353+
// `triggerData` envelope).
354+
const result: any = await automation.execute(action.target, {
355+
record,
356+
...(objectName !== 'global' ? { object: objectName } : {}),
357+
userId: ec?.userId,
358+
...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}),
359+
...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}),
360+
...(ec?.tenantId ? { tenantId: ec.tenantId } : {}),
361+
// Record fields seed flows' named `isInput` variables (like the
362+
// record-change trigger); explicit action params win on clash.
363+
params: { ...record, ...params },
364+
});
365+
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
366+
throw new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
367+
}
368+
return result ?? null;
369+
}
370+
263371
export function actionLooksDestructive(deps: ActionExecutionDeps, action: any): boolean {
264372
if (action?.ai?.requiresConfirmation !== undefined) return Boolean(action.ai.requiresConfirmation);
265373
return Boolean(action?.confirmText || action?.mode === 'delete' || action?.variant === 'danger');
@@ -486,7 +594,7 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
486594
if (isSystemObjectName(objectName)) {
487595
throw new Error(`Action '${name}' is on a system object and is not exposed via MCP`);
488596
}
489-
const hasAutomation = Boolean(await deps.resolveService('automation', envId).catch(() => null));
597+
const hasAutomation = Boolean(await resolveAutomationService(deps, envId));
490598
if (!isHeadlessInvokableAction(deps, action, hasAutomation)) {
491599
throw new Error(
492600
`Action '${name}' (type='${action?.type ?? 'script'}') cannot be invoked via MCP`,
@@ -535,32 +643,10 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
535643
}
536644
: { id: 'system', name: 'system' };
537645

538-
// ── flow dispatch ──
646+
// ── flow dispatch ── (shared with the REST /actions route, #3915)
539647
if (action.type === 'flow') {
540-
const automation: any = await deps.resolveService('automation', envId).catch(() => null);
541-
if (!automation || typeof automation.execute !== 'function') {
542-
throw new Error(`Action '${name}' is a flow but no automation service is available`);
543-
}
544-
// Pass a proper AutomationContext (the engine never read the former
545-
// `triggerData` envelope). Forwarding the caller's identity is what
546-
// lets a `runAs:'user'` flow enforce RLS as the invoker instead of
547-
// falling into the user-less UNSCOPED path (#2849, ADR-0049 / #1888;
548-
// mirrors the record-change trigger's context shape).
549-
const result: any = await automation.execute(action.target, {
550-
record,
551-
...(objectName !== 'global' ? { object: objectName } : {}),
552-
userId: ec?.userId,
553-
...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}),
554-
...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}),
555-
...(ec?.tenantId ? { tenantId: ec.tenantId } : {}),
556-
// Record fields seed flows' named `isInput` variables (like the
557-
// record-change trigger); explicit action params win on clash.
558-
params: { ...record, ...params },
559-
});
560-
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
561-
throw new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
562-
}
563-
return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: result ?? null };
648+
const result = await dispatchFlowAction(deps, action, { objectName, record, params, ec, envId });
649+
return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result };
564650
}
565651

566652
// ── script/body dispatch via the engine's executeAction ──
@@ -696,3 +782,67 @@ export function standaloneActionObjectName(deps: ActionExecutionDeps, action: an
696782
if (typeof action?.object === 'string' && action.object.length > 0) return action.object;
697783
return 'global';
698784
}
785+
786+
/**
787+
* Resolve the DECLARATION behind a `<object>/<action>` route pair — the
788+
* single source the REST `/actions` route reads for the ADR-0066 D4
789+
* permission gate, the ADR-0104 param contract, and (since #3915) the action
790+
* TYPE it must dispatch on.
791+
*
792+
* Three sources, in the same precedence the execution layer uses:
793+
* 1. the object's own `actions[]` (bundle/artifact objects + authored object
794+
* rows) — object-embedded wins, mirroring `collectActionDeclarations`;
795+
* 2. the ObjectQL registry's standalone `action` items — `defineAction`
796+
* artifacts and rehydrated authored rows, which never appear inside any
797+
* object definition;
798+
* 3. the metadata service's standalone `action` rows — the env-scoped
799+
* kernels where the registry carries no copy.
800+
*
801+
* Sources 2 and 3 are what the route was missing: a Studio-authored or
802+
* standalone `defineAction` declaration was invisible here, so its declared
803+
* `type` (and its `requiredPermissions`) were never read on the REST path
804+
* even though the MCP path honoured both. A standalone declaration is
805+
* accepted only when it belongs to the routed object or to the `'global'`
806+
* wildcard — the same `<object>:<name>` / `'*'` key rotation the handler
807+
* lookup performs.
808+
*/
809+
export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps,
810+
args: { ql: any; objectName: string; actionName: string; envId?: string },
811+
): Promise<{ action: any; obj: any } | undefined> {
812+
const { ql, objectName, actionName, envId } = args;
813+
814+
let obj: any;
815+
try {
816+
obj =
817+
(typeof ql?.getSchema === 'function' ? ql.getSchema(objectName) : undefined) ??
818+
ql?.registry?.getObject?.(objectName);
819+
} catch {
820+
obj = undefined; // schema unresolved → handler-only action
821+
}
822+
const embedded = Array.isArray(obj?.actions)
823+
? obj.actions.find((a: any) => a?.name === actionName)
824+
: undefined;
825+
if (embedded) return { action: embedded, obj };
826+
827+
const ownsRoute = (action: any): boolean => {
828+
const owner = standaloneActionObjectName(deps, action);
829+
return owner === objectName || owner === 'global';
830+
};
831+
832+
try {
833+
const fromRegistry: any = ql?.registry?.getItem?.('action', actionName);
834+
if (fromRegistry && ownsRoute(fromRegistry)) return { action: fromRegistry, obj };
835+
} catch {
836+
/* registry without an item lookup → fall through to the metadata service */
837+
}
838+
839+
try {
840+
const meta: any = await deps.resolveService('metadata', envId);
841+
const fromMeta: any = await meta?.load?.('action', actionName);
842+
if (fromMeta && ownsRoute(fromMeta)) return { action: fromMeta, obj };
843+
} catch {
844+
/* no metadata service on this kernel → no declaration to resolve */
845+
}
846+
847+
return obj ? { action: undefined, obj } : undefined;
848+
}

0 commit comments

Comments
 (0)