You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(automation): answer real HTTP status codes on both trigger routes (#9413)
* fix(automation): answer real HTTP status codes on both trigger routes
POST /api/v1/automation/:name/trigger and the legacy
POST /api/v1/automation/trigger/:name both ended `deps.success(result)`
unconditionally, so a flow that ran and failed came back as HTTP 200
wrapping an inner {success:false} — the double envelope #3962 ruled out
for /actions and #8684 closed on the resume route.
Producer first: the engine stamps `status: 'failed'` on the exits that
dispatched a run and were rejected (the same verdict it already writes to
the run log); its never-dispatched exits carry no status. The route reads
that verdict rather than sniffing summary/durationMs, and both doors now
answer through one shared mapper.
- ran and failed (incl. the retry-strategy exit) -> 400 FLOW_FAILED, with
the author's errorMessage and the run summary in error.details
- unknown flow -> 404, through the same registry probe /:name/toggle and
GET /:name use, before anything is dispatched
The ruling's disabled (409) and no-start-node (422) rows are NOT
implemented: both are never-dispatched exits and the closed
AutomationResult.code union has no honest member to tell them apart.
Escalated on the card rather than guessed; they keep today's behaviour
and are pinned as unchanged.
* test(dogfood): migrate the flow-runAs write leg to the ruled 400 FLOW_FAILED
The `runAs:'user'` WRITE leg triggers a flow whose `update_record` node is
refused at the record layer, so the run fails — and since the trigger route
answers real HTTP status codes it answers 400 FLOW_FAILED instead of 200
wrapping an inner {success:false}. `memberTrigger`'s blanket `< 300` was
written against the old contract and is the only dogfood consumer this
change touches.
The semantic the file pins is unchanged and still primary: the run executes
AS the member, so the admin's note stays 'new'. What moves is the transport
expectation, and it is asserted precisely rather than as a widened band —
status 400, error.code FLOW_FAILED, the node-first access-refusal text, and
the `touch` node's failure entry in error.details.summary. A 403 or 500 here
would mean de-elevation broke differently and must not pass.
The READ leg is deliberately NOT migrated: an RLS-scoped read is FILTERED,
not refused, so that run still succeeds with an empty `found` and keeps its
`< 300` expectation. Measured locally, not assumed. That leg also gains
discrimination for free: until now a failed run and an empty read were
indistinguishable there, because both left `found` falsy under HTTP 200.
Verified locally on a built workspace closure:
vitest run test/flow-runas.dogfood.test.ts → 5 passed (was 1 failed)
flow-node · flow-function-effect · flow-durable-suspend ·
showcase-declarative-mcp → 17 passed
showcase-anonymous-deny-surfaces · authz-conformance → 46 passed
pnpm --filter @objectstack/dogfood typecheck → green
Every other dogfood trigger caller expects the run to SUCCEED (or asserts an
anonymous 401), so none is touched by this contract. No changeset change:
@objectstack/dogfood is private internal QA and the wire change is already
documented in .changeset/automation-trigger-status-unification.md.
---------
Co-authored-by: Claude <noreply@anthropic.com>
The run genuinely failed; the transport reported success. A scripted or integration caller
23
+
that branches on the HTTP status alone read a failed run as a successful one. This applies
24
+
the `/actions` ruling (business failures must not ride HTTP 200 inside a double envelope)
25
+
to `POST /api/v1/automation/:name/trigger` and to the legacy
26
+
`POST /api/v1/automation/trigger/:name` — the shape `client.automation.trigger()` calls.
27
+
Both doors answer through one mapper, so they cannot drift.
28
+
29
+
What changes on the wire:
30
+
31
+
-**A flow that ran and then failed ⇒ `400` with `error.code: 'FLOW_FAILED'`.** The node
32
+
failure stays the human-readable `error.message`. The flow author's own `errorMessage`
33
+
travels in `error.details.errorMessage` — one documented location, the same one the
34
+
console reads — and the run's per-node accounting in `error.details.summary`. A flow
35
+
whose `errorHandling.strategy` is `retry` answers the same way once its attempts are
36
+
exhausted. `durationMs` is no longer carried on this response.
37
+
-**A flow name the deployment does not hold ⇒ `404`,** answered before anything is
38
+
dispatched, through the same registry probe `POST /:name/toggle` and `GET /:name` use.
39
+
-**Unchanged:** a successful run still answers 200 with its result, and a run that PAUSED
40
+
at a `screen` node still answers 200 with the next screen — a pause is not a failure.
41
+
-**Also unchanged, pending a ruling:** a DISABLED flow and one with no start node still
42
+
answer 200 with the inner failure. Both are exits that never dispatched anything, and
43
+
telling them apart needs a producer-side classification the closed
44
+
`AutomationResult.code` union cannot yet express. Tracked on #9378.
45
+
46
+
**`@objectstack/service-automation`:**`execute()` now stamps `status: 'failed'` on the
47
+
results of runs that dispatched and were rejected — the same lifecycle verdict it already
48
+
writes to the run log. Its never-dispatched exits carry no `status`, which is what lets a
49
+
transport answer the two classes differently without inspecting the result's internals.
50
+
51
+
**`@objectstack/client`:**`client.automation.trigger()`, `client.automation.execute()` and
52
+
`client.project(id).automation.execute()` now **reject** on a failed run instead of
53
+
resolving with `{ success: false, error }` — the SDK throws on every non-2xx before
54
+
unwrapping. Callers that inspected the resolved value must move to a `catch`:
55
+
56
+
```ts
57
+
try {
58
+
awaitclient.automation.execute(flow, { params });
59
+
} catch (err:any) {
60
+
err.code; // 'FLOW_FAILED' (400) — the run ran and failed
61
+
err.httpStatus; // 400 | 404
62
+
err.message; // the node failure, verbatim
63
+
err.details?.errorMessage; // the flow author's own message, when the flow declares one
64
+
err.details?.summary; // which node failed
65
+
}
66
+
```
67
+
68
+
Raw-HTTP callers that treated `2xx` as success and never opened the inner envelope now see
69
+
the failure they were already being told about, one level up.
70
+
71
+
<!-- adr-0087: not-required (no-migration-prescription) retires no metadata surface: no Zod schema, no authorable key, no stored sys_metadata row changes shape, so `objectstack migrate meta` has nothing to rewrite and no ledger entry can be written for it. What changes is an HTTP status plus an SDK method's promise contract, and the only channel that reaches those consumers is this changeset itself. -->
0 commit comments