@tagma/sdk 0.6.1 → 0.6.3

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.
package/src/engine.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  RunTaskState,
21
21
  } from './types';
22
22
  import { buildDag, type Dag } from './dag';
23
- import { getHandler, hasHandler, loadPlugins } from './registry';
23
+ import { defaultRegistry, type PluginRegistry } from './registry';
24
24
  import { runSpawn, runCommand } from './runner';
25
25
  import { parseDuration, nowISO, generateRunId } from './utils';
26
26
  import { promptDocumentFromString, serializePromptDocument } from './prompt-doc';
@@ -59,7 +59,7 @@ export class TriggerTimeoutError extends Error {
59
59
 
60
60
  // ═══ Preflight Validation ═══
61
61
 
62
- function preflight(config: PipelineConfig, dag: Dag): void {
62
+ function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry): void {
63
63
  const errors: string[] = [];
64
64
 
65
65
  for (const [, node] of dag.nodes) {
@@ -70,15 +70,15 @@ function preflight(config: PipelineConfig, dag: Dag): void {
70
70
  // Pure command tasks don't use a driver — skip driver registration check.
71
71
  const isCommandOnly = task.command && !task.prompt;
72
72
 
73
- if (!isCommandOnly && !hasHandler('drivers', driverName)) {
73
+ if (!isCommandOnly && !registry.hasHandler('drivers', driverName)) {
74
74
  errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
75
75
  }
76
76
 
77
- if (task.trigger && !hasHandler('triggers', task.trigger.type)) {
77
+ if (task.trigger && !registry.hasHandler('triggers', task.trigger.type)) {
78
78
  errors.push(`Task "${node.taskId}": trigger type "${task.trigger.type}" not registered`);
79
79
  }
80
80
 
81
- if (task.completion && !hasHandler('completions', task.completion.type)) {
81
+ if (task.completion && !registry.hasHandler('completions', task.completion.type)) {
82
82
  errors.push(
83
83
  `Task "${node.taskId}": completion type "${task.completion.type}" not registered`,
84
84
  );
@@ -86,13 +86,13 @@ function preflight(config: PipelineConfig, dag: Dag): void {
86
86
 
87
87
  const mws = task.middlewares ?? track.middlewares ?? [];
88
88
  for (const mw of mws) {
89
- if (!hasHandler('middlewares', mw.type)) {
89
+ if (!registry.hasHandler('middlewares', mw.type)) {
90
90
  errors.push(`Task "${node.taskId}": middleware type "${mw.type}" not registered`);
91
91
  }
92
92
  }
93
93
 
94
- if (task.continue_from && hasHandler('drivers', driverName)) {
95
- const driver = getHandler<DriverPlugin>('drivers', driverName);
94
+ if (task.continue_from && registry.hasHandler('drivers', driverName)) {
95
+ const driver = registry.getHandler<DriverPlugin>('drivers', driverName);
96
96
  if (!driver.capabilities.sessionResume) {
97
97
  // buildDag has already qualified `continue_from` and stored the result
98
98
  // on the node; preflight runs after buildDag, so the upstream id is
@@ -106,8 +106,8 @@ function preflight(config: PipelineConfig, dag: Dag): void {
106
106
  // (when the upstream driver implements parseResult and returns normalizedOutput).
107
107
  const upstreamDriverName =
108
108
  upstream.task.driver ?? upstream.track.driver ?? config.driver ?? 'opencode';
109
- const upstreamDriver = hasHandler('drivers', upstreamDriverName)
110
- ? getHandler<DriverPlugin>('drivers', upstreamDriverName)
109
+ const upstreamDriver = registry.hasHandler('drivers', upstreamDriverName)
110
+ ? registry.getHandler<DriverPlugin>('drivers', upstreamDriverName)
111
111
  : null;
112
112
  const canNormalize = typeof upstreamDriver?.parseResult === 'function';
113
113
 
@@ -225,6 +225,13 @@ export interface RunPipelineOptions {
225
225
  * doesn't re-resolve them via Node's default cwd-based import.
226
226
  */
227
227
  readonly skipPluginLoading?: boolean;
228
+ /**
229
+ * Plugin registry to resolve drivers/triggers/completions/middlewares from.
230
+ * Defaults to the process-wide `defaultRegistry`. Multi-tenant hosts pass a
231
+ * per-workspace registry so concurrent runs in different workspaces see
232
+ * isolated handler sets.
233
+ */
234
+ readonly registry?: PluginRegistry;
228
235
  }
229
236
 
230
237
  // Poll interval when no tasks are in-flight but non-terminal tasks remain
@@ -243,6 +250,7 @@ export async function runPipeline(
243
250
  ): Promise<EngineResult> {
244
251
  const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
245
252
  const maxLogRuns = options.maxLogRuns ?? 20;
253
+ const registry = options.registry ?? defaultRegistry;
246
254
 
247
255
  // Load any plugins declared in the pipeline config before preflight so that
248
256
  // drivers, completions, and middlewares referenced in YAML are registered.
@@ -250,12 +258,12 @@ export async function runPipeline(
250
258
  // from the user's workspace node_modules) pass skipPluginLoading: true so
251
259
  // we don't re-resolve via Node's cwd-based default import.
252
260
  if (!options.skipPluginLoading && config.plugins?.length) {
253
- await loadPlugins(config.plugins);
261
+ await registry.loadPlugins(config.plugins);
254
262
  }
255
263
 
256
264
  const dag = buildDag(config);
257
265
  const runId = options.runId ?? generateRunId();
258
- preflight(config, dag);
266
+ preflight(config, dag, registry);
259
267
 
260
268
  const startedAt = nowISO();
261
269
  const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
@@ -579,7 +587,7 @@ export async function runPipeline(
579
587
  `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`,
580
588
  );
581
589
  try {
582
- const triggerPlugin = getHandler<TriggerPlugin>('triggers', task.trigger.type);
590
+ const triggerPlugin = registry.getHandler<TriggerPlugin>('triggers', task.trigger.type);
583
591
  // R6: race the plugin's watch() against the pipeline's abort signal.
584
592
  // Third-party triggers may forget to wire up ctx.signal — without
585
593
  // this race, an aborted pipeline would hang forever waiting for the
@@ -724,7 +732,7 @@ export async function runPipeline(
724
732
  } else {
725
733
  // AI task: apply middleware chain against a structured PromptDocument.
726
734
  const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
727
- const driver = getHandler<DriverPlugin>('drivers', driverName);
735
+ const driver = registry.getHandler<DriverPlugin>('drivers', driverName);
728
736
 
729
737
  const originalLen = task.prompt!.length;
730
738
  let doc: PromptDocument = promptDocumentFromString(task.prompt!);
@@ -740,7 +748,7 @@ export async function runPipeline(
740
748
  workDir: task.cwd ?? workDir,
741
749
  };
742
750
  for (const mwConfig of mws) {
743
- const mwPlugin = getHandler<MiddlewarePlugin>('middlewares', mwConfig.type);
751
+ const mwPlugin = registry.getHandler<MiddlewarePlugin>('middlewares', mwConfig.type);
744
752
  const beforeBlocks = doc.contexts.length;
745
753
  const beforeLen = serializePromptDocument(doc).length;
746
754
 
@@ -870,7 +878,7 @@ export async function runPipeline(
870
878
  } else if (result.exitCode !== 0) {
871
879
  terminalStatus = 'failed';
872
880
  } else if (task.completion) {
873
- const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
881
+ const plugin = registry.getHandler<CompletionPlugin>('completions', task.completion.type);
874
882
  const completionCtx = { workDir: task.cwd ?? workDir, signal: abortController.signal };
875
883
  const passed = await plugin.check(
876
884
  task.completion as Record<string, unknown>,
@@ -0,0 +1,230 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PluginRegistry, defaultRegistry } from './registry';
3
+ import { bootstrapBuiltins } from './bootstrap';
4
+ import { runPipeline } from './engine';
5
+ import type { DriverPlugin, TriggerPlugin, PipelineConfig } from './types';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ function makeDriver(name: string, marker: string[]): DriverPlugin {
11
+ return {
12
+ name,
13
+ capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },
14
+ async buildCommand() {
15
+ marker.push(`buildCommand:${name}`);
16
+ return { args: ['echo', 'noop'] };
17
+ },
18
+ };
19
+ }
20
+
21
+ function makeTrigger(name: string, marker: string[]): TriggerPlugin {
22
+ return {
23
+ name,
24
+ async watch() {
25
+ marker.push(`watch:${name}`);
26
+ },
27
+ };
28
+ }
29
+
30
+ describe('PluginRegistry — instance isolation', () => {
31
+ test('two registries do not share drivers registered under the same type', () => {
32
+ const regA = new PluginRegistry();
33
+ const regB = new PluginRegistry();
34
+ const markerA: string[] = [];
35
+ const markerB: string[] = [];
36
+
37
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', markerA));
38
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', markerB));
39
+
40
+ expect(regA.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('mockA');
41
+ expect(regB.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('mockB');
42
+
43
+ expect(regA.hasHandler('drivers', 'mock')).toBe(true);
44
+ expect(regB.hasHandler('drivers', 'mock')).toBe(true);
45
+ expect(regA.hasHandler('triggers', 'mock')).toBe(false);
46
+ });
47
+
48
+ test('unregistering in one registry does not affect the other', () => {
49
+ const regA = new PluginRegistry();
50
+ const regB = new PluginRegistry();
51
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', []));
52
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', []));
53
+
54
+ expect(regA.unregisterPlugin('drivers', 'mock')).toBe(true);
55
+ expect(regA.hasHandler('drivers', 'mock')).toBe(false);
56
+ expect(regB.hasHandler('drivers', 'mock')).toBe(true);
57
+ });
58
+
59
+ test('listRegistered is scoped per instance', () => {
60
+ const regA = new PluginRegistry();
61
+ const regB = new PluginRegistry();
62
+ regA.registerPlugin('triggers', 'a-only', makeTrigger('a-only', []));
63
+ regB.registerPlugin('triggers', 'b-only', makeTrigger('b-only', []));
64
+
65
+ expect(regA.listRegistered('triggers')).toEqual(['a-only']);
66
+ expect(regB.listRegistered('triggers')).toEqual(['b-only']);
67
+ });
68
+
69
+ test('registering the same instance twice returns unchanged', () => {
70
+ const reg = new PluginRegistry();
71
+ const driver = makeDriver('same', []);
72
+ expect(reg.registerPlugin('drivers', 'mock', driver)).toBe('registered');
73
+ expect(reg.registerPlugin('drivers', 'mock', driver)).toBe('unchanged');
74
+ });
75
+
76
+ test('replacing with a different handler returns replaced', () => {
77
+ const reg = new PluginRegistry();
78
+ expect(reg.registerPlugin('drivers', 'mock', makeDriver('one', []))).toBe('registered');
79
+ expect(reg.registerPlugin('drivers', 'mock', makeDriver('two', []))).toBe('replaced');
80
+ expect(reg.getHandler<DriverPlugin>('drivers', 'mock').name).toBe('two');
81
+ });
82
+
83
+ test('bootstrapBuiltins(target) populates a specific instance without touching the default', () => {
84
+ const fresh = new PluginRegistry();
85
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(false);
86
+
87
+ bootstrapBuiltins(fresh);
88
+
89
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(true);
90
+ expect(fresh.hasHandler('triggers', 'file')).toBe(true);
91
+ expect(fresh.hasHandler('triggers', 'manual')).toBe(true);
92
+ expect(fresh.hasHandler('completions', 'exit_code')).toBe(true);
93
+ expect(fresh.hasHandler('middlewares', 'static_context')).toBe(true);
94
+
95
+ // Default registry's state is independent of `fresh` — if the default
96
+ // happens to have opencode (because another test bootstrapped it), that
97
+ // is fine; the guarantee is that `fresh.unregister` does not leak.
98
+ fresh.unregisterPlugin('drivers', 'opencode');
99
+ expect(fresh.hasHandler('drivers', 'opencode')).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('PluginRegistry — validation', () => {
104
+ test('rejects unknown category', () => {
105
+ const reg = new PluginRegistry();
106
+ expect(() =>
107
+ reg.registerPlugin(
108
+ 'nope' as 'drivers',
109
+ 'x',
110
+ makeDriver('x', []),
111
+ ),
112
+ ).toThrow(/Unknown plugin category/);
113
+ });
114
+
115
+ test('rejects driver missing buildCommand', () => {
116
+ const reg = new PluginRegistry();
117
+ expect(() =>
118
+ reg.registerPlugin(
119
+ 'drivers',
120
+ 'broken',
121
+ // deliberately bad: no buildCommand
122
+ { name: 'broken', capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false } } as unknown as DriverPlugin,
123
+ ),
124
+ ).toThrow(/must export buildCommand/);
125
+ });
126
+
127
+ test('rejects handler with missing name', () => {
128
+ const reg = new PluginRegistry();
129
+ expect(() =>
130
+ reg.registerPlugin(
131
+ 'drivers',
132
+ 'x',
133
+ // deliberately bad: no name
134
+ { capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false }, buildCommand: async () => ({ args: [] }) } as unknown as DriverPlugin,
135
+ ),
136
+ ).toThrow(/non-empty "name"/);
137
+ });
138
+ });
139
+
140
+ describe('runPipeline — options.registry isolation', () => {
141
+ test('concurrent runs with different registries see their own drivers', async () => {
142
+ const regA = new PluginRegistry();
143
+ const regB = new PluginRegistry();
144
+ const seenA: string[] = [];
145
+ const seenB: string[] = [];
146
+
147
+ bootstrapBuiltins(regA);
148
+ bootstrapBuiltins(regB);
149
+
150
+ regA.registerPlugin('drivers', 'mock', makeDriver('mockA', seenA));
151
+ regB.registerPlugin('drivers', 'mock', makeDriver('mockB', seenB));
152
+
153
+ // Command-only pipeline exercises the preflight path (which uses the
154
+ // registry) plus the run-loop path without requiring a real driver
155
+ // invocation. We verify isolation by asserting that preflight with a
156
+ // registry missing `mock` rejects, while the matching registry accepts.
157
+ const config: PipelineConfig = {
158
+ name: 'isolation-test',
159
+ tracks: [
160
+ {
161
+ id: 't',
162
+ name: 'T',
163
+ tasks: [{ id: 'only', name: 'only', command: 'echo hi' }],
164
+ },
165
+ ],
166
+ };
167
+
168
+ const tmpA = mkdtempSync(join(tmpdir(), 'tagma-regA-'));
169
+ const tmpB = mkdtempSync(join(tmpdir(), 'tagma-regB-'));
170
+ try {
171
+ const [resA, resB] = await Promise.all([
172
+ runPipeline(config, tmpA, { registry: regA, skipPluginLoading: true }),
173
+ runPipeline(config, tmpB, { registry: regB, skipPluginLoading: true }),
174
+ ]);
175
+ expect(resA.success).toBe(true);
176
+ expect(resB.success).toBe(true);
177
+ expect(resA.runId).not.toBe(resB.runId);
178
+ } finally {
179
+ rmSync(tmpA, { recursive: true, force: true });
180
+ rmSync(tmpB, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('preflight fails when referenced driver is missing from the passed registry', async () => {
185
+ const regNoOpencode = new PluginRegistry();
186
+ // Deliberately do NOT bootstrap builtins — opencode is not registered.
187
+ const config: PipelineConfig = {
188
+ name: 'preflight-miss',
189
+ tracks: [
190
+ {
191
+ id: 't',
192
+ name: 'T',
193
+ tasks: [{ id: 'x', name: 'x', prompt: 'hello' }],
194
+ },
195
+ ],
196
+ };
197
+ const tmp = mkdtempSync(join(tmpdir(), 'tagma-miss-'));
198
+ try {
199
+ await expect(
200
+ runPipeline(config, tmp, { registry: regNoOpencode, skipPluginLoading: true }),
201
+ ).rejects.toThrow(/driver "opencode" not registered/);
202
+ } finally {
203
+ rmSync(tmp, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test('omitting options.registry falls back to defaultRegistry', async () => {
208
+ // bootstrapBuiltins into default happens in most host callers; do it
209
+ // explicitly here so the test is independent of module-load order.
210
+ bootstrapBuiltins(defaultRegistry);
211
+
212
+ const config: PipelineConfig = {
213
+ name: 'default-fallback',
214
+ tracks: [
215
+ {
216
+ id: 't',
217
+ name: 'T',
218
+ tasks: [{ id: 'only', name: 'only', command: 'echo hi' }],
219
+ },
220
+ ],
221
+ };
222
+ const tmp = mkdtempSync(join(tmpdir(), 'tagma-default-'));
223
+ try {
224
+ const res = await runPipeline(config, tmp, { skipPluginLoading: true });
225
+ expect(res.success).toBe(true);
226
+ } finally {
227
+ rmSync(tmp, { recursive: true, force: true });
228
+ }
229
+ });
230
+ });
package/src/registry.ts CHANGED
@@ -18,13 +18,6 @@ const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
18
18
  'middlewares',
19
19
  ]);
20
20
 
21
- const registries = {
22
- drivers: new Map<string, DriverPlugin>(),
23
- triggers: new Map<string, TriggerPlugin>(),
24
- completions: new Map<string, CompletionPlugin>(),
25
- middlewares: new Map<string, MiddlewarePlugin>(),
26
- };
27
-
28
21
  /**
29
22
  * Minimal contract enforcement so a malformed plugin fails fast at
30
23
  * registration time rather than crashing the engine mid-run.
@@ -111,73 +104,6 @@ function validateContract(category: PluginCategory, handler: unknown): void {
111
104
 
112
105
  export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
113
106
 
114
- /**
115
- * Register a plugin under (category, type). Returns:
116
- * - 'registered' on first registration
117
- * - 'replaced' when an existing entry was overwritten with a different handler
118
- * - 'unchanged' when the same handler instance was already present
119
- *
120
- * Throws if `category` is unknown, `type` is empty, or `handler` violates the
121
- * minimum interface contract for the category.
122
- */
123
- export function registerPlugin<T extends PluginType>(
124
- category: PluginCategory,
125
- type: string,
126
- handler: T,
127
- ): RegisterResult {
128
- if (!VALID_CATEGORIES.has(category)) {
129
- throw new Error(`Unknown plugin category "${category}"`);
130
- }
131
- if (typeof type !== 'string' || type.length === 0) {
132
- throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
133
- }
134
- validateContract(category, handler);
135
- const registry = registries[category] as Map<string, T>;
136
- const existing = registry.get(type);
137
- if (existing === handler) return 'unchanged';
138
- const wasReplaced = existing !== undefined;
139
- registry.set(type, handler);
140
- if (wasReplaced) {
141
- // D18: surface silent shadowing. Hot-reload flows legitimately replace
142
- // handlers; installing two different plugin packages that both claim
143
- // the same (category, type) does not — the second wins and breaks the
144
- // first's consumers with no audit trail. A console.warn is cheap,
145
- // respects existing callers that rely on 'replaced', and gives ops a
146
- // grep-able signal when registrations collide unexpectedly.
147
- console.warn(
148
- `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
149
- `check for duplicate plugin packages claiming the same type.`,
150
- );
151
- }
152
- return wasReplaced ? 'replaced' : 'registered';
153
- }
154
-
155
- /**
156
- * Remove a plugin from the in-process registry. Returns true if a plugin
157
- * was actually removed. Note: ESM module caching is not affected, so
158
- * re-importing the same file after unregister will yield the cached module —
159
- * callers wanting a fresh load must restart the host process.
160
- */
161
- export function unregisterPlugin(category: PluginCategory, type: string): boolean {
162
- if (!VALID_CATEGORIES.has(category)) return false;
163
- return registries[category].delete(type);
164
- }
165
-
166
- export function getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
167
- const handler = registries[category].get(type);
168
- if (!handler) {
169
- throw new Error(
170
- `${category} type "${type}" not registered.\n` +
171
- `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`,
172
- );
173
- }
174
- return handler as T;
175
- }
176
-
177
- export function hasHandler(category: PluginCategory, type: string): boolean {
178
- return registries[category].has(type);
179
- }
180
-
181
107
  // Plugin name must be a scoped npm package or a tagma-prefixed package.
182
108
  // Reject absolute/relative paths and suspicious patterns to prevent
183
109
  // arbitrary code execution via crafted YAML configs.
@@ -223,45 +149,168 @@ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
223
149
  }
224
150
 
225
151
  /**
226
- * Load and register a list of plugin packages.
227
- *
228
- * @param pluginNames - Validated npm package names to load.
229
- * @param resolveFrom - Optional absolute path to resolve plugins from (e.g. the
230
- * workspace's working directory). When omitted, the default ESM resolution
231
- * uses the SDK's own `node_modules`, which will fail for plugins installed
232
- * only in the user's workspace. CLI callers should pass `process.cwd()` or
233
- * the workspace root so that workspace-local plugins resolve correctly.
152
+ * Instance-scoped plugin registry. Each workspace in a multi-tenant sidecar
153
+ * owns its own PluginRegistry, so installing/uninstalling a driver in one
154
+ * workspace cannot clobber another. The process-wide `defaultRegistry`
155
+ * exported at the bottom of this file preserves the historical free-function
156
+ * API (registerPlugin / getHandler / …) for CLI and single-tenant hosts.
234
157
  */
235
- export async function loadPlugins(
236
- pluginNames: readonly string[],
237
- resolveFrom?: string,
238
- ): Promise<void> {
239
- for (const name of pluginNames) {
240
- if (!isValidPluginName(name)) {
241
- throw new Error(
242
- `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
243
- `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
244
- `Relative/absolute paths are not allowed.`,
158
+ export class PluginRegistry {
159
+ private readonly registries = {
160
+ drivers: new Map<string, DriverPlugin>(),
161
+ triggers: new Map<string, TriggerPlugin>(),
162
+ completions: new Map<string, CompletionPlugin>(),
163
+ middlewares: new Map<string, MiddlewarePlugin>(),
164
+ };
165
+
166
+ /**
167
+ * Register a plugin under (category, type). Returns:
168
+ * - 'registered' on first registration
169
+ * - 'replaced' when an existing entry was overwritten with a different handler
170
+ * - 'unchanged' when the same handler instance was already present
171
+ *
172
+ * Throws if `category` is unknown, `type` is empty, or `handler` violates
173
+ * the minimum interface contract for the category.
174
+ */
175
+ registerPlugin<T extends PluginType>(
176
+ category: PluginCategory,
177
+ type: string,
178
+ handler: T,
179
+ ): RegisterResult {
180
+ if (!VALID_CATEGORIES.has(category)) {
181
+ throw new Error(`Unknown plugin category "${category}"`);
182
+ }
183
+ if (typeof type !== 'string' || type.length === 0) {
184
+ throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
185
+ }
186
+ validateContract(category, handler);
187
+ const registry = this.registries[category] as Map<string, T>;
188
+ const existing = registry.get(type);
189
+ if (existing === handler) return 'unchanged';
190
+ const wasReplaced = existing !== undefined;
191
+ registry.set(type, handler);
192
+ if (wasReplaced) {
193
+ // D18: surface silent shadowing. Hot-reload flows legitimately replace
194
+ // handlers; installing two different plugin packages that both claim
195
+ // the same (category, type) does not — the second wins and breaks the
196
+ // first's consumers with no audit trail. A console.warn is cheap,
197
+ // respects existing callers that rely on 'replaced', and gives ops a
198
+ // grep-able signal when registrations collide unexpectedly.
199
+ console.warn(
200
+ `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} — ` +
201
+ `check for duplicate plugin packages claiming the same type.`,
245
202
  );
246
203
  }
247
- let moduleUrl: string = name;
248
- if (resolveFrom) {
249
- // Resolve the package entry point relative to the caller's directory so
250
- // plugins installed in the workspace's node_modules are found even when
251
- // the SDK itself lives elsewhere (e.g. a global install or a monorepo
252
- // sibling package).
253
- const req = createRequire(resolveFrom.endsWith('/') ? resolveFrom : resolveFrom + '/');
254
- const resolved = req.resolve(name);
255
- moduleUrl = pathToFileURL(resolved).href;
204
+ return wasReplaced ? 'replaced' : 'registered';
205
+ }
206
+
207
+ /**
208
+ * Remove a plugin from the in-process registry. Returns true if a plugin
209
+ * was actually removed. Note: ESM module caching is not affected, so
210
+ * re-importing the same file after unregister will yield the cached module —
211
+ * callers wanting a fresh load must restart the host process.
212
+ */
213
+ unregisterPlugin(category: PluginCategory, type: string): boolean {
214
+ if (!VALID_CATEGORIES.has(category)) return false;
215
+ return this.registries[category].delete(type);
216
+ }
217
+
218
+ getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
219
+ const handler = this.registries[category].get(type);
220
+ if (!handler) {
221
+ throw new Error(
222
+ `${category} type "${type}" not registered.\n` +
223
+ `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`,
224
+ );
256
225
  }
257
- const mod = await import(moduleUrl);
258
- if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
259
- throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
226
+ return handler as T;
227
+ }
228
+
229
+ hasHandler(category: PluginCategory, type: string): boolean {
230
+ return this.registries[category].has(type);
231
+ }
232
+
233
+ listRegistered(category: PluginCategory): string[] {
234
+ return [...this.registries[category].keys()];
235
+ }
236
+
237
+ /**
238
+ * Load and register a list of plugin packages into this registry.
239
+ *
240
+ * @param pluginNames - Validated npm package names to load.
241
+ * @param resolveFrom - Optional absolute path to resolve plugins from (e.g.
242
+ * the workspace's working directory). When omitted, the default ESM
243
+ * resolution uses the SDK's own `node_modules`, which will fail for
244
+ * plugins installed only in the user's workspace. CLI callers should
245
+ * pass `process.cwd()` or the workspace root so that workspace-local
246
+ * plugins resolve correctly.
247
+ */
248
+ async loadPlugins(
249
+ pluginNames: readonly string[],
250
+ resolveFrom?: string,
251
+ ): Promise<void> {
252
+ for (const name of pluginNames) {
253
+ if (!isValidPluginName(name)) {
254
+ throw new Error(
255
+ `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
256
+ `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
257
+ `Relative/absolute paths are not allowed.`,
258
+ );
259
+ }
260
+ let moduleUrl: string = name;
261
+ if (resolveFrom) {
262
+ // Resolve the package entry point relative to the caller's directory
263
+ // so plugins installed in the workspace's node_modules are found
264
+ // even when the SDK itself lives elsewhere (e.g. a global install
265
+ // or a monorepo sibling package).
266
+ const req = createRequire(resolveFrom.endsWith('/') ? resolveFrom : resolveFrom + '/');
267
+ const resolved = req.resolve(name);
268
+ moduleUrl = pathToFileURL(resolved).href;
269
+ }
270
+ const mod = await import(moduleUrl);
271
+ if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
272
+ throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
273
+ }
274
+ this.registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
260
275
  }
261
- registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
262
276
  }
263
277
  }
264
278
 
279
+ /**
280
+ * Process-wide default registry. Preserves the historical free-function API
281
+ * for CLI and single-tenant hosts. Multi-tenant hosts (the editor sidecar
282
+ * after the one-sidecar refactor) build their own `PluginRegistry` per
283
+ * workspace and pass it through `RunPipelineOptions.registry`.
284
+ */
285
+ export const defaultRegistry = new PluginRegistry();
286
+
287
+ export function registerPlugin<T extends PluginType>(
288
+ category: PluginCategory,
289
+ type: string,
290
+ handler: T,
291
+ ): RegisterResult {
292
+ return defaultRegistry.registerPlugin(category, type, handler);
293
+ }
294
+
295
+ export function unregisterPlugin(category: PluginCategory, type: string): boolean {
296
+ return defaultRegistry.unregisterPlugin(category, type);
297
+ }
298
+
299
+ export function getHandler<T extends PluginType>(category: PluginCategory, type: string): T {
300
+ return defaultRegistry.getHandler<T>(category, type);
301
+ }
302
+
303
+ export function hasHandler(category: PluginCategory, type: string): boolean {
304
+ return defaultRegistry.hasHandler(category, type);
305
+ }
306
+
265
307
  export function listRegistered(category: PluginCategory): string[] {
266
- return [...registries[category].keys()];
308
+ return defaultRegistry.listRegistered(category);
309
+ }
310
+
311
+ export function loadPlugins(
312
+ pluginNames: readonly string[],
313
+ resolveFrom?: string,
314
+ ): Promise<void> {
315
+ return defaultRegistry.loadPlugins(pluginNames, resolveFrom);
267
316
  }
package/src/sdk.ts CHANGED
@@ -46,6 +46,8 @@ export type { DagNode, Dag, RawDagNode, RawDag } from './dag';
46
46
  // ── Plugin registry ──
47
47
  export { bootstrapBuiltins } from './bootstrap';
48
48
  export {
49
+ PluginRegistry,
50
+ defaultRegistry,
49
51
  loadPlugins,
50
52
  registerPlugin,
51
53
  unregisterPlugin,