diff --git a/.changeset/27395-install-local-uninstall-flow-resync.md b/.changeset/27395-install-local-uninstall-flow-resync.md new file mode 100644 index 00000000000..1c8cf9b3831 --- /dev/null +++ b/.changeset/27395-install-local-uninstall-flow-resync.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cloud-connection': patch +--- + +fix(cloud-connection): an install-local uninstall withdraws the package's flows from the automation engine at once, and unbinds their triggers + +Clause-②: no +Metadata-protocol: none + +`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. + +- **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. +- **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. +- **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. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index f9bc5940f80..e25af962d8b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -111,7 +111,10 @@ * serving them at once too (#24070), then runs the uninstall * cleanups domain plugins registered with the protocol (#21490) — * the package's permission sets and their grants go with it — and - * reports each outcome as `cleanups`, a refused withdrawal included. + * last announces the withdrawal as `metadata:reloaded`, so the + * automation engine withdraws the package's flows and unbinds their + * triggers at once (#27395); it reports each outcome as `cleanups`, + * a refused withdrawal or a re-sync that did not run included. * * Persistence layout: * /.objectstack/installed-packages/.json @@ -303,6 +306,13 @@ const REGISTRY_WITHDRAWAL = 'registry.uninstallPackage'; */ const OBJECT_COPY_WITHDRAWAL = 'metadata.unregister'; +/** + * [#27395] The outcome name this door reports when the running kernel was not + * told of the withdrawal — named for the event it announces, the re-sync the + * automation engine withdraws an uninstalled package's flows on. + */ +const KERNEL_RESYNC = 'metadata:reloaded'; + /** * [#8976] The capability every MUTATING install-local route demands. * @@ -2291,13 +2301,28 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ...(copies ? [copies] : []), ...await this.runUninstallCleanups(ctx, manifestId, admission.userId, declared), ]; - const withdrawals = (withdrawal ? 1 : 0) + (copies ? 1 : 0); + // [#27395] LAST — after the registry withdrawal and the cleanups, never + // before, for the window described above — announce the withdrawal as + // `metadata:reloaded`, the re-sync the install's hot load announces, so + // the consumers that hold what they read from a package re-read it now + // that it is gone. ⛔ Nothing here withdraws a flow or unbinds a trigger + // itself: the uninstall drives the one teardown a reload drives. Not + // when the registry refused: the package is still registered, so a + // re-sync would only read it back. + const resync = withdrawal ? undefined : await this.announceUninstall(ctx, manifestId); + if (resync) cleanups.push(resync); + const withdrawals = (withdrawal ? 1 : 0) + (copies ? 1 : 0) + (resync ? 1 : 0); ctx.logger?.info?.( `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` + (withdrawal ? 'the running kernel did NOT withdraw it, so it stays loaded until the next restart; ' - : copies - ? 'withdrawn from the running kernel\'s registry, but the metadata service kept copies of its objects until the next restart; ' + : copies || resync + ? 'withdrawn from the running kernel\'s registry, but ' + + [ + ...(copies ? ['the metadata service kept copies of its objects'] : []), + ...(resync ? ['the automation engine can still hold its flows'] : []), + ].join(' and ') + + ' until the next restart; ' : 'withdrawn from the running kernel; ') + `${cleanups.length - withdrawals} uninstall cleanup(s) ran)`, ); @@ -2308,13 +2333,81 @@ export class MarketplaceInstallLocalPlugin implements Plugin { cleanups, note: withdrawal ? '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`.' - : copies - ? '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`.' + : copies || resync + ? '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`.' + + (copies + ? ' 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`.' + : '') + + (resync + ? ' 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`.' + : '') : '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`.', }, }, 200); }; + /** + * [#27395] Announce an uninstall this door just completed to the running + * kernel as `metadata:reloaded` — the re-sync signal + * {@link announceHotInstall} announces for an install, with the same + * payload — so every consumer that holds what it read from a package + * re-reads a kernel that no longer has it: + * + * - `service-automation` re-syncs its flows from the protocol + * (`resyncFlowsFromProtocol`) and withdraws each flow it armed that the + * protocol's view no longer lists — the uninstalled package's, which + * the registry withdrawal took out of that view — and `withdrawFlow` + * unbinds each one's record or schedule trigger in the same step. + * + * Before this, the door withdrew the package from the registry and the + * engine never heard of it: `GET /meta/flow/NAME` answered 404 while + * `GET /automation/NAME` still answered `active`, and the trigger door, a + * record write or a schedule still ran the uninstalled package's flow + * until an unrelated reload — a view publish, another package's install — + * cleared it. ⛔ No engine-side list of the package's flows is kept or + * withdrawn here: a second teardown is how the two would drift. + * + * Never throws: the uninstall has already happened. Two answers: + * - `undefined` — announced, and every subscriber returned; + * - one failed outcome named {@link KERNEL_RESYNC}, when the context + * cannot announce or a subscriber threw. It rides on `cleanups`, the + * way a refused registry withdrawal does, and the operator log says + * what it costs and the remedy; the cause is logged, never put on the + * wire. A FUNCTIONAL degradation, so `warn` — the level the install's + * announce failure uses. + */ + private announceUninstall = async ( + ctx: PluginContext, + manifestId: string, + ): Promise => { + const failed: UninstallCleanupOutcome = { + name: KERNEL_RESYNC, + success: false, + removed: 0, + error: 'the running kernel did not re-sync — the automation engine can still hold the package\'s flows until the next restart', + }; + const cost = 'its flows can stay registered in the automation engine — triggerable by name, their record and ' + + 'schedule triggers still armed — until the runtime restarts. The outcome is on the response (`cleanups`). ' + + 'Remedy: restart the runtime — the ledger entry is already gone, so the restart does not bring the package back.'; + if (typeof ctx.trigger !== 'function') { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] uninstalled ${manifestId}, but this kernel context cannot announce ` + + `metadata:reloaded — ${cost}`, + ); + return failed; + } + try { + await ctx.trigger('metadata:reloaded', { changed: [`app/${manifestId}`] }); + return undefined; + } catch (err: any) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] uninstalled ${manifestId}, but the metadata:reloaded re-sync FAILED ` + + `(${err?.message ?? err}) — ${cost}`, + ); + return failed; + } + }; + /** * [#21576] Withdraw a package this door just removed from its ledger from * the running kernel — through `SchemaRegistry.uninstallPackage`, the ONE diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts index f418b2098dc..b38ee4ca8c3 100644 --- a/packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts @@ -136,6 +136,9 @@ async function bootPlugin(protocolMode: ProtocolMode = { kind: 'runner' }, opts: return svc; }, logger, + // Every kernel context can announce; the uninstall's `metadata:reloaded` + // (#27395) is pinned in the resync file, so here it reaches no subscriber. + trigger: async () => undefined, }; const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx); diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts index 8d538863bcd..7978863b2d2 100644 --- a/packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts @@ -187,6 +187,9 @@ async function bootPlugin(opts: { refuseUnregister?: string; before?: (metadata: return services[name]; }, logger, + // Every kernel context can announce; the uninstall's `metadata:reloaded` + // (#27395) is pinned in the resync file, so here it reaches no subscriber. + trigger: async () => undefined, }; const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx as never); diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-resync.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-resync.test.ts new file mode 100644 index 00000000000..2428eafc4c1 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-resync.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27395 — `DELETE /api/v1/marketplace/install-local/:manifestId` announces + * the withdrawal to the running kernel as `metadata:reloaded`, the re-sync + * signal the install door's hot load announces. + * + * The defect: the door withdrew the package from the registry and ran the + * uninstall cleanups, and announced nothing. The automation engine withdraws a + * flow only in its re-sync on that signal (`resyncFlowsFromProtocol`), so the + * uninstalled package's flows stayed registered and triggerable — their + * record and schedule triggers armed — until an unrelated reload, while the + * door's note said the package was withdrawn from the running kernel. + * Triage's ruling: the uninstall drives the same unregister path a reload + * uses; one teardown, not a second engine-side list. + * + * What this file pins about the plugin's half (the engine's half — a re-sync + * withdraws a flow the protocol's view no longer lists, and unbinds its + * trigger — is pinned in service-automation; end to end, on the real showcase + * composition, in the dogfood suite + * `install-local-uninstall-flows-withdrawn.dogfood.test.ts`): + * + * - exactly one `metadata:reloaded`, naming the app the install registered, + * announced LAST: after the registry withdrawal and after the cleanups — + * never before, for the window the door's own comment describes; + * - nothing announced when the uninstall did not happen: a refused caller, + * an id this door never installed, a ledger write that failed; + * - nothing announced when the registry refused the withdrawal — the + * package is still registered, so a re-sync would only read it back; + * - a re-sync that throws, or a context that cannot announce, is one failed + * outcome on the response (`cleanups`), never swallowed, and the cause + * goes to the operator log only. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The first load of the runtime's dist paid at module top, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`): the plugin reaches the +// same module through a dynamic `import()` for its handler binder on rehydrate. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { INSTALLER_USER_ID, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const APP_ID = 'com.example.tasksapp'; +/** A package the running registry holds that this door never installed — config-defined code. */ +const USER_CODE_ID = 'com.example.usercode'; +const CLEANUP_OUTCOMES = [{ name: 'security.package-permissions', success: true, removed: 3 }]; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeDeleteC(manifestId: string) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: `http://localhost:3000/api/v1/marketplace/install-local/${manifestId}`, + raw: new Request('http://localhost:3000/x'), + json: async () => ({}), + param: (name: string) => (name === 'manifestId' ? manifestId : undefined), + }, + json, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-uninstall-resync-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +function seedLedger(): void { + new LocalManifestSource(dir).write({ + packageId: APP_ID, + versionId: 'local', + manifestId: APP_ID, + version: '0.1.0', + manifest: { id: APP_ID, name: 'Tasks App', objects: [] }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: INSTALLER_USER_ID, + withSampleData: false, + }); +} + +const ledgerFile = () => join(dir, `${APP_ID}.json`); + +/** + * Boot the plugin to `kernel:ready` and hand back its DELETE route, over an + * `objectql` registry, a `protocol` cleanup runner and a kernel `trigger` — + * all three recording, in ONE ordered list, each call, whether the package + * was still registered and whether its ledger entry was still on disk. + */ +async function bootPlugin(opts: { + trigger?: 'record' | 'throw' | 'absent'; + refuseWithdrawal?: string; + auth?: unknown; +} = {}) { + const mode = opts.trigger ?? 'record'; + const events: Array<{ step: string; detail: unknown; registered: boolean; ledgerOnDisk: boolean }> = []; + const packages = new Map([APP_ID, USER_CODE_ID].map((id) => [id, { manifest: { id } }])); + const record = (step: string, detail: unknown) => + events.push({ step, detail, registered: packages.has(APP_ID), ledgerOnDisk: existsSync(ledgerFile()) }); + const registry = { + getAllPackages: () => [...packages.values()], + getPackage: (id: string) => packages.get(id), + uninstallPackage: vi.fn((id: string) => { + record('withdraw', id); + if (opts.refuseWithdrawal) throw new Error(opts.refuseWithdrawal); + return packages.delete(id); + }), + }; + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const rawApp = makeRawApp(); + const services: Record = { + manifest: { register: vi.fn() }, + auth: opts.auth ?? installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, registry }), + protocol: { + runUninstallCleanups: vi.fn(async (request: { packageId: string }) => { + record('cleanups', request.packageId); + return CLEANUP_OUTCOMES; + }), + }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + if (mode !== 'absent') { + ctx.trigger = async (event: string, payload: unknown) => { + record(event, payload); + if (mode === 'throw') throw new Error('subscriber exploded'); + }; + } + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + // Only what the DELETE does is read below. + events.length = 0; + const uninstall = async (manifestId = APP_ID) => + rawApp.routes.get('DELETE /api/v1/marketplace/install-local/:manifestId')!(makeDeleteC(manifestId)); + const reloads = () => events.filter((e) => e.step === 'metadata:reloaded'); + const warnings = () => logger.warn.mock.calls.map((c: any[]) => String(c[0])); + return { uninstall, events, reloads, warnings }; +} + +describe('#27395: an install-local uninstall announces metadata:reloaded, so the engine withdraws the package\'s flows', () => { + it('announces exactly once, naming the app, LAST — after the registry withdrawal and after the cleanups', async () => { + seedLedger(); + const { uninstall, events, warnings } = await bootPlugin(); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.success).toBe(true); + expect(events).toEqual([ + { step: 'withdraw', detail: APP_ID, registered: true, ledgerOnDisk: false }, + { step: 'cleanups', detail: APP_ID, registered: false, ledgerOnDisk: false }, + { step: 'metadata:reloaded', detail: { changed: [`app/${APP_ID}`] }, registered: false, ledgerOnDisk: false }, + ]); + // A re-sync that ran adds nothing to the outcomes. + expect(res.payload.data.cleanups).toEqual(CLEANUP_OUTCOMES); + expect(warnings()).toEqual([]); + }); + + it('a refused caller announces nothing', async () => { + seedLedger(); + const { uninstall, reloads } = await bootPlugin({ auth: { api: { getSession: async () => null } } }); + + const res = await uninstall(); + + expect(res.status).toBe(401); + expect(reloads()).toEqual([]); + }); + + it('an id this door never installed announces nothing — even one the running registry holds', async () => { + seedLedger(); + const { uninstall, reloads } = await bootPlugin(); + + const res = await uninstall(USER_CODE_ID); + + expect(res.status).toBe(404); + expect(reloads()).toEqual([]); + }); + + it('a ledger write that fails announces nothing — the package is still installed', async () => { + seedLedger(); + vi.spyOn(LocalManifestSource.prototype, 'remove').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + const { uninstall, events } = await bootPlugin(); + + const res = await uninstall(); + + expect(res.status).toBe(500); + expect(events).toEqual([]); + }); + + it('a refused registry withdrawal announces nothing — the package is still registered', async () => { + seedLedger(); + const { uninstall, events } = await bootPlugin({ + refuseWithdrawal: 'Cannot uninstall package: an object it owns is extended by another package', + }); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(events.map((e) => e.step)).toEqual(['withdraw', 'cleanups']); + expect(res.payload.data.cleanups.map((o: { name: string }) => o.name)).toEqual([ + 'registry.uninstallPackage', + ...CLEANUP_OUTCOMES.map((o) => o.name), + ]); + }); + + it('a re-sync that throws is one failed outcome on the response, LAST, and the cause stays in the log', async () => { + seedLedger(); + const { uninstall, reloads, warnings } = await bootPlugin({ trigger: 'throw' }); + + const res = await uninstall(); + + // The uninstall itself happened — the ledger entry is gone, the + // registry withdrew — so the request succeeded; what did not complete + // is reported, not swallowed. + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.success).toBe(true); + expect(existsSync(ledgerFile())).toBe(false); + expect(reloads()).toHaveLength(1); + const outcomes = res.payload.data.cleanups; + expect(outcomes.slice(0, -1)).toEqual(CLEANUP_OUTCOMES); + expect(outcomes.at(-1)).toMatchObject({ name: 'metadata:reloaded', success: false, removed: 0 }); + expect(typeof outcomes.at(-1).error).toBe('string'); + // The note no longer claims a withdrawal the engine did not see. + expect(res.payload.data.note).toContain('`metadata:reloaded`'); + // The thrown text goes to the operator log only, never onto the wire. + expect(JSON.stringify(res.payload)).not.toContain('subscriber exploded'); + const said = warnings().filter((w) => w.includes('subscriber exploded')); + expect(said).toHaveLength(1); + expect(said[0]).toContain(APP_ID); + expect(said[0]).toContain('restart'); + }); + + it('a context that cannot announce is the same failed outcome, and says so in the log', async () => { + seedLedger(); + const { uninstall, warnings } = await bootPlugin({ trigger: 'absent' }); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.cleanups.at(-1)).toMatchObject({ name: 'metadata:reloaded', success: false, removed: 0 }); + expect(warnings().filter((w) => w.includes('cannot announce metadata:reloaded') && w.includes(APP_ID))).toHaveLength(1); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts index 4c04b288b8a..b74675d1ce4 100644 --- a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts @@ -149,6 +149,9 @@ async function bootPlugin(opts: { return svc; }, logger, + // Every kernel context can announce; the uninstall's `metadata:reloaded` + // (#27395) is pinned in the resync file, so here it reaches no subscriber. + trigger: async () => undefined, }; const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx); diff --git a/packages/qa/dogfood/test/install-local-uninstall-flows-withdrawn.dogfood.test.ts b/packages/qa/dogfood/test/install-local-uninstall-flows-withdrawn.dogfood.test.ts new file mode 100644 index 00000000000..d4ea3f84e85 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-uninstall-flows-withdrawn.dogfood.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — an install-local uninstall (`DELETE +// /api/v1/marketplace/install-local/:manifestId`) withdraws the package's flows +// from the automation engine in the same step, on the real showcase +// composition (#27395). +// +// ## What was measured before the fix +// +// QA run #27392, twice at `c4c515ad`, with two packages: after the uninstall the +// package's flow was gone from the metadata registry (`GET /meta/flow/NAME` +// 404), but the engine still held it — `GET /automation/NAME` answered 200 +// with `status: active` and the uninstalled package's `_packageId`, and +// `POST /automation/NAME/trigger` wrote a run row and dispatched the flow. +// The flow, and every trigger it armed, stayed live until something else +// announced `metadata:reloaded`. The door's own note said the package was +// "withdrawn from the running kernel". +// +// The engine withdraws a flow only in its re-sync on `metadata:reloaded` +// (`resyncFlowsFromProtocol`, which tears down every flow the protocol's view +// no longer lists). The install door announces that re-sync; the uninstall +// door withdrew the package from the registry and announced nothing. Triage's +// ruling: the uninstall drives the same unregister path a reload uses — one +// teardown, not a second engine-side list. +// +// ## What this file pins, on one boot +// +// 1. the install hot-loads a package that ships two flows: one run by name, +// one bound to a record trigger on a showcase object — both served by +// the engine as the package's, both run once (precondition, measured: +// the run-by-name flow through the trigger door, the record-triggered one +// by an insert), each leaving a run row; +// 2. the uninstall answers 200 with no failed outcome, and the registry no +// longer serves the flow (`GET /meta/flow/NAME` 404 — the half that +// already worked); +// 3. the engine no longer serves either flow (`GET /automation/NAME` 404); +// 4. the trigger door refuses the flow (404) and writes no run row; +// 5. the record trigger was unbound in the same step: an insert into the +// watched object runs nothing. +// +// The run counts are read twice: from the engine's run listing, which holds a +// finished run as soon as the dispatch returns (the record trigger awaits its +// flow inside the insert), and from the durable `sys_automation_run` rows. +// +// Boots a fixture stack of its own (the install-local plugin mounted), so it +// stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as ExecutionContext; +const ROUTE = '/marketplace/install-local'; +const PACKAGE_ID = 'com.example.qauninst'; +/** A flow run by name, through the trigger door. */ +const MANUAL_FLOW = 'qauninst_manual_probe'; +/** A flow bound to a record trigger on a showcase object, which outlives the uninstall. */ +const RECORD_FLOW = 'qauninst_category_created'; +const WATCHED = 'showcase_category'; +/** How long a run's durable history row is given to land after the dispatch returned. */ +const ROW_SETTLE_MS = 1_000; + +/** The install body `os package install` sends for an inline manifest that ships two flows and no object. */ +const INSTALL_BODY = { + manifest: { + id: PACKAGE_ID, + namespace: 'qauninst', + name: 'QA uninstall flow withdrawal', + version: '1.0.0', + type: 'app', + flows: [ + { + name: MANUAL_FLOW, + label: 'QA uninstall probe (run by name)', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + { + name: RECORD_FLOW, + label: 'QA uninstall probe (record trigger)', + type: 'record_change', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Category Created', + config: { objectName: WATCHED, triggerType: 'record-after-insert' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], + }, +}; + +type Answer = { status: number; body: any }; + +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +interface RunCounts { listed: number; rows: number } + +describe('dogfood: an install-local uninstall withdraws the package\'s flows from the automation engine', () => { + let stack: VerifyStack; + let storageDir: string; + let ql: IObjectQLEngine; + let automation: { listRuns(name: string, options?: { limit?: number }): Promise }; + + let install: Answer; + const before: Record = {}; + let manualTriggered: Answer; + let recordInsert: Answer; + const runsAfterInstall: Record = {}; + + let uninstall: Answer; + const after: Record = {}; + let metaAfter: Answer; + let manualTriggerAfter: Answer; + let recordInsertAfter: Answer; + const runsAfterUninstall: Record = {}; + + const runCounts = async (flow: string): Promise => ({ + listed: (await automation.listRuns(flow, { limit: 100 })).length, + rows: rowsOf(await ql.find('sys_automation_run', { where: { flow_name: flow }, context: SYS })).length, + }); + + /** Poll until the flow's durable run rows reach `atLeast` (the history write is not awaited by the dispatch). */ + const settledRunCounts = async (flow: string, atLeast: number): Promise => { + const deadline = Date.now() + 10_000; + let counts = await runCounts(flow); + while (counts.rows < atLeast && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + counts = await runCounts(flow); + } + return counts; + }; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-uninstall-flows-')); + stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + ql = stack.kernel.getService('objectql'); + automation = stack.kernel.getService('automation') as typeof automation; + const token = await stack.signIn(); + + // ── install, and both flows run once ──────────────────────────────────── + install = await json(await stack.apiAs(token, 'POST', ROUTE, INSTALL_BODY)); + for (const flow of [MANUAL_FLOW, RECORD_FLOW]) { + before[flow] = await json(await stack.apiAs(token, 'GET', `/automation/${flow}`)); + } + manualTriggered = await json(await stack.apiAs(token, 'POST', `/automation/${MANUAL_FLOW}/trigger`, {})); + recordInsert = await json(await stack.apiAs(token, 'POST', `/data/${WATCHED}`, { name: 'Before uninstall' })); + runsAfterInstall[MANUAL_FLOW] = await settledRunCounts(MANUAL_FLOW, 1); + runsAfterInstall[RECORD_FLOW] = await settledRunCounts(RECORD_FLOW, 1); + + // ── uninstall ────────────────────────────────────────────────────────── + uninstall = await json(await stack.apiAs(token, 'DELETE', `${ROUTE}/${PACKAGE_ID}`)); + metaAfter = await json(await stack.apiAs(token, 'GET', `/meta/flow/${MANUAL_FLOW}`)); + for (const flow of [MANUAL_FLOW, RECORD_FLOW]) { + after[flow] = await json(await stack.apiAs(token, 'GET', `/automation/${flow}`)); + } + manualTriggerAfter = await json(await stack.apiAs(token, 'POST', `/automation/${MANUAL_FLOW}/trigger`, {})); + recordInsertAfter = await json(await stack.apiAs(token, 'POST', `/data/${WATCHED}`, { name: 'After uninstall' })); + // The engine's listing already holds any run these two requests finished; + // the durable row trails it (its write is not awaited), so the row count is + // read after a settle window. + await new Promise((r) => setTimeout(r, ROW_SETTLE_MS)); + runsAfterUninstall[MANUAL_FLOW] = await runCounts(MANUAL_FLOW); + runsAfterUninstall[RECORD_FLOW] = await runCounts(RECORD_FLOW); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: the install hot-loads both flows as the package\'s, and each runs once, leaving a run row', () => { + expect(install.status, JSON.stringify(install.body)).toBe(200); + expect(install.body?.data?.hotLoaded).toBe(true); + for (const flow of [MANUAL_FLOW, RECORD_FLOW]) { + expect(before[flow].status, `${flow}: ${JSON.stringify(before[flow].body)}`).toBe(200); + expect(before[flow].body?.data?._packageId).toBe(PACKAGE_ID); + } + expect(manualTriggered.status, JSON.stringify(manualTriggered.body)).toBe(200); + expect(recordInsert.status, JSON.stringify(recordInsert.body)).toBeLessThan(300); + expect(runsAfterInstall).toEqual({ + [MANUAL_FLOW]: { listed: 1, rows: 1 }, + [RECORD_FLOW]: { listed: 1, rows: 1 }, + }); + }); + + it('the uninstall answers 200 with no failed outcome, and the registry no longer serves the flow', () => { + expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200); + expect(uninstall.body?.success).toBe(true); + const failed = (uninstall.body?.data?.cleanups ?? []).filter((o: { success?: unknown }) => o.success !== true); + expect(failed).toEqual([]); + expect(metaAfter.status, JSON.stringify(metaAfter.body)).toBe(404); + }); + + it('the engine no longer serves either flow: GET /automation/NAME answers 404', () => { + for (const flow of [MANUAL_FLOW, RECORD_FLOW]) { + expect(after[flow].status, `${flow}: ${JSON.stringify(after[flow].body)}`).toBe(404); + } + }); + + it('the trigger door refuses the uninstalled flow with 404, and no run row is written', () => { + expect(manualTriggerAfter.status, JSON.stringify(manualTriggerAfter.body)).toBe(404); + expect(runsAfterUninstall[MANUAL_FLOW]).toEqual(runsAfterInstall[MANUAL_FLOW]); + }); + + it('the record trigger was unbound in the same step: an insert into the watched object runs nothing', () => { + expect(recordInsertAfter.status, JSON.stringify(recordInsertAfter.body)).toBeLessThan(300); + expect(runsAfterUninstall[RECORD_FLOW]).toEqual(runsAfterInstall[RECORD_FLOW]); + }); +});