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
{{ message }}
Repository navigation
Commit aef631b
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cloud-connection): an install-local uninstall withdraws the package's flows from the automation engine and unbinds their triggers (#27395) (#27530)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuTx8CYB9VacnACVpw5eNqMx)_Fixes#27395
Clause-②: no
Metadata-protocol: none
Skills-docs: none — nothing a metadata author writes changes, and no
skill or doc teaches the uninstall leaving flows live; the fix makes the
door's existing note true
Ruling-ref: triage 6489212277
## 维护者速读
- **改了什么**:本地安装的卸载接口(`DELETE
/api/v1/marketplace/install-local/ID`)在撤回注册表、跑完卸载清理之后,最后再宣告一次
`metadata:reloaded`(和安装时热加载宣告的是同一个信号、同一个载荷)。自动化引擎收到后重新同步,把这个包的流程撤掉,并在同一步解绑它们的记录触发和定时触发。宣告失败时,卸载照样成功,`cleanups`
里多一条名为 `metadata:reloaded` 的失败结果,说明里写明流程可能留到下次重启。
- **为什么改**:以前卸载后,元数据里流程已经 404,引擎里却还是
`active`:按名触发照样跑、写运行记录,记录触发和定时触发也还挂着,直到别的什么事碰巧触发一次重载(QA #27392
复现两次)。卸载接口自己的说明还写着「已从运行中的内核撤回」。按 triage 裁决,卸载走重载用的同一条撤回路径,不另起一份引擎侧清单。
-
**风险与回滚**:只改卸载接口这一处,安装路径不动。多出的一次重新同步和安装时那次完全一样(流程重同步、作者钩子与动作重同步、幂等的建表同步)。注册表拒绝撤回时不宣告,因为包还在。回滚
= revert 本 PR。
-
**先看**:`packages/cloud-connection/src/marketplace-install-local-plugin.ts`
里 `handleUninstall` 末尾新加的宣告和说明文字的拼装,以及新方法 `announceUninstall`;然后是
dogfood 新测试
`packages/qa/dogfood/test/install-local-uninstall-flows-withdrawn.dogfood.test.ts`。
- **两分钟验证**:`pnpm --filter @objectstack/cloud-connection exec vitest run
--maxWorkers=2
src/marketplace-install-local-uninstall-resync.test.ts`,应见 7
个用例全部通过(宣告一次、在撤回和清理之后、失败作为一条结果上报,以及没卸载成功时不宣告)。
- **需要你做的**:无,席位落地。
Under the claim `6493772753`. `DELETE
/api/v1/marketplace/install-local/:manifestId` now announces
`metadata:reloaded` after it withdraws the package from the registry and
runs the cleanups, so the automation engine withdraws the package's
flows and unbinds their triggers in the same request. Before, nothing
told the engine: the flows stayed `active` and triggerable until some
unrelated reload.
Branch `claude/project-thread-30b1mx` (the card thread's harness-pinned
branch; it names no issue, so the claim is the identity record), cut
from `origin/main` at `eef83734d9`, with `origin/main` `ded67bfb59`
merged in before this PR was opened.
## What changes
### The mechanism, measured
- The uninstall door withdrew the package with
`SchemaRegistry.uninstallPackage`, withdrew the object copies from the
`metadata` service, ran the protocol's uninstall cleanups, and returned.
It announced nothing.
- service-automation withdraws a flow in exactly one place after boot:
`resyncFlowsFromProtocol`, on `metadata:reloaded`. It re-reads the
protocol's flow view and calls `withdrawFlow` for every flow it had
armed that the view no longer lists. `withdrawFlow` calls
`deactivateFlowTrigger` first, so the trigger is unbound in the same
step.
- The install door announces that signal (`announceHotInstall`, `{
changed: ['app/ID'] }`). That is why a view publish, or any other
package's install, cleared the stale flows in the QA run.
So the dispatch hypothesis held as written. The ruling (triage
6489212277, verbatim): "The uninstall door drives the same unregister
path a reload uses. It is one teardown, not a second engine-side list."
### The change
(`packages/cloud-connection/src/marketplace-install-local-plugin.ts`)
- `handleUninstall`: after the cleanups, `announceUninstall(ctx,
manifestId)` runs last. It is never run before the withdrawal and the
cleanups, for the window the door's existing comment describes. It is
skipped when the registry refused the withdrawal: the package is still
registered, so a re-sync would only read it back.
- `announceUninstall` announces `metadata:reloaded` with the install's
payload, `{ changed: ['app/MANIFEST_ID'] }`. It never throws. If a
subscriber throws, or the context cannot announce, it returns one failed
outcome named `metadata:reloaded` (the new `KERNEL_RESYNC` constant,
named for the event, as `registry.uninstallPackage` and
`metadata.unregister` are named for their verbs). That outcome rides on
`cleanups` the way a refused registry withdrawal does. The cause and the
remedy (restart) go to the operator log at `warn`, the level the
install's announce failure uses, and never onto the wire.
- The note and the info log are composed per outcome. When the re-sync
did not run, the note says the engine can still hold the package's flows
until the next restart, naming the `metadata:reloaded` entry. The
copy-withdrawal sentence is unchanged, byte for byte. When everything
ran, the note's claim ("withdrawn from the running kernel") is now true.
- ⛔ Nothing in the door withdraws a flow or unbinds a trigger itself. No
engine-side list of the package's flows is kept.
- The module note's `DELETE` paragraph says so.
`packages/services/service-automation/src/plugin.ts` was not needed and
is not touched. No `packages/spec` change.
### Changeset
`.changeset/27395-install-local-uninstall-flow-resync.md`:
`@objectstack/cloud-connection` patch, with `Clause-②: no` and
`Metadata-protocol: none` copied from the claim.
## Tests
- **New, end to end:**
`packages/qa/dogfood/test/install-local-uninstall-flows-withdrawn.dogfood.test.ts`.
One showcase boot with `MarketplaceInstallLocalPlugin` mounted. It
installs an inline package that ships two flows: one run by name, and
one bound to `record-after-insert` on `showcase_category`, an object
that outlives the uninstall. Five cases:
1. PRECONDITION: the install hot-loads both flows as the package's
(`_packageId`), and each runs once: the first through the trigger door,
the second through an insert. Each leaves one run in the engine's
listing and one `sys_automation_run` row.
2. The uninstall answers 200 with no failed outcome, and `GET
/meta/flow/NAME` answers 404 (the half that already worked).
3. `GET /automation/NAME` answers 404 for both flows.
4. `POST /automation/NAME/trigger` answers 404, and no run is listed or
written.
5. An insert into the watched object runs nothing: the record trigger
was unbound in the same step.
- **New, the door's half:**
`packages/cloud-connection/src/marketplace-install-local-uninstall-resync.test.ts`,
7 cases. One ordered event log over the registry, the cleanup runner and
the kernel's `trigger` pins: exactly one `metadata:reloaded`, naming
`app/ID`, after the withdrawal and after the cleanups, with the ledger
entry gone. Nothing is announced for a refused caller, an unknown id, a
failed ledger write, or a refused registry withdrawal. A throwing
subscriber and a context with no `trigger` each give one failed
`metadata:reloaded` outcome, last; the cause stays in the log.
- **Moved pins (rig only):** the three existing uninstall rigs
(`…-uninstall-withdrawal`, `…-uninstall-cleanups`,
`…-uninstall-meta-reads`) gain `trigger: async () => undefined` on their
kernel context. Every real `PluginContext` declares `trigger`. Without
it, these rigs now get the failed re-sync outcome on `cleanups`, which
their `toEqual` assertions on `cleanups` and their empty-warning
assertions refuse. No assertion changed.
## Verification
All local readings below are on `7cb1ca3adb` (the merge of `ded67bfb59`)
unless a line says otherwise.
<details><summary>Red first, on the base source (eef8373)</summary>
The red tests were committed first (`243399d5`). Each leg wrote the base
blob of the plugin file over the fix with `git show`, ran the pins, and
restored from `HEAD`. Every restore was proved by `git hash-object`
equal to the HEAD blob and a clean `git status --porcelain`.
- Unit: `src/marketplace-install-local-uninstall-resync.test.ts` → 3
failed / 4 passed. The three positive cases fail. The four "announces
nothing" controls pass on the base, as they should.
- Dogfood, after rebuilding `@objectstack/cloud-connection` from the
base blob (`ablation-dist-preflight … 'announceUninstall' --absent` →
the marker is absent from all 6 built files) → 3 failed / 2 passed. The
precondition and the 200 passed; the three engine cases failed with the
QA run's exact readings:
- `GET /automation/qauninst_manual_probe` → 200 `status: active`,
`_provenance: package`, `_packageId: com.example.qauninst`;
- the trigger → 200, the run completed;
- the record flow: listed 2 / rows 2, expected 1 / 1.
</details>
<details><summary>Green, and the ablation (ablation-replace, fix
committed first)</summary>
- Green, after the fix was rebuilt (preflight: `announceUninstall`
present in 4 built files, tree clean): dogfood 5/5; unit 7/7.
- Mutate leg (`scripts/ablation-replace.mjs`). The anchor `const resync
= withdrawal ? undefined : await this.announceUninstall(ctx,
manifestId);` hit once and was replaced by `void this.announceUninstall;
const resync: UninstallCleanupOutcome | undefined = ((globalThis as
any).ablated27395 = undefined);`. Blob `e25af962d8ba` → `3bdeeea26dea`.
Build exit 0, and preflight found the marker in 2 built files. Result:
dogfood 3 failed / 2 passed (the same three cases), unit 3 failed / 4
passed. The restore checked blob == HEAD and an empty `git diff HEAD`.
- A first mutate leg without the `void` read is not counted: its JS
bundles carried the marker, but the DTS build exited 1 on TS6133 (the
now-unused private). It gave the same 3 red / 3 red.
- Restore leg: rebuild exit 0; preflight `ablated27395 --absent` found
the marker absent from all 6 built files, tree clean; dogfood 5/5.
</details>
<details><summary>Package suite, typecheck and gates</summary>
On `7cb1ca3adb`, after `pnpm install --frozen-lockfile` and a rebuild of
the dogfood closure plus `@objectstack/driver-turso` (lock VERDICT
command-exit 0, 67/67 tasks):
- `pnpm --filter @objectstack/cloud-connection test` → VERDICT
command-exit 0, Test Files 60 passed (60), Tests 754 passed (754).
- `pnpm --filter @objectstack/cloud-connection typecheck` → VERDICT
command-exit 0.
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/install-local-uninstall-flows-withdrawn.dogfood.test.ts` → VERDICT
command-exit 0, 5 passed (5).
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 64
commands on this tree. All 64 ran, each exit captured before any pipe;
64 exit 0. `pnpm check:dual-build-cjs-loads` first answered exit 3
(seven packages outside the closure had no `dist/`); those seven were
built and it then exited 0, so that is the recorded reading.
`dispatch-gates --ran` → "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN",
a derived zero (every line carries its exit code, none is 3).
- `test:repo` was not run locally: the diff edits no repo-project test
and the derivation does not name it. CI's `Test Core` runs it.
</details>
## Acceptance notes
- **Serial order.** No code was edited before the claim `6493772753`
named this branch.
- **Overlap with #27344** (os-bill, open, also edits this plugin file).
Its hunks are at base lines 214, 734, 1954, 1969 and 3464. This diff's
hunks are at 111, 303 and 2291 to 2308, so they are disjoint. A
bare-clone `git merge-tree --write-tree` of this head against #27344's
head `355edc0624` exits 0 (tree `674cf2d5f8`). Whichever lands second
merges `main`.
- **The install door's own re-sync failure** is still only logged
(`announceHotInstall`, unchanged): the install answers 200 and says
nothing on the wire. That is outside this card; this PR reports the
uninstall's failure on the response because the uninstall already
reports partial completion there (`cleanups`).
- **A sibling door, not measured.** The runtime's `DELETE
/api/v1/packages/:id` (`packages/runtime/src/domains/packages.ts`) also
withdraws through `deletePackage` / `uninstallPackage` and announces no
`metadata:reloaded`. Whether its flows stay live depends on what
`deletePackage`'s stored-row deletes signal to the engine, and that was
not measured here. It is a read-only inference, so per the dev contract
it is noted, not filed.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(Not touched.)
- [x] 2. Every name I added uses the casing for its kind
(`KERNEL_RESYNC`, `announceUninstall`).
- [x] 3. No formatter ran; lint is CI's run.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A patch changeset for `@objectstack/cloud-connection`; both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English, apart from the
maintainer quick-read block the project asks for.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgTsswEK7wz8UFCtF5z
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgTsswEK7wz8UFCtF5z)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cloud-connection): an install-local uninstall withdraws the package's flows from the automation engine at once, and unbinds their triggers
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
`DELETE /api/v1/marketplace/install-local/:manifestId` withdrew the package from the registry and ran its uninstall cleanups, but never told the running kernel. The automation engine withdraws a flow only when it re-syncs on `metadata:reloaded`, so the uninstalled package's flows stayed registered: `GET /api/v1/meta/flow/NAME` answered 404 while `GET /api/v1/automation/NAME` still answered `active`, `POST /api/v1/automation/NAME/trigger` still ran the flow and wrote a run row, and its record and schedule triggers stayed armed. That lasted until some unrelated reload, such as a view publish or another package's install. The door's note said the package was withdrawn from the running kernel.
11
+
12
+
-**Now.** After the registry withdrawal and the cleanups, the door announces `metadata:reloaded` with `{ changed: ['app/MANIFEST_ID'] }`, the same signal the install door's hot load announces. The engine's re-sync withdraws every flow it had armed that the package no longer provides and unbinds each one's trigger. Right after the `DELETE` returns, `GET /api/v1/automation/NAME` and its trigger door answer 404, and a write to a watched object runs nothing.
13
+
-**When the re-sync does not run.** If a subscriber throws, or the kernel context cannot announce, the uninstall still answers 200, and `cleanups` carries one failed outcome named `metadata:reloaded`. The note then says the engine can still hold the package's flows until the next restart. The cause goes to the operator log at `warn`, with the restart as the remedy.
14
+
-**Unchanged.** Nothing is announced when the uninstall did not happen (a refused caller, an id this door never installed, a ledger write that failed), or when the registry refused the withdrawal, because the package is still registered.
@@ -2308,13 +2333,81 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
2308
2333
cleanups,
2309
2334
note: withdrawal
2310
2335
? 'Cached manifest removed, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`. The running kernel did not withdraw the package, so it stays loaded until the next restart; that is the `registry.uninstallPackage` entry in `cleanups`.'
2311
-
: copies
2312
-
? 'Cached manifest removed, the package withdrawn from the running kernel\'s registry, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`. The metadata service kept copies of the package\'s objects, so the object listing and reads keep serving them until the next restart; that is the `metadata.unregister` entry in `cleanups`.'
2336
+
: copies||resync
2337
+
? 'Cached manifest removed, the package withdrawn from the running kernel\'s registry, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`.'
2338
+
+(copies
2339
+
? ' The metadata service kept copies of the package\'s objects, so the object listing and reads keep serving them until the next restart; that is the `metadata.unregister` entry in `cleanups`.'
2340
+
: '')
2341
+
+(resync
2342
+
? ' The running kernel\'s re-sync did not run, so the automation engine can still hold the package\'s flows — triggerable by name, their triggers armed — until the next restart; that is the `metadata:reloaded` entry in `cleanups`.'
2343
+
: '')
2313
2344
: 'Cached manifest removed, the package withdrawn from the running kernel, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`.',
2314
2345
},
2315
2346
},200);
2316
2347
};
2317
2348
2349
+
/**
2350
+
* [#27395] Announce an uninstall this door just completed to the running
2351
+
* kernel as `metadata:reloaded` — the re-sync signal
2352
+
* {@link announceHotInstall} announces for an install, with the same
2353
+
* payload — so every consumer that holds what it read from a package
2354
+
* re-reads a kernel that no longer has it:
2355
+
*
2356
+
* - `service-automation` re-syncs its flows from the protocol
2357
+
* (`resyncFlowsFromProtocol`) and withdraws each flow it armed that the
2358
+
* protocol's view no longer lists — the uninstalled package's, which
2359
+
* the registry withdrawal took out of that view — and `withdrawFlow`
2360
+
* unbinds each one's record or schedule trigger in the same step.
2361
+
*
2362
+
* Before this, the door withdrew the package from the registry and the
2363
+
* engine never heard of it: `GET /meta/flow/NAME` answered 404 while
2364
+
* `GET /automation/NAME` still answered `active`, and the trigger door, a
2365
+
* record write or a schedule still ran the uninstalled package's flow
2366
+
* until an unrelated reload — a view publish, another package's install —
2367
+
* cleared it. ⛔ No engine-side list of the package's flows is kept or
2368
+
* withdrawn here: a second teardown is how the two would drift.
2369
+
*
2370
+
* Never throws: the uninstall has already happened. Two answers:
2371
+
* - `undefined` — announced, and every subscriber returned;
2372
+
* - one failed outcome named {@link KERNEL_RESYNC}, when the context
2373
+
* cannot announce or a subscriber threw. It rides on `cleanups`, the
2374
+
* way a refused registry withdrawal does, and the operator log says
2375
+
* what it costs and the remedy; the cause is logged, never put on the
2376
+
* wire. A FUNCTIONAL degradation, so `warn` — the level the install's
2377
+
* announce failure uses.
2378
+
*/
2379
+
privateannounceUninstall=async(
2380
+
ctx: PluginContext,
2381
+
manifestId: string,
2382
+
): Promise<UninstallCleanupOutcome|undefined>=>{
2383
+
constfailed: UninstallCleanupOutcome={
2384
+
name: KERNEL_RESYNC,
2385
+
success: false,
2386
+
removed: 0,
2387
+
error: 'the running kernel did not re-sync — the automation engine can still hold the package\'s flows until the next restart',
2388
+
};
2389
+
constcost='its flows can stay registered in the automation engine — triggerable by name, their record and '
2390
+
+'schedule triggers still armed — until the runtime restarts. The outcome is on the response (`cleanups`). '
2391
+
+'Remedy: restart the runtime — the ledger entry is already gone, so the restart does not bring the package back.';
2392
+
if(typeofctx.trigger!=='function'){
2393
+
ctx.logger?.warn?.(
2394
+
`[MarketplaceInstallLocal] uninstalled ${manifestId}, but this kernel context cannot announce `
0 commit comments