@checkstack/backend 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +18 -13
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/cache-manager.test.ts +172 -0
  13. package/src/services/cache-manager.ts +67 -14
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/event-bus.test.ts +52 -0
  19. package/src/services/event-bus.ts +27 -1
  20. package/src/services/plugin-artifact-store.ts +131 -0
  21. package/src/services/plugin-bundle-resolver.ts +76 -0
  22. package/src/services/plugin-event-recorder.ts +87 -0
  23. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  24. package/src/services/plugin-installers/github-installer.ts +207 -0
  25. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  26. package/src/services/plugin-installers/installer-registry.ts +51 -0
  27. package/src/services/plugin-installers/npm-installer.ts +156 -0
  28. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  29. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  30. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  31. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  32. package/src/services/plugin-manager-orchestrator.ts +522 -0
  33. package/src/services/plugin-manager-router.ts +219 -0
  34. package/src/services/queue-manager.ts +77 -2
  35. package/src/services/queue-proxy.ts +7 -0
  36. package/src/utils/plugin-discovery.test.ts +6 -0
  37. package/src/utils/plugin-discovery.ts +6 -1
  38. package/tsconfig.json +3 -0
  39. package/src/plugin-lifecycle.test.ts +0 -276
  40. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  41. package/src/services/plugin-installer.test.ts +0 -90
  42. package/src/services/plugin-installer.ts +0 -70
@@ -1,4 +1,6 @@
1
1
  import type { Hono } from "hono";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
2
4
  import { adminPool, db } from "./db";
3
5
  import { ServiceRegistry } from "./services/service-registry";
4
6
  import type { CoreCollectorRegistry } from "./services/collector-registry";
@@ -14,12 +16,14 @@ import {
14
16
  } from "@checkstack/backend-api";
15
17
  import type { AnyContractRouter } from "@orpc/contract";
16
18
  import type { AccessRule, PluginMetadata } from "@checkstack/common";
19
+ import { extractErrorMessage } from "@checkstack/common";
17
20
 
18
21
  // Extracted modules
19
22
  import { registerCoreServices } from "./plugin-manager/core-services";
20
23
  import { createExtensionPointManager } from "./plugin-manager/extension-points";
21
24
  import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
22
25
  import { rootLogger } from "./logger";
26
+ import type { PluginEventRecorder } from "./services/plugin-event-recorder";
23
27
 
