Skip to content

Commit dc71845

Browse files
feat(spec,console): a type: 'flow' action may declare onSuccess, and the console navigates after a run that completes (#26011)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbK3S53FNy4JF9JjaiJuZu7X)_ Fixes #25800 Clause-②: yes Metadata-protocol: added ## Summary A `type: 'flow'` action that creates a record could not take the user to it: `ActionSchema` refused `onSuccess` beside `type: 'flow'`, and the page action rows refused it beside a `flow` executor. This PR admits `flow` into the one executor set both read (`ON_SUCCESS_EXECUTORS` in `packages/spec/src/ui/action-on-success-executor.ts`), rewords the refusal prose for `url`, `navigation`, `modal` and `form` to name the three executors, opens the Studio action form's `onSuccess` row to a flow action, and regenerates the reference docs. On the console side, the post-success hop fires after a flow run that **completes** and never after one that pauses at a screen or ends refused. On a completed run, `${result.*}` reads the run's answer (`AutomationResult`), so `${result.output.VAR}` resolves to one of the flow's `isOutput` variables: ```ts { name: 'start_renewal', type: 'flow', target: 'renewal_start', onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}' }, } // the flow declares variables: [{ name: 'renewal', type: 'object', isOutput: true }] // and its create_record node writes outputVariable: 'renewal' ``` ## Mechanism-assumption correction: why `silent` cannot mark a hand-off The dispatch assumed the ActionRunner needed no change: the launch judge's `paused` and `refused` arms already answer `silent: true`, so it looked as if the hop could be keyed off that. Measured, it cannot. `silent` is a fact about the **toast** alone, and the `done` arm sets it too. When the flow's own `successMessage` wins the toast (objectui#12274), `judgeFlowLaunch` returns `{ success: true, data, reload, silent: true }` for a run that **completed**. A runner that skipped the hop on `silent` would therefore drop the hop for every completed flow that authors its own success copy, which is exactly the record-creating flow this card is about. The three success arms are indistinguishable on `success` and `silent`, so the runner needs a fact of its own. That fact is the new `ActionResult.handedOff` (`ui/packages/types/src/ui-action.ts`): the success is a hand-off to a follow-up surface, not a completion. `judgeFlowLaunch` sets it on the `paused` and `refused` arms, and `ActionRunner.handlePostExecution` gates the hop on `result.success && !result.handedOff && action.onSuccess`. The seat approved the file-surface amendment (types, core, app-shell) before the console half was written. ## Each launch arm and its hop `judgeFlowLaunch` in `ui/packages/app-shell/src/utils/flowResponse.ts` now states the hop conclusion for every arm in its docblock, and each conclusion is pinned by a test that runs the real `ActionRunner`: | Arm | Result | Hop | Why | |---|---|---|---| | `failed` | `success: false` | none | The runner hops only after a success. | | `paused` | `success: true`, `silent`, `handedOff` | none, now or later | The action only opened the wizard. Navigating away would leave the screen behind, and the run has not answered with the output the hop reads. `FlowRunner` finishing the run later does not hop either. | | `refused` | `success: true`, `silent`, `handedOff` | none | The Close-only notice is the follow-up, and a hop would navigate past it to a record the refusal never produced. | | `done` | `success: true`, `data` = `AutomationResult` (`silent` when the flow's copy wins) | hops | The run completed. `${result.*}` reads `data`; `${result.output.VAR}` is an `isOutput` variable. Not `handedOff`, whether `silent` or not. | ## Landing point and surface delta The claim named the spec half (`action-on-success-executor.ts`, `action.zod.ts`, `component.zod.ts`, `action.form.ts`, their tests, the liveness ledger, the reference docs) and the console tests. The correction above added three source files to the surface, approved by the seat: `ui/packages/types/src/ui-action.ts` (`handedOff`), `ui/packages/core/src/actions/ActionRunner.ts` (the gate) and `ui/packages/app-shell/src/utils/flowResponse.ts` (the arms). Following from those: - `scripts/sdui-manifest.record.json` is re-recorded because the registry sources moved; `sdui.manifest.json` itself is byte-identical (106 components). No conflict with #25903 arose, so main was not merged. - `packages/lint/src/validate-predicate-path-refs.test.ts` recounts the predicate census (63) because the form gate is now spelled `data.type in ['api','script','flow']`. - Eight console view tests (`useConsoleActionRuntime.*`, `RecordDetailView.*`) that pin the paused or refused result with `toEqual` now include `handedOff: true`. ## Premise check: what `${result.output.VAR}` reads The card reported a run answering `output: {}`. The runtime is not dropping anything: `AutomationResult.output` is built from the flow's `isOutput` variables only (`packages/services/service-automation/src/engine.ts`, the "Collect output variables" loop at about line 5643), and both the trigger route and the action door (`dispatchFlowAction`, `packages/runtime/src/action-execution.ts`) serve that answer. A `create_record` node writes the record object into its `outputVariable`, but the variable reaches `output` only when the flow declares it `isOutput: true`. The empty `output` is a flow-authoring gap in the reporting app: declare the variable `isOutput: true` and point `outputVariable` at it. The spec docblock and the changeset now say so, and a test pins that an empty `output` resolves the template to `/apps/clm/clm_contract/record/` rather than throwing. ## Tests Measured on this branch. The spec and ui core/types suites ran at `344ac6f5`, which differs from the head only in `scripts/sdui-manifest.record.json`; the app-shell suite and the type-checks ran at the head, `9cb24ac6`. - `pnpm --filter @objectstack/spec test`: Test Files 722 passed (722), Tests 21877 passed, 1 todo. - `ui/` vitest over `packages/core` and `packages/types`: Test Files 601 passed (601), Tests 14680 passed, 27 skipped. - `ui/` vitest over `packages/app-shell`: Test Files 1721 passed, 1 skipped (1722); Tests 15875 passed, 9 skipped. - `pnpm run type-check` in `ui/packages/types`, `ui/packages/core` and `ui/packages/app-shell` (after building the app-shell dependency closure): exit 0, 0 errors each. - New pins: `ActionRunner.onSuccessNavigation.test.ts` (reads `${result.output.renewal.id}` to `/apps/clm/clm_contract/record/ctr_18`; a `silent` completed run still hops; a `handedOff` success does not, with a positive control on the same runner) and a `flowResponse.test.ts` block composing `interpretFlowResponse`, `judgeFlowLaunch` and the real `ActionRunner` for all four arms plus the empty-`output` case. - Spec: `action-on-success.test.ts` and `component-action-row-on-success-executor.test.ts` move `flow` from refused to accepted and parse the renewal template. ## Ablation `scripts/ablation-replace.mjs` replaced the runner gate `if (result.success && !result.handedOff && action.onSuccess) {` with `if (result.success && action.onSuccess) {` (anchor 1 to 0, blob `3aebb3cab4ea` to `ac1fc1dff4a2`) and ran the two new test files: Test Files 2 failed (2), Tests 3 failed, 70 passed. The three reds are exactly the hand-off pins: the core "handed-off success performs no hop" test, and the paused and refused composition tests. The file was restored to its HEAD blob `3aebb3cab4ea` with `git diff HEAD` empty. This was a one-time proof; no permanent test file was added for it. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `9cb24ac6` (125 commands), each run with its exit code captured before any pipe, then reconciled with `--ran`: "125 derived famil(ies) accounted for — 123 run, 2 NOT-MEASURED". The 123 that ran exited 0, among them `check:generated`, `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:liveness`, `check:exported-any`, `check-sdui-manifest`, `check:type-check-debt` (re-measured under the verify lock: "26 raw tsc error(s) total, none above its recorded number") and `check:nul-bytes`. Not measured locally, left to CI: `check:dual-build-cjs-loads` (needs a full workspace `pnpm build`) and `check:i18n` (needs the CLI's build closure). Both refused on their prerequisite with exit 3 before reading anything; this diff adds no i18n key and changes no package's build shape. Two environment notes. `check-plugin-teardown-shape --self-test` reads a fixture pinned to commit `621a4876`, which this shallow clone lacked; after fetching that one commit it exits 0. And an earlier sweep ran unlocked while the console manifest regeneration rebuilt `packages/spec/dist`, which produced spurious `check:generated` and `check:exported-any` reds; that sweep was discarded and the numbers above come from the clean rerun. ## Acceptance notes - Two pre-existing runner behaviours still apply to a handed-off success, unchanged by this PR: an action authored with `refreshAfter: true` sets `result.reload`, and an authored `chain` runs. Neither is new, and neither is the hop this card asked about. Noted, not filed. - A flow action whose target flow starts at a screen will always pause, so its `onSuccess` can never fire. A publish-time hint for that pairing would catch the mistake earlier; noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_013w86pS2SMm6BU2PfGQ3vJ7)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013w86pS2SMm6BU2PfGQ3vJ7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f7e041 commit dc71845

31 files changed

Lines changed: 393 additions & 122 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@object-ui/types': minor
4+
'@object-ui/core': minor
5+
'@object-ui/app-shell': minor
6+
---
7+
8+
feat(spec,console): a `type: 'flow'` action may declare `onSuccess`, and the console navigates after a run that completes
9+
10+
Clause-②: yes
11+
Metadata-protocol: added
12+
13+
A flow action that creates a record could not take the user to it. `ActionSchema` refused `onSuccess` beside `type: 'flow'` (and the page action rows refused it beside a `flow` executor), so after the run the user stayed on the page they started from.
14+
15+
- **`@objectstack/spec`:** `onSuccess` is now accepted on a `type: 'flow'` action and beside a page action row's `flow` executor, next to `api` and `script`. On a flow action, `${result.*}` reads the completed run's answer (`AutomationResult`). Its `output` holds the flow's `isOutput` variables, so a `create_record` node that writes an `isOutput` variable `renewal` is reached as `${result.output.renewal.id}`. A variable the flow does not declare `isOutput: true` is not in `output`. The refusal text for `url`, `navigation`, `modal` and `form` now names the three executors. The Studio action form shows the `onSuccess` row for a flow action.
16+
- **`@object-ui/types`:** `ActionResult` gains `handedOff`: the success is a hand-off to a follow-up surface, not a completion.
17+
- **`@object-ui/app-shell`:** the flow launch judge marks a run that pauses at a screen, or ends refused, `handedOff`. A run that completes is not marked, including one whose toast is the flow's own `successMessage`.
18+
- **`@object-ui/core`:** the action runner performs no `onSuccess` hop for a `handedOff` success. A flow run that pauses at a screen or ends refused does not navigate, neither when the screen or the refusal notice opens nor when the flow runner later finishes the run.
19+
20+
Example:
21+
22+
```ts
23+
{
24+
name: 'start_renewal',
25+
type: 'flow',
26+
target: 'renewal_start',
27+
onSuccess: { navigate: '/apps/clm/clm_contract/record/${result.output.renewal.id}' },
28+
}
29+
```
30+
31+
with the flow declaring `variables: [{ name: 'renewal', type: 'object', isOutput: true }]` and its `create_record` node writing `outputVariable: 'renewal'`.

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ const result = ApiMethod.parse(data);
522522
| **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. |
523523
| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
524524
| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. |
525-
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
525+
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
526526
| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
527527
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
528528
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ const result = MetadataBulkResultSchema.parse(data);
355355
| **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. |
356356
| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
357357
| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. |
358-
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
358+
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
359359
| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
360360
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
361361
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

‎content/docs/references/ui/action.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const result = ActionSchema.parse(data);
6969
| **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. |
7070
| **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
7171
| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. |
72-
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
72+
| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. |
7373
| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
7474
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
7575
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -178,7 +178,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
178178

179179
| Property | Type | Required | Description |
180180
| :--- | :--- | :--- | :--- |
181-
| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with $&#123; is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. |
181+
| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with $&#123; is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. |
182182
| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. |
183183

184184

0 commit comments

Comments
 (0)