bopodev-api 0.1.34 → 0.1.35

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 (90) hide show
  1. package/package.json +5 -5
  2. package/src/app.ts +4 -2
  3. package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
  4. package/src/assets/starter-packs/devrel-growth.zip +0 -0
  5. package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
  6. package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
  7. package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
  8. package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
  9. package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
  10. package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
  11. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
  12. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
  13. package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
  14. package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
  15. package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
  16. package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
  17. package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
  18. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
  19. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
  20. package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
  21. package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
  22. package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
  23. package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
  24. package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
  25. package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
  26. package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
  27. package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
  28. package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
  29. package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
  30. package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
  31. package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
  32. package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
  33. package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
  34. package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
  35. package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
  36. package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
  37. package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
  38. package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
  39. package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
  40. package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
  41. package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
  42. package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
  43. package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
  44. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
  45. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
  46. package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
  47. package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
  48. package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
  49. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
  50. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
  51. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
  52. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
  53. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
  54. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
  55. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
  56. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
  57. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
  58. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
  59. package/src/lib/agent-issue-permissions.ts +56 -0
  60. package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
  61. package/src/realtime/office-space.ts +7 -0
  62. package/src/routes/agents.ts +23 -1
  63. package/src/routes/assistant.ts +40 -1
  64. package/src/routes/companies.ts +227 -15
  65. package/src/routes/issues.ts +48 -0
  66. package/src/routes/plugins.ts +393 -103
  67. package/src/routes/{loops.ts → routines.ts} +72 -76
  68. package/src/scripts/onboard-seed.ts +2 -0
  69. package/src/server.ts +3 -1
  70. package/src/services/company-assistant-context-snapshot.ts +4 -2
  71. package/src/services/company-assistant-service.ts +17 -15
  72. package/src/services/company-file-archive-service.ts +56 -3
  73. package/src/services/company-file-import-service.ts +210 -31
  74. package/src/services/governance-service.ts +58 -3
  75. package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
  76. package/src/services/plugin-artifact-installer.ts +115 -0
  77. package/src/services/plugin-artifact-store.ts +28 -0
  78. package/src/services/plugin-capability-policy.ts +31 -0
  79. package/src/services/plugin-jobs-service.ts +74 -0
  80. package/src/services/plugin-manifest-loader.ts +78 -3
  81. package/src/services/plugin-rpc.ts +102 -0
  82. package/src/services/plugin-runtime.ts +240 -209
  83. package/src/services/plugin-worker-host.ts +167 -0
  84. package/src/services/starter-pack-registry.ts +68 -0
  85. package/src/services/template-apply-service.ts +3 -1
  86. package/src/services/template-catalog.ts +29 -0
  87. package/src/services/work-loop-service/work-loop-service.ts +18 -18
  88. package/src/shutdown/graceful-shutdown.ts +3 -1
  89. package/src/worker/scheduler.ts +21 -1
  90. package/src/services/company-export-service.ts +0 -63
