diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 02d8d2d26..14241584a 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -4,6 +4,7 @@ import { MikroORM } from '@mikro-orm/core'; import { DatabaseService } from '../database/database.service'; import { UnitOfWork } from '../database/unit-of-work'; import { withRequestContext } from '../database/request-context'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; import { logError } from '../audit/audit-log.logger'; import { pluginsEnabled } from './kill-switch'; import { setPluginEventSink } from '../../plugin-event-sink'; @@ -47,6 +48,19 @@ import type { PluginActionDescriptor, PluginActionResult, PluginActionScope } fr // any embedded space — the string is interpolated into the egress guard and the CSP. const EGRESS_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; +// Plan 3j Task 0 (R-scheduler): the persistent per-plugin scheduler sweep + the GDPR +// erasure drain used to run on a bare, unregistered `setInterval` — no +// `CronRegistrarService` registration, no request context, no test-mode no-op. Six-field +// (seconds-resolution) cron expression: every :00 and :30 of every minute, the nearest +// exact equivalent to the legacy `setInterval(fn, 30_000)` cadence the `cron` package's +// `CronJob` supports (confirmed: it parses an optional leading seconds field). The one +// observable difference: the legacy interval fired 30s after PROCESS START and then +// every 30s from there (a rolling cadence); this fires at wall-clock :00/:30 boundaries +// (an absolute cadence) — still exactly once every 30 seconds, never coarser, just no +// longer phase-locked to boot time. See task-0-report.md for the full "For the user" note. +const PLUGIN_SCHEDULER_SWEEP_NAME = 'plugin-scheduler-sweep'; +const PLUGIN_SCHEDULER_SWEEP_CRON = '*/30 * * * * *'; + /** * Remove `/` whether it is a real directory, a POSIX symlink or a @@ -169,8 +183,6 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // author's source auto-reloads. Empty unless dev-link is used. private readonly linkWatchers = new Map(); - // Sweeps plugin_scheduled_tasks for due callbacks and fires them on active plugins. - private schedulerSweep: ReturnType | null = null; // Coalesces overlapping erasure drains (the sweep and enqueue both trigger one). private drainInFlight: Promise | null = null; @@ -211,6 +223,17 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // does needs this, the same lesson `CronRegistrarService` already learned // (task-2-fix-report.md's "Concerns"). @Optional() private readonly orm?: MikroORM, + // Plan 3j Task 0 (R-scheduler): the one path this codebase schedules a cron through + // (server/CLAUDE.md). `@Optional()` for the same reason `uow`/`orm` are — the wide + // hand-construction surface this class has across the test suite (plugin-host.ts, + // boot-registry-order.test.ts, settings-isolation.test.ts, plugin-runtime.boot-no- + // orm.test.ts) never passes a 9th positional arg; a hand-built instance without one + // simply never registers the sweep (no log — mirrors how an absent `hostFactory`/ + // `registry` produces no boot-time log either, only a failure at first use, and + // registering nothing is exactly what happens today too whenever a test never lets + // the 30s interval actually fire). Nest always injects the real one in production + // (PluginsRuntimeModule imports SchedulingModule). + @Optional() private readonly registrar?: CronRegistrarService, ) {} private get db() { @@ -258,8 +281,28 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // boot must NEVER block app init, even in a context without plugin tables // (e.g. a slimmed-down test app that only imports AdminModule). try { - await discoverPlugins(this.db); - const installed = await this.installedDepRows(); + // Plan 3j Task 0 (R-scheduler, inventory §8): `discoverPlugins` + this dependency- + // order read are raw better-sqlite3 today and need no request context to run + // correctly, but the MOMENT either becomes a repository call (Task 2/3) this + // window needs one — landing the wrap now, ahead of that conversion, is the whole + // point of doing this in Task 0. Wrapped ONLY when an ORM is available: the + // no-ORM branch below still needs `order` computed (from THESE two reads) to name + // the skipped plugins in its own log line, so running them unwrapped when `this. + // orm` is absent preserves that already-tested behavior exactly (RT-BOOT-NOORM-001/ + // 002) rather than gating discovery itself on the ORM's presence — a broader + // restructure (option (b): wrap the WHOLE onApplicationBootstrap body in one call, + // matching CronRegistrarService.runOnBoot's shape) was considered and rejected for + // this reason; see task-0-report.md. + let installed: Map; + if (this.orm) { + installed = await withRequestContext(this.orm, async () => { + await discoverPlugins(this.db); + return this.installedDepRows(); + }); + } else { + await discoverPlugins(this.db); + installed = await this.installedDepRows(); + } const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id); let order: string[]; try { @@ -302,16 +345,22 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes } // Fire due scheduled tasks (persistent, userless) on a coarse tick — the // scheduler is minute-granularity by contract, so 30s precision is plenty and - // cheap. Unref'd so it never holds the process open. - this.schedulerSweep = setInterval(() => { + // cheap. Plan 3j Task 0 (R-scheduler): registered through CronRegistrarService + // instead of a bare setInterval, so this tick gets the registrar's own + // withRequestContext wrap (wrappedTick) for free — the SAME choke point every + // other domain's cron already gets — and its test-mode no-op (register() is a + // no-op under NODE_ENV=test, isEnabled() false). No bespoke wrapping code needed + // here: PR4–PR10 stay raw better-sqlite3 for now (Task 2 converts their bodies on + // top of this landed registration) and their calling shape below is byte-for-byte + // what the old setInterval callback ran. + this.registrar?.register(PLUGIN_SCHEDULER_SWEEP_NAME, PLUGIN_SCHEDULER_SWEEP_CRON, () => { // R1.5: a timer callback cannot await. Both helpers already swallow their own // errors (each body is wrapped in try/catch); the .catch below is defense-in- // depth against a future regression, same shape as the pruneErrorLog call in // the onLog hook above. void this.fireDueScheduled().catch(() => { /* fireDueScheduled already handles its own errors — this is a backstop */ }); void this.drainUserErasures(); - }, 30_000); - this.schedulerSweep.unref?.(); + }); } /** Fire every scheduled task that is due on an ACTIVE plugin; re-arm recurring @@ -497,7 +546,13 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes setPluginEventSink(null); setUserDeletedSink(null); setStagedRestoreApplier(null); - if (this.schedulerSweep) { clearInterval(this.schedulerSweep); this.schedulerSweep = null; } + // Plan 3j Task 0: the registrar now owns the sweep's timer lifecycle + // (CronRegistrarService.onApplicationShutdown unregisters every job it holds + // regardless), but this explicit unregister keeps a module-level teardown + // symmetric with the setup above rather than relying solely on a LATER Nest + // shutdown hook — a no-op both when no registrar was ever provided and when + // nothing was ever registered (e.g. plugins disabled). + this.registrar?.unregister(PLUGIN_SCHEDULER_SWEEP_NAME); for (const w of this.linkWatchers.values()) { try { w.close(); } catch { /* ignore */ } } diff --git a/server/src/nest/plugins/plugins-runtime.module.ts b/server/src/nest/plugins/plugins-runtime.module.ts index 3572a3ac7..3eb05174a 100644 --- a/server/src/nest/plugins/plugins-runtime.module.ts +++ b/server/src/nest/plugins/plugins-runtime.module.ts @@ -37,6 +37,7 @@ import { AtlasModule } from '../atlas/atlas.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { JournalRpcModule } from '../journey/journal-rpc.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; /** * The plugin execution half (#plugins, M1/M2): the process supervisor, the @@ -62,6 +63,12 @@ import { JournalRpcModule } from '../journey/journal-rpc.module'; // module: the domains would then have to import this one back and close a cycle. PluginGuardsModule, PluginOAuthModule, + // Plan 3j Task 0 (R-scheduler): PluginRuntimeService now registers its persistent + // scheduler sweep + GDPR erasure drain through CronRegistrarService, which lives + // in a deliberately non-@Global module (server/CLAUDE.md: the one cron path) — so + // it must be imported explicitly here, same as every other job-owning module + // (place-shadow.module.ts is the precedent). + SchedulingModule, WeatherModule, TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PlacesModule, diff --git a/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts new file mode 100644 index 000000000..dc2f0c883 --- /dev/null +++ b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts @@ -0,0 +1,329 @@ +/** + * Plan 3j Task 0 (R-scheduler, plan3j-sql-inventory.md §8 — "the plan's single + * biggest finding"): `PluginRuntimeService`'s persistent per-plugin scheduler sweep + * (`fireDueScheduled`) and its GDPR erasure drain (`drainUserErasures`) used to run + * on a bare, unregistered `setInterval(fn, 30_000)` inside `onApplicationBootstrap` + * — no `CronRegistrarService` registration, no `withRequestContext` wrap, no + * test-mode no-op, and (per program rule 12) the one cron-shaped tick in this + * codebase found bypassing the registrar entirely. Fixed by registering the SAME + * two calls through `CronRegistrarService.register()` instead, at the same cadence + * (a six-field, every-30-seconds cron expression — the `cron` package supports an + * optional leading seconds field; confirmed by reading its README and pinned by the + * cadence assertion below, PLUGIN_SCHEDULER_SWEEP_CRON in plugin-runtime.service.ts). + * PR4–PR10 (the sweep/drain's own SQL) stay raw + * better-sqlite3 in this task — only WHO calls them and WHETHER a request context + * wraps the call changes here; Task 2 converts their statement bodies on top of + * this landed registration. + * + * Two mandatory tests (task-0-brief.md deliverable 2): + * + * (a) RATCHET (`SCHED-SWEEP-001`/`-002`) — the sweep is registered through + * `CronRegistrarService.register()`, not a bare `setInterval`: mocking `cron`'s + * `CronJob.from` the same way `cron-registrar.service.test.ts` does and + * asserting exactly one job was captured, with the expected name and cadence, + * is itself the first mutation-proof (revert to a bare `setInterval` and + * `CronJob.from` is never called for this sweep — `h.jobs` stays empty and the + * length assertion fails before this file gets anywhere near firing a tick). + * Firing that captured tick from a bare context — nothing this file's own test + * body sets up ahead of the call — must not log `cannotUseGlobalContext`/ + * "global EntityManager" (the literal assertion task-0-brief.md deliverable + * 2(a) asks for) AND must POSITIVELY show a request context was active when + * `fireDueScheduled`/`drainUserErasures` ran (`demo-seed-request-context. + * test.ts`'s own M2 lesson: a log-line-only check cannot distinguish a wrapped + * call from an unwrapped one when the wrapped body doesn't yet touch the ORM — + * true here today, since PR4–PR10 haven't converted). A second, harsher + * mutation-proof (`SCHED-SWEEP-002`): a `CronRegistrarService` built WITHOUT an + * ORM fails the SAME sweep's tick closed with the registrar's own distinct + * "no MikroORM available" error (`CRONREG-010`'s exact proof, reproduced here + * against `PluginRuntimeService`'s real registration) — proving this sweep + * genuinely routes through the registrar's one choke point rather than a + * bespoke wrapper of its own that might swallow the missing-context case. + * + * (b) CADENCE/BEHAVIOUR PARITY (`SCHED-SWEEP-003`) — a due one-shot task, a due + * recurring task and a queued GDPR erasure are seeded; firing ONE tick through + * the REAL registered path (not the private methods called directly) proves: + * the one-shot row is deleted BEFORE its delivery is attempted, the recurring + * row is re-armed (a later `due_at`) BEFORE its delivery is attempted (crash- + * safety ordering — asserted AT THE MOMENT delivery fires, not just as an + * end-state, so a future reordering is caught even if delivery itself no-ops), + * and the queued erasure row drains once the plugin ACKs — the SAME + * assertions the legacy `setInterval`-driven sweep produced, now proven + * through `CronRegistrarService.register()`. + * + * A fourth case (`SCHED-SWEEP-004`) covers task-0-brief.md deliverable 3, the + * `onApplicationBootstrap` pre-loop wrap decision: `discoverPlugins`+ + * `installedDepRows` (both raw SQL today, needing no context to run correctly — + * option (a) wraps them together, ONLY when an ORM is available, rather than + * gating the whole method body on one, which would have broken + * `plugin-runtime.boot-no-orm.test.ts`'s already-pinned no-ORM boot behavior; see + * plugin-runtime.service.ts's own comment at the wrap site) already ran inside a + * request context by the time this file's shared `runtime` finished booting. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { RequestContext } from '@mikro-orm/core'; +import { SchedulerRegistry } from '@nestjs/schedule'; + +const h = vi.hoisted(() => ({ + jobs: [] as Array<{ + cronTime: string; + timeZone?: string; + start?: boolean; + onTick: () => unknown; + stopped: boolean; + stop(): void; + }>, +})); +// Same mock shape as tests/unit/nest/cron-registrar.service.test.ts: capture what +// CronRegistrarService.register() hands CronJob.from (including the REAL wrappedTick, +// since register() itself is not mocked) without arming any real timer. +vi.mock('cron', () => ({ + CronJob: { + from: (opts: { cronTime: string; timeZone?: string; start?: boolean; onTick: () => unknown }) => { + const job = { ...opts, stopped: false, stop() { this.stopped = true; } }; + h.jobs.push(job); + return job; + }, + }, +})); + +import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; +import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { AuditService } from '../../../src/nest/audit/audit.service'; +import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service'; +import { createTestAddonsService } from '../../helpers/test-addons'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let runtime: PluginRuntimeService; + +const prevEnabled = process.env.TREK_PLUGINS_ENABLED; +const prevDir = process.env.TREK_PLUGINS_DIR; +const prevDataDir = process.env.TREK_PLUGINS_DATA_DIR; +let codeRoot: string; +let dataRoot: string; +let preLoopContextSeen: boolean | undefined; + +async function buildRuntime(registrar?: CronRegistrarService, orm?: TestOrm['orm']): Promise { + const dbs = new DatabaseService(testDb); + const audit = new AuditService(t.repo(AuditLog), t.repo(Users)); + const addons = await createTestAddonsService(testDb, dbs); + const userSettings = new PluginUserSettingsService(dbs); + return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, orm, registrar); +} + +beforeAll(async () => { + // Global context disallowed — the production setting (matches CRONREG's own D6 + // suite and plugin-runtime.boot-no-orm.test.ts), so a genuinely unwrapped call + // really does throw rather than happening to work under a permissive default. + t = await createTestOrm(testDb, { allowGlobalContext: false }); + process.env.TREK_PLUGINS_ENABLED = 'true'; + // No plugins on disk and none `enabled` in the seeded row set below — discovery + // and the activate loop find nothing to do, so this file only exercises the + // scheduler registration, never a real child spawn (plugin-runtime.test.ts / + // dev-link.test.ts already cover the spawn path). Real (empty) temp dirs rather + // than the server/data default, so discovery is deterministic across environments. + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-sweep-code-')); + dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-sweep-data-')); + process.env.TREK_PLUGINS_DIR = codeRoot; + process.env.TREK_PLUGINS_DATA_DIR = dataRoot; + + const registry = new SchedulerRegistry(); + const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm); + runtime = await buildRuntime(registrar, t.orm); + + // Plan 3j Task 0 deliverable 3 (the onApplicationBootstrap pre-loop wrap): spied + // BEFORE the one onApplicationBootstrap call this file drives, so SCHED-SWEEP-004 + // can assert a request context was already active when installedDepRows (the + // second of the two pre-loop reads discoverPlugins+installedDepRows share ONE + // withRequestContext wrap around, option (a) — see plugin-runtime.service.ts) + // ran, proving the wrap decision without needing either read to touch the ORM + // yet (raw SQL until Task 2/3 convert them). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const preLoopSpy = vi.spyOn(runtime as any, 'installedDepRows').mockImplementation(async function (this: PluginRuntimeService) { + preLoopContextSeen = RequestContext.currentRequestContext() !== undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (Object.getPrototypeOf(runtime) as any).installedDepRows.apply(this); + }); + await runtime.onApplicationBootstrap(); + preLoopSpy.mockRestore(); +}); + +afterAll(async () => { + if (prevEnabled === undefined) delete process.env.TREK_PLUGINS_ENABLED; else process.env.TREK_PLUGINS_ENABLED = prevEnabled; + if (prevDir === undefined) delete process.env.TREK_PLUGINS_DIR; else process.env.TREK_PLUGINS_DIR = prevDir; + if (prevDataDir === undefined) delete process.env.TREK_PLUGINS_DATA_DIR; else process.env.TREK_PLUGINS_DATA_DIR = prevDataDir; + await runtime.onModuleDestroy(); + await t.close(); + testDb.close(); + fs.rmSync(codeRoot, { recursive: true, force: true }); + fs.rmSync(dataRoot, { recursive: true, force: true }); +}); + +const settle = async () => { + // drainUserErasures is fire-and-forget from the tick's own body (parity with the + // legacy `void this.drainUserErasures();`), so its awaited chain (runDrainOnce's + // `await this.supervisor.deliverUserErasure(...)`) is still resolving after + // `onTick()`'s own promise settles — same reasoning boot-sweeps-request- + // context.test.ts's docstring gives for its own extra `setImmediate` waits. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +}; + +describe('PluginRuntimeService scheduler sweep — registered through CronRegistrarService (Plan 3j Task 0, R-scheduler)', () => { + it('SCHED-SWEEP-001: registered via CronJob.from (not a bare setInterval) at the exact legacy cadence; firing the captured tick from a bare context logs no cannotUseGlobalContext line and DOES run fireDueScheduled/drainUserErasures inside a live request context', async () => { + expect(h.jobs).toHaveLength(1); + expect(h.jobs[0].cronTime).toBe('*/30 * * * * *'); + expect(h.jobs[0].start).toBe(true); + + const contextSeen: Record = {}; + // Positive proof (demo-seed-request-context.test.ts's own M2 lesson): a + // log-line-only check can't distinguish "wrapped, body just doesn't touch the + // ORM yet" from "never wrapped at all" — both produce zero error lines today, + // since PR4-PR10 stay raw SQL in this task. Recording + // RequestContext.currentRequestContext() at the instant each private method is + // entered is the real signal (the same one MikroORM's allowGlobalContext gate + // reads at query time), and it is what a genuine `withRequestContext` wrap + // controls, independent of what the wrapped body happens to read. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fireSpy = vi.spyOn(runtime as any, 'fireDueScheduled').mockImplementation(async function (this: PluginRuntimeService, ...args: unknown[]) { + contextSeen.fireDueScheduled = RequestContext.currentRequestContext() !== undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (Object.getPrototypeOf(runtime) as any).fireDueScheduled.apply(this, args); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const drainSpy = vi.spyOn(runtime as any, 'drainUserErasures').mockImplementation(async function (this: PluginRuntimeService, ...args: unknown[]) { + contextSeen.drainUserErasures = RequestContext.currentRequestContext() !== undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (Object.getPrototypeOf(runtime) as any).drainUserErasures.apply(this, args); + }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await h.jobs[0].onTick(); + await settle(); + + expect(fireSpy).toHaveBeenCalledTimes(1); + expect(drainSpy).toHaveBeenCalledTimes(1); + expect(contextSeen.fireDueScheduled).toBe(true); + expect(contextSeen.drainUserErasures).toBe(true); + + const suspicious = errSpy.mock.calls + .map((args) => args.map(String).join(' ')) + .filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line)); + expect(suspicious).toEqual([]); + } finally { + fireSpy.mockRestore(); + drainSpy.mockRestore(); + errSpy.mockRestore(); + } + }); + + it('SCHED-SWEEP-002: without an ORM behind the registrar, the SAME sweep\'s tick throws the registrar\'s own "no MikroORM available" error rather than running unwrapped (CRONREG-010, reproduced against the real PluginRuntimeService registration)', async () => { + const registry2 = new SchedulerRegistry(); + const registrar2 = new CronRegistrarService(registry2, { isTest: () => false } as RuntimeEnvService); // no orm arg + const runtime2 = await buildRuntime(registrar2, undefined); + const before = h.jobs.length; + try { + await runtime2.onApplicationBootstrap(); + expect(h.jobs).toHaveLength(before + 1); + const job2 = h.jobs[before]; + let bodyRan = false; + // Prove the fail-closed error comes from the registrar's own wrapper, not a + // side effect of the tick body itself ever having run. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(runtime2 as any, 'fireDueScheduled').mockImplementation(async () => { bodyRan = true; }); + await expect(job2.onTick()).rejects.toThrow(/no MikroORM available/i); + expect(bodyRan).toBe(false); + } finally { + await runtime2.onModuleDestroy(); + } + }); + + it('SCHED-SWEEP-003: a due one-shot task deletes-before-fires, a due recurring task re-arms-before-fires, and a queued GDPR erasure drains on ACK — fired through the REAL registered tick', async () => { + const pluginId = 'sched-sweep-fixture'; + const now = Date.now(); + const oneShotSeedDueAt = now - 5_000; + const recurringSeedDueAt = now - 5_000; + // runDrainOnce's own first pass reaps a `plugin_user_erasure_queue` row as an + // "orphan" once its plugin_id has no matching `plugins` row AND no data dir on + // disk — a real installed plugin always has (at least) a `plugins` row, so this + // fixture needs one too, or the erasure row would be reaped before ever + // reaching the ACK-gated delivery path this test is actually proving. + testDb.prepare("INSERT INTO plugins (id, name) VALUES (?, ?)").run(pluginId, pluginId); + testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)') + .run(pluginId, 'oneshot', oneShotSeedDueAt, 'null', null); + testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)') + .run(pluginId, 'recurring', recurringSeedDueAt, 'null', 60_000); + testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?,?)').run(pluginId, 4242); + + // Mark the plugin active without a real child spawn — the same technique + // tests/unit/plugins/supervisor-lifecycle.test.ts already uses to drive + // PluginSupervisor's status-gated methods directly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supervisor = (runtime as any).supervisor; + supervisor.running.set(pluginId, { id: pluginId, status: 'active' }); + + const invokeCalls: Array<{ method: string; payload: unknown }> = []; + const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation((...args: unknown[]) => { + const [id, method, payload] = args as [string, string, unknown]; + invokeCalls.push({ method, payload }); + if (method === 'invoke.scheduled') { + const name = (payload as { name: string }).name; + // Crash-safety ordering, asserted AT delivery time (not just end-state): + // the row's disposition write must already have landed by the moment + // delivery is attempted, since a crash here must never double-fire. + if (name === 'oneshot') { + const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'oneshot'); + expect(row).toBeUndefined(); // already deleted before delivery + } else if (name === 'recurring') { + const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'recurring') as { due_at: number } | undefined; + expect(row).toBeDefined(); + expect(row!.due_at).toBeGreaterThan(recurringSeedDueAt); // already re-armed before delivery + } + } + return Promise.resolve({}); + }); + + try { + await h.jobs[0].onTick(); + await settle(); + + // Same-shape delivery calls the legacy setInterval-driven sweep made. + expect(invokeCalls.filter((c) => c.method === 'invoke.scheduled').map((c) => (c.payload as { name: string }).name).sort()) + .toEqual(['oneshot', 'recurring']); + expect(invokeCalls).toContainEqual({ method: 'invoke.deleteUserData', payload: { userId: 4242 } }); + + const oneShotRow = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'oneshot'); + expect(oneShotRow).toBeUndefined(); + const recurringRow = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined; + expect(recurringRow).toBeDefined(); + // Re-armed to Date.now() (read fresh, at fire time — not the seed timestamp) + // + every_ms: bounded rather than exact, since real wall-clock time elapses + // between seeding the row and the tick firing it. + expect(recurringRow!.due_at).toBeGreaterThanOrEqual(now + 60_000); + expect(recurringRow!.due_at).toBeLessThan(now + 60_000 + 30_000); + const erasureRow = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 4242); + expect(erasureRow).toBeUndefined(); // drained on ACK + } finally { + invokeSpy.mockRestore(); + supervisor.running.delete(pluginId); + } + }); + + it('SCHED-SWEEP-004: the onApplicationBootstrap pre-loop reads (discoverPlugins + installedDepRows, option (a) — plugin-runtime.service.ts) already ran inside a request context', () => { + // Recorded in beforeAll, before this file's one onApplicationBootstrap call — + // see that block's own comment. A regression that drops the wrap (or narrows it + // to only one of the two reads while calling them separately, unwrapped) fails + // this assertion directly, independent of PR4-PR53's own conversion state. + expect(preLoopContextSeen).toBe(true); + }); +});