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.
- package/package.json +5 -5
- package/src/app.ts +4 -2
- package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
- package/src/assets/starter-packs/devrel-growth.zip +0 -0
- package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
- package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
- package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
- package/src/lib/agent-issue-permissions.ts +56 -0
- package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +23 -1
- package/src/routes/assistant.ts +40 -1
- package/src/routes/companies.ts +227 -15
- package/src/routes/issues.ts +48 -0
- package/src/routes/plugins.ts +393 -103
- package/src/routes/{loops.ts → routines.ts} +72 -76
- package/src/scripts/onboard-seed.ts +2 -0
- package/src/server.ts +3 -1
- package/src/services/company-assistant-context-snapshot.ts +4 -2
- package/src/services/company-assistant-service.ts +17 -15
- package/src/services/company-file-archive-service.ts +56 -3
- package/src/services/company-file-import-service.ts +210 -31
- package/src/services/governance-service.ts +58 -3
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
- package/src/services/plugin-artifact-installer.ts +115 -0
- package/src/services/plugin-artifact-store.ts +28 -0
- package/src/services/plugin-capability-policy.ts +31 -0
- package/src/services/plugin-jobs-service.ts +74 -0
- package/src/services/plugin-manifest-loader.ts +78 -3
- package/src/services/plugin-rpc.ts +102 -0
- package/src/services/plugin-runtime.ts +240 -209
- package/src/services/plugin-worker-host.ts +167 -0
- package/src/services/starter-pack-registry.ts +68 -0
- package/src/services/template-apply-service.ts +3 -1
- package/src/services/template-catalog.ts +29 -0
- package/src/services/work-loop-service/work-loop-service.ts +18 -18
- package/src/shutdown/graceful-shutdown.ts +3 -1
- package/src/worker/scheduler.ts +21 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
469
|
-
|
|
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.
|
|
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) {
|
package/src/worker/scheduler.ts
CHANGED
|
@@ -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
|
-
}
|