Skip to content

Commit ca2e020

Browse files
os-zhuangclaude
andauthored
fix(runtime): /actions answers the #9378 flow-dispatch status table, from one shared definition (#9584)
* fix(runtime): /actions answers the #9378 flow-dispatch status table (#9446) `dispatchFlowAction` mapped every `success: false` automation result to `400 FLOW_FAILED` under a comment asserting "The flow RAN and rejected" — false for two of the four exits it caught. A disabled flow invoked through an action told the caller a run had failed when no node ever executed, and the producer's own `result.code` was available and ignored. The table now lives in one module, `flow-dispatch-status.ts`, read by both the `/actions` door and the trigger door: 404 (flow not found) / 409 FLOW_DISABLED / 422 FLOW_NO_START_NODE / 400 FLOW_FAILED. Maintainer ruling 2026-08-18, verbatim 「同意」: the table is a property of the flow-dispatch contract, not of the trigger route. Co-Authored-By: Claude <noreply@anthropic.com> * docs(actions): the action door answers the flow-dispatch table, and add the changeset (#9446) `ui/actions.mdx` documented the divergence this change closes; it now states the converged table. `automation/flows.mdx` gains the cross-door note, and `http-protocol.mdx`'s declared-endpoint row points at #9462 — the door that is still unconverged — instead of at this card, which would read as "fixed" once this closes. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 793567f commit ca2e020

8 files changed