@@ -0,0 +1,167 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import type { PluginManifest } from "bopodev-contracts";
6
+ import { PluginManifestV2Schema } from "bopodev-contracts";
7
+ import { z } from "zod";
8
+ import {
9
+ PluginRpcLineBuffer,
10
+ createPluginRpcRequest,
11
+ encodePluginRpcMessage,
12
+ type PluginRpcResponse
13
+ } from "./plugin-rpc";
14
+
15
+ type WorkerEntry = {
16
+ process: ChildProcessWithoutNullStreams;
17
+ parser: PluginRpcLineBuffer;
18
+ pending: Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }>;
19
+ };
20
+
21
+ type PluginWorkerHostOptions = {
22
+ maxPayloadBytes: number;
23
+ requestTimeoutMs: number;
24
+ disabled: boolean;
25
+ };
26
+ type PluginManifestV2 = z.infer<typeof PluginManifestV2Schema>;
27
+
28
+ function parseBoolEnv(value: string | undefined): boolean {
29
+ return value === "1" || value === "true";
30
+ }
31
+
32
+ /**
33
+ * Older demos used `dist/worker.js`, which is often missing (and gitignored). Prefer `worker/worker.js` in the same plugin directory.
34
+ */
35
+ function resolveWorkerExecutable(manifest: PluginManifestV2): string {
36
+ const workerPath = manifest.entrypoints.worker;
37
+ if (existsSync(workerPath)) {
38
+ return workerPath;
39
+ }
40
+ const pluginRoot = dirname(dirname(workerPath));
41
+ const fallback = join(pluginRoot, "worker", "worker.js");
42
+ if (existsSync(fallback)) {
43
+ return fallback;
44
+ }
45
+ return workerPath;
46
+ }
47
+
48
+ export class PluginWorkerHost {
49
+ private readonly workers = new Map<string, WorkerEntry>();
50
+ private readonly options: PluginWorkerHostOptions;
51
+
52
+ constructor() {
53
+ this.options = {
54
+ maxPayloadBytes: Number(process.env.BOPO_PLUGIN_RPC_MAX_PAYLOAD_BYTES ?? 256_000),
55
+ requestTimeoutMs: Number(process.env.BOPO_PLUGIN_WORKER_REQUEST_TIMEOUT_MS ?? 8_000),
56
+ disabled: parseBoolEnv(process.env.BOPO_PLUGIN_WORKERS_DISABLED)
57
+ };
58
+ }
59
+
60
+ isEnabled() {
61
+ return !this.options.disabled;
62
+ }
63
+
64
+ async shutdown() {
65
+ for (const [pluginId, entry] of this.workers.entries()) {
66
+ for (const pending of entry.pending.values()) {
67
+ clearTimeout(pending.timeout);
68
+ pending.reject(new Error(`plugin worker '${pluginId}' was shut down`));
69
+ }
70
+ entry.pending.clear();
71
+ entry.process.kill("SIGTERM");
72
+ }
73
+ this.workers.clear();
74
+ }
75
+
76
+ async invoke(
77
+ manifest: PluginManifest,
78
+ input: {
79
+ method: "plugin.health" | "plugin.action" | "plugin.data" | "plugin.hook" | "plugin.job" | "plugin.webhook";
80
+ params: Record<string, unknown>;
81
+ }
82
+ ) {
83
+ if (!this.isEnabled()) {
84
+ throw new Error("plugin workers disabled by BOPO_PLUGIN_WORKERS_DISABLED");
85
+ }
86
+ const v2 = PluginManifestV2Schema.safeParse(manifest);
87
+ if (!v2.success) {
88
+ throw new Error(`plugin '${manifest.id}' does not declare apiVersion '2'`);
89
+ }
90
+ const entry = await this.ensureWorker(v2.data);
91
+ const requestId = randomUUID();
92
+ const request = createPluginRpcRequest(input.method, input.params, requestId);
93
+ const encoded = encodePluginRpcMessage(request);
94
+ if (Buffer.byteLength(encoded, "utf8") > this.options.maxPayloadBytes) {
95
+ throw new Error(`plugin rpc payload exceeds ${this.options.maxPayloadBytes} bytes`);
96
+ }
97
+ return await new Promise<unknown>((resolve, reject) => {
98
+ const timeout = setTimeout(() => {
99
+ entry.pending.delete(requestId);
100
+ reject(new Error(`plugin rpc request timed out for '${manifest.id}' method '${input.method}'`));
101
+ }, this.options.requestTimeoutMs);
102
+ entry.pending.set(requestId, { resolve, reject, timeout });
103
+ entry.process.stdin.write(encoded);
104
+ });
105
+ }
106
+
107
+ private async ensureWorker(manifest: PluginManifestV2) {
108
+ const existing = this.workers.get(manifest.id);
109
+ if (existing && !existing.process.killed) {
110
+ return existing;
111
+ }
112
+ const command = process.env.BOPO_PLUGIN_WORKER_COMMAND ?? "node";
113
+ const workerScript = resolveWorkerExecutable(manifest);
114
+ const child = spawn(command, [workerScript], {
115
+ stdio: "pipe",
116
+ env: process.env
117
+ });
118
+ const parser = new PluginRpcLineBuffer();
119
+ const pending = new Map<
120
+ string,
121
+ { resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }
122
+ >();
123
+ parser.on("message", (message: PluginRpcResponse) => {
124
+ const waiter = pending.get(message.id);
125
+ if (!waiter) {
126
+ return;
127
+ }
128
+ clearTimeout(waiter.timeout);
129
+ pending.delete(message.id);
130
+ if ("error" in message) {
131
+ waiter.reject(new Error(`${message.error.message} (${message.error.code})`));
132
+ return;
133
+ }
134
+ waiter.resolve(message.result);
135
+ });
136
+ child.stdout.on("data", (chunk) => {
137
+ parser.push(String(chunk));
138
+ });
139
+ child.stderr.on("data", (chunk) => {
140
+ // eslint-disable-next-line no-console
141
+ console.warn(`[plugins] worker '${manifest.id}' stderr: ${String(chunk).trim()}`);
142
+ });
143
+ child.on("exit", (code, signal) => {
144
+ if (code !== 0 && code !== null) {
145
+ // eslint-disable-next-line no-console
146
+ console.warn(
147
+ `[plugins] worker '${manifest.id}' exited with code ${code}${signal ? ` signal ${signal}` : ""} (script: ${workerScript})`
148
+ );
149
+ }
150
+ this.workers.delete(manifest.id);
151
+ for (const wait of pending.values()) {
152
+ clearTimeout(wait.timeout);
153
+ wait.reject(new Error(`plugin worker '${manifest.id}' exited`));
154
+ }
155
+ pending.clear();
156
+ });
157
+ child.on("error", (error) => {
158
+ // eslint-disable-next-line no-console
159
+ console.warn(`[plugins] worker '${manifest.id}' failed to spawn: ${String(error.message)}`);
160
+ });
161
+ const entry: WorkerEntry = { process: child, parser, pending };
162
+ this.workers.set(manifest.id, entry);
163
+ return entry;
164
+ }
165
+ }
166
+
167
+ export const pluginWorkerHost = new PluginWorkerHost();
@@ -0,0 +1,68 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { listBuiltinStarterTemplates } from "./template-catalog";
5
+
6
+ const PACK_DIR = join(dirname(fileURLToPath(import.meta.url)), "../assets/starter-packs");
7
+
8
+ export type StarterPackDefinition = {
9
+ id: string;
10
+ label: string;
11
+ description: string;
12
+ filename: string;
13
+ };
14
+
15
+ /** Optional extra packs as zips only (id must not match a builtin template slug). */
16
+ export const STARTER_PACK_DEFINITIONS: StarterPackDefinition[] = [
17
+ {
18
+ id: "product-delivery-trio",
19
+ label: "Product Delivery Trio",
20
+ description:
21
+ "CEO, Product, and Engineering with strategy, delivery, and quality projects; weekly leadership sync and release hygiene; skills for triage and RCA.",
22
+ filename: "product-delivery-trio.zip"
23
+ },
24
+ {
25
+ id: "revenue-gtm-b2b",
26
+ label: "Revenue GTM (B2B)",
27
+ description:
28
+ "CEO, Head of GTM, and Pipeline Owner with pipeline, deals, and customer success projects; revenue review and pipeline hygiene loops; ICP and discovery skills.",
29
+ filename: "revenue-gtm-b2b.zip"
30
+ },
31
+ {
32
+ id: "devrel-growth",
33
+ label: "DevRel & Growth",
34
+ description:
35
+ "CEO, DevRel Lead, and Technical Content Producer with docs, community, and partners projects; content planning and community health loops.",
36
+ filename: "devrel-growth.zip"
37
+ },
38
+ {
39
+ id: "customer-support-excellence",
40
+ label: "Customer Support Excellence",
41
+ description:
42
+ "CEO, Support Lead, and Support Specialist with queue, knowledge base, and quality projects; daily standup and KB gap sweep loops.",
43
+ filename: "customer-support-excellence.zip"
44
+ }
45
+ ];
46
+
47
+ export function listStarterPackMetadata(): Array<{ id: string; label: string; description: string }> {
48
+ const fromTemplates = listBuiltinStarterTemplates();
49
+ const builtinIds = new Set(fromTemplates.map((t) => t.id));
50
+ const fromZips = STARTER_PACK_DEFINITIONS.filter((p) => !builtinIds.has(p.id)).map(({ id, label, description }) => ({
51
+ id,
52
+ label,
53
+ description
54
+ }));
55
+ return [...fromTemplates, ...fromZips];
56
+ }
57
+
58
+ export function resolveStarterPackDefinition(id: string): StarterPackDefinition | null {
59
+ return STARTER_PACK_DEFINITIONS.find((p) => p.id === id) ?? null;
60
+ }
61
+
62
+ export async function readStarterPackZipBuffer(id: string): Promise<Buffer> {
63
+ const def = resolveStarterPackDefinition(id);
64
+ if (!def) {
65
+ throw new Error(`Unknown starter pack id: ${id}`);
66
+ }
67
+ return readFile(join(PACK_DIR, def.filename));
68
+ }
@@ -69,6 +69,8 @@ export async function applyTemplateManifest(
69
69
  heartbeatCron: agent.heartbeatCron,
70
70
  monthlyBudgetUsd: agent.monthlyBudgetUsd.toFixed(4),
71
71
  canHireAgents: agent.canHireAgents,
72
+ canAssignAgents: agent.canAssignAgents,
73
+ canCreateIssues: agent.canCreateIssues,
72
74
  runtimeCommand: agent.runtimeConfig?.runtimeCommand,
73
75
  runtimeArgsJson,
74
76
  runtimeCwd: agent.runtimeConfig?.runtimeCwd,
@@ -150,7 +152,7 @@ export async function applyTemplateManifest(
150
152
  if (loop) {
151
153
  await addWorkLoopTrigger(db, {
152
154
  companyId: input.companyId,
153
- workLoopId: loop.id,
155
+ routineId: loop.id,
154
156
  cronExpression: job.cron
155
157
  });
156
158
  }
@@ -89,6 +89,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
89
89
  heartbeatCron: "*/15 * * * *",
90
90
  monthlyBudgetUsd: 150,
91
91
  canHireAgents: true,
92
+ canAssignAgents: true,
93
+ canCreateIssues: true,
92
94
  runtimeConfig: {
93
95
  bootstrapPrompt: [
94
96
  "You are Founder CEO for {{brandName}}.",
@@ -139,6 +141,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
139
141
  heartbeatCron: "*/15 * * * *",
140
142
  monthlyBudgetUsd: 300,
141
143
  canHireAgents: false,
144
+ canAssignAgents: true,
145
+ canCreateIssues: true,
142
146
  runtimeConfig: {
143
147
  bootstrapPrompt: [
144
148
  "You are Founding Engineer for {{productName}}.",
@@ -176,6 +180,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
176
180
  heartbeatCron: "*/30 * * * *",
177
181
  monthlyBudgetUsd: 200,
178
182
  canHireAgents: false,
183
+ canAssignAgents: true,
184
+ canCreateIssues: true,
179
185
  runtimeConfig: {
180
186
  bootstrapPrompt: [
181
187
  "You are Growth Operator for {{brandName}}.",
@@ -304,6 +310,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
304
310
  heartbeatCron: "*/20 * * * *",
305
311
  monthlyBudgetUsd: 250,
306
312
  canHireAgents: true,
313
+ canAssignAgents: true,
314
+ canCreateIssues: true,
307
315
  runtimeConfig: {
308
316
  bootstrapPrompt: [
309
317
  "You are Head of Marketing for {{brandName}}.",
@@ -345,6 +353,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
345
353
  heartbeatCron: "*/30 * * * *",
346
354
  monthlyBudgetUsd: 180,
347
355
  canHireAgents: false,
356
+ canAssignAgents: true,
357
+ canCreateIssues: true,
348
358
  runtimeConfig: {
349
359
  bootstrapPrompt: [
350
360
  "You are Content Strategist for {{brandName}}.",
@@ -378,6 +388,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
378
388
  heartbeatCron: "*/30 * * * *",
379
389
  monthlyBudgetUsd: 220,
380
390
  canHireAgents: false,
391
+ canAssignAgents: true,
392
+ canCreateIssues: true,
381
393
  runtimeConfig: {
382
394
  bootstrapPrompt: [
383
395
  "You are Content Writer for {{brandName}}.",
@@ -412,6 +424,8 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
412
424
  heartbeatCron: "*/30 * * * *",
413
425
  monthlyBudgetUsd: 180,
414
426
  canHireAgents: false,
427
+ canAssignAgents: true,
428
+ canCreateIssues: true,
415
429
  runtimeConfig: {
416
430
  bootstrapPrompt: [
417
431
  "You are Distribution Manager for {{brandName}}.",
@@ -483,6 +497,21 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
483
497
  }
484
498
  ];
485
499
 
500
+ export type BuiltinStarterTemplateDefinition = (typeof builtinTemplateDefinitions)[number];
501
+
502
+ /** Built-in DB templates offered as “Start with” options when creating a company (full org, same as Templates UI). */
503
+ export function listBuiltinStarterTemplates(): Array<{ id: string; label: string; description: string }> {
504
+ return builtinTemplateDefinitions.map((d) => ({
505
+ id: d.slug,
506
+ label: d.name,
507
+ description: d.description
508
+ }));
509
+ }
510
+
511
+ export function getBuiltinStarterTemplateBySlug(slug: string): BuiltinStarterTemplateDefinition | null {
512
+ return builtinTemplateDefinitions.find((d) => d.slug === slug) ?? null;
513
+ }
514
+
486
515
  export async function ensureBuiltinTemplatesRegistered(db: BopoDb, companyIds: string[] = []) {
487
516
  for (const companyId of companyIds) {
488
517
  await ensureCompanyBuiltinTemplateDefaults(db, companyId);
@@ -130,7 +130,7 @@ export async function addWorkLoopTrigger(
130
130
  db: BopoDb,
131
131
  input: {
132
132
  companyId: string;
133
- workLoopId: string;
133
+ routineId: string;
134
134
  cronExpression: string;
135
135
  timezone?: string;
136
136
  label?: string | null;
@@ -148,7 +148,7 @@ export async function addWorkLoopTrigger(
148
148
  await db.insert(workLoopTriggers).values({
149
149
  id,
150
150
  companyId: input.companyId,
151
- workLoopId: input.workLoopId,
151
+ routineId: input.routineId,
152
152
  kind: "schedule",
153
153
  label: input.label ?? null,
154
154
  enabled: input.enabled ?? true,
@@ -164,7 +164,7 @@ export async function addWorkLoopTriggerFromPreset(
164
164
  db: BopoDb,
165
165
  input: {
166
166
  companyId: string;
167
- workLoopId: string;
167
+ routineId: string;
168
168
  preset: "daily" | "weekly";
169
169
  hour24: number;
170
170
  minute: number;
@@ -182,7 +182,7 @@ export async function addWorkLoopTriggerFromPreset(
182
182
  : weeklyCronAtLocalTime(input.dayOfWeek ?? 1, input.hour24, input.minute);
183
183
  return addWorkLoopTrigger(db, {
184
184
  companyId: input.companyId,
185
- workLoopId: input.workLoopId,
185
+ routineId: input.routineId,
186
186
  cronExpression: cron,
187
187
  timezone: tz,
188
188
  label: input.label,
@@ -242,7 +242,7 @@ export async function updateWorkLoopTrigger(
242
242
  export async function deleteWorkLoopTrigger(
243
243
  db: BopoDb,
244
244
  companyId: string,
245
- workLoopId: string,
245
+ routineId: string,
246
246
  triggerId: string
247
247
  ): Promise<boolean> {
248
248
  const [existing] = await db
@@ -252,7 +252,7 @@ export async function deleteWorkLoopTrigger(
252
252
  and(
253
253
  eq(workLoopTriggers.id, triggerId),
254
254
  eq(workLoopTriggers.companyId, companyId),
255
- eq(workLoopTriggers.workLoopId, workLoopId)
255
+ eq(workLoopTriggers.routineId, routineId)
256
256
  )
257
257
  )
258
258
  .limit(1);
@@ -263,19 +263,19 @@ export async function deleteWorkLoopTrigger(
263
263
  return true;
264
264
  }
265
265
 
266
- export async function listWorkLoopTriggers(db: BopoDb, companyId: string, workLoopId: string) {
266
+ export async function listWorkLoopTriggers(db: BopoDb, companyId: string, routineId: string) {
267
267
  return db
268
268
  .select()
269
269
  .from(workLoopTriggers)
270
- .where(and(eq(workLoopTriggers.companyId, companyId), eq(workLoopTriggers.workLoopId, workLoopId)))
270
+ .where(and(eq(workLoopTriggers.companyId, companyId), eq(workLoopTriggers.routineId, routineId)))
271
271
  .orderBy(asc(workLoopTriggers.createdAt));
272
272
  }
273
273
 
274
- export async function listWorkLoopRuns(db: BopoDb, companyId: string, workLoopId: string, limit = 100) {
274
+ export async function listWorkLoopRuns(db: BopoDb, companyId: string, routineId: string, limit = 100) {
275
275
  return db
276
276
  .select()
277
277
  .from(workLoopRuns)
278
- .where(and(eq(workLoopRuns.companyId, companyId), eq(workLoopRuns.workLoopId, workLoopId)))
278
+ .where(and(eq(workLoopRuns.companyId, companyId), eq(workLoopRuns.routineId, routineId)))
279
279
  .orderBy(desc(workLoopRuns.triggeredAt), desc(workLoopRuns.id))
280
280
  .limit(Math.min(500, Math.max(1, limit)));
281
281
  }
@@ -287,7 +287,7 @@ async function findOpenIssueForLoop(db: BopoDb, companyId: string, loopId: strin
287
287
  .where(
288
288
  and(
289
289
  eq(issues.companyId, companyId),
290
- eq(issues.loopId, loopId),
290
+ eq(issues.routineId, loopId),
291
291
  inArray(issues.status, [...OPEN_ISSUE_STATUSES])
292
292
  )
293
293
  )
@@ -348,7 +348,7 @@ export async function dispatchLoopRun(
348
348
  .where(
349
349
  and(
350
350
  eq(workLoopRuns.companyId, input.companyId),
351
- eq(workLoopRuns.workLoopId, input.loopId),
351
+ eq(workLoopRuns.routineId, input.loopId),
352
352
  eq(workLoopRuns.source, input.source),
353
353
  eq(workLoopRuns.idempotencyKey, input.idempotencyKey),
354
354
  input.triggerId ? eq(workLoopRuns.triggerId, input.triggerId) : isNull(workLoopRuns.triggerId)
@@ -371,7 +371,7 @@ export async function dispatchLoopRun(
371
371
  await txDb.insert(workLoopRuns).values({
372
372
  id: runId,
373
373
  companyId: input.companyId,
374
- workLoopId: loop.id,
374
+ routineId: loop.id,
375
375
  triggerId: input.triggerId,
376
376
  source: input.source,
377
377
  status: "failed",
@@ -390,7 +390,7 @@ export async function dispatchLoopRun(
390
390
  await txDb.insert(workLoopRuns).values({
391
391
  id: runId,
392
392
  companyId: input.companyId,
393
- workLoopId: loop.id,
393
+ routineId: loop.id,
394
394
  triggerId: input.triggerId,
395
395
  source: input.source,
396
396
  status: "received",
@@ -410,7 +410,7 @@ export async function dispatchLoopRun(
410
410
  .set({
411
411
  status,
412
412
  linkedIssueId: activeIssue.id,
413
- coalescedIntoRunId: activeIssue.loopRunId,
413
+ coalescedIntoRunId: activeIssue.routineRunId,
414
414
  completedAt: triggeredAt,
415
415
  updatedAt: new Date()
416
416
  })
@@ -465,8 +465,8 @@ export async function dispatchLoopRun(
465
465
  assigneeAgentId: loop.assigneeAgentId,
466
466
  labelsJson: JSON.stringify(["work-loop"]),
467
467
  tagsJson: "[]",
468
- loopId: loop.id,
469
- loopRunId: runId
468
+ routineId: loop.id,
469
+ routineRunId: runId
470
470
  });
471
471
  if (goalIds.length > 0) {
472
472
  await syncIssueGoals(txDb, {
@@ -567,7 +567,7 @@ export async function runLoopSweep(
567
567
  loop: workLoops
568
568
  })
569
569
  .from(workLoopTriggers)
570
- .innerJoin(workLoops, eq(workLoopTriggers.workLoopId, workLoops.id))
570
+ .innerJoin(workLoops, eq(workLoopTriggers.routineId, workLoops.id))
571
571
  .where(
572
572
  and(
573
573
  eq(workLoopTriggers.companyId, companyId),
@@ -19,8 +19,9 @@ export function attachGracefulShutdownHandlers(options: {
19
19
  realtimeHub: RealtimeHub;
20
20
  dbClient: unknown;
21
21
  scheduler?: { stop: () => Promise<void> };
22
+ pluginWorkers?: { shutdown: () => Promise<void> };
22
23
  }) {
23
- const { server, realtimeHub, dbClient, scheduler } = options;
24
+ const { server, realtimeHub, dbClient, scheduler, pluginWorkers } = options;
24
25
  let shutdownInFlight: Promise<void> | null = null;
25
26
 
26
27
  function shutdown(signal: string) {
@@ -38,6 +39,7 @@ export function attachGracefulShutdownHandlers(options: {
38
39
  beginIssueCommentDispatchShutdown();
39
40
  await Promise.allSettled([
40
41
  scheduler?.stop() ?? Promise.resolve(),
42
+ pluginWorkers?.shutdown() ?? Promise.resolve(),
41
43
  new Promise<void>((resolve, reject) => {
42
44
  server.close((err) => {
43
45
  if (err) {
@@ -4,6 +4,7 @@ import { runHeartbeatSweep } from "../services/heartbeat-service";
4
4
  import { runHeartbeatQueueSweep } from "../services/heartbeat-queue-service";
5
5
  import { runIssueCommentDispatchSweep } from "../services/comment-recipient-dispatch-service";
6
6
  import { runLoopSweep } from "../services/work-loop-service";
7
+ import { runPluginJobSweep } from "../services/plugin-jobs-service";
7
8
 
8
9
  export type HeartbeatSchedulerHandle = {
9
10
  stop: () => Promise<void>;
@@ -14,15 +15,18 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
14
15
  const queueIntervalMs = Number(process.env.BOPO_HEARTBEAT_QUEUE_SWEEP_MS ?? 2_000);
15
16
  const commentDispatchIntervalMs = Number(process.env.BOPO_COMMENT_DISPATCH_SWEEP_MS ?? 3_000);
16
17
  const loopSweepIntervalMs = Number(process.env.BOPO_LOOP_SWEEP_MS ?? 60_000);
18
+ const pluginJobSweepIntervalMs = Number(process.env.BOPO_PLUGIN_JOB_SWEEP_MS ?? 60_000);
17
19
  const loopSweepEnabled = (process.env.BOPO_LOOP_SWEEP_ENABLED ?? "1").trim() !== "0";
18
20
  let heartbeatRunning = false;
19
21
  let queueRunning = false;
20
22
  let commentDispatchRunning = false;
21
23
  let loopSweepRunning = false;
24
+ let pluginJobSweepRunning = false;
22
25
  let heartbeatPromise: Promise<unknown> | null = null;
23
26
  let queuePromise: Promise<unknown> | null = null;
24
27
  let commentDispatchPromise: Promise<unknown> | null = null;
25
28
  let loopSweepPromise: Promise<unknown> | null = null;
29
+ let pluginJobSweepPromise: Promise<unknown> | null = null;
26
30
  const heartbeatTimer = setInterval(() => {
27
31
  if (heartbeatRunning) {
28
32
  return;
@@ -85,6 +89,21 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
85
89
  });
86
90
  }, loopSweepIntervalMs)
87
91
  : null;
92
+ const pluginJobTimer = setInterval(() => {
93
+ if (pluginJobSweepRunning) {
94
+ return;
95
+ }
96
+ pluginJobSweepRunning = true;
97
+ pluginJobSweepPromise = runPluginJobSweep(db, companyId)
98
+ .catch((error) => {
99
+ // eslint-disable-next-line no-console
100
+ console.error("[scheduler] plugin job sweep failed", error);
101
+ })
102
+ .finally(() => {
103
+ pluginJobSweepRunning = false;
104
+ pluginJobSweepPromise = null;
105
+ });
106
+ }, pluginJobSweepIntervalMs);
88
107
  const stop = async () => {
89
108
  clearInterval(heartbeatTimer);
90
109
  clearInterval(queueTimer);
@@ -92,8 +111,9 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
92
111
  if (loopSweepTimer) {
93
112
  clearInterval(loopSweepTimer);
94
113
  }
114
+ clearInterval(pluginJobTimer);
95
115
  await Promise.allSettled(
96
- [heartbeatPromise, queuePromise, commentDispatchPromise, loopSweepPromise].filter(
116
+ [heartbeatPromise, queuePromise, commentDispatchPromise, loopSweepPromise, pluginJobSweepPromise].filter(
97
117
  (promise): promise is Promise<unknown> => promise !== null
98
118
  )
99
119
  );
@@ -1,63 +0,0 @@
1
- import type { BopoDb } from "bopodev-db";
2
- import { getCompany, listAgents, listGoals, listIssues, listProjects } from "bopodev-db";
3
-
4
- function serializeValue(value: unknown): unknown {
5
- if (value instanceof Date) {
6
- return value.toISOString();
7
- }
8
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
9
- return serializeRow(value as Record<string, unknown>);
10
- }
11
- if (Array.isArray(value)) {
12
- return value.map((entry) => serializeValue(entry));
13
- }
14
- return value;
15
- }
16
-
17
- function serializeRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
18
- const out: Record<string, unknown> = {};
19
- for (const [key, val] of Object.entries(row)) {
20
- out[key] = serializeValue(val);
21
- }
22
- return out;
23
- }
24
-
25
- /** Strip agent fields that commonly hold secrets or session state for shareable exports. */
26
- function scrubAgentRow(agent: Record<string, unknown>) {
27
- const base = serializeRow(agent);
28
- return {
29
- ...base,
30
- runtimeEnvJson: "{}",
31
- bootstrapPrompt: null,
32
- stateBlob: "{}",
33
- runtimeCommand: null,
34
- runtimeArgsJson: "[]"
35
- };
36
- }
37
-
38
- /**
39
- * Portable company snapshot for backup, templates, or handoff (secrets and agent session state redacted).
40
- */
41
- export async function buildCompanyPortabilityExport(db: BopoDb, companyId: string) {
42
- const company = await getCompany(db, companyId);
43
- if (!company) {
44
- return null;
45
- }
46
-
47
- const [projects, goals, issues, agents] = await Promise.all([
48
- listProjects(db, companyId),
49
- listGoals(db, companyId),
50
- listIssues(db, companyId),
51
- listAgents(db, companyId)
52
- ]);
53
-
54
- return {
55
- exportedAt: new Date().toISOString(),
56
- formatVersion: 1,
57
- company: serializeRow(company as unknown as Record<string, unknown>),
58
- projects: projects.map((p) => serializeRow(p as unknown as Record<string, unknown>)),
59
- goals: goals.map((g) => serializeRow(g as unknown as Record<string, unknown>)),
60
- issues: issues.map((issue) => serializeRow(issue as unknown as Record<string, unknown>)),
61
- agents: agents.map((agent) => scrubAgentRow(agent as unknown as Record<string, unknown>))
62
- };
63
- }