24
28
  export interface DeregisterOptions {
25
29
  deleteSchema: boolean;
@@ -58,6 +62,14 @@ export class PluginManager {
58
62
  // Global readiness registry — plugins contribute probes, /ready aggregates them
59
63
  private readinessRegistry: CoreReadinessRegistry;
60
64
 
65
+ // Audit/error event recorder — wired post-construction in index.ts.
66
+ private eventRecorder: PluginEventRecorder | undefined;
67
+
68
+ // Filesystem location plugins are installed into at runtime (set in
69
+ // index.ts). The runtime install pipeline reads from here on bootstrap
70
+ // and writes to here when handling broadcast install hooks.
71
+ private runtimeDir: string | undefined;
72
+
61
73
  // Resolves once `/api/:pluginId/*` is registered on the root router and
62
74
  // Phase 2 (per-plugin init) is starting. The HTTP server awaits this
63
75
  // promise to know when it is safe to stop gating incoming requests.
@@ -82,6 +94,60 @@ export class PluginManager {
82
94
  this.readinessRegistry = registries.readinessRegistry;
83
95
  }
84
96
 
97
+ /**
98
+ * Register access rules owned by a core router (not a regular plugin).
99
+ * Used by `pluginmanager` and other built-in admin endpoints whose access
100
+ * rules need to be visible to the autoAuthMiddleware.
101
+ */
102
+ registerCoreAccessRules(
103
+ pluginId: string,
104
+ accessRules: AccessRule[],
105
+ ): void {
106
+ const prefixed = accessRules.map((rule) => ({
107
+ ...rule,
108
+ pluginId,
109
+ id: `${pluginId}.${rule.id}`,
110
+ }));
111
+ this.registeredAccessRules.push(...prefixed);
112
+ }
113
+
114
+ /**
115
+ * Register plugin metadata owned by a core router. The /api/:pluginId/*
116
+ * dispatcher in api-router.ts looks up `pluginMetadataRegistry` to build
117
+ * the RpcContext and 500s with "Plugin metadata not found in registry"
118
+ * when the lookup misses. Regular plugins populate the registry as part
119
+ * of their register() lifecycle; core routers (which never go through
120
+ * that lifecycle) need to call this method to register theirs.
121
+ */
122
+ registerCorePluginMetadata(metadata: PluginMetadata): void {
123
+ this.pluginMetadataRegistry.set(metadata.pluginId, metadata);
124
+ }
125
+
126
+ /**
127
+ * Expose the underlying ServiceRegistry to internal core code. Plugins
128
+ * should NEVER touch this directly — they go through the registered
129
+ * service refs.
130
+ */
131
+ getRegistry(): ServiceRegistry {
132
+ return this.registry;
133
+ }
134
+
135
+ setEventRecorder(recorder: PluginEventRecorder): void {
136
+ this.eventRecorder = recorder;
137
+ }
138
+
139
+ getEventRecorder(): PluginEventRecorder | undefined {
140
+ return this.eventRecorder;
141
+ }
142
+
143
+ setRuntimeDir(dir: string): void {
144
+ this.runtimeDir = dir;
145
+ }
146
+
147
+ getRuntimeDir(): string | undefined {
148
+ return this.runtimeDir;
149
+ }
150
+
85
151
  /**
86
152
  * Get the global readiness registry so the server-level /ready endpoint
87
153
  * can aggregate plugin-contributed probes.
@@ -158,17 +224,17 @@ export class PluginManager {
158
224
  }
159
225
 
160
226
  /**
161
- * Deregister a plugin at runtime.
162
- * Only works for plugins with isUninstallable: true.
227
+ * In-process teardown of a plugin. Runs on EVERY instance that receives
228
+ * the deregistration broadcast (the originator AND all replicas).
229
+ *
230
+ * Strictly memory-only: clears registries, runs cleanup handlers, removes
231
+ * collectors and access rules. **Does NOT touch shared persistent state**
232
+ * (Postgres schemas, plugin_configs, plugin_artifacts, plugins rows) —
233
+ * destructive cleanup is the originator's job, see `deletePluginData`.
163
234
  */
164
- async deregisterPlugin(
165
- pluginId: string,
166
- options: DeregisterOptions
167
- ): Promise<void> {
168
- rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
235
+ async deregisterPluginInProcess(pluginId: string): Promise<void> {
236
+ rootLogger.info(`🔄 Deregistering plugin in-process: ${pluginId}...`);
169
237
 
170
- // 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
171
- // This lets other plugins on THIS instance cleanup dependencies
172
238
  const eventBus = await this.registry.get(coreServices.eventBus, {
173
239
  pluginId: "core",
174
240
  });
@@ -177,7 +243,6 @@ export class PluginManager {
177
243
  reason: "uninstall" as const,
178
244
  });
179
245
 
180
- // 2. Run cleanup handlers (LIFO order)
181
246
  const handlers = this.cleanupHandlers.get(pluginId) || [];
182
247
  for (const handler of handlers.toReversed()) {
183
248
  try {
@@ -188,7 +253,6 @@ export class PluginManager {
188
253
  }
189
254
  this.cleanupHandlers.delete(pluginId);
190
255
 
191
- // 3. Unsubscribe from all EventBus hooks
192
256
  const subscriptions = this.hookSubscriptions.get(pluginId) || [];
193
257
  for (const unsubscribe of subscriptions) {
194
258
  try {
@@ -199,48 +263,94 @@ export class PluginManager {
199
263
  }
200
264
  this.hookSubscriptions.delete(pluginId);
201
265
 
202
- // 4. Remove from router maps and contract registry
203
266
  this.pluginRpcRouters.delete(pluginId);
204
267
  this.pluginHttpHandlers.delete(pluginId);
205
268
  this.pluginContractRegistry.delete(pluginId);
206
- rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
269
+ this.pluginMetadataRegistry.delete(pluginId);
207
270
 
208
- // 4b. Cleanup collectors
209
- // - Remove collectors owned by this plugin
210
271
  this.collectorRegistry.unregisterByOwner(pluginId);
211
- // - Remove collectors that have no loaded strategy plugins
212
- // (exclude the current plugin being deregistered)
213
272
  const loadedPluginIds = new Set(
214
- [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
273
+ [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId),
215
274
  );
216
275
  this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
217
276
 
218
- // 5. Remove access rules from registry
219
- const beforeCount = this.registeredAccessRules.length;
220
277
  this.registeredAccessRules = this.registeredAccessRules.filter(
221
- (p) => p.pluginId !== pluginId
278
+ (p) => p.pluginId !== pluginId,
222
279
  );
223
- rootLogger.debug(
224
- ` -> Removed ${
225
- beforeCount - this.registeredAccessRules.length
226
- } access rules`
280
+
281
+ await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
282
+ rootLogger.info(`✅ Plugin deregistered in-process: ${pluginId}`);
283
+ }
284
+
285
+ /**
286
+ * Originator-only destructive cleanup.
287
+ *
288
+ * Runs AFTER `deregisterPluginInProcess` has been broadcast and acked
289
+ * across all instances. Drops the plugin's Postgres schema, deletes its
290
+ * plugin_configs rows, deletes the artifact, deletes the `plugins` row.
291
+ *
292
+ * Multiple instances must NOT call this concurrently — coordination via
293
+ * the originator's request handler is the contract.
294
+ */
295
+ async deletePluginData({
296
+ pluginIds,
297
+ bundleId,
298
+ deleteSchema,
299
+ deleteConfigs,
300
+ }: {
301
+ pluginIds: string[];
302
+ bundleId?: string | null;
303
+ deleteSchema: boolean;
304
+ deleteConfigs: boolean;
305
+ }): Promise<void> {
306
+ rootLogger.info(
307
+ `🗑 Originator: cleaning up data for ${pluginIds.join(", ")}...`,
227
308
  );
228
309
 
229
- // 6. Drop schema if requested
230
- if (options.deleteSchema) {
231
- try {
232
- const schemaName = `plugin_${pluginId}`;
233
- await db.execute(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
234
- rootLogger.info(` -> Dropped schema: ${schemaName}`);
235
- } catch (error) {
236
- rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
310
+ const { plugins, pluginConfigs } = await import("./schema");
311
+ const { inArray, eq, and } = await import("drizzle-orm");
312
+
313
+ if (deleteSchema) {
314
+ for (const pluginId of pluginIds) {
315
+ try {
316
+ const schemaName = `plugin_${pluginId}`;
317
+ await db.execute(
318
+ `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`,
319
+ );
320
+ rootLogger.info(` -> Dropped schema: ${schemaName}`);
321
+ } catch (error) {
322
+ rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
323
+ }
237
324
  }
238
325
  }
239
326
 
240
- // 7. Emit pluginDeregistered hook (for access rule cleanup in auth-backend)
241
- await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
327
+ if (deleteConfigs) {
328
+ await db
329
+ .delete(pluginConfigs)
330
+ .where(inArray(pluginConfigs.pluginId, pluginIds));
331
+ rootLogger.debug(` -> Deleted plugin_configs rows`);
332
+ }
242
333
 
243
- rootLogger.info(`✅ Plugin deregistered: ${pluginId}`);
334
+ const artifactStore = await this.registry.get(
335
+ coreServices.pluginArtifactStore,
336
+ { pluginId: "core" },
337
+ );
338
+ if (bundleId) {
339
+ await artifactStore.deleteByBundle({ bundleId });
340
+ } else {
341
+ for (const pluginName of pluginIds) {
342
+ await artifactStore.delete({ pluginName });
343
+ }
344
+ }
345
+ rootLogger.debug(` -> Deleted plugin_artifacts rows`);
346
+
347
+ await (bundleId ? db.delete(plugins).where(eq(plugins.bundleId, bundleId)) : db.delete(plugins).where(
348
+ and(
349
+ inArray(plugins.name, pluginIds),
350
+ eq(plugins.isUninstallable, true),
351
+ ),
352
+ ));
353
+ rootLogger.info(`✅ Originator: data cleanup complete`);
244
354
  }
245
355
 
246
356
  /**
@@ -253,49 +363,39 @@ export class PluginManager {
253
363
  }
254
364
 
255
365
  /**
256
- * Request deregistration of a plugin across all instances.
257
- * This emits a broadcast hook that all instances (including this one) will receive.
258
- * Each instance then performs local cleanup via deregisterPlugin().
366
+ * Originator-side: broadcast in-process deregistration to all instances.
367
+ * Carries plugin ids only no destructive flags. Destructive ops happen
368
+ * locally on the originator after the broadcast settles
369
+ * (see `deletePluginData`).
259
370
  */
260
- async requestDeregistration(
261
- pluginId: string,
262
- options: DeregisterOptions
263
- ): Promise<void> {
264
- rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
265
-
266
- // Emit broadcast hook - all instances receive and perform local cleanup
371
+ async broadcastDeregistration(pluginIds: string[]): Promise<void> {
372
+ rootLogger.info(`📢 Broadcasting deregistration: ${pluginIds.join(", ")}`);
267
373
  const eventBus = await this.registry.get(coreServices.eventBus, {
268
374
  pluginId: "core",
269
375
  });
270
- await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
271
- pluginId,
272
- deleteSchema: options.deleteSchema,
273
- });
274
-
275
- rootLogger.info(`✅ Deregistration request broadcast for: ${pluginId}`);
376
+ for (const pluginId of pluginIds) {
377
+ await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
378
+ pluginId,
379
+ deleteSchema: false, // unused by listeners now — destructive ops are originator-only
380
+ });
381
+ }
276
382
  }
277
383
 
278
384
  /**
279
- * Request installation/loading of a plugin across all instances.
280
- * This emits a broadcast hook that all instances (including this one) will receive.
281
- * Each instance then loads the plugin into memory.
385
+ * Originator-side: broadcast in-process installation to all instances.
386
+ * Each receiver pulls the artifact from `plugin_artifacts` and loads.
282
387
  */
283
- async requestInstallation(
284
- pluginId: string,
285
- pluginPath: string
286
- ): Promise<void> {
287
- rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
288
-
289
- // Emit broadcast hook - all instances receive and load the plugin
388
+ async broadcastInstallation(pluginIds: string[]): Promise<void> {
389
+ rootLogger.info(`📢 Broadcasting installation: ${pluginIds.join(", ")}`);
290
390
  const eventBus = await this.registry.get(coreServices.eventBus, {
291
391
  pluginId: "core",
292
392
  });
293
- await eventBus.emit(coreHooks.pluginInstallationRequested, {
294
- pluginId,
295
- pluginPath,
296
- });
297
-
298
- rootLogger.info(`✅ Installation request broadcast for: ${pluginId}`);
393
+ for (const pluginId of pluginIds) {
394
+ await eventBus.emit(coreHooks.pluginInstallationRequested, {
395
+ pluginId,
396
+ pluginPath: "", // ignored — receivers resolve via plugin_artifacts
397
+ });
398
+ }
299
399
  }
300
400
 
301
401
  /**
@@ -307,26 +407,61 @@ export class PluginManager {
307
407
  pluginId: "core",
308
408
  });
309
409
 
310
- // Listen for deregistration broadcasts (from any instance)
410
+ // Listen for deregistration broadcasts (from any instance) — every
411
+ // instance does in-process teardown only. Originator separately runs
412
+ // `deletePluginData` after the broadcast settles.
311
413
  await eventBus.subscribe(
312
414
  "core",
313
415
  coreHooks.pluginDeregistrationRequested,
314
- async ({ pluginId, deleteSchema }) => {
416
+ async ({ pluginId }) => {
315
417
  rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
316
- await this.deregisterPlugin(pluginId, { deleteSchema });
317
- }
418
+ try {
419
+ await this.deregisterPluginInProcess(pluginId);
420
+ await this.eventRecorder?.record({
421
+ pluginName: pluginId,
422
+ action: "uninstall",
423
+ phase: "in-process-unload",
424
+ status: "succeeded",
425
+ });
426
+ } catch (error) {
427
+ await this.eventRecorder?.record({
428
+ pluginName: pluginId,
429
+ action: "uninstall",
430
+ phase: "in-process-unload",
431
+ status: "failed",
432
+ error: extractErrorMessage(error),
433
+ });
434
+ throw error;
435
+ }
436
+ },
318
437
  );
319
438
 
320
- // Listen for installation broadcasts (from any instance)
439
+ // Listen for installation broadcasts (from any instance) — every
440
+ // instance hydrates the artifact (if not already on disk) then loads.
321
441
  await eventBus.subscribe(
322
442
  "core",
323
443
  coreHooks.pluginInstallationRequested,
324
- async ({ pluginId, pluginPath }) => {
325
- rootLogger.info(
326
- `📥 Received installation request for: ${pluginId} at ${pluginPath}`
327
- );
328
- await this.loadSinglePlugin(pluginId, pluginPath);
329
- }
444
+ async ({ pluginId }) => {
445
+ rootLogger.info(`📥 Received installation request for: ${pluginId}`);
446
+ try {
447
+ await this.hydrateAndLoadPlugin(pluginId);
448
+ await this.eventRecorder?.record({
449
+ pluginName: pluginId,
450
+ action: "install",
451
+ phase: "in-process-load",
452
+ status: "succeeded",
453
+ });
454
+ } catch (error) {
455
+ await this.eventRecorder?.record({
456
+ pluginName: pluginId,
457
+ action: "install",
458
+ phase: "in-process-load",
459
+ status: "failed",
460
+ error: extractErrorMessage(error),
461
+ });
462
+ throw error;
463
+ }
464
+ },
330
465
  );
331
466
 
332
467
  rootLogger.debug("🔗 Lifecycle listeners registered");
@@ -345,51 +480,106 @@ export class PluginManager {
345
480
  }
346
481
 
347
482
  /**
348
- * Load a single plugin at runtime (for dynamic installation).
349
- * If the plugin isn't available locally, it will be installed via npm first.
350
- * This imports the plugin module, registers it, and initializes it.
483
+ * Resolve a plugin's `plugins` row, fetch its artifact from the artifact
484
+ * store (or original `PluginSource` as a fallback), install it into the
485
+ * runtime dir if not present, and run `loadSinglePlugin`. Used by both
486
+ * the installation broadcast handler AND the fresh-instance bootstrap
487
+ * step in `loadPlugins`.
488
+ */
489
+ async hydrateAndLoadPlugin(pluginId: string): Promise<void> {
490
+ const { plugins: pluginsTable } = await import("./schema");
491
+ const { eq } = await import("drizzle-orm");
492
+
493
+ const rows = await db
494
+ .select()
495
+ .from(pluginsTable)
496
+ .where(eq(pluginsTable.name, pluginId))
497
+ .limit(1);
498
+
499
+ if (rows.length === 0) {
500
+ throw new Error(`Plugin '${pluginId}' has no row in 'plugins' table`);
501
+ }
502
+ const row = rows[0];
503
+
504
+ // Fast path: monorepo-local plugin, just load by package name / path.
505
+ if (!row.isUninstallable) {
506
+ await this.loadSinglePlugin(pluginId, row.path);
507
+ return;
508
+ }
509
+
510
+ if (!this.runtimeDir) {
511
+ throw new Error(
512
+ `Runtime plugin dir not configured — call setRuntimeDir before hydrating runtime plugins.`,
513
+ );
514
+ }
515
+
516
+ const pkgDir = path.join(this.runtimeDir, "node_modules", pluginId);
517
+ if (!fs.existsSync(path.join(pkgDir, "package.json"))) {
518
+ // Module not installed yet — pull from artifact store and install.
519
+ const artifactStore = await this.registry.get(
520
+ coreServices.pluginArtifactStore,
521
+ { pluginId: "core" },
522
+ );
523
+ const artifact = await artifactStore.fetch({
524
+ pluginName: pluginId,
525
+ version: row.version,
526
+ });
527
+ if (!artifact) {
528
+ throw new Error(
529
+ `No tarball found in plugin_artifacts for ${pluginId}@${row.version}. ` +
530
+ `The plugin row exists but its artifact is missing — re-install from the original source.`,
531
+ );
532
+ }
533
+ const allowInstallScripts =
534
+ (row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
535
+ ?.checkstack?.allowInstallScripts === true;
536
+
537
+ const installerRegistry = await this.registry.get(
538
+ coreServices.pluginInstallerRegistry,
539
+ { pluginId: "core" },
540
+ );
541
+ // Any installer's installFromArtifact does the same thing (delegates
542
+ // to the shared install-from-tarball helper) — pick npm.
543
+ await installerRegistry
544
+ .forSource("npm")
545
+ .installFromArtifact({
546
+ tarball: artifact.tarball,
547
+ pluginName: pluginId,
548
+ allowInstallScripts,
549
+ });
550
+ }
551
+
552
+ await this.loadSinglePlugin(pluginId, pkgDir);
553
+ }
554
+
555
+ /**
556
+ * Register + initialize an installed plugin module.
351
557
  *
352
- * @param pluginId - The plugin ID (package name)
353
- * @param pluginPath - The expected path (may not exist yet on this instance)
558
+ * Pre-condition: the package must already be importable (either monorepo
559
+ * source via the workspace, or installed under `runtime_plugins/node_modules`
560
+ * by `hydrateAndLoadPlugin`).
354
561
  */
355
562
  async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
356
563
  rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
357
564
 
358
- // Emit instance-local installing hook
359
565
  const eventBus = await this.registry.get(coreServices.eventBus, {
360
566
  pluginId: "core",
361
567
  });
362
568
  await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
363
569
 
364
570
  try {
365
- // 1. Try to import the plugin - if it fails, install it first
366
571
  let pluginModule;
367
572
 
368
573
  try {
369
- // Try importing by package name first
370
574
  pluginModule = await import(pluginId);
371
575
  } catch {
372
576
  try {
373
- // Try importing by path
374
577
  pluginModule = await import(pluginPath);
375
- } catch {
376
- // Plugin not available locally - need to install it
377
- rootLogger.info(
378
- ` -> Plugin ${pluginId} not found locally, installing via npm...`
379
- );
380
-
381
- const installer = await this.registry.get(
382
- coreServices.pluginInstaller,
383
- { pluginId: "core" }
578
+ } catch (error) {
579
+ throw new Error(
580
+ `Plugin ${pluginId} module not available locally — call hydrateAndLoadPlugin instead, or check that the plugin is correctly installed.`,
581
+ { cause: error },
384
582
  );
385
- const result = await installer.install(pluginId);
386
-
387
- // Now try importing again
388
- try {
389
- pluginModule = await import(result.name);
390
- } catch {
391
- pluginModule = await import(result.path);
392
- }
393
583
  }
394
584
  }
395
585
 
package/src/schema.ts CHANGED
@@ -7,6 +7,10 @@ import {
7
7
  timestamp,
8
8
  jsonb,
9
9
  primaryKey,
10
+ uuid,
11
+ integer,
12
+ index,
13
+ uniqueIndex,
10
14
  } from "drizzle-orm/pg-core";
11
15
 
12
16
  // --- Plugin System Schema ---
@@ -18,6 +22,18 @@ export const plugins = pgTable("plugins", {
18
22
  config: json("config").default({}),
19
23
  enabled: boolean("enabled").default(true).notNull(),
20
24
  type: text("type").default("backend").notNull(),
25
+ // ── Runtime plugin system additions ────────────────────────────────────────
26
+ /** Installed version (matches package.json `version`). Empty string for
27
+ * legacy rows that predate the runtime install system. */
28
+ version: text("version").default("").notNull(),
29
+ /** Full validated `InstallPackageMetadata` snapshot taken at install time. */
30
+ metadata: jsonb("metadata").default({}).notNull(),
31
+ /** The `PluginSource` used to install this plugin (NULL for monorepo-local). */
32
+ source: jsonb("source"),
33
+ /** Groups sibling rows installed together as one bundle. */
34
+ bundleId: uuid("bundle_id"),
35
+ /** True on the primary row of a bundle (the row that declared the bundle). */
36
+ isPrimary: boolean("is_primary").default(false).notNull(),
21
37
  });
22
38
 
23
39
  // --- JWT Key Store Schema ---
@@ -42,5 +58,67 @@ export const pluginConfigs = pgTable(
42
58
  },
43
59
  (table) => ({
44
60
  pk: primaryKey({ columns: [table.pluginId, table.configId] }),
45
- })
61
+ }),
62
+ );
63
+
64
+ // --- Plugin Artifact Store ---
65
+ // Tarball bytes for runtime-installed plugins. Shared across all instances
66
+ // so a freshly spun replica can recover plugins without re-fetching from
67
+ // the original source.
68
+ export const pluginArtifacts = pgTable(
69
+ "plugin_artifacts",
70
+ {
71
+ id: uuid("id").primaryKey().defaultRandom(),
72
+ pluginName: text("plugin_name").notNull(),
73
+ version: text("version").notNull(),
74
+ bundleId: uuid("bundle_id"),
75
+ tarball: text("tarball").notNull(), // base64-encoded bytes (drizzle bytea handling is awkward; text is portable)
76
+ contentHash: text("content_hash").notNull(), // sha256 hex
77
+ sizeBytes: integer("size_bytes").notNull(),
78
+ createdAt: timestamp("created_at").notNull().defaultNow(),
79
+ },
80
+ (table) => ({
81
+ pluginNameVersionIdx: uniqueIndex("plugin_artifacts_name_version_idx").on(
82
+ table.pluginName,
83
+ table.version,
84
+ ),
85
+ contentHashIdx: index("plugin_artifacts_content_hash_idx").on(
86
+ table.contentHash,
87
+ ),
88
+ }),
89
+ );
90
+
91
+ // --- Plugin Install Events (audit + reviewable error log) ---
92
+ export const pluginInstallEvents = pgTable(
93
+ "plugin_install_events",
94
+ {
95
+ id: uuid("id").primaryKey().defaultRandom(),
96
+ pluginName: text("plugin_name"),
97
+ bundleId: uuid("bundle_id"),
98
+ /** "install" | "uninstall" */
99
+ action: text("action").notNull(),
100
+ /**
101
+ * Phase within action:
102
+ * "validate" | "persist" | "broadcast" | "in-process-load" |
103
+ * "in-process-unload" | "destructive-cleanup" | "audit"
104
+ */
105
+ phase: text("phase").notNull(),
106
+ /** "started" | "succeeded" | "failed" */
107
+ status: text("status").notNull(),
108
+ source: jsonb("source"),
109
+ error: text("error"),
110
+ instanceId: text("instance_id").notNull(),
111
+ userId: text("user_id"),
112
+ createdAt: timestamp("created_at").notNull().defaultNow(),
113
+ },
114
+ (table) => ({
115
+ pluginNameCreatedIdx: index("plugin_install_events_name_created_idx").on(
116
+ table.pluginName,
117
+ table.createdAt,
118
+ ),
119
+ statusCreatedIdx: index("plugin_install_events_status_created_idx").on(
120
+ table.status,
121
+ table.createdAt,
122
+ ),
123
+ }),
46
124
  );