bopodev-api 0.1.11 → 0.1.13
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/package.json +6 -4
- package/src/app.ts +2 -0
- package/src/lib/instance-paths.ts +12 -0
- package/src/lib/opencode-model.ts +35 -0
- package/src/lib/workspace-policy.ts +5 -0
- package/src/realtime/heartbeat-runs.ts +78 -0
- package/src/realtime/hub.ts +37 -1
- package/src/realtime/office-space.ts +10 -1
- package/src/routes/agents.ts +89 -2
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +9 -2
- package/src/routes/heartbeats.ts +2 -1
- package/src/routes/issues.ts +321 -0
- package/src/routes/observability.ts +546 -18
- package/src/routes/plugins.ts +257 -0
- package/src/scripts/onboard-seed.ts +57 -12
- package/src/server.ts +62 -3
- package/src/services/governance-service.ts +97 -23
- package/src/services/heartbeat-service.ts +633 -31
- package/src/services/memory-file-service.ts +249 -0
- package/src/services/plugin-manifest-loader.ts +65 -0
- package/src/services/plugin-runtime.ts +580 -0
- package/src/services/plugin-webhook-executor.ts +94 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginHook,
|
|
3
|
+
PluginInvocationResult,
|
|
4
|
+
PluginManifest,
|
|
5
|
+
PluginPromptExecutionResult,
|
|
6
|
+
PluginTraceEvent,
|
|
7
|
+
PluginWebhookRequest
|
|
8
|
+
} from "bopodev-contracts";
|
|
9
|
+
import {
|
|
10
|
+
PluginHookSchema,
|
|
11
|
+
PluginInvocationResultSchema,
|
|
12
|
+
PluginManifestSchema,
|
|
13
|
+
PluginPromptExecutionResultSchema,
|
|
14
|
+
PluginTraceEventSchema,
|
|
15
|
+
PluginWebhookRequestSchema
|
|
16
|
+
} from "bopodev-contracts";
|
|
17
|
+
import type { BopoDb } from "bopodev-db";
|
|
18
|
+
import {
|
|
19
|
+
appendAuditEvent,
|
|
20
|
+
appendPluginRun,
|
|
21
|
+
listCompanyPluginConfigs,
|
|
22
|
+
upsertPlugin,
|
|
23
|
+
updatePluginConfig
|
|
24
|
+
} from "bopodev-db";
|
|
25
|
+
import { loadFilesystemPluginManifests } from "./plugin-manifest-loader";
|
|
26
|
+
import { executePluginWebhooks } from "./plugin-webhook-executor";
|
|
27
|
+
|
|
28
|
+
type HookContext = {
|
|
29
|
+
companyId: string;
|
|
30
|
+
agentId: string;
|
|
31
|
+
runId: string;
|
|
32
|
+
requestId?: string;
|
|
33
|
+
providerType?: string;
|
|
34
|
+
runtime?: {
|
|
35
|
+
command?: string;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
};
|
|
38
|
+
workItemCount?: number;
|
|
39
|
+
summary?: string;
|
|
40
|
+
status?: string;
|
|
41
|
+
trace?: unknown;
|
|
42
|
+
outcome?: unknown;
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
export type PluginHookResult = {
|
|
46
|
+
blocked: boolean;
|
|
47
|
+
applied: number;
|
|
48
|
+
failures: string[];
|
|
49
|
+
promptAppend: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type BuiltinPluginExecutor = (context: HookContext) => Promise<PluginInvocationResult> | PluginInvocationResult;
|
|
53
|
+
|
|
54
|
+
const HIGH_RISK_CAPABILITIES = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
|
|
55
|
+
|
|
56
|
+
const builtinPluginDefinitions = [
|
|
57
|
+
{
|
|
58
|
+
id: "trace-exporter",
|
|
59
|
+
version: "0.1.0",
|
|
60
|
+
displayName: "Trace Exporter",
|
|
61
|
+
description: "Emit normalized heartbeat trace events for downstream observability sinks.",
|
|
62
|
+
kind: "lifecycle",
|
|
63
|
+
hooks: ["afterAdapterExecute", "onError", "afterPersist"],
|
|
64
|
+
capabilities: ["emit_audit"],
|
|
65
|
+
runtime: {
|
|
66
|
+
type: "builtin",
|
|
67
|
+
entrypoint: "builtin:trace-exporter",
|
|
68
|
+
timeoutMs: 5000,
|
|
69
|
+
retryCount: 0
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "memory-enricher",
|
|
74
|
+
version: "0.1.0",
|
|
75
|
+
displayName: "Memory Enricher",
|
|
76
|
+
description: "Derive and dedupe memory candidate facts from heartbeat outcomes.",
|
|
77
|
+
kind: "lifecycle",
|
|
78
|
+
hooks: ["afterAdapterExecute", "afterPersist"],
|
|
79
|
+
capabilities: ["read_memory", "emit_audit"],
|
|
80
|
+
runtime: {
|
|
81
|
+
type: "builtin",
|
|
82
|
+
entrypoint: "builtin:memory-enricher",
|
|
83
|
+
timeoutMs: 5000,
|
|
84
|
+
retryCount: 0
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "queue-publisher",
|
|
89
|
+
version: "0.1.0",
|
|
90
|
+
displayName: "Queue Publisher",
|
|
91
|
+
description: "Publish heartbeat completion/failure payloads to queue integrations.",
|
|
92
|
+
kind: "integration",
|
|
93
|
+
hooks: ["afterPersist", "onError"],
|
|
94
|
+
capabilities: ["queue_publish", "network", "emit_audit"],
|
|
95
|
+
runtime: {
|
|
96
|
+
type: "builtin",
|
|
97
|
+
entrypoint: "builtin:queue-publisher",
|
|
98
|
+
timeoutMs: 5000,
|
|
99
|
+
retryCount: 0
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "heartbeat-tagger",
|
|
104
|
+
version: "0.1.0",
|
|
105
|
+
displayName: "Heartbeat Tagger",
|
|
106
|
+
description: "Attach a simple diagnostic tag to heartbeat plugin runs.",
|
|
107
|
+
kind: "lifecycle",
|
|
108
|
+
hooks: ["afterAdapterExecute"],
|
|
109
|
+
capabilities: ["emit_audit"],
|
|
110
|
+
runtime: {
|
|
111
|
+
type: "builtin",
|
|
112
|
+
entrypoint: "builtin:heartbeat-tagger",
|
|
113
|
+
timeoutMs: 3000,
|
|
114
|
+
retryCount: 0
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
] as const;
|
|
118
|
+
|
|
119
|
+
const builtinExecutors: Record<string, BuiltinPluginExecutor> = {
|
|
120
|
+
"trace-exporter": async (context) => ({
|
|
121
|
+
status: "ok",
|
|
122
|
+
summary: "trace-exporter emitted heartbeat trace metadata",
|
|
123
|
+
blockers: [],
|
|
124
|
+
diagnostics: {
|
|
125
|
+
runId: context.runId,
|
|
126
|
+
providerType: context.providerType ?? null,
|
|
127
|
+
status: context.status ?? null
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
"memory-enricher": async (context) => ({
|
|
131
|
+
status: "ok",
|
|
132
|
+
summary: "memory-enricher evaluated summary for memory candidates",
|
|
133
|
+
blockers: [],
|
|
134
|
+
diagnostics: {
|
|
135
|
+
runId: context.runId,
|
|
136
|
+
summaryPresent: typeof context.summary === "string" && context.summary.trim().length > 0
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
"queue-publisher": async (context) => ({
|
|
140
|
+
status: "ok",
|
|
141
|
+
summary: "queue-publisher prepared outbound heartbeat event",
|
|
142
|
+
blockers: [],
|
|
143
|
+
diagnostics: {
|
|
144
|
+
runId: context.runId,
|
|
145
|
+
status: context.status ?? null,
|
|
146
|
+
eventType: context.error ? "heartbeat.failed" : "heartbeat.completed"
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
"heartbeat-tagger": async (context) => ({
|
|
150
|
+
status: "ok",
|
|
151
|
+
summary: "heartbeat-tagger attached diagnostic tag",
|
|
152
|
+
blockers: [],
|
|
153
|
+
diagnostics: {
|
|
154
|
+
tag: "hello-plugin",
|
|
155
|
+
runId: context.runId,
|
|
156
|
+
providerType: context.providerType ?? null
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function pluginSystemEnabled() {
|
|
162
|
+
const disabled = process.env.BOPO_PLUGIN_SYSTEM_DISABLED;
|
|
163
|
+
if (disabled === "1" || disabled === "true") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const legacyEnabled = process.env.BOPO_PLUGIN_SYSTEM_ENABLED;
|
|
167
|
+
if (legacyEnabled === "0" || legacyEnabled === "false") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function ensureBuiltinPluginsRegistered(db: BopoDb, companyIds: string[] = []) {
|
|
174
|
+
const manifests = builtinPluginDefinitions.map((definition) => PluginManifestSchema.parse(definition));
|
|
175
|
+
const manifestIds = new Set(manifests.map((manifest) => manifest.id));
|
|
176
|
+
const fileManifestResult = await loadFilesystemPluginManifests();
|
|
177
|
+
for (const warning of fileManifestResult.warnings) {
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.warn(`[plugins] ${warning}`);
|
|
180
|
+
}
|
|
181
|
+
for (const fileManifest of fileManifestResult.manifests) {
|
|
182
|
+
if (manifestIds.has(fileManifest.id)) {
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.warn(`[plugins] Skipping filesystem plugin '${fileManifest.id}' because id already exists.`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
manifests.push(fileManifest);
|
|
188
|
+
manifestIds.add(fileManifest.id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const manifest of manifests) {
|
|
192
|
+
await registerPluginManifest(db, manifest);
|
|
193
|
+
}
|
|
194
|
+
for (const companyId of companyIds) {
|
|
195
|
+
await ensureCompanyBuiltinPluginDefaults(db, companyId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function registerPluginManifest(db: BopoDb, manifest: PluginManifest) {
|
|
200
|
+
await upsertPlugin(db, {
|
|
201
|
+
id: manifest.id,
|
|
202
|
+
name: manifest.displayName,
|
|
203
|
+
version: manifest.version,
|
|
204
|
+
kind: manifest.kind,
|
|
205
|
+
runtimeType: manifest.runtime.type,
|
|
206
|
+
runtimeEntrypoint: manifest.runtime.entrypoint,
|
|
207
|
+
hooksJson: JSON.stringify(manifest.hooks),
|
|
208
|
+
capabilitiesJson: JSON.stringify(manifest.capabilities),
|
|
209
|
+
manifestJson: JSON.stringify(manifest)
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function ensureCompanyBuiltinPluginDefaults(db: BopoDb, companyId: string) {
|
|
214
|
+
const existing = await listCompanyPluginConfigs(db, companyId);
|
|
215
|
+
const existingIds = new Set(existing.map((row) => row.pluginId));
|
|
216
|
+
const defaults = [
|
|
217
|
+
{ pluginId: "trace-exporter", enabled: true, priority: 40 },
|
|
218
|
+
{ pluginId: "memory-enricher", enabled: true, priority: 60 },
|
|
219
|
+
{ pluginId: "queue-publisher", enabled: false, priority: 80 },
|
|
220
|
+
{ pluginId: "heartbeat-tagger", enabled: false, priority: 90 }
|
|
221
|
+
];
|
|
222
|
+
for (const entry of defaults) {
|
|
223
|
+
if (existingIds.has(entry.pluginId)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
await updatePluginConfig(db, {
|
|
227
|
+
companyId,
|
|
228
|
+
pluginId: entry.pluginId,
|
|
229
|
+
enabled: entry.enabled,
|
|
230
|
+
priority: entry.priority,
|
|
231
|
+
configJson: "{}",
|
|
232
|
+
grantedCapabilitiesJson: "[]"
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function runPluginHook(
|
|
238
|
+
db: BopoDb,
|
|
239
|
+
input: {
|
|
240
|
+
hook: PluginHook;
|
|
241
|
+
context: HookContext;
|
|
242
|
+
failClosed?: boolean;
|
|
243
|
+
}
|
|
244
|
+
): Promise<PluginHookResult> {
|
|
245
|
+
if (!pluginSystemEnabled()) {
|
|
246
|
+
return { blocked: false, applied: 0, failures: [], promptAppend: null };
|
|
247
|
+
}
|
|
248
|
+
const parsedHook = PluginHookSchema.parse(input.hook);
|
|
249
|
+
const rows = await listCompanyPluginConfigs(db, input.context.companyId);
|
|
250
|
+
const candidates = rows
|
|
251
|
+
.filter((row) => row.enabled)
|
|
252
|
+
.map((row) => {
|
|
253
|
+
const hooks = safeParseStringArray(row.hooksJson);
|
|
254
|
+
const caps = safeParseStringArray(row.capabilitiesJson);
|
|
255
|
+
const grants = safeParseStringArray(row.grantedCapabilitiesJson);
|
|
256
|
+
const manifest = safeParseManifest(row.manifestJson);
|
|
257
|
+
return {
|
|
258
|
+
...row,
|
|
259
|
+
hooks,
|
|
260
|
+
caps,
|
|
261
|
+
grants,
|
|
262
|
+
manifest
|
|
263
|
+
};
|
|
264
|
+
})
|
|
265
|
+
.filter((row) => row.hooks.includes(parsedHook));
|
|
266
|
+
|
|
267
|
+
const failures: string[] = [];
|
|
268
|
+
const promptAppends: string[] = [];
|
|
269
|
+
let applied = 0;
|
|
270
|
+
for (const plugin of candidates) {
|
|
271
|
+
const startedAt = Date.now();
|
|
272
|
+
try {
|
|
273
|
+
const missingHighRiskCapability = plugin.caps.find(
|
|
274
|
+
(cap) => HIGH_RISK_CAPABILITIES.has(cap) && !plugin.grants.includes(cap)
|
|
275
|
+
);
|
|
276
|
+
if (missingHighRiskCapability) {
|
|
277
|
+
const msg = `plugin '${plugin.pluginId}' requires granted capability '${missingHighRiskCapability}'`;
|
|
278
|
+
failures.push(msg);
|
|
279
|
+
await appendPluginRun(db, {
|
|
280
|
+
companyId: input.context.companyId,
|
|
281
|
+
runId: input.context.runId,
|
|
282
|
+
pluginId: plugin.pluginId,
|
|
283
|
+
hook: parsedHook,
|
|
284
|
+
status: "blocked",
|
|
285
|
+
durationMs: Date.now() - startedAt,
|
|
286
|
+
error: msg
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const promptResult = await executePromptPlugin(plugin.manifest, plugin.pluginId, input.context, {
|
|
291
|
+
hook: parsedHook,
|
|
292
|
+
pluginConfig: safeParseJsonRecord(plugin.configJson)
|
|
293
|
+
});
|
|
294
|
+
if (promptResult) {
|
|
295
|
+
const processed = await processPromptPluginResult(db, {
|
|
296
|
+
pluginId: plugin.pluginId,
|
|
297
|
+
companyId: input.context.companyId,
|
|
298
|
+
runId: input.context.runId,
|
|
299
|
+
requestId: input.context.requestId,
|
|
300
|
+
pluginCapabilities: plugin.caps,
|
|
301
|
+
promptResult
|
|
302
|
+
});
|
|
303
|
+
if (processed.promptAppend) {
|
|
304
|
+
promptAppends.push(processed.promptAppend);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const result =
|
|
308
|
+
promptResult && plugin.manifest?.runtime.type === "prompt"
|
|
309
|
+
? ({
|
|
310
|
+
status: "ok",
|
|
311
|
+
summary: "prompt plugin applied runtime patches",
|
|
312
|
+
blockers: [],
|
|
313
|
+
diagnostics: {
|
|
314
|
+
source: "prompt-runtime"
|
|
315
|
+
}
|
|
316
|
+
} as PluginInvocationResult)
|
|
317
|
+
: await executePlugin(plugin.pluginId, input.context);
|
|
318
|
+
const validated = PluginInvocationResultSchema.parse(result);
|
|
319
|
+
await appendPluginRun(db, {
|
|
320
|
+
companyId: input.context.companyId,
|
|
321
|
+
runId: input.context.runId,
|
|
322
|
+
pluginId: plugin.pluginId,
|
|
323
|
+
hook: parsedHook,
|
|
324
|
+
status: validated.status,
|
|
325
|
+
durationMs: Date.now() - startedAt,
|
|
326
|
+
diagnosticsJson: JSON.stringify({
|
|
327
|
+
...(validated.diagnostics ?? {}),
|
|
328
|
+
promptAppendApplied: promptResult?.promptAppend ?? null
|
|
329
|
+
}),
|
|
330
|
+
error: validated.status === "failed" || validated.status === "blocked" ? validated.summary : null
|
|
331
|
+
});
|
|
332
|
+
if (validated.status === "failed" || validated.status === "blocked") {
|
|
333
|
+
failures.push(`plugin '${plugin.pluginId}' returned ${validated.status}: ${validated.summary}`);
|
|
334
|
+
} else {
|
|
335
|
+
applied += 1;
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const msg = String(error);
|
|
339
|
+
failures.push(`plugin '${plugin.pluginId}' failed: ${msg}`);
|
|
340
|
+
await appendPluginRun(db, {
|
|
341
|
+
companyId: input.context.companyId,
|
|
342
|
+
runId: input.context.runId,
|
|
343
|
+
pluginId: plugin.pluginId,
|
|
344
|
+
hook: parsedHook,
|
|
345
|
+
status: "failed",
|
|
346
|
+
durationMs: Date.now() - startedAt,
|
|
347
|
+
error: msg
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (failures.length > 0) {
|
|
353
|
+
await appendAuditEvent(db, {
|
|
354
|
+
companyId: input.context.companyId,
|
|
355
|
+
actorType: "system",
|
|
356
|
+
eventType: "plugin.hook.failures",
|
|
357
|
+
entityType: "heartbeat_run",
|
|
358
|
+
entityId: input.context.runId,
|
|
359
|
+
correlationId: input.context.requestId ?? input.context.runId,
|
|
360
|
+
payload: {
|
|
361
|
+
hook: parsedHook,
|
|
362
|
+
failures
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const blocked = Boolean(input.failClosed) && failures.length > 0;
|
|
367
|
+
return {
|
|
368
|
+
blocked,
|
|
369
|
+
applied,
|
|
370
|
+
failures,
|
|
371
|
+
promptAppend: promptAppends.length > 0 ? promptAppends.join("\n\n") : null
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function safeParseStringArray(value: string | null | undefined) {
|
|
376
|
+
if (!value) {
|
|
377
|
+
return [] as string[];
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(value) as unknown;
|
|
381
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function safeParseJsonRecord(value: string | null | undefined) {
|
|
388
|
+
if (!value) {
|
|
389
|
+
return {} as Record<string, unknown>;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(value) as unknown;
|
|
393
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
394
|
+
} catch {
|
|
395
|
+
return {};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function safeParseManifest(value: string | null | undefined) {
|
|
400
|
+
if (!value) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(value) as unknown;
|
|
405
|
+
const manifestParsed = PluginManifestSchema.safeParse(parsed);
|
|
406
|
+
return manifestParsed.success ? manifestParsed.data : null;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function executePromptPlugin(
|
|
413
|
+
manifest: PluginManifest | null,
|
|
414
|
+
pluginId: string,
|
|
415
|
+
context: HookContext,
|
|
416
|
+
input: {
|
|
417
|
+
hook: PluginHook;
|
|
418
|
+
pluginConfig: Record<string, unknown>;
|
|
419
|
+
}
|
|
420
|
+
) {
|
|
421
|
+
if (!manifest || manifest.runtime.type !== "prompt") {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
const promptTemplate = manifest.runtime.promptTemplate ?? "";
|
|
425
|
+
const webhookRequests = normalizeWebhookRequests(input.pluginConfig.webhookRequests);
|
|
426
|
+
const traceEvents = normalizeTraceEvents(input.pluginConfig.traceEvents);
|
|
427
|
+
const firstWebhookUrl = webhookRequests[0]?.url ?? "";
|
|
428
|
+
const renderedTemplate = renderPromptTemplate(promptTemplate, {
|
|
429
|
+
pluginId,
|
|
430
|
+
companyId: context.companyId,
|
|
431
|
+
agentId: context.agentId,
|
|
432
|
+
runId: context.runId,
|
|
433
|
+
hook: input.hook,
|
|
434
|
+
summary: context.summary ?? "",
|
|
435
|
+
providerType: context.providerType ?? "",
|
|
436
|
+
pluginConfig: input.pluginConfig,
|
|
437
|
+
webhookRequests,
|
|
438
|
+
traceEvents,
|
|
439
|
+
webhookUrl: firstWebhookUrl
|
|
440
|
+
});
|
|
441
|
+
return PluginPromptExecutionResultSchema.parse({
|
|
442
|
+
promptAppend: renderedTemplate.trim().length > 0 ? renderedTemplate : undefined,
|
|
443
|
+
webhookRequests,
|
|
444
|
+
traceEvents,
|
|
445
|
+
diagnostics: {
|
|
446
|
+
source: "prompt-runtime",
|
|
447
|
+
templateLength: promptTemplate.length
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function processPromptPluginResult(
|
|
453
|
+
db: BopoDb,
|
|
454
|
+
input: {
|
|
455
|
+
pluginId: string;
|
|
456
|
+
companyId: string;
|
|
457
|
+
runId: string;
|
|
458
|
+
requestId?: string;
|
|
459
|
+
pluginCapabilities: string[];
|
|
460
|
+
promptResult: PluginPromptExecutionResult;
|
|
461
|
+
}
|
|
462
|
+
) {
|
|
463
|
+
const canEmitAudit = input.pluginCapabilities.includes("emit_audit");
|
|
464
|
+
const canUseWebhooks = input.pluginCapabilities.includes("network") || input.pluginCapabilities.includes("queue_publish");
|
|
465
|
+
|
|
466
|
+
if (input.promptResult.traceEvents.length > 0) {
|
|
467
|
+
if (!canEmitAudit) {
|
|
468
|
+
throw new Error(`plugin '${input.pluginId}' emitted trace events without granted 'emit_audit' capability`);
|
|
469
|
+
}
|
|
470
|
+
for (const event of input.promptResult.traceEvents) {
|
|
471
|
+
await appendAuditEvent(db, {
|
|
472
|
+
companyId: input.companyId,
|
|
473
|
+
actorType: "system",
|
|
474
|
+
eventType: event.eventType,
|
|
475
|
+
entityType: "heartbeat_run",
|
|
476
|
+
entityId: input.runId,
|
|
477
|
+
correlationId: input.requestId ?? input.runId,
|
|
478
|
+
payload: {
|
|
479
|
+
pluginId: input.pluginId,
|
|
480
|
+
...event.payload
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let webhookResults: Awaited<ReturnType<typeof executePluginWebhooks>> = [];
|
|
487
|
+
if (input.promptResult.webhookRequests.length > 0) {
|
|
488
|
+
if (!canUseWebhooks) {
|
|
489
|
+
throw new Error(`plugin '${input.pluginId}' requested webhooks without granted 'network/queue_publish' capability`);
|
|
490
|
+
}
|
|
491
|
+
webhookResults = await executePluginWebhooks(input.promptResult.webhookRequests, {
|
|
492
|
+
pluginId: input.pluginId,
|
|
493
|
+
companyId: input.companyId,
|
|
494
|
+
runId: input.runId
|
|
495
|
+
});
|
|
496
|
+
const failedWebhook = webhookResults.find((entry) => !entry.ok);
|
|
497
|
+
if (failedWebhook) {
|
|
498
|
+
throw new Error(`plugin '${input.pluginId}' webhook request failed: ${failedWebhook.url} (${failedWebhook.error ?? failedWebhook.statusCode ?? "unknown"})`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
promptAppend: input.promptResult.promptAppend ?? null,
|
|
504
|
+
webhookResults
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizeWebhookRequests(value: unknown): PluginWebhookRequest[] {
|
|
509
|
+
if (!Array.isArray(value)) {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
const requests: PluginWebhookRequest[] = [];
|
|
513
|
+
for (const entry of value) {
|
|
514
|
+
const parsed = PluginWebhookRequestSchema.safeParse(entry);
|
|
515
|
+
if (parsed.success) {
|
|
516
|
+
requests.push(parsed.data);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return requests;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeTraceEvents(value: unknown): PluginTraceEvent[] {
|
|
523
|
+
if (!Array.isArray(value)) {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
const events: PluginTraceEvent[] = [];
|
|
527
|
+
for (const entry of value) {
|
|
528
|
+
const parsed = PluginTraceEventSchema.safeParse(entry);
|
|
529
|
+
if (parsed.success) {
|
|
530
|
+
events.push(parsed.data);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return events;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderPromptTemplate(
|
|
537
|
+
template: string,
|
|
538
|
+
input: {
|
|
539
|
+
pluginId: string;
|
|
540
|
+
companyId: string;
|
|
541
|
+
agentId: string;
|
|
542
|
+
runId: string;
|
|
543
|
+
hook: string;
|
|
544
|
+
summary: string;
|
|
545
|
+
providerType: string;
|
|
546
|
+
pluginConfig: Record<string, unknown>;
|
|
547
|
+
webhookRequests: PluginWebhookRequest[];
|
|
548
|
+
traceEvents: PluginTraceEvent[];
|
|
549
|
+
webhookUrl: string;
|
|
550
|
+
}
|
|
551
|
+
) {
|
|
552
|
+
if (!template) {
|
|
553
|
+
return "";
|
|
554
|
+
}
|
|
555
|
+
return template
|
|
556
|
+
.replaceAll("{{pluginId}}", input.pluginId)
|
|
557
|
+
.replaceAll("{{companyId}}", input.companyId)
|
|
558
|
+
.replaceAll("{{agentId}}", input.agentId)
|
|
559
|
+
.replaceAll("{{runId}}", input.runId)
|
|
560
|
+
.replaceAll("{{hook}}", input.hook)
|
|
561
|
+
.replaceAll("{{summary}}", input.summary)
|
|
562
|
+
.replaceAll("{{providerType}}", input.providerType)
|
|
563
|
+
.replaceAll("{{pluginConfig}}", JSON.stringify(input.pluginConfig))
|
|
564
|
+
.replaceAll("{{webhookUrl}}", input.webhookUrl)
|
|
565
|
+
.replaceAll("{{webhookRequests}}", JSON.stringify(input.webhookRequests))
|
|
566
|
+
.replaceAll("{{traceEvents}}", JSON.stringify(input.traceEvents));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function executePlugin(pluginId: string, context: HookContext): Promise<PluginInvocationResult> {
|
|
570
|
+
const executor = builtinExecutors[pluginId];
|
|
571
|
+
if (!executor) {
|
|
572
|
+
return {
|
|
573
|
+
status: "skipped",
|
|
574
|
+
summary: `No executor is registered for plugin '${pluginId}'.`,
|
|
575
|
+
blockers: [],
|
|
576
|
+
diagnostics: { pluginId }
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return executor(context);
|
|
580
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PluginWebhookRequest } from "bopodev-contracts";
|
|
2
|
+
|
|
3
|
+
export type PluginWebhookExecutionResult = {
|
|
4
|
+
url: string;
|
|
5
|
+
method: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
statusCode: number | null;
|
|
8
|
+
elapsedMs: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function executePluginWebhooks(
|
|
13
|
+
requests: PluginWebhookRequest[],
|
|
14
|
+
input: {
|
|
15
|
+
pluginId: string;
|
|
16
|
+
companyId: string;
|
|
17
|
+
runId: string;
|
|
18
|
+
}
|
|
19
|
+
) {
|
|
20
|
+
const results: PluginWebhookExecutionResult[] = [];
|
|
21
|
+
const allowlist = resolveWebhookAllowlist();
|
|
22
|
+
for (const request of requests) {
|
|
23
|
+
if (!isWebhookAllowed(request.url, allowlist)) {
|
|
24
|
+
results.push({
|
|
25
|
+
url: request.url,
|
|
26
|
+
method: request.method,
|
|
27
|
+
ok: false,
|
|
28
|
+
statusCode: null,
|
|
29
|
+
elapsedMs: 0,
|
|
30
|
+
error: "Webhook URL not allowed by BOPO_PLUGIN_WEBHOOK_ALLOWLIST."
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
try {
|
|
36
|
+
const timeoutController = new AbortController();
|
|
37
|
+
const timeoutId = setTimeout(() => timeoutController.abort(), request.timeoutMs);
|
|
38
|
+
const response = await fetch(request.url, {
|
|
39
|
+
method: request.method,
|
|
40
|
+
headers: {
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
"x-bopo-plugin-id": input.pluginId,
|
|
43
|
+
"x-bopo-company-id": input.companyId,
|
|
44
|
+
"x-bopo-run-id": input.runId,
|
|
45
|
+
...request.headers
|
|
46
|
+
},
|
|
47
|
+
body: request.body ? JSON.stringify(request.body) : undefined,
|
|
48
|
+
signal: timeoutController.signal
|
|
49
|
+
});
|
|
50
|
+
clearTimeout(timeoutId);
|
|
51
|
+
results.push({
|
|
52
|
+
url: request.url,
|
|
53
|
+
method: request.method,
|
|
54
|
+
ok: response.ok,
|
|
55
|
+
statusCode: response.status,
|
|
56
|
+
elapsedMs: Date.now() - startedAt
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
results.push({
|
|
60
|
+
url: request.url,
|
|
61
|
+
method: request.method,
|
|
62
|
+
ok: false,
|
|
63
|
+
statusCode: null,
|
|
64
|
+
elapsedMs: Date.now() - startedAt,
|
|
65
|
+
error: String(error)
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveWebhookAllowlist() {
|
|
73
|
+
const raw = process.env.BOPO_PLUGIN_WEBHOOK_ALLOWLIST;
|
|
74
|
+
if (!raw || raw.trim().length === 0) {
|
|
75
|
+
return [] as string[];
|
|
76
|
+
}
|
|
77
|
+
return raw
|
|
78
|
+
.split(",")
|
|
79
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
80
|
+
.filter((entry) => entry.length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isWebhookAllowed(url: string, allowlist: string[]) {
|
|
84
|
+
if (allowlist.length === 0) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(url);
|
|
89
|
+
const host = parsed.hostname.toLowerCase();
|
|
90
|
+
return allowlist.includes(host);
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|