Commit 73b2246
fix(service-automation): a flow saved through the metadata API is armed on the running engine at once (#21746)
Fixes #21725
Clause-②: no
A flow saved active through `PUT /api/v1/meta/flow/:name` is now armed
on the running engine at once. A save that sets `status: 'obsolete'` or
`'invalid'` disarms it, and a delete unregisters it. Hooks and actions
saved through the same door already behaved this way. The work follows
triage's direction (one door, one arming rule): `service-automation`
subscribes to the metadata protocol's existing mutation signal, and no
new event is added.
## Reproduced first, at `316be321ef`
This was measured at the producer behind the REST route (real kernel,
ObjectQLPlugin's metadata protocol, driver-sql on better-sqlite3
`:memory:`), before the change:
- `saveMetaItem({ type: 'flow', name: 'qa_meta_flow', item })` answered
`"Saved flow 'qa_meta_flow' (env-wide, state=active) [seq=1]"`, the
card's step 2.
- `getMetaItemsForExecution({ type: 'flow' })` listed the flow.
- `engine.getFlow('qa_meta_flow')` was `null`, both at once and 50 ms
later.
- `engine.execute('qa_meta_flow')` returned `Flow 'qa_meta_flow' not
found`, the card's step 3.
Where each side lives:
- **The engine's flow set** is filled only by `registerFlow`, at
`service-automation/src/engine.ts:4415` (`this.flows.set`). Its callers
are the boot pull (`plugin.ts:1280`), the `kernel:ready` sync (`:2434`)
and the `metadata:reloaded` re-sync (`:2383`, hook at `:1371`).
- **The direct save's signal** fires at
`metadata-protocol/src/protocol.ts:19404` (`saveMetaItem` →
`emitMetadataMutation`). ObjectQL's listener
(`objectql/src/plugin.ts:328-352`) re-binds hooks and actions on it, and
nothing in `service-automation` listened.
## The signal is reachable without an `objectql` edit
`onMetadataMutation` is a public method of the `protocol` **kernel
service** (`ObjectStackProtocolImplementation`,
`@objectstack/metadata-protocol`). It is a server-side extension, not
part of the wire contract. `plugin-email`, `plugin-security`,
`service-i18n` and core's authored-translation sync already subscribe
through `ctx.getService('protocol')`. The `MetadataMutationEvent` type
is imported type-only. No file in `objectql`, `spec` or
`metadata-protocol` changes.
## The change (`packages/services/service-automation/src/plugin.ts`)
- **Subscription.** `subscribeFlowMutations` runs at `start()`, after
the inert-mode return, beside the other runtime re-bind hooks, so
`armRuntime: false` arms nothing. It handles `type === 'flow'` and
ignores `state: 'draft'`, because a draft leaves the live row unchanged.
The listener only records the name, and one queued sync drains every
name reported before it starts.
- **The sync (`syncMutatedFlows`)** re-reads each named flow through the
execution view, with the same read, precedence (`resolveFlowContenders`)
and env-wide scope (no organization) as the boot:
- **In the view:** `registerFlow`. For `status: 'obsolete' | 'invalid'`
the flow stays registered and unbound, which is exactly what a boot
gives such a flow.
- **Gone from the view:** `withdrawFlow`, the re-sync's own teardown.
This applies only to a flow armed from the view, so a flow registered
straight into the engine, with no stored row, is left alone.
- **Read failed:** nothing changes.
- **One flow sync queue.** The `kernel:ready` bind, the
`metadata:reloaded` re-sync and the mutation sync now run serially, so
no read and its registrations interleave with another's.
- **Idempotency with the existing paths.**
- A publish raises the mutation signal and then `metadata:reloaded`. The
re-sync skips a flow the mutation sync just armed from the identical
stored body, so a per-item publish registers the published flow
**once**.
- `PUT /api/v1/automation/:name` registers before it saves
(`runtime/src/domains/automation.ts`, `registerAndSaveFlow`). Its save
no longer causes a second registration, because the sync skips a
definition whose canonical parse the engine already holds.
- **Shutdown.** `destroy()` unsubscribes and waits for any queued sync.
## Measured beside the change
- **Timing.** The signal is synchronous and fire-and-forget, so the
save's answer comes before the arming. Measured: the flow is not yet
registered when `await saveMetaItem` resolves, is registered after one
`setImmediate` turn, and is armed 4.9 ms after the save on sqlite
`:memory:`. The window is one read of the protocol's view. Hooks and
actions on this door have the same shape.
- **A run in flight when its flow is replaced** finishes on the
definition it started with: the `tail_v1` node ran.
- **A suspended run** resumes against the definition registered when it
resumes: the `tail_v2` node ran. Once the flow is deleted, resume
answers `RUN_NOT_FOUND` with `Flow 'held' not found for run …`. A
re-registration through a publish or `PUT /automation/:name` already
behaved this way (`resumeInternal` reads the current `this.flows`
entry).
- **Tenancy.** `flow` has no per-org channel. An organization-scoped
flow save is refused at the door with `403 NOT_OVERRIDABLE` before any
signal is raised (pinned). The sync never passes an event's organization
to the read (pinned with a protocol stand-in), so it cannot arm beyond
the boot's reach.
- **PM assumption 3, refined.** "Unregister when inactive" became
"registered and disarmed", which is what boot, kernel:ready and the
re-sync all do with an `obsolete` flow. A delete unregisters.
## Pins
`flow-metadata-save-arming.integration.test.ts` runs the real write
path. `saveMetaItem`, `publishMetaItem` and `deleteMetaItem` are the
producers behind the REST doors. The record-change trigger is a stand-in
bound through ObjectQL's `registerHook` / `unregisterHooksByPackage`,
because `@objectstack/trigger-record-change` depends on this package. A
real insert fires it.
1. An active save is triggerable with no restart and no publish, and
`execute` succeeds.
2. A record-triggered flow saved that way fires on the next matching
insert.
3. A save to `status: 'obsolete'` leaves the flow registered with
`enabled: false, bound: false`, and an insert does not fire it.
4. A delete unregisters the flow (`getFlow` returns `null`), and an
insert does not fire it.
5. A draft save registers nothing.
6. The per-item publish door registers the published flow exactly once,
and one insert launches it once.
7. `PUT /automation/:name`'s register-then-save order registers once.
8. An organization-scoped save is refused with `403 NOT_OVERRIDABLE` and
arms nothing.
`flow-metadata-mutation-reach.test.ts` covers two cases: the event's
organization never reaches the read, and the env-wide control is armed.
`inert-mode.test.ts` checks that inert mode subscribes nothing and that
the default mode subscribes exactly once.
## Ablations (each through `scripts/ablation-replace.mjs`, run at
`b8d422a2f4`, restore proven blob == HEAD with an empty `git diff HEAD`)
Tests import `./plugin.js` directly, so `src` is the subject and no
`dist` is involved.
| Mutation | Predicted | Observed |
|:---|:---|:---|
| A1: remove the `subscribeFlowMutations(ctx)` call | the four
direct-door rows (1–4), both reach rows and "subscribes once" go red;
publish-once, draft, PUT-once, org-refused and inert-none stay green | 7
failed / 12 passed, exactly that split |
| A2: the sync skips a body whose status is `obsolete`/`invalid`
(handler ignores inactive state) | only row 3 (disarm) goes red | 1
failed / 18 passed: row 3 |
| A3: drop the re-sync's skip for a mutation-armed body | only row 6
goes red, with 2 registrations | 1 failed / 22 passed: row 6, "expected
length 1 but got 2" |
| A4: drop the already-held skip | only row 7 goes red | 1 failed / 18
passed: row 7 |
The first attempt at A2 never ran. `ablation-replace` refused it because
the replacement still contained the anchor, so the count did not drop,
and it restored the file. It was re-anchored and run as recorded above.
## Verification (code at `b8d422a2f4`; HEAD `ae04563d15` adds only docs
and the changeset, with an empty `git diff b8d422a ae04563 --
packages/`)
- `pnpm --filter @objectstack/service-automation test`: 172 files,
**2110 passed**.
- `pnpm --filter @objectstack/service-automation typecheck`: `tsc
--noEmit` and `check:test-typecheck` OK, 0 debt.
- **Derived gates at `ae04563d15`.** `dispatch-gates --commands` derived
92 commands. All 92 ran; 90 exited 0 on the first run.
- `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3
(PREREQUISITE NOT MET: no dist). After a full `turbo run build`, both
exited 0. Readings: 262 examples; 106 entry points across 66 packages
load.
- `dispatch-gates --ran` reconciled 92 derived, 92 run, 0 NOT-MEASURED,
all with exit codes.
- **Narrowed lint** at `ae04563d15`: `eslint --no-inline-config --format
json` on the four changed TS files read 4 files with 0 errors and 0
warnings. `--print-config` shows no `parserOptions.project` or
`projectService`, so type-aware linting is off and this diff cannot move
any untouched file's verdict. The repo-wide `pnpm lint` is left to CI.
## Docs
- `content/docs/automation/flows.mdx` → "Discovery & Registration" gains
one paragraph on how a runtime-authored flow follows its stored row. No
existing sentence there was false.
- `content/docs/references/api/automation-api.mdx:31-32` is
auto-generated from `packages/spec`. It names `PUT /automation/:name` as
one door that arms, which stays true.
- `skills/**`: no sentence was made false (not edited; governed).
## Acceptance notes
- **The answer-before-arm window**, one view read, is the signal's
documented fire-and-forget shape and is shared with hooks and actions.
The awaited ADR-0094 projector seam would close it, but it is a single
slot per type that this package already fills for credential pruning,
and it is not replayed to peer replicas, which `onMetadataMutation` is.
Not taken: triage named the mutation signal.
- `skills/objectstack-platform/references/plugin-hooks.md:52` lists the
dev reload and publish-drafts as `metadata:reloaded` announcers and
omits the per-item publish door. This predates this PR, is incomplete
rather than false, and is not changed by it. Noted, not filed (carrier:
none).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 309224d commit 73b2246
6 files changed
Lines changed: 623 additions & 9 deletions
File tree
- .changeset
- content/docs/automation
- packages/services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1845 | 1845 | | |
1846 | 1846 | | |
1847 | 1847 | | |
| 1848 | + | |
| 1849 | + | |
| 1850 | + | |
| 1851 | + | |
| 1852 | + | |
| 1853 | + | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
1848 | 1857 | | |
1849 | 1858 | | |
1850 | 1859 | | |
| |||
Lines changed: 104 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
Lines changed: 254 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
0 commit comments