Lines changed: 618 additions & 33 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
`POST /api/v1/actions/:object/:action` answers the flow-dispatch status table instead of one blanket `400 FLOW_FAILED` (#9446).
6+
7+
**What a caller sees differently.** A `type: 'flow'` action whose dispatch is REFUSED no longer reports a failed run. Three answers changed:
8+
9+
| the flow behind the action | before | now |
10+
|---|---|---|
11+
| is not registered | `400` `FLOW_FAILED` | `404` `RESOURCE_NOT_FOUND` |
12+
| is switched off | `400` `FLOW_FAILED` | `409` `FLOW_DISABLED` |
13+
| has no `start` node | `400` `FLOW_FAILED` | `422` `FLOW_NO_START_NODE` |
14+
| ran and was rejected | `400` `FLOW_FAILED` | `400` `FLOW_FAILED` (unchanged) |
15+
16+
These are the same four rows `POST /api/v1/automation/:name/trigger` has answered since #9378 + #9415, and they now come from one shared definition both doors read, so the two cannot drift apart again.
17+
18+
**Behaviourally breaking for a caller that branches on the status or the code.** Every one of these was a `400` before, so a caller treating `400` as "the run failed" was being told something false in three of the four cases: nothing had dispatched and no node had executed. A client that lumps all four together keeps working — they are all still refusals, all still `success: false` with no inner envelope — but one that reports "the flow failed" on a `400` should now distinguish. **Retry semantics differ per row**, which is the practical reason to: `409 FLOW_DISABLED` is reversible operational state (enable the flow and the identical request succeeds), while `404` and `422 FLOW_NO_START_NODE` are authoring defects that no retry fixes. `400 FLOW_FAILED` remains terminal, exactly as the console already treats it.
19+
20+
**Unchanged on purpose.** A successful run still answers `200` with the single `data` wrap (#3962). The `400 FLOW_FAILED` message keeps its existing wording (`Flow '<target>' failed: …`), which names the flow the action dispatches — the trigger route's URL carries that name and this route's does not. A `success: false` result the automation engine did not classify still refuses with `400 FLOW_FAILED` rather than falling back to `200 {success:true,data:{success:false}}` — the double envelope #3962 removed from this route.
21+
22+
**Not in scope.** Declared endpoints (`type: 'flow'` endpoints, `endpoint-executor.ts`) still answer `200` for every outcome. That door converges in its own change (#9462), where the envelope flip is a breaking change for consumers of the current double envelope and is sequenced against them.

content/docs/automation/flows.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,14 @@ to do it — never the message text:
13101310
The first three never dispatched anything: no node executed, no record was
13111311
written, and there is no run to look up. Only `400` describes a run.
13121312

1313+
**The same table answers at the action door.** Invoking a `type: 'flow'`
1314+
[action](/docs/ui/actions) through `POST /api/v1/actions/:object/:action`
1315+
dispatches the same flow through the same service call, and it classifies the
1316+
outcome the same way — the table above has one definition that both doors read,
1317+
so they cannot answer one engine outcome differently. Declared endpoints
1318+
(`type: 'flow'`) are the remaining exception: they still answer `200` for every
1319+
outcome, so read `data.success` there rather than the status.
1320+
13131321
```
13141322
POST /api/v1/automation/order_approval/trigger
13151323

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1216,7 +1216,7 @@ declaration to shadow a built-in route:
12161216
| Endpoint declares | Answer |
12171217
|:---|:---|
12181218
| `type: 'object_operation'` | delegated to the same `callData` binding that serves `/api/v1/data/{object}` — byte-identical `data` |
1219-
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder and the same `execute` call, so the run itself is identical. **The response is not**: the trigger route classifies a refused or failed run into real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), while this seam still answers `200` with the result in `data` for every outcome ([#9446](https://github.com/objectstack-ai/objectstack/issues/9446)). Read `data.success` here, not the status |
1219+
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder and the same `execute` call, so the run itself is identical. **The response is not**: the trigger route classifies a refused or failed run into real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), and `POST /api/v1/actions/{object}/{action}` answers that same table since #9446, while this seam still answers `200` with the result in `data` for every outcome ([#9462](https://github.com/objectstack-ai/objectstack/issues/9462)). Read `data.success` here, not the status |
12201220
| `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers |
12211221
| `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive |
12221222
| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30``private` is a security rule, not tuning: any response can be RLS-trimmed |

content/docs/ui/actions.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
343343
| `type` | Over REST |
344344
|:---|:---|
345345
| `script` | Runs the registered handler / inline body. |
346-
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name. ⚠️ It does **not** answer the same way: any unsuccessful outcome comes back as **400** `FLOW_FAILED`, where the trigger route separates a run that failed (400) from one that was never dispatched (404 / 409 / 422) — see [#9446](https://github.com/objectstack-ai/objectstack/issues/9446). |
346+
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name**and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE`). See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. |
347347
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
348348
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |
349349

packages/runtime/src/action-execution.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr
1919
import type { ExecutionContext } from '@objectstack/spec/kernel';
2020
import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
2121
import { checkApiExposure } from './api-exposure.js';
22+
// [#9446] The ONE #9378 status table. Imported rather than re-read here: this
23+
// door's blanket `FLOW_FAILED` was the second of three readings of one engine
24+
// result, and a second definition of the rule is what let the doors diverge.
25+
import {
26+
classifyFlowRefusal,
27+
flowIsUnknown,
28+
flowNotFoundMessage,
29+
FLOW_NOT_FOUND_STATUS,
30+
} from './flow-dispatch-status.js';
2231
// [#5138] The ONE 404 envelope a single-record path answers. Imported rather
2332
// than re-spelled so `callData`'s ObjectQL fallback and the protocol service it
2433
// falls back FROM cannot disagree about what "this id names no row" looks like.
@@ -561,10 +570,33 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,
561570
* The ONE implementation both headless surfaces share — the MCP `run_action`
562571
* tool and the REST `/actions/:object/:action` route (#3915, which is exactly
563572
* the asymmetry that let this branch exist on only one of them). Throws on a
564-
* missing automation service and converts a `{ success: false }` engine result
565-
* into a throw so both callers report failure the same way; returns the raw
573+
* missing automation service and converts a refused or failed dispatch into a
574+
* throw so both callers report failure the same way; returns the raw
566575
* automation result otherwise.
567576
*
577+
* [#9446] **The refusal it throws is the #9378 table, read from the ONE
578+
* definition** (`./flow-dispatch-status.js`) that the trigger door reads too:
579+
*
580+
* | engine exit | this door answers |
581+
* |------------------------|----------------------------|
582+
* | flow not found | `404` |
583+
* | flow disabled | `409` `FLOW_DISABLED` |
584+
* | flow has no start node | `422` `FLOW_NO_START_NODE` |
585+
* | ran and failed | `400` `FLOW_FAILED` |
586+
*
587+
* Maintainer ruling (2026-08-18, verbatim 「同意」): the table is a property of
588+
* the flow-dispatch CONTRACT, not of the trigger route, so this door converges
589+
* on it rather than keeping its own reading. It used to map EVERY
590+
* `success: false` to `400 FLOW_FAILED` under a comment asserting "The flow
591+
* RAN and rejected" — a false statement for the two never-dispatched exits it
592+
* caught, told to a caller whose only machine-readable signal is that code.
593+
*
594+
* The throw carries `status` and `code` and the route serves them through
595+
* `errorFromThrown`; `error.details` is whatever `resolveThrownHttpError`
596+
* reads off a thrown value, so the trigger door's `errorMessage` / `summary`
597+
* details do NOT ride this door — see the shared module's note on what the
598+
* table deliberately does not answer.
599+
*
568600
* Forwarding the caller's identity (rather than just executing the flow) is
569601
* what lets a `runAs: 'user'` flow enforce RLS as the invoker instead of
570602
* falling into the user-less UNSCOPED path (#2849, ADR-0049 / #1888; mirrors
@@ -593,6 +625,16 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
593625
if (!automation) {
594626
throw new Error(flowActionUnavailableError(action));
595627
}
628+
// [#9446] Row 1 of the table, answered by the SAME optional `getFlow`
629+
// registry probe the trigger door uses — the engine's not-found exit
630+
// carries no classification, so this is the only way to read it that is not
631+
// a regex over its message. A service that omits `getFlow` cannot be asked
632+
// and dispatches as before.
633+
if (await flowIsUnknown(automation, action.target)) {
634+
const err: any = new Error(flowNotFoundMessage(action.target));
635+
err.status = FLOW_NOT_FOUND_STATUS;
636+
throw err;
637+
}
596638
// Pass a proper AutomationContext (the engine never read the former
597639
// `triggerData` envelope).
598640
const result: any = await automation.execute(action.target, {
@@ -604,11 +646,40 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
604646
...(ec?.tenantId ? { tenantId: ec.tenantId } : {}),
605647
params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }),
606648
});
649+
// [#9446] Rows 2-4, read off the PRODUCER's classification through the one
650+
// shared table. What stood here mapped every `success: false` to
651+
// `400 FLOW_FAILED` under a comment claiming "the flow RAN and rejected" —
652+
// false for two of the exits it caught, and the producer's own `code` was
653+
// available and ignored. A disabled flow invoked through an action told the
654+
// caller a run had failed when no node ever executed.
655+
const refusal = classifyFlowRefusal(action.target, result);
656+
if (refusal) {
657+
const err: any = new Error(
658+
// The ran-and-failed row keeps THIS door's wording, byte for byte:
659+
// it has been on the wire since #3962, the ruling is about status
660+
// and code, and re-labelling a message nobody asked about would be
661+
// an unruled change riding along. It also names the flow, which
662+
// this door needs and the trigger door does not — the flow name is
663+
// in that route's URL and is nowhere in this one. The two
664+
// never-dispatched rows are NEW here, so they take the shared
665+
// table's message: the producer's own words, exactly as the
666+
// trigger door serves them.
667+
refusal.code === 'FLOW_FAILED'
668+
? `Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`
669+
: refusal.message,
670+
);
671+
err.status = refusal.status;
672+
err.code = refusal.code;
673+
throw err;
674+
}
675+
// An UNCLASSIFIED `success: false` still refuses, and `FLOW_FAILED` stays
676+
// its answer — deliberately NOT the trigger door's 200. This route settled
677+
// in #3962 that failures speak HTTP, so the alternative residual here is
678+
// the `200 {success:true,data:{success:false}}` double envelope that
679+
// ruling removed. `FLOW_FAILED` is what this exit has answered all along;
680+
// narrowing which refusals reach it is this card's change, re-labelling
681+
// the residual is not.
607682
if (result && typeof result === 'object' && 'success' in result && result.success === false) {
608-
// The flow RAN and rejected — a deliberate business rejection, served
609-
// as a 400 (#3962). Tagging the status/code here (rather than relying
610-
// on the route's name heuristic) keeps the semantic `FLOW_FAILED` on
611-
// the wire for callers that branch on `err.code`.
612683
const err: any = new Error(`Flow '${action.target}' failed: ${result.error ?? 'unknown error'}`);
613684
err.status = 400;
614685
err.code = 'FLOW_FAILED';

0 commit comments

Comments
 (0)