bopodev-api 0.1.7 → 0.1.9
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 +7 -4
- package/src/lib/agent-config.ts +255 -0
- package/src/lib/instance-paths.ts +88 -0
- package/src/lib/workspace-policy.ts +75 -0
- package/src/middleware/request-actor.ts +26 -5
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +335 -66
- package/src/routes/heartbeats.ts +21 -2
- package/src/routes/issues.ts +122 -4
- package/src/routes/projects.ts +60 -3
- package/src/scripts/backfill-project-workspaces.ts +118 -0
- package/src/scripts/onboard-seed.ts +314 -0
- package/src/server.ts +45 -2
- package/src/services/governance-service.ts +144 -18
- package/src/services/heartbeat-service.ts +616 -3
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
bootstrapDatabase,
|
|
5
|
+
createAgent,
|
|
6
|
+
createCompany,
|
|
7
|
+
createIssue,
|
|
8
|
+
createProject,
|
|
9
|
+
listAgents,
|
|
10
|
+
listCompanies,
|
|
11
|
+
listIssues,
|
|
12
|
+
listProjects,
|
|
13
|
+
updateIssue,
|
|
14
|
+
updateAgent,
|
|
15
|
+
updateCompany
|
|
16
|
+
} from "bopodev-db";
|
|
17
|
+
import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
|
|
18
|
+
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
19
|
+
|
|
20
|
+
export interface OnboardSeedSummary {
|
|
21
|
+
companyId: string;
|
|
22
|
+
companyName: string;
|
|
23
|
+
companyCreated: boolean;
|
|
24
|
+
ceoCreated: boolean;
|
|
25
|
+
ceoProviderType: AgentProvider;
|
|
26
|
+
ceoMigrated: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
|
|
30
|
+
const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
|
|
31
|
+
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
32
|
+
type AgentProvider = "codex" | "claude_code" | "shell";
|
|
33
|
+
const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
|
|
34
|
+
const STARTUP_PROJECT_NAME = "Leadership Setup";
|
|
35
|
+
const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
|
|
36
|
+
const CEO_STARTUP_TASK_MARKER = "[bopohq:onboarding:ceo-startup:v1]";
|
|
37
|
+
|
|
38
|
+
export async function ensureOnboardingSeed(input: {
|
|
39
|
+
dbPath?: string;
|
|
40
|
+
companyName: string;
|
|
41
|
+
companyId?: string;
|
|
42
|
+
agentProvider?: AgentProvider;
|
|
43
|
+
}): Promise<OnboardSeedSummary> {
|
|
44
|
+
const companyName = input.companyName.trim();
|
|
45
|
+
if (companyName.length === 0) {
|
|
46
|
+
throw new Error("BOPO_DEFAULT_COMPANY_NAME is required for onboarding seed.");
|
|
47
|
+
}
|
|
48
|
+
const agentProvider = parseAgentProvider(input.agentProvider) ?? "shell";
|
|
49
|
+
|
|
50
|
+
const { db, client } = await bootstrapDatabase(input.dbPath);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const companies = await listCompanies(db);
|
|
54
|
+
let companyRow =
|
|
55
|
+
(input.companyId ? companies.find((entry) => entry.id === input.companyId) : undefined) ??
|
|
56
|
+
companies.find((entry) => entry.name === companyName);
|
|
57
|
+
let companyCreated = false;
|
|
58
|
+
|
|
59
|
+
if (!companyRow) {
|
|
60
|
+
const createdCompany = await createCompany(db, { name: companyName });
|
|
61
|
+
companyRow = {
|
|
62
|
+
id: createdCompany.id,
|
|
63
|
+
name: createdCompany.name,
|
|
64
|
+
mission: createdCompany.mission ?? null,
|
|
65
|
+
createdAt: new Date()
|
|
66
|
+
};
|
|
67
|
+
companyCreated = true;
|
|
68
|
+
} else if (companyRow.name !== companyName) {
|
|
69
|
+
companyRow = (await updateCompany(db, { id: companyRow.id, name: companyName })) ?? companyRow;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const companyId = companyRow.id;
|
|
73
|
+
const resolvedCompanyName = companyRow.name;
|
|
74
|
+
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
75
|
+
await mkdir(defaultRuntimeCwd, { recursive: true });
|
|
76
|
+
const defaultRuntimeConfig = normalizeRuntimeConfig({
|
|
77
|
+
defaultRuntimeCwd,
|
|
78
|
+
runtimeConfig: {
|
|
79
|
+
runtimeEnv: resolveSeedRuntimeEnv(agentProvider)
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const agents = await listAgents(db, companyId);
|
|
83
|
+
const existingCeo = agents.find((agent) => agent.role === "CEO" || agent.name === "CEO");
|
|
84
|
+
let ceoCreated = false;
|
|
85
|
+
let ceoMigrated = false;
|
|
86
|
+
let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
|
|
87
|
+
|
|
88
|
+
let ceoId = existingCeo?.id ?? null;
|
|
89
|
+
|
|
90
|
+
if (!existingCeo) {
|
|
91
|
+
const ceo = await createAgent(db, {
|
|
92
|
+
companyId,
|
|
93
|
+
role: "CEO",
|
|
94
|
+
name: "CEO",
|
|
95
|
+
providerType: agentProvider,
|
|
96
|
+
heartbeatCron: "*/5 * * * *",
|
|
97
|
+
monthlyBudgetUsd: "100.0000",
|
|
98
|
+
canHireAgents: true,
|
|
99
|
+
...runtimeConfigToDb(defaultRuntimeConfig),
|
|
100
|
+
initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
|
|
101
|
+
});
|
|
102
|
+
ceoId = ceo.id;
|
|
103
|
+
ceoCreated = true;
|
|
104
|
+
ceoProviderType = agentProvider;
|
|
105
|
+
} else if (isBootstrapCeoRuntime(existingCeo.providerType, existingCeo.stateBlob)) {
|
|
106
|
+
const nextState = {
|
|
107
|
+
...stripRuntimeFromState(existingCeo.stateBlob),
|
|
108
|
+
...runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
|
|
109
|
+
};
|
|
110
|
+
await updateAgent(db, {
|
|
111
|
+
companyId,
|
|
112
|
+
id: existingCeo.id,
|
|
113
|
+
providerType: agentProvider,
|
|
114
|
+
...runtimeConfigToDb(defaultRuntimeConfig),
|
|
115
|
+
stateBlob: nextState
|
|
116
|
+
});
|
|
117
|
+
ceoMigrated = true;
|
|
118
|
+
ceoProviderType = agentProvider;
|
|
119
|
+
ceoId = existingCeo.id;
|
|
120
|
+
} else {
|
|
121
|
+
ceoId = existingCeo.id;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ceoId) {
|
|
125
|
+
const startupProjectId = await ensureStartupProject(db, companyId);
|
|
126
|
+
await ensureCeoStartupTask(db, {
|
|
127
|
+
companyId,
|
|
128
|
+
projectId: startupProjectId,
|
|
129
|
+
ceoId
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
companyId,
|
|
135
|
+
companyName: resolvedCompanyName,
|
|
136
|
+
companyCreated,
|
|
137
|
+
ceoCreated,
|
|
138
|
+
ceoProviderType,
|
|
139
|
+
ceoMigrated
|
|
140
|
+
};
|
|
141
|
+
} finally {
|
|
142
|
+
const maybeClose = (client as { close?: () => Promise<void> }).close;
|
|
143
|
+
if (maybeClose) {
|
|
144
|
+
await maybeClose.call(client);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function ensureStartupProject(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"], companyId: string) {
|
|
150
|
+
const projects = await listProjects(db, companyId);
|
|
151
|
+
const existing = projects.find((project) => project.name === STARTUP_PROJECT_NAME);
|
|
152
|
+
if (existing) {
|
|
153
|
+
return existing.id;
|
|
154
|
+
}
|
|
155
|
+
const created = await createProject(db, {
|
|
156
|
+
companyId,
|
|
157
|
+
name: STARTUP_PROJECT_NAME,
|
|
158
|
+
description: "Initial leadership onboarding and operating setup."
|
|
159
|
+
});
|
|
160
|
+
return created.id;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function ensureCeoStartupTask(
|
|
164
|
+
db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
|
|
165
|
+
input: { companyId: string; projectId: string; ceoId: string }
|
|
166
|
+
) {
|
|
167
|
+
const issues = await listIssues(db, input.companyId);
|
|
168
|
+
const existing = issues.find(
|
|
169
|
+
(issue) =>
|
|
170
|
+
issue.assigneeAgentId === input.ceoId &&
|
|
171
|
+
issue.title === CEO_STARTUP_TASK_TITLE &&
|
|
172
|
+
typeof issue.body === "string" &&
|
|
173
|
+
issue.body.includes(CEO_STARTUP_TASK_MARKER)
|
|
174
|
+
);
|
|
175
|
+
const body = [
|
|
176
|
+
CEO_STARTUP_TASK_MARKER,
|
|
177
|
+
"",
|
|
178
|
+
"Stand up your leadership operating baseline before taking on additional delivery work.",
|
|
179
|
+
"",
|
|
180
|
+
"1. Create the folder `agents/ceo/` in the repository workspace.",
|
|
181
|
+
"2. Author these files with your own voice and responsibilities:",
|
|
182
|
+
" - `agents/ceo/AGENTS.md`",
|
|
183
|
+
" - `agents/ceo/HEARTBEAT.md`",
|
|
184
|
+
" - `agents/ceo/SOUL.md`",
|
|
185
|
+
" - `agents/ceo/TOOLS.md`",
|
|
186
|
+
"3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
|
|
187
|
+
" - Supported simple body: `{ \"bootstrapPrompt\": \"Primary operating reference: agents/ceo/AGENTS.md ...\" }`",
|
|
188
|
+
" - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
|
|
189
|
+
" - Use a temp JSON file or heredoc with `curl --data @file`; do not hand-escape multiline JSON.",
|
|
190
|
+
"4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
|
|
191
|
+
" - `GET /agents` may be wrapped as `{ \"ok\": true, \"data\": [...] }` or returned as a raw array.",
|
|
192
|
+
" - Shape-safe filter: `jq -er --arg id \"$BOPOHQ_AGENT_ID\" '(.data? // .) | if type==\"array\" then . else [] end | map(select(.id == $id))[0] | {id,name,role,bootstrapPrompt}'`",
|
|
193
|
+
"5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
|
|
194
|
+
"6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
|
|
195
|
+
" - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
|
|
196
|
+
" - optional `managerAgentId`, `bootstrapPrompt`, `runtimeConfig`, `canHireAgents`",
|
|
197
|
+
" - `requestApproval: true` and `sourceIssueId`",
|
|
198
|
+
"7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
|
|
199
|
+
"",
|
|
200
|
+
"Safety checks before requesting hire:",
|
|
201
|
+
"- Do not request duplicates if a Founding Engineer already exists.",
|
|
202
|
+
"- Do not request duplicates if a pending approval for the same role is already open.",
|
|
203
|
+
"- Do not assume `python` is installed in the runtime shell; prefer direct headers, `node`, or `jq` when scripting.",
|
|
204
|
+
"- Shell commands run under `zsh`; avoid Bash-only features such as `local -n`, `declare -n`, `mapfile`, and `readarray`."
|
|
205
|
+
].join("\n");
|
|
206
|
+
if (existing) {
|
|
207
|
+
if (existing.body !== body) {
|
|
208
|
+
await updateIssue(db, {
|
|
209
|
+
companyId: input.companyId,
|
|
210
|
+
id: existing.id,
|
|
211
|
+
body
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return existing.id;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const startupIssue = await createIssue(db, {
|
|
218
|
+
companyId: input.companyId,
|
|
219
|
+
projectId: input.projectId,
|
|
220
|
+
title: CEO_STARTUP_TASK_TITLE,
|
|
221
|
+
body,
|
|
222
|
+
status: "todo",
|
|
223
|
+
priority: "high",
|
|
224
|
+
assigneeAgentId: input.ceoId,
|
|
225
|
+
labels: ["onboarding", "leadership", "agent-setup"],
|
|
226
|
+
tags: ["ceo-startup"]
|
|
227
|
+
});
|
|
228
|
+
return startupIssue.id;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function main() {
|
|
232
|
+
const companyName = process.env[DEFAULT_COMPANY_NAME_ENV]?.trim() ?? "";
|
|
233
|
+
const companyId = process.env[DEFAULT_COMPANY_ID_ENV]?.trim() || undefined;
|
|
234
|
+
const agentProvider = parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ?? undefined;
|
|
235
|
+
const result = await ensureOnboardingSeed({
|
|
236
|
+
dbPath: process.env.BOPO_DB_PATH,
|
|
237
|
+
companyName,
|
|
238
|
+
companyId,
|
|
239
|
+
agentProvider
|
|
240
|
+
});
|
|
241
|
+
// eslint-disable-next-line no-console
|
|
242
|
+
console.log(JSON.stringify(result));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
246
|
+
void main();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseAgentProvider(value: unknown): AgentProvider | null {
|
|
250
|
+
if (value === "codex" || value === "claude_code" || value === "shell") {
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
|
|
257
|
+
if (agentProvider !== "codex") {
|
|
258
|
+
return {};
|
|
259
|
+
}
|
|
260
|
+
const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
|
|
261
|
+
if (!key) {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
OPENAI_API_KEY: key
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
|
|
270
|
+
if (providerType !== "shell") {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const runtime = parseRuntimeFromState(stateBlob);
|
|
274
|
+
if (!runtime || runtime.command !== "echo") {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const args = Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry).toLowerCase()) : [];
|
|
278
|
+
return args.some((entry) => entry.includes(CEO_BOOTSTRAP_SUMMARY));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseRuntimeFromState(stateBlob: string | null): { command?: string; args?: string[] } | null {
|
|
282
|
+
if (!stateBlob) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(stateBlob) as { runtime?: { command?: unknown; args?: unknown } };
|
|
287
|
+
const runtime = parsed.runtime;
|
|
288
|
+
if (!runtime || typeof runtime !== "object") {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
command: typeof runtime.command === "string" ? runtime.command : undefined,
|
|
293
|
+
args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined
|
|
294
|
+
};
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function stripRuntimeFromState(stateBlob: string | null) {
|
|
301
|
+
if (!stateBlob) {
|
|
302
|
+
return {};
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(stateBlob) as Record<string, unknown>;
|
|
306
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
307
|
+
return {};
|
|
308
|
+
}
|
|
309
|
+
const { runtime: _runtime, ...rest } = parsed;
|
|
310
|
+
return rest;
|
|
311
|
+
} catch {
|
|
312
|
+
return {};
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { sql } from "drizzle-orm";
|
|
5
|
+
import { config as loadDotenv } from "dotenv";
|
|
3
6
|
import { bootstrapDatabase } from "bopodev-db";
|
|
4
7
|
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
5
8
|
import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
@@ -9,6 +12,8 @@ import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
|
|
|
9
12
|
import { attachRealtimeHub } from "./realtime/hub";
|
|
10
13
|
import { createHeartbeatScheduler } from "./worker/scheduler";
|
|
11
14
|
|
|
15
|
+
loadApiEnv();
|
|
16
|
+
|
|
12
17
|
async function main() {
|
|
13
18
|
const dbPath = process.env.BOPO_DB_PATH;
|
|
14
19
|
const port = Number(process.env.PORT ?? 4020);
|
|
@@ -60,8 +65,9 @@ async function main() {
|
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
|
|
69
|
+
if (schedulerCompanyId) {
|
|
70
|
+
createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
|
@@ -77,6 +83,34 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
|
|
|
77
83
|
return (result.rows ?? []).length > 0;
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
async function resolveSchedulerCompanyId(
|
|
87
|
+
db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
|
|
88
|
+
configuredCompanyId: string | null
|
|
89
|
+
) {
|
|
90
|
+
if (configuredCompanyId) {
|
|
91
|
+
const configured = await db.execute(sql`
|
|
92
|
+
SELECT id
|
|
93
|
+
FROM companies
|
|
94
|
+
WHERE id = ${configuredCompanyId}
|
|
95
|
+
LIMIT 1
|
|
96
|
+
`);
|
|
97
|
+
if ((configured.rows ?? []).length > 0) {
|
|
98
|
+
return configuredCompanyId;
|
|
99
|
+
}
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const fallback = await db.execute(sql`
|
|
105
|
+
SELECT id
|
|
106
|
+
FROM companies
|
|
107
|
+
ORDER BY created_at ASC
|
|
108
|
+
LIMIT 1
|
|
109
|
+
`);
|
|
110
|
+
const id = fallback.rows?.[0]?.id;
|
|
111
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
|
|
81
115
|
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
82
116
|
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
@@ -90,3 +124,12 @@ function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
|
|
|
90
124
|
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
91
125
|
}
|
|
92
126
|
}
|
|
127
|
+
|
|
128
|
+
function loadApiEnv() {
|
|
129
|
+
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
130
|
+
const repoRoot = resolve(sourceDir, "../../../");
|
|
131
|
+
const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
|
|
132
|
+
for (const path of candidates) {
|
|
133
|
+
loadDotenv({ path, override: false, quiet: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
2
3
|
import { z } from "zod";
|
|
4
|
+
import { AgentCreateRequestSchema } from "bopodev-contracts";
|
|
3
5
|
import type { BopoDb } from "bopodev-db";
|
|
4
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
approvalRequests,
|
|
8
|
+
createAgent,
|
|
9
|
+
createGoal,
|
|
10
|
+
createIssue,
|
|
11
|
+
createProject,
|
|
12
|
+
goals,
|
|
13
|
+
listIssues,
|
|
14
|
+
listProjects,
|
|
15
|
+
projects
|
|
16
|
+
} from "bopodev-db";
|
|
17
|
+
import {
|
|
18
|
+
normalizeRuntimeConfig,
|
|
19
|
+
requiresRuntimeCwd,
|
|
20
|
+
runtimeConfigToDb,
|
|
21
|
+
runtimeConfigToStateBlobPatch
|
|
22
|
+
} from "../lib/agent-config";
|
|
23
|
+
import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
5
24
|
|
|
6
25
|
const approvalGatedActions = new Set([
|
|
7
26
|
"hire_agent",
|
|
@@ -11,18 +30,23 @@ const approvalGatedActions = new Set([
|
|
|
11
30
|
"terminate_agent"
|
|
12
31
|
]);
|
|
13
32
|
|
|
14
|
-
const hireAgentPayloadSchema =
|
|
15
|
-
managerAgentId: z.string().optional(),
|
|
16
|
-
role: z.string().min(1),
|
|
17
|
-
name: z.string().min(1),
|
|
18
|
-
providerType: z.enum(["claude_code", "codex", "http", "shell"]),
|
|
19
|
-
heartbeatCron: z.string().min(1),
|
|
20
|
-
monthlyBudgetUsd: z.number().nonnegative(),
|
|
21
|
-
canHireAgents: z.boolean().default(false),
|
|
33
|
+
const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
22
34
|
runtimeCommand: z.string().optional(),
|
|
23
35
|
runtimeArgs: z.array(z.string()).optional(),
|
|
24
36
|
runtimeCwd: z.string().optional(),
|
|
25
|
-
runtimeTimeoutMs: z.number().int().positive().max(600000).optional()
|
|
37
|
+
runtimeTimeoutMs: z.number().int().positive().max(600000).optional(),
|
|
38
|
+
runtimeModel: z.string().optional(),
|
|
39
|
+
runtimeThinkingEffort: z.enum(["auto", "low", "medium", "high"]).optional(),
|
|
40
|
+
bootstrapPrompt: z.string().optional(),
|
|
41
|
+
runtimeTimeoutSec: z.number().int().nonnegative().optional(),
|
|
42
|
+
interruptGraceSec: z.number().int().nonnegative().optional(),
|
|
43
|
+
runtimeEnv: z.record(z.string(), z.string()).optional(),
|
|
44
|
+
runPolicy: z
|
|
45
|
+
.object({
|
|
46
|
+
sandboxMode: z.enum(["workspace_write", "full_access"]).optional(),
|
|
47
|
+
allowWebSearch: z.boolean().optional()
|
|
48
|
+
})
|
|
49
|
+
.optional()
|
|
26
50
|
});
|
|
27
51
|
|
|
28
52
|
const activateGoalPayloadSchema = z.object({
|
|
@@ -32,6 +56,8 @@ const activateGoalPayloadSchema = z.object({
|
|
|
32
56
|
title: z.string().min(1),
|
|
33
57
|
description: z.string().optional()
|
|
34
58
|
});
|
|
59
|
+
const AGENT_STARTUP_PROJECT_NAME = "Agent Onboarding";
|
|
60
|
+
const AGENT_STARTUP_TASK_MARKER = "[bopohq:onboarding:agent-startup:v1]";
|
|
35
61
|
|
|
36
62
|
export class GovernanceError extends Error {
|
|
37
63
|
constructor(message: string) {
|
|
@@ -132,6 +158,30 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
132
158
|
if (!parsed.success) {
|
|
133
159
|
throw new GovernanceError("Approval payload for agent hiring is invalid.");
|
|
134
160
|
}
|
|
161
|
+
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
162
|
+
const runtimeConfig = normalizeRuntimeConfig({
|
|
163
|
+
runtimeConfig: parsed.data.runtimeConfig,
|
|
164
|
+
legacy: {
|
|
165
|
+
runtimeCommand: parsed.data.runtimeCommand,
|
|
166
|
+
runtimeArgs: parsed.data.runtimeArgs,
|
|
167
|
+
runtimeCwd: parsed.data.runtimeCwd,
|
|
168
|
+
runtimeTimeoutMs: parsed.data.runtimeTimeoutMs,
|
|
169
|
+
runtimeModel: parsed.data.runtimeModel,
|
|
170
|
+
runtimeThinkingEffort: parsed.data.runtimeThinkingEffort,
|
|
171
|
+
bootstrapPrompt: parsed.data.bootstrapPrompt,
|
|
172
|
+
runtimeTimeoutSec: parsed.data.runtimeTimeoutSec,
|
|
173
|
+
interruptGraceSec: parsed.data.interruptGraceSec,
|
|
174
|
+
runtimeEnv: parsed.data.runtimeEnv,
|
|
175
|
+
runPolicy: parsed.data.runPolicy
|
|
176
|
+
},
|
|
177
|
+
defaultRuntimeCwd
|
|
178
|
+
});
|
|
179
|
+
if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
|
|
180
|
+
throw new GovernanceError("Approval payload for agent hiring is missing runtime working directory.");
|
|
181
|
+
}
|
|
182
|
+
if (requiresRuntimeCwd(parsed.data.providerType) && hasText(runtimeConfig.runtimeCwd)) {
|
|
183
|
+
await mkdir(runtimeConfig.runtimeCwd!, { recursive: true });
|
|
184
|
+
}
|
|
135
185
|
|
|
136
186
|
const agent = await createAgent(db, {
|
|
137
187
|
companyId,
|
|
@@ -142,15 +192,11 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
142
192
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
143
193
|
monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
|
|
144
194
|
canHireAgents: parsed.data.canHireAgents,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
command: parsed.data.runtimeCommand,
|
|
148
|
-
args: parsed.data.runtimeArgs,
|
|
149
|
-
cwd: parsed.data.runtimeCwd,
|
|
150
|
-
timeoutMs: parsed.data.runtimeTimeoutMs
|
|
151
|
-
}
|
|
152
|
-
}
|
|
195
|
+
...runtimeConfigToDb(runtimeConfig),
|
|
196
|
+
initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
|
|
153
197
|
});
|
|
198
|
+
const startupProjectId = await ensureAgentStartupProject(db, companyId);
|
|
199
|
+
await ensureAgentStartupIssue(db, companyId, startupProjectId, agent.id, agent.role, agent.name);
|
|
154
200
|
|
|
155
201
|
return {
|
|
156
202
|
applied: true,
|
|
@@ -227,3 +273,83 @@ function parsePayload(payloadJson: string) {
|
|
|
227
273
|
return {};
|
|
228
274
|
}
|
|
229
275
|
}
|
|
276
|
+
|
|
277
|
+
async function ensureAgentStartupProject(db: BopoDb, companyId: string) {
|
|
278
|
+
const projects = await listProjects(db, companyId);
|
|
279
|
+
const existing = projects.find((project) => project.name === AGENT_STARTUP_PROJECT_NAME);
|
|
280
|
+
if (existing) {
|
|
281
|
+
return existing.id;
|
|
282
|
+
}
|
|
283
|
+
const created = await createProject(db, {
|
|
284
|
+
companyId,
|
|
285
|
+
name: AGENT_STARTUP_PROJECT_NAME,
|
|
286
|
+
description: "Operating baseline tasks for newly approved hires."
|
|
287
|
+
});
|
|
288
|
+
return created.id;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function ensureAgentStartupIssue(
|
|
292
|
+
db: BopoDb,
|
|
293
|
+
companyId: string,
|
|
294
|
+
projectId: string,
|
|
295
|
+
agentId: string,
|
|
296
|
+
role: string,
|
|
297
|
+
name: string
|
|
298
|
+
) {
|
|
299
|
+
const title = `Set up ${role} operating files`;
|
|
300
|
+
const body = buildAgentStartupTaskBody(name, agentId);
|
|
301
|
+
const existingIssues = await listIssues(db, companyId);
|
|
302
|
+
const existing = existingIssues.find(
|
|
303
|
+
(issue) =>
|
|
304
|
+
issue.assigneeAgentId === agentId &&
|
|
305
|
+
issue.title === title &&
|
|
306
|
+
typeof issue.body === "string" &&
|
|
307
|
+
issue.body.includes(AGENT_STARTUP_TASK_MARKER)
|
|
308
|
+
);
|
|
309
|
+
if (existing) {
|
|
310
|
+
return existing.id;
|
|
311
|
+
}
|
|
312
|
+
const created = await createIssue(db, {
|
|
313
|
+
companyId,
|
|
314
|
+
projectId,
|
|
315
|
+
title,
|
|
316
|
+
body,
|
|
317
|
+
status: "todo",
|
|
318
|
+
priority: "high",
|
|
319
|
+
assigneeAgentId: agentId,
|
|
320
|
+
labels: ["onboarding", "agent-setup"],
|
|
321
|
+
tags: ["agent-startup"]
|
|
322
|
+
});
|
|
323
|
+
return created.id;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function buildAgentStartupTaskBody(agentName: string, agentId: string) {
|
|
327
|
+
const slug = slugifyAgentPath(agentName || agentId);
|
|
328
|
+
return [
|
|
329
|
+
AGENT_STARTUP_TASK_MARKER,
|
|
330
|
+
"",
|
|
331
|
+
`Create your operating baseline before starting feature delivery work.`,
|
|
332
|
+
"",
|
|
333
|
+
`1. Create the folder \`agents/${slug}/\` in the repository workspace.`,
|
|
334
|
+
"2. Author these files with your own responsibilities and working style:",
|
|
335
|
+
` - \`agents/${slug}/AGENTS.md\``,
|
|
336
|
+
` - \`agents/${slug}/HEARTBEAT.md\``,
|
|
337
|
+
` - \`agents/${slug}/SOUL.md\``,
|
|
338
|
+
` - \`agents/${slug}/TOOLS.md\``,
|
|
339
|
+
`3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`agents/${slug}/AGENTS.md\` as your primary guide.`,
|
|
340
|
+
"4. Post an issue comment summarizing completed setup artifacts.",
|
|
341
|
+
"",
|
|
342
|
+
"Safety checks:",
|
|
343
|
+
"- Do not overwrite another agent's folder.",
|
|
344
|
+
"- Keep content original to your role and scope."
|
|
345
|
+
].join("\n");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function slugifyAgentPath(value: string) {
|
|
349
|
+
const base = value
|
|
350
|
+
.toLowerCase()
|
|
351
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
352
|
+
.replace(/^-+|-+$/g, "");
|
|
353
|
+
return base || "agent";
|
|
354
|
+
}
|
|
355
|
+
|