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,257 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { PluginManifestSchema } from "bopodev-contracts";
|
|
4
|
+
import {
|
|
5
|
+
createApprovalRequest,
|
|
6
|
+
deletePluginById,
|
|
7
|
+
deletePluginConfig,
|
|
8
|
+
listCompanyPluginConfigs,
|
|
9
|
+
listCompanies,
|
|
10
|
+
listPluginRuns,
|
|
11
|
+
listPlugins,
|
|
12
|
+
updatePluginConfig
|
|
13
|
+
} from "bopodev-db";
|
|
14
|
+
import type { AppContext } from "../context";
|
|
15
|
+
import { sendError, sendOk } from "../http";
|
|
16
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
|
+
import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
|
|
18
|
+
import { registerPluginManifest } from "../services/plugin-runtime";
|
|
19
|
+
|
|
20
|
+
const pluginConfigSchema = z.object({
|
|
21
|
+
enabled: z.boolean().optional(),
|
|
22
|
+
priority: z.number().int().min(0).max(1000).optional(),
|
|
23
|
+
config: z.record(z.string(), z.unknown()).default({}),
|
|
24
|
+
grantedCapabilities: z.array(z.string().min(1)).default([]),
|
|
25
|
+
requestApproval: z.boolean().default(true)
|
|
26
|
+
});
|
|
27
|
+
const pluginManifestCreateSchema = z.object({
|
|
28
|
+
manifestJson: z.string().min(2),
|
|
29
|
+
install: z.boolean().default(true)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const HIGH_RISK_CAPABILITIES = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
|
|
33
|
+
|
|
34
|
+
export function createPluginsRouter(ctx: AppContext) {
|
|
35
|
+
const router = Router();
|
|
36
|
+
router.use(requireCompanyScope);
|
|
37
|
+
|
|
38
|
+
router.get("/", async (req, res) => {
|
|
39
|
+
const [catalog, configs] = await Promise.all([listPlugins(ctx.db), listCompanyPluginConfigs(ctx.db, req.companyId!)]);
|
|
40
|
+
const configByPluginId = new Map(configs.map((row) => [row.pluginId, row]));
|
|
41
|
+
return sendOk(
|
|
42
|
+
res,
|
|
43
|
+
catalog.map((plugin) => {
|
|
44
|
+
const config = configByPluginId.get(plugin.id);
|
|
45
|
+
const manifest = safeParseJsonObject(plugin.manifestJson) as Record<string, unknown>;
|
|
46
|
+
return {
|
|
47
|
+
id: plugin.id,
|
|
48
|
+
name: plugin.name,
|
|
49
|
+
description: typeof manifest.description === "string" ? manifest.description : null,
|
|
50
|
+
promptTemplate:
|
|
51
|
+
typeof manifest.runtime === "object" &&
|
|
52
|
+
manifest.runtime !== null &&
|
|
53
|
+
typeof (manifest.runtime as Record<string, unknown>).promptTemplate === "string"
|
|
54
|
+
? ((manifest.runtime as Record<string, unknown>).promptTemplate as string)
|
|
55
|
+
: null,
|
|
56
|
+
version: plugin.version,
|
|
57
|
+
kind: plugin.kind,
|
|
58
|
+
runtimeType: plugin.runtimeType,
|
|
59
|
+
runtimeEntrypoint: plugin.runtimeEntrypoint,
|
|
60
|
+
hooks: safeParseStringArray(plugin.hooksJson),
|
|
61
|
+
capabilities: safeParseStringArray(plugin.capabilitiesJson),
|
|
62
|
+
companyConfig: config
|
|
63
|
+
? {
|
|
64
|
+
enabled: config.enabled,
|
|
65
|
+
priority: config.priority,
|
|
66
|
+
config: safeParseJsonObject(config.configJson),
|
|
67
|
+
grantedCapabilities: safeParseStringArray(config.grantedCapabilitiesJson)
|
|
68
|
+
}
|
|
69
|
+
: null
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.put("/:pluginId", async (req, res) => {
|
|
76
|
+
const parsed = pluginConfigSchema.safeParse(req.body);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
return sendError(res, parsed.error.message, 422);
|
|
79
|
+
}
|
|
80
|
+
const pluginId = req.params.pluginId;
|
|
81
|
+
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
82
|
+
const pluginExists = catalog.some((plugin) => plugin.id === pluginId);
|
|
83
|
+
if (!pluginExists) {
|
|
84
|
+
return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
|
|
85
|
+
}
|
|
86
|
+
const companyExists = companies.some((company) => company.id === req.companyId);
|
|
87
|
+
if (!companyExists) {
|
|
88
|
+
return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
|
|
89
|
+
}
|
|
90
|
+
const riskyCaps = parsed.data.grantedCapabilities.filter((cap) => HIGH_RISK_CAPABILITIES.has(cap));
|
|
91
|
+
if (riskyCaps.length > 0 && parsed.data.requestApproval) {
|
|
92
|
+
const approvalId = await createApprovalRequest(ctx.db, {
|
|
93
|
+
companyId: req.companyId!,
|
|
94
|
+
requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
|
|
95
|
+
action: "grant_plugin_capabilities",
|
|
96
|
+
payload: {
|
|
97
|
+
pluginId,
|
|
98
|
+
enabled: parsed.data.enabled,
|
|
99
|
+
priority: parsed.data.priority,
|
|
100
|
+
grantedCapabilities: parsed.data.grantedCapabilities,
|
|
101
|
+
config: parsed.data.config
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return sendOk(res, { approvalId, status: "pending" });
|
|
105
|
+
}
|
|
106
|
+
await updatePluginConfig(ctx.db, {
|
|
107
|
+
companyId: req.companyId!,
|
|
108
|
+
pluginId,
|
|
109
|
+
enabled: parsed.data.enabled,
|
|
110
|
+
priority: parsed.data.priority,
|
|
111
|
+
configJson: JSON.stringify(parsed.data.config),
|
|
112
|
+
grantedCapabilitiesJson: JSON.stringify(parsed.data.grantedCapabilities)
|
|
113
|
+
});
|
|
114
|
+
return sendOk(res, { ok: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
router.post("/install-from-json", async (req, res) => {
|
|
118
|
+
const parsed = pluginManifestCreateSchema.safeParse(req.body);
|
|
119
|
+
if (!parsed.success) {
|
|
120
|
+
return sendError(res, parsed.error.message, 422);
|
|
121
|
+
}
|
|
122
|
+
let rawManifest: unknown;
|
|
123
|
+
try {
|
|
124
|
+
rawManifest = JSON.parse(parsed.data.manifestJson);
|
|
125
|
+
} catch {
|
|
126
|
+
return sendError(res, "manifestJson must be valid JSON.", 422);
|
|
127
|
+
}
|
|
128
|
+
const manifestParsed = PluginManifestSchema.safeParse(rawManifest);
|
|
129
|
+
if (!manifestParsed.success) {
|
|
130
|
+
return sendError(res, manifestParsed.error.message, 422);
|
|
131
|
+
}
|
|
132
|
+
const manifest = manifestParsed.data;
|
|
133
|
+
const [companies] = await Promise.all([listCompanies(ctx.db)]);
|
|
134
|
+
const companyExists = companies.some((company) => company.id === req.companyId);
|
|
135
|
+
if (!companyExists) {
|
|
136
|
+
return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const manifestPath = await writePluginManifestToFilesystem(manifest);
|
|
140
|
+
await registerPluginManifest(ctx.db, manifest);
|
|
141
|
+
if (parsed.data.install) {
|
|
142
|
+
await updatePluginConfig(ctx.db, {
|
|
143
|
+
companyId: req.companyId!,
|
|
144
|
+
pluginId: manifest.id,
|
|
145
|
+
enabled: false,
|
|
146
|
+
priority: 100,
|
|
147
|
+
configJson: "{}",
|
|
148
|
+
grantedCapabilitiesJson: "[]"
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return sendOk(res, { ok: true, pluginId: manifest.id, manifestPath, installed: parsed.data.install });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
router.post("/:pluginId/install", async (req, res) => {
|
|
155
|
+
const pluginId = req.params.pluginId;
|
|
156
|
+
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
157
|
+
const plugin = catalog.find((item) => item.id === pluginId);
|
|
158
|
+
if (!plugin) {
|
|
159
|
+
return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
|
|
160
|
+
}
|
|
161
|
+
const companyExists = companies.some((company) => company.id === req.companyId);
|
|
162
|
+
if (!companyExists) {
|
|
163
|
+
return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
|
|
164
|
+
}
|
|
165
|
+
await updatePluginConfig(ctx.db, {
|
|
166
|
+
companyId: req.companyId!,
|
|
167
|
+
pluginId,
|
|
168
|
+
enabled: false,
|
|
169
|
+
priority: 100,
|
|
170
|
+
configJson: "{}",
|
|
171
|
+
grantedCapabilitiesJson: "[]"
|
|
172
|
+
});
|
|
173
|
+
return sendOk(res, { ok: true, pluginId, installed: true, enabled: false });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
router.delete("/:pluginId/install", async (req, res) => {
|
|
177
|
+
const pluginId = req.params.pluginId;
|
|
178
|
+
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
179
|
+
const plugin = catalog.find((item) => item.id === pluginId);
|
|
180
|
+
if (!plugin) {
|
|
181
|
+
return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
|
|
182
|
+
}
|
|
183
|
+
const companyExists = companies.some((company) => company.id === req.companyId);
|
|
184
|
+
if (!companyExists) {
|
|
185
|
+
return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
|
|
186
|
+
}
|
|
187
|
+
await deletePluginConfig(ctx.db, {
|
|
188
|
+
companyId: req.companyId!,
|
|
189
|
+
pluginId
|
|
190
|
+
});
|
|
191
|
+
return sendOk(res, { ok: true, pluginId, installed: false });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
router.delete("/:pluginId", async (req, res) => {
|
|
195
|
+
const pluginId = req.params.pluginId;
|
|
196
|
+
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
197
|
+
const plugin = catalog.find((item) => item.id === pluginId);
|
|
198
|
+
if (!plugin) {
|
|
199
|
+
return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
|
|
200
|
+
}
|
|
201
|
+
if (plugin.runtimeEntrypoint.startsWith("builtin:")) {
|
|
202
|
+
return sendError(res, `Plugin '${pluginId}' is built-in and cannot be deleted.`, 400);
|
|
203
|
+
}
|
|
204
|
+
const companyExists = companies.some((company) => company.id === req.companyId);
|
|
205
|
+
if (!companyExists) {
|
|
206
|
+
return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
|
|
207
|
+
}
|
|
208
|
+
await deletePluginManifestFromFilesystem(pluginId);
|
|
209
|
+
await deletePluginById(ctx.db, pluginId);
|
|
210
|
+
return sendOk(res, { ok: true, pluginId, deleted: true });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
router.get("/runs", async (req, res) => {
|
|
214
|
+
const pluginId = typeof req.query.pluginId === "string" ? req.query.pluginId : undefined;
|
|
215
|
+
const runId = typeof req.query.runId === "string" ? req.query.runId : undefined;
|
|
216
|
+
const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
|
217
|
+
const rows = await listPluginRuns(ctx.db, {
|
|
218
|
+
companyId: req.companyId!,
|
|
219
|
+
pluginId,
|
|
220
|
+
runId,
|
|
221
|
+
limit
|
|
222
|
+
});
|
|
223
|
+
return sendOk(
|
|
224
|
+
res,
|
|
225
|
+
rows.map((row) => ({
|
|
226
|
+
...row,
|
|
227
|
+
diagnostics: safeParseJsonObject(row.diagnosticsJson)
|
|
228
|
+
}))
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return router;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function safeParseStringArray(value: string | null | undefined) {
|
|
236
|
+
if (!value) {
|
|
237
|
+
return [] as string[];
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(value) as unknown;
|
|
241
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function safeParseJsonObject(value: string | null | undefined) {
|
|
248
|
+
if (!value) {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const parsed = JSON.parse(value) as unknown;
|
|
253
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
254
|
+
} catch {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { getAdapterModels } from "bopodev-agent-sdk";
|
|
3
4
|
import {
|
|
4
5
|
bootstrapDatabase,
|
|
5
6
|
createAgent,
|
|
@@ -29,7 +30,7 @@ export interface OnboardSeedSummary {
|
|
|
29
30
|
const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
|
|
30
31
|
const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
|
|
31
32
|
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
32
|
-
type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "shell";
|
|
33
|
+
type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "openai_api" | "anthropic_api" | "shell";
|
|
33
34
|
const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
|
|
34
35
|
const STARTUP_PROJECT_NAME = "Leadership Setup";
|
|
35
36
|
const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
|
|
@@ -73,10 +74,15 @@ export async function ensureOnboardingSeed(input: {
|
|
|
73
74
|
const resolvedCompanyName = companyRow.name;
|
|
74
75
|
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
75
76
|
await mkdir(defaultRuntimeCwd, { recursive: true });
|
|
77
|
+
const seedRuntimeEnv = resolveSeedRuntimeEnv(agentProvider);
|
|
76
78
|
const defaultRuntimeConfig = normalizeRuntimeConfig({
|
|
77
79
|
defaultRuntimeCwd,
|
|
78
80
|
runtimeConfig: {
|
|
79
|
-
runtimeEnv:
|
|
81
|
+
runtimeEnv: seedRuntimeEnv,
|
|
82
|
+
runtimeModel: await resolveSeedRuntimeModel(agentProvider, {
|
|
83
|
+
defaultRuntimeCwd,
|
|
84
|
+
runtimeEnv: seedRuntimeEnv
|
|
85
|
+
})
|
|
80
86
|
}
|
|
81
87
|
});
|
|
82
88
|
const agents = await listAgents(db, companyId);
|
|
@@ -177,16 +183,17 @@ async function ensureCeoStartupTask(
|
|
|
177
183
|
"",
|
|
178
184
|
"Stand up your leadership operating baseline before taking on additional delivery work.",
|
|
179
185
|
"",
|
|
180
|
-
|
|
186
|
+
`1. Create the folder \`agents/${input.ceoId}/\` in the repository workspace.`,
|
|
181
187
|
"2. Author these files with your own voice and responsibilities:",
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
` - \`agents/${input.ceoId}/AGENTS.md\``,
|
|
189
|
+
` - \`agents/${input.ceoId}/HEARTBEAT.md\``,
|
|
190
|
+
` - \`agents/${input.ceoId}/SOUL.md\``,
|
|
191
|
+
` - \`agents/${input.ceoId}/TOOLS.md\``,
|
|
186
192
|
"3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
|
|
187
|
-
|
|
193
|
+
` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: agents/${input.ceoId}/AGENTS.md ..." }\``,
|
|
188
194
|
" - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
|
|
189
|
-
" -
|
|
195
|
+
" - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
|
|
196
|
+
` - If you must use payload files, store them in \`agents/${input.ceoId}/tmp/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
|
|
190
197
|
"4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
|
|
191
198
|
" - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
|
|
192
199
|
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role,bootstrapPrompt}'`",
|
|
@@ -248,14 +255,22 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
function parseAgentProvider(value: unknown): AgentProvider | null {
|
|
251
|
-
if (
|
|
258
|
+
if (
|
|
259
|
+
value === "codex" ||
|
|
260
|
+
value === "claude_code" ||
|
|
261
|
+
value === "cursor" ||
|
|
262
|
+
value === "opencode" ||
|
|
263
|
+
value === "openai_api" ||
|
|
264
|
+
value === "anthropic_api" ||
|
|
265
|
+
value === "shell"
|
|
266
|
+
) {
|
|
252
267
|
return value;
|
|
253
268
|
}
|
|
254
269
|
return null;
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
|
|
258
|
-
if (agentProvider === "codex") {
|
|
273
|
+
if (agentProvider === "codex" || agentProvider === "openai_api") {
|
|
259
274
|
const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
|
|
260
275
|
if (!key) {
|
|
261
276
|
return {};
|
|
@@ -264,7 +279,7 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
|
|
|
264
279
|
OPENAI_API_KEY: key
|
|
265
280
|
};
|
|
266
281
|
}
|
|
267
|
-
if (agentProvider === "claude_code") {
|
|
282
|
+
if (agentProvider === "claude_code" || agentProvider === "anthropic_api") {
|
|
268
283
|
const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
|
|
269
284
|
if (!key) {
|
|
270
285
|
return {};
|
|
@@ -276,6 +291,36 @@ function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, str
|
|
|
276
291
|
return {};
|
|
277
292
|
}
|
|
278
293
|
|
|
294
|
+
async function resolveSeedRuntimeModel(
|
|
295
|
+
agentProvider: AgentProvider,
|
|
296
|
+
input: { defaultRuntimeCwd: string; runtimeEnv: Record<string, string> }
|
|
297
|
+
): Promise<string | undefined> {
|
|
298
|
+
if (agentProvider !== "opencode") {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
const configured =
|
|
302
|
+
process.env.BOPO_OPENCODE_MODEL?.trim() ||
|
|
303
|
+
process.env.OPENCODE_MODEL?.trim();
|
|
304
|
+
try {
|
|
305
|
+
const discovered = await getAdapterModels("opencode", {
|
|
306
|
+
command: process.env.BOPO_OPENCODE_COMMAND?.trim() || "opencode",
|
|
307
|
+
cwd: input.defaultRuntimeCwd,
|
|
308
|
+
env: input.runtimeEnv
|
|
309
|
+
});
|
|
310
|
+
if (configured && discovered.some((entry) => entry.id === configured)) {
|
|
311
|
+
return configured;
|
|
312
|
+
}
|
|
313
|
+
if (discovered.length > 0) {
|
|
314
|
+
return discovered[0]!.id;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
if (configured) {
|
|
318
|
+
return configured;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return configured;
|
|
322
|
+
}
|
|
323
|
+
|
|
279
324
|
function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
|
|
280
325
|
if (providerType !== "shell") {
|
|
281
326
|
return false;
|
package/src/server.ts
CHANGED
|
@@ -3,13 +3,15 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { sql } from "drizzle-orm";
|
|
5
5
|
import { config as loadDotenv } from "dotenv";
|
|
6
|
-
import { bootstrapDatabase } from "bopodev-db";
|
|
6
|
+
import { bootstrapDatabase, listCompanies } from "bopodev-db";
|
|
7
7
|
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
8
8
|
import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
9
9
|
import { createApp } from "./app";
|
|
10
10
|
import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
|
|
11
11
|
import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
|
|
12
|
+
import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
|
|
12
13
|
import { attachRealtimeHub } from "./realtime/hub";
|
|
14
|
+
import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
|
|
13
15
|
import { createHeartbeatScheduler } from "./worker/scheduler";
|
|
14
16
|
|
|
15
17
|
loadApiEnv();
|
|
@@ -18,11 +20,21 @@ async function main() {
|
|
|
18
20
|
const dbPath = process.env.BOPO_DB_PATH;
|
|
19
21
|
const port = Number(process.env.PORT ?? 4020);
|
|
20
22
|
const { db } = await bootstrapDatabase(dbPath);
|
|
23
|
+
const existingCompanies = await listCompanies(db);
|
|
24
|
+
await ensureBuiltinPluginsRegistered(
|
|
25
|
+
db,
|
|
26
|
+
existingCompanies.map((company) => company.id)
|
|
27
|
+
);
|
|
21
28
|
const codexCommand = process.env.BOPO_CODEX_COMMAND ?? "codex";
|
|
29
|
+
const openCodeCommand = process.env.BOPO_OPENCODE_COMMAND ?? "opencode";
|
|
22
30
|
const skipCodexPreflight = process.env.BOPO_SKIP_CODEX_PREFLIGHT === "1";
|
|
31
|
+
const skipOpenCodePreflight = process.env.BOPO_SKIP_OPENCODE_PREFLIGHT === "1";
|
|
23
32
|
const codexHealthRequired =
|
|
24
33
|
!skipCodexPreflight &&
|
|
25
34
|
(process.env.BOPO_REQUIRE_CODEX_HEALTH === "1" || (await hasCodexAgentsConfigured(db)));
|
|
35
|
+
const openCodeHealthRequired =
|
|
36
|
+
!skipOpenCodePreflight &&
|
|
37
|
+
(process.env.BOPO_REQUIRE_OPENCODE_HEALTH === "1" || (await hasOpenCodeAgentsConfigured(db)));
|
|
26
38
|
const getRuntimeHealth = async () => {
|
|
27
39
|
const codex = codexHealthRequired
|
|
28
40
|
? await checkRuntimeCommandHealth(codexCommand, {
|
|
@@ -37,8 +49,22 @@ async function main() {
|
|
|
37
49
|
? "Skipped by configuration: BOPO_SKIP_CODEX_PREFLIGHT=1."
|
|
38
50
|
: "Skipped: no Codex agents configured."
|
|
39
51
|
};
|
|
52
|
+
const opencode = openCodeHealthRequired
|
|
53
|
+
? await checkRuntimeCommandHealth(openCodeCommand, {
|
|
54
|
+
timeoutMs: 5_000
|
|
55
|
+
})
|
|
56
|
+
: {
|
|
57
|
+
command: openCodeCommand,
|
|
58
|
+
available: skipOpenCodePreflight ? false : true,
|
|
59
|
+
exitCode: null,
|
|
60
|
+
elapsedMs: 0,
|
|
61
|
+
error: skipOpenCodePreflight
|
|
62
|
+
? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
|
|
63
|
+
: "Skipped: no OpenCode agents configured."
|
|
64
|
+
};
|
|
40
65
|
return {
|
|
41
|
-
codex
|
|
66
|
+
codex,
|
|
67
|
+
opencode
|
|
42
68
|
};
|
|
43
69
|
};
|
|
44
70
|
if (codexHealthRequired) {
|
|
@@ -49,12 +75,21 @@ async function main() {
|
|
|
49
75
|
emitCodexPreflightWarning(startupCodexHealth);
|
|
50
76
|
}
|
|
51
77
|
}
|
|
78
|
+
if (openCodeHealthRequired) {
|
|
79
|
+
const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
|
|
80
|
+
timeoutMs: 5_000
|
|
81
|
+
});
|
|
82
|
+
if (!startupOpenCodeHealth.available) {
|
|
83
|
+
emitOpenCodePreflightWarning(startupOpenCodeHealth);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
52
86
|
|
|
53
87
|
const server = createServer();
|
|
54
88
|
const realtimeHub = attachRealtimeHub(server, {
|
|
55
89
|
bootstrapLoaders: {
|
|
56
90
|
governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
|
|
57
|
-
"office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId)
|
|
91
|
+
"office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId),
|
|
92
|
+
"heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
|
|
58
93
|
}
|
|
59
94
|
});
|
|
60
95
|
const app = createApp({ db, getRuntimeHealth, realtimeHub });
|
|
@@ -83,6 +118,16 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
|
|
|
83
118
|
return (result.rows ?? []).length > 0;
|
|
84
119
|
}
|
|
85
120
|
|
|
121
|
+
async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
122
|
+
const result = await db.execute(sql`
|
|
123
|
+
SELECT id
|
|
124
|
+
FROM agents
|
|
125
|
+
WHERE provider_type = 'opencode'
|
|
126
|
+
LIMIT 1
|
|
127
|
+
`);
|
|
128
|
+
return (result.rows ?? []).length > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
86
131
|
async function resolveSchedulerCompanyId(
|
|
87
132
|
db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
|
|
88
133
|
configuredCompanyId: string | null
|
|
@@ -125,6 +170,20 @@ function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
|
|
|
125
170
|
}
|
|
126
171
|
}
|
|
127
172
|
|
|
173
|
+
function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
|
|
174
|
+
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
175
|
+
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
176
|
+
const reset = process.stderr.isTTY ? "\x1b[0m" : "";
|
|
177
|
+
const symbol = `${red}✖${reset}`;
|
|
178
|
+
process.stderr.write(
|
|
179
|
+
`${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
|
|
180
|
+
);
|
|
181
|
+
process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
|
|
182
|
+
if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
|
|
183
|
+
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
function loadApiEnv() {
|
|
129
188
|
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
130
189
|
const repoRoot = resolve(sourceDir, "../../../");
|