agent-detective 1.0.0
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/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/chunk-H2IXGHNA.js +1237 -0
- package/dist/chunk-OIYJYLCB.js +685 -0
- package/dist/doctor-3ZMDZLW6.js +193 -0
- package/dist/index.js +661 -0
- package/dist/init-IPCDLNHA.js +179 -0
- package/package.json +88 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/queue.ts
|
|
4
|
+
function createLimitedConcurrencyTaskQueue(inner, maxConcurrent, logger) {
|
|
5
|
+
if (maxConcurrent < 1) {
|
|
6
|
+
throw new Error("maxConcurrent must be at least 1");
|
|
7
|
+
}
|
|
8
|
+
let active = 0;
|
|
9
|
+
const waiters = [];
|
|
10
|
+
async function acquire() {
|
|
11
|
+
if (active < maxConcurrent) {
|
|
12
|
+
active += 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await new Promise((resolve2) => {
|
|
16
|
+
waiters.push(() => {
|
|
17
|
+
active += 1;
|
|
18
|
+
resolve2();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function release() {
|
|
23
|
+
active -= 1;
|
|
24
|
+
const next = waiters.shift();
|
|
25
|
+
if (next) next();
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
enqueue(queueKey, fn) {
|
|
29
|
+
return inner.enqueue(queueKey, async () => {
|
|
30
|
+
await acquire();
|
|
31
|
+
const waitersDepth = waiters.length;
|
|
32
|
+
if (waitersDepth > 0 && waitersDepth % 10 === 0) {
|
|
33
|
+
logger.info(`task_concurrency_queue depth=${waitersDepth} max=${maxConcurrent}`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await fn();
|
|
37
|
+
} finally {
|
|
38
|
+
release();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
shutdown: inner.shutdown
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function createEnqueue(queues, logger) {
|
|
46
|
+
return function enqueue(queueKey, fn) {
|
|
47
|
+
const enqueuedAt = Date.now();
|
|
48
|
+
const prevTail = queues.get(queueKey) || Promise.resolve();
|
|
49
|
+
const work = prevTail.then(() => {
|
|
50
|
+
const queueWaitMs = Date.now() - enqueuedAt;
|
|
51
|
+
logger.info(`queue_start key=${queueKey} queue_wait_ms=${queueWaitMs}`);
|
|
52
|
+
return fn();
|
|
53
|
+
});
|
|
54
|
+
const nextTail = work.catch((err) => {
|
|
55
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
56
|
+
logger.error(`queue_error key=${queueKey}: ${e.message}`);
|
|
57
|
+
});
|
|
58
|
+
queues.set(queueKey, nextTail);
|
|
59
|
+
work.finally(() => {
|
|
60
|
+
if (queues.get(queueKey) === nextTail) {
|
|
61
|
+
queues.delete(queueKey);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return work;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function createMemoryTaskQueue(logger) {
|
|
68
|
+
const queues = /* @__PURE__ */ new Map();
|
|
69
|
+
return { enqueue: createEnqueue(queues, logger) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/core/schema-validator.ts
|
|
73
|
+
var SCHEMA_VERSION = "1.0";
|
|
74
|
+
function validatePluginSchema(pluginExport) {
|
|
75
|
+
if (!pluginExport || typeof pluginExport !== "object") {
|
|
76
|
+
throw new Error("Plugin must export an object");
|
|
77
|
+
}
|
|
78
|
+
const plugin = pluginExport;
|
|
79
|
+
if (typeof plugin.name !== "string" || !plugin.name) {
|
|
80
|
+
throw new Error("Plugin must have a name string");
|
|
81
|
+
}
|
|
82
|
+
if (typeof plugin.version !== "string" || !plugin.version) {
|
|
83
|
+
throw new Error("Plugin must have a version string");
|
|
84
|
+
}
|
|
85
|
+
if (typeof plugin.register !== "function") {
|
|
86
|
+
throw new Error("Plugin must export a register function");
|
|
87
|
+
}
|
|
88
|
+
if (!plugin.schemaVersion) {
|
|
89
|
+
throw new Error(`Plugin must declare schemaVersion: '${SCHEMA_VERSION}'`);
|
|
90
|
+
}
|
|
91
|
+
if (plugin.schemaVersion !== SCHEMA_VERSION) {
|
|
92
|
+
throw new Error(`Plugin schema version mismatch: expected ${SCHEMA_VERSION}, got ${plugin.schemaVersion}`);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
function validatePluginConfig(pluginExport, config) {
|
|
97
|
+
if (!pluginExport.schema) {
|
|
98
|
+
return config || {};
|
|
99
|
+
}
|
|
100
|
+
const schema = pluginExport.schema;
|
|
101
|
+
const errors = [];
|
|
102
|
+
if (schema.required) {
|
|
103
|
+
for (const field of schema.required) {
|
|
104
|
+
if (config?.[field] === void 0) {
|
|
105
|
+
errors.push(`Required field missing: ${field}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (schema.properties && config) {
|
|
110
|
+
for (const [key, value] of Object.entries(config)) {
|
|
111
|
+
if (!schema.properties[key]) {
|
|
112
|
+
errors.push(`Unrecognized key: "${key}"`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const propSchema = schema.properties[key];
|
|
116
|
+
if (propSchema.type === "string" && typeof value !== "string") {
|
|
117
|
+
errors.push(`${key} must be a string`);
|
|
118
|
+
}
|
|
119
|
+
if (propSchema.type === "boolean" && typeof value !== "boolean") {
|
|
120
|
+
errors.push(`${key} must be a boolean`);
|
|
121
|
+
}
|
|
122
|
+
if (propSchema.type === "number" && typeof value !== "number") {
|
|
123
|
+
errors.push(`${key} must be a number`);
|
|
124
|
+
}
|
|
125
|
+
if (propSchema.type === "array" && !Array.isArray(value)) {
|
|
126
|
+
errors.push(`${key} must be an array`);
|
|
127
|
+
}
|
|
128
|
+
if (propSchema.type === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
129
|
+
errors.push(`${key} must be an object`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (errors.length > 0) {
|
|
134
|
+
throw new Error(`Invalid plugin config: ${errors.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
return config || {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/core/plugin-system.ts
|
|
140
|
+
import { existsSync } from "fs";
|
|
141
|
+
import { resolve } from "path";
|
|
142
|
+
import { pathToFileURL } from "url";
|
|
143
|
+
import { CODE_ANALYSIS_SERVICE, REPO_CONTEXT_SERVICE, StandardCapabilities } from "@agent-detective/sdk";
|
|
144
|
+
|
|
145
|
+
// src/core/built-in-plugins.ts
|
|
146
|
+
import jiraAdapter from "@agent-detective/jira-adapter";
|
|
147
|
+
import linearAdapter from "@agent-detective/linear-adapter";
|
|
148
|
+
import localRepos from "@agent-detective/local-repos-plugin";
|
|
149
|
+
import prPipeline from "@agent-detective/pr-pipeline";
|
|
150
|
+
function normalizeModule(mod) {
|
|
151
|
+
const m = mod;
|
|
152
|
+
return (m && typeof m === "object" && "default" in m ? m.default : m) ?? mod;
|
|
153
|
+
}
|
|
154
|
+
var builtIns = {
|
|
155
|
+
"@agent-detective/local-repos-plugin": normalizeModule(localRepos),
|
|
156
|
+
"@agent-detective/jira-adapter": normalizeModule(jiraAdapter),
|
|
157
|
+
"@agent-detective/linear-adapter": normalizeModule(linearAdapter),
|
|
158
|
+
"@agent-detective/pr-pipeline": normalizeModule(prPipeline)
|
|
159
|
+
};
|
|
160
|
+
function getBuiltInPlugin(spec) {
|
|
161
|
+
return builtIns[spec] ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/core/plugin-system.ts
|
|
165
|
+
async function importPluginModuleFromSpecifier(spec, resolveRootDir) {
|
|
166
|
+
const builtIn = getBuiltInPlugin(spec);
|
|
167
|
+
if (builtIn) {
|
|
168
|
+
return { default: builtIn };
|
|
169
|
+
}
|
|
170
|
+
if (spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/")) {
|
|
171
|
+
return import(pathToFileURL(resolve(resolveRootDir, spec)).href);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return await import(spec);
|
|
175
|
+
} catch {
|
|
176
|
+
const short = spec.replace("@agent-detective/", "");
|
|
177
|
+
const base = resolve(resolveRootDir, "packages", short);
|
|
178
|
+
const distJs = resolve(base, "dist/index.js");
|
|
179
|
+
const srcJs = resolve(base, "src/index.js");
|
|
180
|
+
let filePath = null;
|
|
181
|
+
if (existsSync(distJs)) filePath = distJs;
|
|
182
|
+
else if (existsSync(srcJs)) filePath = srcJs;
|
|
183
|
+
if (!filePath) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Failed to resolve ${spec} from monorepo fallback. Expected ${distJs} (run workspace build) or ${srcJs} (JS source).`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return import(pathToFileURL(filePath).href);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function sanitizePluginName(name) {
|
|
192
|
+
return name.replace(/^@/, "").replace(/\//g, "-");
|
|
193
|
+
}
|
|
194
|
+
function createPluginSystem(context) {
|
|
195
|
+
const {
|
|
196
|
+
agentRunner,
|
|
197
|
+
taskQueue: initialTaskQueue,
|
|
198
|
+
logger = console,
|
|
199
|
+
events,
|
|
200
|
+
metrics,
|
|
201
|
+
health,
|
|
202
|
+
pathResolutionRoot = process.cwd(),
|
|
203
|
+
failOnContractErrors = false,
|
|
204
|
+
failOnDependencyErrors = true,
|
|
205
|
+
failOnPluginLoadErrors = true
|
|
206
|
+
} = context;
|
|
207
|
+
const defaultQueue = initialTaskQueue ?? createMemoryTaskQueue(logger);
|
|
208
|
+
let activeQueue = defaultQueue;
|
|
209
|
+
const enqueueDelegate = (queueKey, fn) => activeQueue.enqueue(queueKey, fn);
|
|
210
|
+
function registerTaskQueue(queue) {
|
|
211
|
+
const previous = activeQueue;
|
|
212
|
+
activeQueue = queue;
|
|
213
|
+
try {
|
|
214
|
+
const shutdownResult = previous.shutdown?.();
|
|
215
|
+
if (shutdownResult !== void 0 && typeof shutdownResult.then === "function") {
|
|
216
|
+
shutdownResult.catch(
|
|
217
|
+
(err) => logger.warn(`Previous task queue shutdown failed: ${err.message}`)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger.warn(`Previous task queue shutdown failed: ${err.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const loadedPlugins = /* @__PURE__ */ new Map();
|
|
225
|
+
const activePlugins = /* @__PURE__ */ new Set();
|
|
226
|
+
const servicesRegistry = /* @__PURE__ */ new Map();
|
|
227
|
+
const capabilitiesRegistry = /* @__PURE__ */ new Map();
|
|
228
|
+
const shutdownHooks = [];
|
|
229
|
+
let providerPriorityByPluginName = null;
|
|
230
|
+
const pluginLoadFailures = [];
|
|
231
|
+
let healthCheckRegistered = false;
|
|
232
|
+
let lastConfiguredPluginsCount = 0;
|
|
233
|
+
const MULTI_PROVIDER_SERVICE_KEYS = /* @__PURE__ */ new Set([
|
|
234
|
+
// Capability-backed services
|
|
235
|
+
REPO_CONTEXT_SERVICE,
|
|
236
|
+
CODE_ANALYSIS_SERVICE
|
|
237
|
+
]);
|
|
238
|
+
const CAPABILITY_BACKED_CONTRACTS = [
|
|
239
|
+
{ capability: StandardCapabilities.REPO_CONTEXT, serviceKey: REPO_CONTEXT_SERVICE },
|
|
240
|
+
{ capability: StandardCapabilities.CODE_ANALYSIS, serviceKey: CODE_ANALYSIS_SERVICE }
|
|
241
|
+
];
|
|
242
|
+
function isFirstPartyPlugin(pluginName) {
|
|
243
|
+
return pluginName.startsWith("@agent-detective/");
|
|
244
|
+
}
|
|
245
|
+
function registerCapabilityForPlugin(providerPluginName, capability) {
|
|
246
|
+
const existing = capabilitiesRegistry.get(capability);
|
|
247
|
+
if (!existing) {
|
|
248
|
+
capabilitiesRegistry.set(capability, /* @__PURE__ */ new Set([providerPluginName]));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
existing.add(providerPluginName);
|
|
252
|
+
}
|
|
253
|
+
function hasCapability(capability) {
|
|
254
|
+
const providers = capabilitiesRegistry.get(capability);
|
|
255
|
+
if (!providers || providers.size === 0) return false;
|
|
256
|
+
for (const provider of providers) {
|
|
257
|
+
if (isPluginActiveOrLoaded(provider)) return true;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
function registerServiceForPlugin(providerPluginName, name, service) {
|
|
262
|
+
const existing = servicesRegistry.get(name);
|
|
263
|
+
if (!existing) {
|
|
264
|
+
servicesRegistry.set(name, /* @__PURE__ */ new Map([[providerPluginName, service]]));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const hadProvider = existing.has(providerPluginName);
|
|
268
|
+
const hadAny = existing.size > 0;
|
|
269
|
+
if (hadProvider) {
|
|
270
|
+
logger.warn(`Service ${name} already registered, overwriting`);
|
|
271
|
+
} else if (hadAny && !MULTI_PROVIDER_SERVICE_KEYS.has(name)) {
|
|
272
|
+
logger.warn(`Service ${name} already registered, overwriting`);
|
|
273
|
+
}
|
|
274
|
+
existing.set(providerPluginName, service);
|
|
275
|
+
}
|
|
276
|
+
function isPluginActiveOrLoaded(name) {
|
|
277
|
+
return activePlugins.has(name) || loadedPlugins.has(name);
|
|
278
|
+
}
|
|
279
|
+
function getActiveProvidersForService(name) {
|
|
280
|
+
const providers = servicesRegistry.get(name);
|
|
281
|
+
if (!providers) return /* @__PURE__ */ new Map();
|
|
282
|
+
const active = /* @__PURE__ */ new Map();
|
|
283
|
+
for (const [providerName, svc] of providers) {
|
|
284
|
+
if (isPluginActiveOrLoaded(providerName)) {
|
|
285
|
+
active.set(providerName, svc);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return active;
|
|
289
|
+
}
|
|
290
|
+
function getServiceFromPlugin(name, providerPluginName) {
|
|
291
|
+
if (!isPluginActiveOrLoaded(providerPluginName)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Service ${name} not found for provider ${providerPluginName}. Ensure the providing plugin is loaded.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const providers = getActiveProvidersForService(name);
|
|
297
|
+
const service = providers.get(providerPluginName);
|
|
298
|
+
if (!service) {
|
|
299
|
+
const knownProviders = providers ? Array.from(providers.keys()) : [];
|
|
300
|
+
const suffix = knownProviders.length ? ` Known providers: ${knownProviders.join(", ")}` : "";
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Service ${name} not found for provider ${providerPluginName}. Ensure the providing plugin is loaded.${suffix}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return service;
|
|
306
|
+
}
|
|
307
|
+
function selectDefaultProviderService(_name, providers) {
|
|
308
|
+
if (providers.size === 1) {
|
|
309
|
+
return providers.values().next().value;
|
|
310
|
+
}
|
|
311
|
+
const priority = providerPriorityByPluginName;
|
|
312
|
+
if (!priority) {
|
|
313
|
+
for (const [providerName, service] of providers) {
|
|
314
|
+
if (isFirstPartyPlugin(providerName)) return service;
|
|
315
|
+
}
|
|
316
|
+
return providers.values().next().value;
|
|
317
|
+
}
|
|
318
|
+
const candidates = [];
|
|
319
|
+
for (const [providerName, service] of providers) {
|
|
320
|
+
const rank = priority.get(providerName) ?? Number.MAX_SAFE_INTEGER;
|
|
321
|
+
candidates.push({ providerName, service, isFirstParty: isFirstPartyPlugin(providerName), rank });
|
|
322
|
+
}
|
|
323
|
+
candidates.sort((a, b) => {
|
|
324
|
+
if (a.isFirstParty !== b.isFirstParty) return a.isFirstParty ? -1 : 1;
|
|
325
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
326
|
+
return 0;
|
|
327
|
+
});
|
|
328
|
+
return candidates[0].service;
|
|
329
|
+
}
|
|
330
|
+
function getService(name) {
|
|
331
|
+
const providers = getActiveProvidersForService(name);
|
|
332
|
+
if (!providers || providers.size === 0) {
|
|
333
|
+
throw new Error(`Service ${name} not found. Ensure the providing plugin is loaded.`);
|
|
334
|
+
}
|
|
335
|
+
return selectDefaultProviderService(name, providers);
|
|
336
|
+
}
|
|
337
|
+
function buildPluginContext(plugin, mergedConfig) {
|
|
338
|
+
return {
|
|
339
|
+
agentRunner,
|
|
340
|
+
enqueue: enqueueDelegate,
|
|
341
|
+
config: mergedConfig,
|
|
342
|
+
logger: "child" in logger && typeof logger.child === "function" ? logger.child({ plugin: plugin.name }) : logger,
|
|
343
|
+
events,
|
|
344
|
+
registerService: (name, service) => registerServiceForPlugin(plugin.name, name, service),
|
|
345
|
+
getService,
|
|
346
|
+
getServiceFromPlugin,
|
|
347
|
+
registerAgent: (agent) => agentRunner.registerAgent(agent),
|
|
348
|
+
registerCapability: (capability) => registerCapabilityForPlugin(plugin.name, capability),
|
|
349
|
+
hasCapability,
|
|
350
|
+
registerTaskQueue,
|
|
351
|
+
onShutdown: (fn) => shutdownHooks.push(fn)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function preparePlugin(plugin, pluginConfig) {
|
|
355
|
+
validatePluginSchema(plugin);
|
|
356
|
+
const mergedConfig = { ...pluginConfig };
|
|
357
|
+
if (plugin.schema?.properties) {
|
|
358
|
+
for (const [key, prop] of Object.entries(plugin.schema.properties)) {
|
|
359
|
+
if (mergedConfig[key] === void 0 && prop.default !== void 0) {
|
|
360
|
+
mergedConfig[key] = prop.default;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
validatePluginConfig(plugin, mergedConfig);
|
|
365
|
+
return { mergedConfig, ctx: buildPluginContext(plugin, mergedConfig) };
|
|
366
|
+
}
|
|
367
|
+
async function loadPlugin(plugin, app, pluginConfig) {
|
|
368
|
+
if (loadedPlugins.has(plugin.name)) {
|
|
369
|
+
logger.warn(`Plugin ${plugin.name} already loaded, skipping`);
|
|
370
|
+
return loadedPlugins.get(plugin.name) ?? null;
|
|
371
|
+
}
|
|
372
|
+
let prepared;
|
|
373
|
+
try {
|
|
374
|
+
prepared = preparePlugin(plugin, pluginConfig);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
pluginLoadFailures.push({ plugin: plugin.name, stage: "validate", message: err.message });
|
|
377
|
+
logger.warn(`Failed to load plugin ${plugin.name}: ${err.message}. Continuing...`);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const start = Date.now();
|
|
381
|
+
activePlugins.add(plugin.name);
|
|
382
|
+
try {
|
|
383
|
+
await plugin.register(app, prepared.ctx);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
pluginLoadFailures.push({ plugin: plugin.name, stage: "register", message: err.message });
|
|
386
|
+
logger.warn(`Failed to load plugin ${plugin.name}: ${err.message}. Continuing...`);
|
|
387
|
+
return null;
|
|
388
|
+
} finally {
|
|
389
|
+
activePlugins.delete(plugin.name);
|
|
390
|
+
metrics?.pluginLoadDuration.observe({ plugin: plugin.name }, Date.now() - start);
|
|
391
|
+
}
|
|
392
|
+
const loaded = {
|
|
393
|
+
name: plugin.name,
|
|
394
|
+
version: plugin.version,
|
|
395
|
+
config: prepared.mergedConfig,
|
|
396
|
+
dependsOn: plugin.dependsOn || []
|
|
397
|
+
};
|
|
398
|
+
loadedPlugins.set(plugin.name, loaded);
|
|
399
|
+
metrics?.pluginsLoaded.set({ plugin: plugin.name }, 1);
|
|
400
|
+
const contractErrors = [];
|
|
401
|
+
if (plugin.requiresCapabilities) {
|
|
402
|
+
for (const req of plugin.requiresCapabilities) {
|
|
403
|
+
if (!hasCapability(req)) {
|
|
404
|
+
const available = Array.from(capabilitiesRegistry.keys()).sort();
|
|
405
|
+
contractErrors.push(
|
|
406
|
+
`Plugin ${plugin.name} requires capability '${req}' which is not provided by any loaded plugin.`
|
|
407
|
+
);
|
|
408
|
+
logger.error(
|
|
409
|
+
`Plugin ${plugin.name} requires capability '${req}' which is not provided by any loaded plugin. Available capabilities: ${available.length ? available.join(", ") : "(none)"}`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const { capability, serviceKey } of CAPABILITY_BACKED_CONTRACTS) {
|
|
415
|
+
const providersForCapability = capabilitiesRegistry.get(capability);
|
|
416
|
+
if (providersForCapability?.has(plugin.name)) {
|
|
417
|
+
const svcProviders = servicesRegistry.get(serviceKey);
|
|
418
|
+
if (!svcProviders || !svcProviders.has(plugin.name)) {
|
|
419
|
+
contractErrors.push(
|
|
420
|
+
`Plugin ${plugin.name} declares capability '${capability}' but did not register required service '${serviceKey}'.`
|
|
421
|
+
);
|
|
422
|
+
logger.error(
|
|
423
|
+
`Plugin ${plugin.name} declares capability '${capability}' but did not register required service '${serviceKey}'.`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (plugin.requiresCapabilities?.includes(capability)) {
|
|
428
|
+
const activeProviders = getActiveProvidersForService(serviceKey);
|
|
429
|
+
if (activeProviders.size === 0) {
|
|
430
|
+
contractErrors.push(
|
|
431
|
+
`Plugin ${plugin.name} requires capability '${capability}' but no provider registered service '${serviceKey}'.`
|
|
432
|
+
);
|
|
433
|
+
logger.error(
|
|
434
|
+
`Plugin ${plugin.name} requires capability '${capability}' but no provider registered service '${serviceKey}'.`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (failOnContractErrors && contractErrors.length > 0) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Plugin contract errors detected (failOnContractErrors=true):
|
|
442
|
+
- ${contractErrors.join("\n- ")}`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
logger.info(`Loaded plugin ${plugin.name}@${plugin.version}`);
|
|
446
|
+
return loaded;
|
|
447
|
+
}
|
|
448
|
+
async function loadAll(app, config) {
|
|
449
|
+
const plugins = config.plugins || [];
|
|
450
|
+
const pluginData = /* @__PURE__ */ new Map();
|
|
451
|
+
const configOrder = [];
|
|
452
|
+
const loadOrder = [];
|
|
453
|
+
const errors = [];
|
|
454
|
+
for (const p of plugins) {
|
|
455
|
+
const packageName = typeof p === "string" ? p : p.package;
|
|
456
|
+
if (!packageName) continue;
|
|
457
|
+
let plugin;
|
|
458
|
+
try {
|
|
459
|
+
const pluginModule = await importPluginModuleFromSpecifier(packageName, pathResolutionRoot);
|
|
460
|
+
plugin = pluginModule.default ?? pluginModule;
|
|
461
|
+
} catch (err) {
|
|
462
|
+
pluginLoadFailures.push({
|
|
463
|
+
plugin: packageName,
|
|
464
|
+
stage: "import",
|
|
465
|
+
message: err.message
|
|
466
|
+
});
|
|
467
|
+
logger.warn(`Failed to load plugin metadata for ${packageName}: ${err.message}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (loadedPlugins.has(plugin.name)) continue;
|
|
471
|
+
pluginData.set(plugin.name, {
|
|
472
|
+
plugin,
|
|
473
|
+
packageName,
|
|
474
|
+
options: typeof p === "object" ? p.options || {} : {}
|
|
475
|
+
});
|
|
476
|
+
configOrder.push(plugin.name);
|
|
477
|
+
}
|
|
478
|
+
providerPriorityByPluginName = new Map(configOrder.map((name, idx) => [name, idx]));
|
|
479
|
+
const visited = /* @__PURE__ */ new Set();
|
|
480
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
481
|
+
function visit(name, path) {
|
|
482
|
+
if (visited.has(name)) return true;
|
|
483
|
+
if (visiting.has(name)) {
|
|
484
|
+
errors.push(`Circular dependency detected: ${path.join(" -> ")} -> ${name}`);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
const data = pluginData.get(name);
|
|
488
|
+
if (!data) {
|
|
489
|
+
if (loadedPlugins.has(name)) {
|
|
490
|
+
visited.add(name);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
errors.push(`Plugin ${name} not found in config but required by ${path.join(" -> ")}`);
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
visiting.add(name);
|
|
497
|
+
const deps = data.plugin.dependsOn || [];
|
|
498
|
+
for (const dep of deps) {
|
|
499
|
+
if (!visit(dep, [...path, name])) {
|
|
500
|
+
visiting.delete(name);
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
visiting.delete(name);
|
|
505
|
+
visited.add(name);
|
|
506
|
+
loadOrder.push(name);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
for (const name of pluginData.keys()) {
|
|
510
|
+
visit(name, [name]);
|
|
511
|
+
}
|
|
512
|
+
for (const error of errors) {
|
|
513
|
+
logger.error(`Plugin dependency error: ${error}`);
|
|
514
|
+
}
|
|
515
|
+
if (failOnDependencyErrors && errors.length > 0) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`Plugin dependency errors detected (failOnDependencyErrors=true):
|
|
518
|
+
- ${errors.join("\n- ")}`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
logger.info(`Plugin load order: ${loadOrder.join(" -> ")}`);
|
|
522
|
+
for (const name of loadOrder) {
|
|
523
|
+
const data = pluginData.get(name);
|
|
524
|
+
const { plugin, options } = data;
|
|
525
|
+
if (loadedPlugins.has(plugin.name)) {
|
|
526
|
+
logger.warn(`Plugin ${plugin.name} already loaded, skipping`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
let prepared;
|
|
530
|
+
try {
|
|
531
|
+
prepared = preparePlugin(plugin, options);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
pluginLoadFailures.push({ plugin: plugin.name, stage: "validate", message: err.message });
|
|
534
|
+
logger.warn(`Failed to load plugin ${plugin.name}: ${err.message}. Continuing...`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const prefix = `/plugins/${sanitizePluginName(plugin.name)}`;
|
|
538
|
+
app.register(
|
|
539
|
+
async (childScope) => {
|
|
540
|
+
activePlugins.add(plugin.name);
|
|
541
|
+
const start = Date.now();
|
|
542
|
+
try {
|
|
543
|
+
await plugin.register(childScope, prepared.ctx);
|
|
544
|
+
const loaded = {
|
|
545
|
+
name: plugin.name,
|
|
546
|
+
version: plugin.version,
|
|
547
|
+
config: prepared.mergedConfig,
|
|
548
|
+
dependsOn: plugin.dependsOn || []
|
|
549
|
+
};
|
|
550
|
+
loadedPlugins.set(plugin.name, loaded);
|
|
551
|
+
metrics?.pluginsLoaded.set({ plugin: plugin.name }, 1);
|
|
552
|
+
logger.info(`Loaded plugin ${plugin.name}@${plugin.version}`);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
pluginLoadFailures.push({ plugin: plugin.name, stage: "register", message: err.message });
|
|
555
|
+
logger.warn(
|
|
556
|
+
`Failed to load plugin ${plugin.name}: ${err.message}. Continuing...`
|
|
557
|
+
);
|
|
558
|
+
} finally {
|
|
559
|
+
activePlugins.delete(plugin.name);
|
|
560
|
+
metrics?.pluginLoadDuration.observe({ plugin: plugin.name }, Date.now() - start);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
{ prefix }
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
await app.ready();
|
|
567
|
+
if (failOnPluginLoadErrors && pluginLoadFailures.length > 0) {
|
|
568
|
+
const rendered = pluginLoadFailures.map((f) => `${f.plugin} (${f.stage}): ${f.message}`).join("\n- ");
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Plugin load errors detected (failOnPluginLoadErrors=true):
|
|
571
|
+
- ${rendered}`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const contractErrors = [];
|
|
575
|
+
lastConfiguredPluginsCount = Array.isArray(config.plugins) ? config.plugins.length : 0;
|
|
576
|
+
if (health && !healthCheckRegistered) {
|
|
577
|
+
health.registerPluginCheck("plugin-system", async () => {
|
|
578
|
+
const start = Date.now();
|
|
579
|
+
const configured = lastConfiguredPluginsCount;
|
|
580
|
+
const loaded = loadedPlugins.size;
|
|
581
|
+
const status = configured === loaded ? "ok" : loaded > 0 ? "degraded" : "unhealthy";
|
|
582
|
+
return {
|
|
583
|
+
name: "plugin-system",
|
|
584
|
+
status,
|
|
585
|
+
durationMs: Date.now() - start,
|
|
586
|
+
details: {
|
|
587
|
+
configured,
|
|
588
|
+
loaded,
|
|
589
|
+
failures: pluginLoadFailures.slice(-10)
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
healthCheckRegistered = true;
|
|
594
|
+
}
|
|
595
|
+
for (const { capability, serviceKey } of CAPABILITY_BACKED_CONTRACTS) {
|
|
596
|
+
const providersForCapability = capabilitiesRegistry.get(capability) ?? /* @__PURE__ */ new Set();
|
|
597
|
+
for (const provider of providersForCapability) {
|
|
598
|
+
if (!loadedPlugins.has(provider)) continue;
|
|
599
|
+
const svcProviders = servicesRegistry.get(serviceKey);
|
|
600
|
+
if (!svcProviders || !svcProviders.has(provider)) {
|
|
601
|
+
contractErrors.push(
|
|
602
|
+
`Plugin ${provider} declares capability '${capability}' but did not register required service '${serviceKey}'.`
|
|
603
|
+
);
|
|
604
|
+
logger.error(
|
|
605
|
+
`Plugin ${provider} declares capability '${capability}' but did not register required service '${serviceKey}'.`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const loaded of loadedPlugins.values()) {
|
|
611
|
+
const plugin = Array.from(pluginData.values()).find((d) => d.plugin.name === loaded.name)?.plugin;
|
|
612
|
+
if (plugin && plugin.requiresCapabilities) {
|
|
613
|
+
for (const req of plugin.requiresCapabilities) {
|
|
614
|
+
if (!hasCapability(req)) {
|
|
615
|
+
const available = Array.from(capabilitiesRegistry.keys()).sort();
|
|
616
|
+
contractErrors.push(
|
|
617
|
+
`Plugin ${loaded.name} requires capability '${req}' which is not provided by any loaded plugin.`
|
|
618
|
+
);
|
|
619
|
+
logger.error(
|
|
620
|
+
`Plugin ${loaded.name} requires capability '${req}' which is not provided by any loaded plugin. Available capabilities: ${available.length ? available.join(", ") : "(none)"}`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const loaded of loadedPlugins.values()) {
|
|
627
|
+
const plugin = Array.from(pluginData.values()).find((d) => d.plugin.name === loaded.name)?.plugin;
|
|
628
|
+
if (!plugin?.requiresCapabilities) continue;
|
|
629
|
+
for (const { capability, serviceKey } of CAPABILITY_BACKED_CONTRACTS) {
|
|
630
|
+
if (!plugin.requiresCapabilities.includes(capability)) continue;
|
|
631
|
+
const activeProviders = getActiveProvidersForService(serviceKey);
|
|
632
|
+
if (activeProviders.size === 0) {
|
|
633
|
+
contractErrors.push(
|
|
634
|
+
`Plugin ${loaded.name} requires capability '${capability}' but no provider registered service '${serviceKey}'.`
|
|
635
|
+
);
|
|
636
|
+
logger.error(
|
|
637
|
+
`Plugin ${loaded.name} requires capability '${capability}' but no provider registered service '${serviceKey}'.`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (failOnContractErrors && contractErrors.length > 0) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
`Plugin contract errors detected (failOnContractErrors=true):
|
|
645
|
+
- ${contractErrors.join("\n- ")}`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function getLoadedPlugins() {
|
|
650
|
+
return Array.from(loadedPlugins.values());
|
|
651
|
+
}
|
|
652
|
+
function getPluginLoadFailures() {
|
|
653
|
+
return [...pluginLoadFailures];
|
|
654
|
+
}
|
|
655
|
+
function getPluginTags() {
|
|
656
|
+
return Array.from(loadedPlugins.keys());
|
|
657
|
+
}
|
|
658
|
+
async function shutdown() {
|
|
659
|
+
for (const hook of shutdownHooks) {
|
|
660
|
+
try {
|
|
661
|
+
await hook();
|
|
662
|
+
} catch (err) {
|
|
663
|
+
logger.warn(`Plugin shutdown hook failed: ${err.message}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
loadPlugin,
|
|
669
|
+
loadAll,
|
|
670
|
+
getLoadedPlugins,
|
|
671
|
+
getPluginLoadFailures,
|
|
672
|
+
getPluginTags,
|
|
673
|
+
enqueue: enqueueDelegate,
|
|
674
|
+
shutdown
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export {
|
|
679
|
+
createLimitedConcurrencyTaskQueue,
|
|
680
|
+
createMemoryTaskQueue,
|
|
681
|
+
validatePluginSchema,
|
|
682
|
+
validatePluginConfig,
|
|
683
|
+
importPluginModuleFromSpecifier,
|
|
684
|
+
createPluginSystem
|
|
685
|
+
};
|