bopodev-api 0.1.18 → 0.1.21
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 +4 -4
- package/src/lib/ceo-bootstrap-prompt.ts +12 -0
- package/src/lib/hiring-delegate.ts +60 -0
- package/src/routes/agents.ts +129 -10
- package/src/routes/companies.ts +130 -9
- package/src/routes/governance.ts +15 -2
- package/src/routes/issues.ts +54 -1
- package/src/routes/observability.ts +5 -0
- package/src/routes/plugins.ts +38 -5
- package/src/scripts/onboard-seed.ts +8 -3
- package/src/services/governance-service.ts +27 -1
- package/src/services/heartbeat-service.ts +30 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"nanoid": "^5.1.5",
|
|
18
18
|
"ws": "^8.19.0",
|
|
19
19
|
"zod": "^4.1.5",
|
|
20
|
-
"bopodev-agent-sdk": "0.1.
|
|
21
|
-
"bopodev-db": "0.1.
|
|
22
|
-
"bopodev-contracts": "0.1.
|
|
20
|
+
"bopodev-agent-sdk": "0.1.21",
|
|
21
|
+
"bopodev-db": "0.1.21",
|
|
22
|
+
"bopodev-contracts": "0.1.21"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/cors": "^2.8.19",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function buildDefaultCeoBootstrapPrompt() {
|
|
2
|
+
return [
|
|
3
|
+
"You are the CEO agent responsible for organization design and hiring quality.",
|
|
4
|
+
"When a delegated request asks you to create an agent:",
|
|
5
|
+
"- Clarify missing constraints before hiring when requirements are ambiguous.",
|
|
6
|
+
"- Choose reporting lines, provider, model, and permissions that fit company goals and budget.",
|
|
7
|
+
"- Use governance-safe hiring via `POST /agents` with `requestApproval: true` unless explicitly told otherwise.",
|
|
8
|
+
"- Avoid duplicate hires by checking existing agents and pending approvals first.",
|
|
9
|
+
"- Use the control-plane coordination skill as the source of truth for endpoint paths, required headers, and approval workflow steps.",
|
|
10
|
+
"- Record hiring rationale and key decisions in issue comments for auditability."
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
type AgentLike = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
role: string;
|
|
5
|
+
status: string;
|
|
6
|
+
canHireAgents?: boolean | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type HiringDelegateResolution =
|
|
10
|
+
| {
|
|
11
|
+
delegate: {
|
|
12
|
+
agentId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
role: string;
|
|
15
|
+
};
|
|
16
|
+
reason: "ceo_with_hiring_capability" | "first_hiring_capable_agent";
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
delegate: null;
|
|
20
|
+
reason: "no_hiring_capable_agent";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolution {
|
|
24
|
+
const eligible = agents
|
|
25
|
+
.filter((agent) => agent.status !== "terminated")
|
|
26
|
+
.filter((agent) => Boolean(agent.canHireAgents));
|
|
27
|
+
if (eligible.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
delegate: null,
|
|
30
|
+
reason: "no_hiring_capable_agent"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const normalized = eligible.map((agent) => ({
|
|
34
|
+
...agent,
|
|
35
|
+
normalizedRole: agent.role.trim().toLowerCase(),
|
|
36
|
+
normalizedName: agent.name.trim().toLowerCase()
|
|
37
|
+
}));
|
|
38
|
+
const ceo =
|
|
39
|
+
normalized.find((agent) => agent.normalizedRole === "ceo") ??
|
|
40
|
+
normalized.find((agent) => agent.normalizedName === "ceo");
|
|
41
|
+
if (ceo) {
|
|
42
|
+
return {
|
|
43
|
+
delegate: {
|
|
44
|
+
agentId: ceo.id,
|
|
45
|
+
name: ceo.name,
|
|
46
|
+
role: ceo.role
|
|
47
|
+
},
|
|
48
|
+
reason: "ceo_with_hiring_capability"
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const fallback = [...normalized].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]!;
|
|
52
|
+
return {
|
|
53
|
+
delegate: {
|
|
54
|
+
agentId: fallback.id,
|
|
55
|
+
name: fallback.name,
|
|
56
|
+
role: fallback.role
|
|
57
|
+
},
|
|
58
|
+
reason: "first_hiring_capable_agent"
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
runtimeConfigToDb,
|
|
28
28
|
runtimeConfigToStateBlobPatch
|
|
29
29
|
} from "../lib/agent-config";
|
|
30
|
+
import { resolveHiringDelegate } from "../lib/hiring-delegate";
|
|
30
31
|
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
31
32
|
import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
32
33
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
@@ -147,6 +148,53 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
147
148
|
);
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
router.get("/hiring-delegate", async (req, res) => {
|
|
152
|
+
const rows = await listAgents(ctx.db, req.companyId!);
|
|
153
|
+
const resolution = resolveHiringDelegate(
|
|
154
|
+
rows.map((row) => ({
|
|
155
|
+
id: row.id,
|
|
156
|
+
name: row.name,
|
|
157
|
+
role: row.role,
|
|
158
|
+
status: row.status,
|
|
159
|
+
canHireAgents: row.canHireAgents
|
|
160
|
+
}))
|
|
161
|
+
);
|
|
162
|
+
return sendOk(res, {
|
|
163
|
+
delegate: resolution.delegate,
|
|
164
|
+
reason: resolution.reason
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
router.get("/leadership-diagnostics", async (req, res) => {
|
|
169
|
+
const rows = await listAgents(ctx.db, req.companyId!);
|
|
170
|
+
return sendOk(
|
|
171
|
+
res,
|
|
172
|
+
rows.map((row) => {
|
|
173
|
+
const normalizedRole = row.role.trim().toLowerCase();
|
|
174
|
+
const isLeadership = normalizedRole === "ceo" || Boolean(row.canHireAgents);
|
|
175
|
+
const issues: string[] = [];
|
|
176
|
+
const hasBootstrapPrompt = hasText(row.bootstrapPrompt);
|
|
177
|
+
if (isLeadership && !hasBootstrapPrompt) {
|
|
178
|
+
issues.push("missing_bootstrap_prompt");
|
|
179
|
+
}
|
|
180
|
+
if (isLeadership && !providerSupportsSkillInjection(row.providerType)) {
|
|
181
|
+
issues.push("provider_without_runtime_skill_injection");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
agentId: row.id,
|
|
185
|
+
name: row.name,
|
|
186
|
+
role: row.role,
|
|
187
|
+
providerType: row.providerType,
|
|
188
|
+
canHireAgents: Boolean(row.canHireAgents),
|
|
189
|
+
isLeadership,
|
|
190
|
+
hasBootstrapPrompt,
|
|
191
|
+
supportsSkillInjection: providerSupportsSkillInjection(row.providerType),
|
|
192
|
+
issues
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
150
198
|
router.get("/runtime-default-cwd", async (req, res) => {
|
|
151
199
|
let runtimeCwd: string;
|
|
152
200
|
try {
|
|
@@ -314,7 +362,9 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
314
362
|
await mkdir(runtimeConfig.runtimeCwd!, { recursive: true });
|
|
315
363
|
}
|
|
316
364
|
|
|
317
|
-
|
|
365
|
+
const sourceIssueIds = normalizeSourceIssueIds(parsed.data.sourceIssueId, parsed.data.sourceIssueIds);
|
|
366
|
+
const shouldRequestApproval = (parsed.data.requestApproval || req.actor?.type === "agent") && isApprovalRequired("hire_agent");
|
|
367
|
+
if (shouldRequestApproval) {
|
|
318
368
|
const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
|
|
319
369
|
role: parsed.data.role,
|
|
320
370
|
managerAgentId: parsed.data.managerAgentId ?? null
|
|
@@ -330,10 +380,12 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
330
380
|
}
|
|
331
381
|
const approvalId = await createApprovalRequest(ctx.db, {
|
|
332
382
|
companyId: req.companyId!,
|
|
383
|
+
requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
|
|
333
384
|
action: "hire_agent",
|
|
334
385
|
payload: {
|
|
335
386
|
...parsed.data,
|
|
336
|
-
runtimeConfig
|
|
387
|
+
runtimeConfig,
|
|
388
|
+
sourceIssueIds
|
|
337
389
|
}
|
|
338
390
|
});
|
|
339
391
|
const approval = await getApprovalRequest(ctx.db, req.companyId!, approvalId);
|
|
@@ -361,14 +413,18 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
361
413
|
...runtimeConfigToDb(runtimeConfig),
|
|
362
414
|
initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
|
|
363
415
|
});
|
|
364
|
-
|
|
416
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
365
417
|
await appendAuditEvent(ctx.db, {
|
|
366
418
|
companyId: req.companyId!,
|
|
367
|
-
actorType:
|
|
419
|
+
actorType: auditActor.actorType,
|
|
420
|
+
actorId: auditActor.actorId,
|
|
368
421
|
eventType: "agent.hired",
|
|
369
422
|
entityType: "agent",
|
|
370
423
|
entityId: agent.id,
|
|
371
|
-
payload:
|
|
424
|
+
payload: {
|
|
425
|
+
...agent,
|
|
426
|
+
sourceIssueIds
|
|
427
|
+
}
|
|
372
428
|
});
|
|
373
429
|
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
374
430
|
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
@@ -398,6 +454,34 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
398
454
|
if (!existingAgent) {
|
|
399
455
|
return sendError(res, "Agent not found.", 404);
|
|
400
456
|
}
|
|
457
|
+
if (req.actor?.type === "agent") {
|
|
458
|
+
if (req.actor.id !== req.params.agentId) {
|
|
459
|
+
return sendError(res, "Agents can only update their own record.", 403);
|
|
460
|
+
}
|
|
461
|
+
const forbiddenFieldUpdates: string[] = [];
|
|
462
|
+
if (parsed.data.canHireAgents !== undefined) {
|
|
463
|
+
forbiddenFieldUpdates.push("canHireAgents");
|
|
464
|
+
}
|
|
465
|
+
if (parsed.data.status !== undefined) {
|
|
466
|
+
forbiddenFieldUpdates.push("status");
|
|
467
|
+
}
|
|
468
|
+
if (parsed.data.monthlyBudgetUsd !== undefined) {
|
|
469
|
+
forbiddenFieldUpdates.push("monthlyBudgetUsd");
|
|
470
|
+
}
|
|
471
|
+
if (parsed.data.providerType !== undefined) {
|
|
472
|
+
forbiddenFieldUpdates.push("providerType");
|
|
473
|
+
}
|
|
474
|
+
if (parsed.data.managerAgentId !== undefined) {
|
|
475
|
+
forbiddenFieldUpdates.push("managerAgentId");
|
|
476
|
+
}
|
|
477
|
+
if (forbiddenFieldUpdates.length > 0) {
|
|
478
|
+
return sendError(
|
|
479
|
+
res,
|
|
480
|
+
`Agents cannot update restricted fields: ${forbiddenFieldUpdates.join(", ")}.`,
|
|
481
|
+
403
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
401
485
|
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
|
|
402
486
|
const existingRuntime = parseRuntimeConfigFromAgentRow(existingAgent as unknown as Record<string, unknown>);
|
|
403
487
|
const effectiveProviderType = parsed.data.providerType ?? existingAgent.providerType;
|
|
@@ -474,9 +558,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
474
558
|
return sendError(res, "Agent not found.", 404);
|
|
475
559
|
}
|
|
476
560
|
|
|
561
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
477
562
|
await appendAuditEvent(ctx.db, {
|
|
478
563
|
companyId: req.companyId!,
|
|
479
|
-
actorType:
|
|
564
|
+
actorType: auditActor.actorType,
|
|
565
|
+
actorId: auditActor.actorId,
|
|
480
566
|
eventType: "agent.updated",
|
|
481
567
|
entityType: "agent",
|
|
482
568
|
entityId: agent.id,
|
|
@@ -499,9 +585,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
499
585
|
return sendError(res, "Agent not found.", 404);
|
|
500
586
|
}
|
|
501
587
|
|
|
588
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
502
589
|
await appendAuditEvent(ctx.db, {
|
|
503
590
|
companyId: req.companyId!,
|
|
504
|
-
actorType:
|
|
591
|
+
actorType: auditActor.actorType,
|
|
592
|
+
actorId: auditActor.actorId,
|
|
505
593
|
eventType: "agent.deleted",
|
|
506
594
|
entityType: "agent",
|
|
507
595
|
entityId: req.params.agentId,
|
|
@@ -524,9 +612,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
524
612
|
if (!agent) {
|
|
525
613
|
return sendError(res, "Agent not found.", 404);
|
|
526
614
|
}
|
|
615
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
527
616
|
await appendAuditEvent(ctx.db, {
|
|
528
617
|
companyId: req.companyId!,
|
|
529
|
-
actorType:
|
|
618
|
+
actorType: auditActor.actorType,
|
|
619
|
+
actorId: auditActor.actorId,
|
|
530
620
|
eventType: "agent.paused",
|
|
531
621
|
entityType: "agent",
|
|
532
622
|
entityId: agent.id,
|
|
@@ -549,9 +639,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
549
639
|
if (!agent) {
|
|
550
640
|
return sendError(res, "Agent not found.", 404);
|
|
551
641
|
}
|
|
642
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
552
643
|
await appendAuditEvent(ctx.db, {
|
|
553
644
|
companyId: req.companyId!,
|
|
554
|
-
actorType:
|
|
645
|
+
actorType: auditActor.actorType,
|
|
646
|
+
actorId: auditActor.actorId,
|
|
555
647
|
eventType: "agent.resumed",
|
|
556
648
|
entityType: "agent",
|
|
557
649
|
entityId: agent.id,
|
|
@@ -574,9 +666,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
574
666
|
if (!agent) {
|
|
575
667
|
return sendError(res, "Agent not found.", 404);
|
|
576
668
|
}
|
|
669
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
577
670
|
await appendAuditEvent(ctx.db, {
|
|
578
671
|
companyId: req.companyId!,
|
|
579
|
-
actorType:
|
|
672
|
+
actorType: auditActor.actorType,
|
|
673
|
+
actorId: auditActor.actorId,
|
|
580
674
|
eventType: "agent.terminated",
|
|
581
675
|
entityType: "agent",
|
|
582
676
|
entityId: agent.id,
|
|
@@ -670,3 +764,28 @@ function duplicateMessage(input: { existingAgentId: string | null; pendingApprov
|
|
|
670
764
|
}
|
|
671
765
|
return `Duplicate hire request blocked: pending approval ${input.pendingApprovalId}.`;
|
|
672
766
|
}
|
|
767
|
+
|
|
768
|
+
function normalizeSourceIssueIds(sourceIssueId: string | undefined, sourceIssueIds: string[] | undefined) {
|
|
769
|
+
const merged = new Set<string>();
|
|
770
|
+
for (const value of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
|
|
771
|
+
const normalized = value?.trim();
|
|
772
|
+
if (normalized) {
|
|
773
|
+
merged.add(normalized);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return Array.from(merged);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function providerSupportsSkillInjection(providerType: string) {
|
|
780
|
+
return providerType === "codex" || providerType === "cursor" || providerType === "opencode" || providerType === "claude_code";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
|
|
784
|
+
if (!actor) {
|
|
785
|
+
return { actorType: "human" as const, actorId: null as string | null };
|
|
786
|
+
}
|
|
787
|
+
if (actor.type === "agent") {
|
|
788
|
+
return { actorType: "agent" as const, actorId: actor.id };
|
|
789
|
+
}
|
|
790
|
+
return { actorType: "human" as const, actorId: actor.id };
|
|
791
|
+
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import type { NextFunction, Request, Response } from "express";
|
|
1
3
|
import { Router } from "express";
|
|
2
4
|
import { z } from "zod";
|
|
3
|
-
import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
5
|
+
import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
4
6
|
import type { AppContext } from "../context";
|
|
5
7
|
import { sendError, sendOk } from "../http";
|
|
8
|
+
import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
|
|
9
|
+
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
10
|
+
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
11
|
+
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
12
|
+
import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
6
13
|
import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
|
|
7
14
|
import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
|
|
8
15
|
import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
|
|
9
16
|
|
|
17
|
+
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
18
|
+
const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
|
|
19
|
+
|
|
10
20
|
const createCompanySchema = z.object({
|
|
11
21
|
name: z.string().min(1),
|
|
12
|
-
mission: z.string().optional()
|
|
22
|
+
mission: z.string().optional(),
|
|
23
|
+
providerType: z
|
|
24
|
+
.enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "shell"])
|
|
25
|
+
.optional(),
|
|
26
|
+
runtimeModel: z.string().optional()
|
|
13
27
|
});
|
|
14
28
|
|
|
15
29
|
const updateCompanySchema = z
|
|
@@ -22,38 +36,98 @@ const updateCompanySchema = z
|
|
|
22
36
|
export function createCompaniesRouter(ctx: AppContext) {
|
|
23
37
|
const router = Router();
|
|
24
38
|
|
|
25
|
-
router.get("/", async (
|
|
39
|
+
router.get("/", async (req, res) => {
|
|
26
40
|
const companies = await listCompanies(ctx.db);
|
|
27
|
-
|
|
41
|
+
if (req.actor?.type === "board") {
|
|
42
|
+
return sendOk(res, companies);
|
|
43
|
+
}
|
|
44
|
+
const visibleCompanyIds = new Set(req.actor?.companyIds ?? []);
|
|
45
|
+
return sendOk(
|
|
46
|
+
res,
|
|
47
|
+
companies.filter((company) => visibleCompanyIds.has(company.id))
|
|
48
|
+
);
|
|
28
49
|
});
|
|
29
50
|
|
|
30
|
-
router.post("/", async (req, res) => {
|
|
51
|
+
router.post("/", requireBoardRole, async (req, res) => {
|
|
31
52
|
const parsed = createCompanySchema.safeParse(req.body);
|
|
32
53
|
if (!parsed.success) {
|
|
33
54
|
return sendError(res, parsed.error.message, 422);
|
|
34
55
|
}
|
|
35
56
|
const company = await createCompany(ctx.db, parsed.data);
|
|
57
|
+
const providerType =
|
|
58
|
+
parseAgentProvider(parsed.data.providerType) ??
|
|
59
|
+
parseAgentProvider(process.env[DEFAULT_AGENT_PROVIDER_ENV]) ??
|
|
60
|
+
"shell";
|
|
61
|
+
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, company.id);
|
|
62
|
+
await mkdir(defaultRuntimeCwd, { recursive: true });
|
|
63
|
+
const requestedModel = parsed.data.runtimeModel?.trim() || process.env[DEFAULT_AGENT_MODEL_ENV]?.trim() || undefined;
|
|
64
|
+
const resolvedRuntimeModel = resolveRuntimeModelForProvider(
|
|
65
|
+
providerType,
|
|
66
|
+
await resolveOpencodeRuntimeModel(
|
|
67
|
+
providerType,
|
|
68
|
+
normalizeRuntimeConfig({
|
|
69
|
+
defaultRuntimeCwd,
|
|
70
|
+
runtimeConfig: {
|
|
71
|
+
runtimeModel: requestedModel,
|
|
72
|
+
runtimeEnv: resolveSeedRuntimeEnv(providerType)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
const defaultRuntimeConfig = normalizeRuntimeConfig({
|
|
78
|
+
defaultRuntimeCwd,
|
|
79
|
+
runtimeConfig: {
|
|
80
|
+
runtimeModel: resolvedRuntimeModel,
|
|
81
|
+
bootstrapPrompt: buildDefaultCeoBootstrapPrompt(),
|
|
82
|
+
runtimeEnv: resolveSeedRuntimeEnv(providerType),
|
|
83
|
+
...(providerType === "shell"
|
|
84
|
+
? {
|
|
85
|
+
runtimeCommand: "echo",
|
|
86
|
+
runtimeArgs: ["ceo bootstrap heartbeat"]
|
|
87
|
+
}
|
|
88
|
+
: {})
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await createAgent(ctx.db, {
|
|
92
|
+
companyId: company.id,
|
|
93
|
+
role: "CEO",
|
|
94
|
+
name: "CEO",
|
|
95
|
+
providerType,
|
|
96
|
+
heartbeatCron: "*/5 * * * *",
|
|
97
|
+
monthlyBudgetUsd: "100.0000",
|
|
98
|
+
canHireAgents: true,
|
|
99
|
+
...runtimeConfigToDb(defaultRuntimeConfig),
|
|
100
|
+
initialState: runtimeConfigToStateBlobPatch(defaultRuntimeConfig)
|
|
101
|
+
});
|
|
36
102
|
await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
|
|
37
103
|
await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
|
|
38
104
|
await ensureCompanyModelPricingDefaults(ctx.db, company.id);
|
|
39
105
|
return sendOk(res, company);
|
|
40
106
|
});
|
|
41
107
|
|
|
42
|
-
router.put("/:companyId", async (req, res) => {
|
|
108
|
+
router.put("/:companyId", requireCompanyWriteAccess, async (req, res) => {
|
|
43
109
|
const parsed = updateCompanySchema.safeParse(req.body);
|
|
44
110
|
if (!parsed.success) {
|
|
45
111
|
return sendError(res, parsed.error.message, 422);
|
|
46
112
|
}
|
|
47
113
|
|
|
48
|
-
const
|
|
114
|
+
const companyId = readCompanyIdParam(req);
|
|
115
|
+
if (!companyId) {
|
|
116
|
+
return sendError(res, "Missing company id.", 422);
|
|
117
|
+
}
|
|
118
|
+
const company = await updateCompany(ctx.db, { id: companyId, ...parsed.data });
|
|
49
119
|
if (!company) {
|
|
50
120
|
return sendError(res, "Company not found.", 404);
|
|
51
121
|
}
|
|
52
122
|
return sendOk(res, company);
|
|
53
123
|
});
|
|
54
124
|
|
|
55
|
-
router.delete("/:companyId", async (req, res) => {
|
|
56
|
-
const
|
|
125
|
+
router.delete("/:companyId", requireCompanyWriteAccess, async (req, res) => {
|
|
126
|
+
const companyId = readCompanyIdParam(req);
|
|
127
|
+
if (!companyId) {
|
|
128
|
+
return sendError(res, "Missing company id.", 422);
|
|
129
|
+
}
|
|
130
|
+
const deleted = await deleteCompany(ctx.db, companyId);
|
|
57
131
|
if (!deleted) {
|
|
58
132
|
return sendError(res, "Company not found.", 404);
|
|
59
133
|
}
|
|
@@ -62,3 +136,50 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
62
136
|
|
|
63
137
|
return router;
|
|
64
138
|
}
|
|
139
|
+
|
|
140
|
+
function requireCompanyWriteAccess(req: Request, res: Response, next: NextFunction) {
|
|
141
|
+
const targetCompanyId = readCompanyIdParam(req);
|
|
142
|
+
if (!targetCompanyId) {
|
|
143
|
+
return sendError(res, "Missing company id.", 422);
|
|
144
|
+
}
|
|
145
|
+
if (!canAccessCompany(req, targetCompanyId)) {
|
|
146
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
147
|
+
}
|
|
148
|
+
if (req.actor?.type === "board") {
|
|
149
|
+
next();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
return requirePermission("companies:write")(req, res, next);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readCompanyIdParam(req: Request) {
|
|
156
|
+
return typeof req.params.companyId === "string" ? req.params.companyId : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseAgentProvider(value: unknown) {
|
|
160
|
+
if (
|
|
161
|
+
value === "codex" ||
|
|
162
|
+
value === "claude_code" ||
|
|
163
|
+
value === "cursor" ||
|
|
164
|
+
value === "gemini_cli" ||
|
|
165
|
+
value === "opencode" ||
|
|
166
|
+
value === "openai_api" ||
|
|
167
|
+
value === "anthropic_api" ||
|
|
168
|
+
value === "shell"
|
|
169
|
+
) {
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveSeedRuntimeEnv(providerType: string): Record<string, string> {
|
|
176
|
+
if (providerType === "codex" || providerType === "openai_api") {
|
|
177
|
+
const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
|
|
178
|
+
return key ? { OPENAI_API_KEY: key } : {};
|
|
179
|
+
}
|
|
180
|
+
if (providerType === "claude_code" || providerType === "anthropic_api") {
|
|
181
|
+
const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
|
|
182
|
+
return key ? { ANTHROPIC_API_KEY: key } : {};
|
|
183
|
+
}
|
|
184
|
+
return {};
|
|
185
|
+
}
|
package/src/routes/governance.ts
CHANGED
|
@@ -159,9 +159,11 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
159
159
|
throw error;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
const auditActor = resolveAuditActor(req.actor);
|
|
162
163
|
await appendAuditEvent(ctx.db, {
|
|
163
164
|
companyId: req.companyId!,
|
|
164
|
-
actorType:
|
|
165
|
+
actorType: auditActor.actorType,
|
|
166
|
+
actorId: auditActor.actorId,
|
|
165
167
|
eventType: "governance.approval_resolved",
|
|
166
168
|
entityType: "approval_request",
|
|
167
169
|
entityId: parsed.data.approvalId,
|
|
@@ -181,7 +183,8 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
181
183
|
: "memory.promoted_from_approval";
|
|
182
184
|
await appendAuditEvent(ctx.db, {
|
|
183
185
|
companyId: req.companyId!,
|
|
184
|
-
actorType:
|
|
186
|
+
actorType: auditActor.actorType,
|
|
187
|
+
actorId: auditActor.actorId,
|
|
185
188
|
eventType,
|
|
186
189
|
entityType: resolution.execution.entityType,
|
|
187
190
|
entityId: resolution.execution.entityId,
|
|
@@ -213,6 +216,16 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
213
216
|
return router;
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
|
|
220
|
+
if (!actor) {
|
|
221
|
+
return { actorType: "human" as const, actorId: null as string | null };
|
|
222
|
+
}
|
|
223
|
+
if (actor.type === "agent") {
|
|
224
|
+
return { actorType: "agent" as const, actorId: actor.id };
|
|
225
|
+
}
|
|
226
|
+
return { actorType: "human" as const, actorId: actor.id };
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
function parsePayload(payloadJson: string) {
|
|
217
230
|
try {
|
|
218
231
|
const parsed = JSON.parse(payloadJson) as unknown;
|
package/src/routes/issues.ts
CHANGED
|
@@ -37,6 +37,20 @@ const createIssueSchema = z.object({
|
|
|
37
37
|
parentIssueId: z.string().optional(),
|
|
38
38
|
title: z.string().min(1),
|
|
39
39
|
body: z.string().optional(),
|
|
40
|
+
metadata: z
|
|
41
|
+
.object({
|
|
42
|
+
delegatedHiringIntent: z
|
|
43
|
+
.object({
|
|
44
|
+
intentType: z.literal("agent_hiring_request"),
|
|
45
|
+
requestedRole: z.string().nullable().optional(),
|
|
46
|
+
requestedName: z.string().nullable().optional(),
|
|
47
|
+
requestedManagerAgentId: z.string().nullable().optional(),
|
|
48
|
+
requestedProviderType: z.string().nullable().optional(),
|
|
49
|
+
requestedRuntimeModel: z.string().nullable().optional()
|
|
50
|
+
})
|
|
51
|
+
.optional()
|
|
52
|
+
})
|
|
53
|
+
.optional(),
|
|
40
54
|
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
|
|
41
55
|
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
|
|
42
56
|
assigneeAgentId: z.string().nullable().optional(),
|
|
@@ -165,7 +179,18 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
165
179
|
return sendError(res, assignmentValidation, 422);
|
|
166
180
|
}
|
|
167
181
|
}
|
|
168
|
-
const issue = await createIssue(ctx.db, {
|
|
182
|
+
const issue = await createIssue(ctx.db, {
|
|
183
|
+
companyId: req.companyId!,
|
|
184
|
+
projectId: parsed.data.projectId,
|
|
185
|
+
parentIssueId: parsed.data.parentIssueId,
|
|
186
|
+
title: parsed.data.title,
|
|
187
|
+
body: applyIssueMetadataToBody(parsed.data.body, parsed.data.metadata),
|
|
188
|
+
status: parsed.data.status,
|
|
189
|
+
priority: parsed.data.priority,
|
|
190
|
+
assigneeAgentId: parsed.data.assigneeAgentId,
|
|
191
|
+
labels: parsed.data.labels,
|
|
192
|
+
tags: parsed.data.tags
|
|
193
|
+
});
|
|
169
194
|
await appendActivity(ctx.db, {
|
|
170
195
|
companyId: req.companyId!,
|
|
171
196
|
issueId: issue.id,
|
|
@@ -618,6 +643,34 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
618
643
|
return router;
|
|
619
644
|
}
|
|
620
645
|
|
|
646
|
+
function applyIssueMetadataToBody(
|
|
647
|
+
body: string | undefined,
|
|
648
|
+
metadata:
|
|
649
|
+
| {
|
|
650
|
+
delegatedHiringIntent?: {
|
|
651
|
+
intentType: "agent_hiring_request";
|
|
652
|
+
requestedRole?: string | null;
|
|
653
|
+
requestedName?: string | null;
|
|
654
|
+
requestedManagerAgentId?: string | null;
|
|
655
|
+
requestedProviderType?: string | null;
|
|
656
|
+
requestedRuntimeModel?: string | null;
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
| undefined
|
|
660
|
+
) {
|
|
661
|
+
if (!metadata || Object.keys(metadata).length === 0) {
|
|
662
|
+
return body;
|
|
663
|
+
}
|
|
664
|
+
const metadataBlock = [
|
|
665
|
+
"",
|
|
666
|
+
"---",
|
|
667
|
+
"<!-- bopodev:issue-metadata:v1",
|
|
668
|
+
JSON.stringify(metadata),
|
|
669
|
+
"-->"
|
|
670
|
+
].join("\n");
|
|
671
|
+
return `${body ?? ""}${metadataBlock}`.trim();
|
|
672
|
+
}
|
|
673
|
+
|
|
621
674
|
function parsePayload(payloadJson: string) {
|
|
622
675
|
try {
|
|
623
676
|
const parsed = JSON.parse(payloadJson) as unknown;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import type { AppContext } from "../context";
|
|
15
15
|
import { sendError, sendOk } from "../http";
|
|
16
16
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
|
+
import { requirePermission } from "../middleware/request-actor";
|
|
17
18
|
import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
|
|
18
19
|
|
|
19
20
|
export function createObservabilityRouter(ctx: AppContext) {
|
|
@@ -70,6 +71,10 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
router.put("/models/pricing", async (req, res) => {
|
|
74
|
+
requirePermission("observability:write")(req, res, () => {});
|
|
75
|
+
if (res.headersSent) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
73
78
|
const parsed = modelPricingUpdateSchema.safeParse(req.body);
|
|
74
79
|
if (!parsed.success) {
|
|
75
80
|
return sendError(res, parsed.error.message, 422);
|
package/src/routes/plugins.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import type { AppContext } from "../context";
|
|
15
15
|
import { sendError, sendOk } from "../http";
|
|
16
16
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
|
+
import { requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
17
18
|
import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
|
|
18
19
|
import { registerPluginManifest } from "../services/plugin-runtime";
|
|
19
20
|
|
|
@@ -73,11 +74,18 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
router.put("/:pluginId", async (req, res) => {
|
|
77
|
+
requirePermission("plugins:write")(req, res, () => {});
|
|
78
|
+
if (res.headersSent) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
76
81
|
const parsed = pluginConfigSchema.safeParse(req.body);
|
|
77
82
|
if (!parsed.success) {
|
|
78
83
|
return sendError(res, parsed.error.message, 422);
|
|
79
84
|
}
|
|
80
|
-
const pluginId = req.params.pluginId;
|
|
85
|
+
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
86
|
+
if (!pluginId) {
|
|
87
|
+
return sendError(res, "Missing plugin id.", 422);
|
|
88
|
+
}
|
|
81
89
|
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
82
90
|
const pluginExists = catalog.some((plugin) => plugin.id === pluginId);
|
|
83
91
|
if (!pluginExists) {
|
|
@@ -115,6 +123,10 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
115
123
|
});
|
|
116
124
|
|
|
117
125
|
router.post("/install-from-json", async (req, res) => {
|
|
126
|
+
requirePermission("plugins:write")(req, res, () => {});
|
|
127
|
+
if (res.headersSent) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
118
130
|
const parsed = pluginManifestCreateSchema.safeParse(req.body);
|
|
119
131
|
if (!parsed.success) {
|
|
120
132
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -152,7 +164,14 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
152
164
|
});
|
|
153
165
|
|
|
154
166
|
router.post("/:pluginId/install", async (req, res) => {
|
|
155
|
-
|
|
167
|
+
requirePermission("plugins:write")(req, res, () => {});
|
|
168
|
+
if (res.headersSent) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
172
|
+
if (!pluginId) {
|
|
173
|
+
return sendError(res, "Missing plugin id.", 422);
|
|
174
|
+
}
|
|
156
175
|
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
157
176
|
const plugin = catalog.find((item) => item.id === pluginId);
|
|
158
177
|
if (!plugin) {
|
|
@@ -174,7 +193,14 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
174
193
|
});
|
|
175
194
|
|
|
176
195
|
router.delete("/:pluginId/install", async (req, res) => {
|
|
177
|
-
|
|
196
|
+
requirePermission("plugins:write")(req, res, () => {});
|
|
197
|
+
if (res.headersSent) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
201
|
+
if (!pluginId) {
|
|
202
|
+
return sendError(res, "Missing plugin id.", 422);
|
|
203
|
+
}
|
|
178
204
|
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
179
205
|
const plugin = catalog.find((item) => item.id === pluginId);
|
|
180
206
|
if (!plugin) {
|
|
@@ -191,8 +217,11 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
191
217
|
return sendOk(res, { ok: true, pluginId, installed: false });
|
|
192
218
|
});
|
|
193
219
|
|
|
194
|
-
router.delete("/:pluginId", async (req, res) => {
|
|
195
|
-
const pluginId = req.params.pluginId;
|
|
220
|
+
router.delete("/:pluginId", requireBoardRole, async (req, res) => {
|
|
221
|
+
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
222
|
+
if (!pluginId) {
|
|
223
|
+
return sendError(res, "Missing plugin id.", 422);
|
|
224
|
+
}
|
|
196
225
|
const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
|
|
197
226
|
const plugin = catalog.find((item) => item.id === pluginId);
|
|
198
227
|
if (!plugin) {
|
|
@@ -232,6 +261,10 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
232
261
|
return router;
|
|
233
262
|
}
|
|
234
263
|
|
|
264
|
+
function readPluginIdParam(value: string | string[] | undefined) {
|
|
265
|
+
return typeof value === "string" ? value : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
235
268
|
function safeParseStringArray(value: string | null | undefined) {
|
|
236
269
|
if (!value) {
|
|
237
270
|
return [] as string[];
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveAgentFallbackWorkspacePath,
|
|
30
30
|
resolveProjectWorkspacePath
|
|
31
31
|
} from "../lib/instance-paths";
|
|
32
|
+
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
32
33
|
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
33
34
|
import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
|
|
34
35
|
import { applyTemplateManifest } from "../services/template-apply-service";
|
|
@@ -122,6 +123,10 @@ export async function ensureOnboardingSeed(input: {
|
|
|
122
123
|
});
|
|
123
124
|
let ceoId = existingCeo?.id ?? null;
|
|
124
125
|
if (!existingCeo) {
|
|
126
|
+
const ceoCreateRuntimeConfig = {
|
|
127
|
+
...defaultRuntimeConfig,
|
|
128
|
+
bootstrapPrompt: buildDefaultCeoBootstrapPrompt()
|
|
129
|
+
};
|
|
125
130
|
const ceo = await createAgent(db, {
|
|
126
131
|
companyId,
|
|
127
132
|
role: "CEO",
|
|
@@ -130,13 +135,13 @@ export async function ensureOnboardingSeed(input: {
|
|
|
130
135
|
heartbeatCron: "*/5 * * * *",
|
|
131
136
|
monthlyBudgetUsd: "100.0000",
|
|
132
137
|
canHireAgents: true,
|
|
133
|
-
...runtimeConfigToDb(
|
|
134
|
-
initialState: runtimeConfigToStateBlobPatch(
|
|
138
|
+
...runtimeConfigToDb(ceoCreateRuntimeConfig),
|
|
139
|
+
initialState: runtimeConfigToStateBlobPatch(ceoCreateRuntimeConfig)
|
|
135
140
|
});
|
|
136
141
|
ceoId = ceo.id;
|
|
137
142
|
ceoCreated = true;
|
|
138
143
|
ceoProviderType = agentProvider;
|
|
139
|
-
ceoRuntimeModel = ceo.runtimeModel ??
|
|
144
|
+
ceoRuntimeModel = ceo.runtimeModel ?? ceoCreateRuntimeConfig.runtimeModel ?? null;
|
|
140
145
|
} else if (isBootstrapCeoRuntime(existingCeo.providerType, existingCeo.stateBlob)) {
|
|
141
146
|
const nextState = {
|
|
142
147
|
...stripRuntimeFromState(existingCeo.stateBlob),
|
|
@@ -50,6 +50,21 @@ const approvalGatedActions = new Set([
|
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
52
|
const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
53
|
+
sourceIssueId: z.string().min(1).optional(),
|
|
54
|
+
sourceIssueIds: z.array(z.string().min(1)).default([]),
|
|
55
|
+
delegationIntent: z
|
|
56
|
+
.object({
|
|
57
|
+
intentType: z.literal("agent_hiring_request"),
|
|
58
|
+
requestedRole: z.string().nullable().optional(),
|
|
59
|
+
requestedName: z.string().nullable().optional(),
|
|
60
|
+
requestedManagerAgentId: z.string().nullable().optional(),
|
|
61
|
+
requestedProviderType: z
|
|
62
|
+
.enum(["claude_code", "codex", "cursor", "opencode", "gemini_cli", "openai_api", "anthropic_api", "http", "shell"])
|
|
63
|
+
.nullable()
|
|
64
|
+
.optional(),
|
|
65
|
+
requestedRuntimeModel: z.string().nullable().optional()
|
|
66
|
+
})
|
|
67
|
+
.optional(),
|
|
53
68
|
runtimeCommand: z.string().optional(),
|
|
54
69
|
runtimeArgs: z.array(z.string()).optional(),
|
|
55
70
|
runtimeCwd: z.string().optional(),
|
|
@@ -194,6 +209,13 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
194
209
|
if (!parsed.success) {
|
|
195
210
|
throw new GovernanceError("Approval payload for agent hiring is invalid.");
|
|
196
211
|
}
|
|
212
|
+
const sourceIssueIds = Array.from(
|
|
213
|
+
new Set(
|
|
214
|
+
[parsed.data.sourceIssueId, ...(parsed.data.sourceIssueIds ?? [])]
|
|
215
|
+
.map((entry) => entry?.trim())
|
|
216
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
217
|
+
)
|
|
218
|
+
);
|
|
197
219
|
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
198
220
|
const runtimeConfig = normalizeRuntimeConfig({
|
|
199
221
|
runtimeConfig: parsed.data.runtimeConfig,
|
|
@@ -265,7 +287,11 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
265
287
|
applied: true,
|
|
266
288
|
entityType: "agent" as const,
|
|
267
289
|
entityId: agent.id,
|
|
268
|
-
entity:
|
|
290
|
+
entity: {
|
|
291
|
+
...(agent as Record<string, unknown>),
|
|
292
|
+
sourceIssueIds,
|
|
293
|
+
delegationIntent: parsed.data.delegationIntent ?? null
|
|
294
|
+
}
|
|
269
295
|
};
|
|
270
296
|
}
|
|
271
297
|
|
|
@@ -385,6 +385,7 @@ export async function runHeartbeatForAgent(
|
|
|
385
385
|
let transcriptLiveHighSignalCount = 0;
|
|
386
386
|
let transcriptPersistFailureReported = false;
|
|
387
387
|
let pluginFailureSummary: string[] = [];
|
|
388
|
+
const seenResultMessages = new Set<string>();
|
|
388
389
|
|
|
389
390
|
const enqueueTranscriptEvent = (event: {
|
|
390
391
|
kind: string;
|
|
@@ -401,6 +402,10 @@ export async function runHeartbeatForAgent(
|
|
|
401
402
|
const signalLevel = normalizeTranscriptSignalLevel(event.signalLevel, event.kind);
|
|
402
403
|
const groupKey = event.groupKey ?? defaultTranscriptGroupKey(event.kind, event.label);
|
|
403
404
|
const source = event.source ?? "stdout";
|
|
405
|
+
const normalizedResultText = event.kind === "result" ? normalizeTranscriptResultText(event.text) : "";
|
|
406
|
+
if (event.kind === "result" && normalizedResultText.length > 0) {
|
|
407
|
+
seenResultMessages.add(normalizedResultText);
|
|
408
|
+
}
|
|
404
409
|
transcriptLiveCount += 1;
|
|
405
410
|
if (isUsefulTranscriptSignal(signalLevel)) {
|
|
406
411
|
transcriptLiveUsefulCount += 1;
|
|
@@ -480,6 +485,10 @@ export async function runHeartbeatForAgent(
|
|
|
480
485
|
if (!trimmed) {
|
|
481
486
|
return;
|
|
482
487
|
}
|
|
488
|
+
const normalized = normalizeTranscriptResultText(trimmed);
|
|
489
|
+
if (normalized.length > 0 && seenResultMessages.has(normalized)) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
483
492
|
enqueueTranscriptEvent({
|
|
484
493
|
kind: "result",
|
|
485
494
|
label,
|
|
@@ -993,6 +1002,20 @@ export async function runHeartbeatForAgent(
|
|
|
993
1002
|
(transcriptLiveHighSignalCount < 2 && fallbackHighSignalCount > transcriptLiveHighSignalCount));
|
|
994
1003
|
if (shouldAppendFallback) {
|
|
995
1004
|
const createdAt = new Date();
|
|
1005
|
+
const dedupedFallbackMessages = fallbackMessages.filter((message) => {
|
|
1006
|
+
if (message.kind !== "result") {
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
const normalized = normalizeTranscriptResultText(message.text);
|
|
1010
|
+
if (!normalized) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
if (seenResultMessages.has(normalized)) {
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
seenResultMessages.add(normalized);
|
|
1017
|
+
return true;
|
|
1018
|
+
});
|
|
996
1019
|
const rows: Array<{
|
|
997
1020
|
id: string;
|
|
998
1021
|
sequence: number;
|
|
@@ -1004,7 +1027,7 @@ export async function runHeartbeatForAgent(
|
|
|
1004
1027
|
groupKey: string | null;
|
|
1005
1028
|
source: "trace_fallback";
|
|
1006
1029
|
createdAt: Date;
|
|
1007
|
-
}> =
|
|
1030
|
+
}> = dedupedFallbackMessages.map((message) => ({
|
|
1008
1031
|
id: nanoid(14),
|
|
1009
1032
|
sequence: transcriptSequence++,
|
|
1010
1033
|
kind: message.kind,
|
|
@@ -1711,6 +1734,11 @@ function normalizeTranscriptKind(
|
|
|
1711
1734
|
return "system";
|
|
1712
1735
|
}
|
|
1713
1736
|
|
|
1737
|
+
function normalizeTranscriptResultText(value: string | undefined) {
|
|
1738
|
+
const normalized = (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
1739
|
+
return normalized;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1714
1742
|
function defaultTranscriptGroupKey(kind: string, label?: string) {
|
|
1715
1743
|
if (kind === "tool_call" || kind === "tool_result") {
|
|
1716
1744
|
return `tool:${(label ?? "unknown").trim().toLowerCase()}`;
|
|
@@ -2219,7 +2247,7 @@ function buildHeartbeatRuntimeEnv(input: {
|
|
|
2219
2247
|
canHireAgents: boolean;
|
|
2220
2248
|
}) {
|
|
2221
2249
|
const apiBaseUrl = resolveControlPlaneApiBaseUrl();
|
|
2222
|
-
const actorPermissions = ["issues:write", "agents:write"].join(",");
|
|
2250
|
+
const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
|
|
2223
2251
|
const actorHeaders = JSON.stringify({
|
|
2224
2252
|
"x-company-id": input.companyId,
|
|
2225
2253
|
"x-actor-type": "agent",
|