bopodev-api 0.1.24 → 0.1.26
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/app.ts +11 -1
- package/src/http.ts +39 -0
- package/src/lib/comment-recipients.ts +105 -0
- package/src/lib/hiring-delegate.ts +7 -6
- package/src/lib/instance-paths.ts +11 -0
- package/src/realtime/attention.ts +47 -0
- package/src/realtime/governance.ts +11 -3
- package/src/realtime/heartbeat-runs.ts +33 -11
- package/src/realtime/hub.ts +34 -2
- package/src/realtime/office-space.ts +17 -1
- package/src/routes/agents.ts +81 -12
- package/src/routes/attention.ts +112 -0
- package/src/routes/companies.ts +13 -5
- package/src/routes/goals.ts +10 -2
- package/src/routes/governance.ts +85 -2
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +219 -10
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +8 -7
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +412 -0
- package/src/services/budget-service.ts +99 -2
- package/src/services/comment-recipient-dispatch-service.ts +158 -0
- package/src/services/governance-service.ts +237 -14
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +2341 -278
- package/src/services/memory-file-service.ts +510 -35
- package/src/services/plugin-runtime.ts +33 -1
- package/src/services/template-apply-service.ts +37 -2
- package/src/worker/scheduler.ts +46 -8
package/src/routes/agents.ts
CHANGED
|
@@ -6,7 +6,13 @@ import {
|
|
|
6
6
|
getAdapterModels,
|
|
7
7
|
runAdapterEnvironmentTest
|
|
8
8
|
} from "bopodev-agent-sdk";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
AGENT_ROLE_LABELS,
|
|
11
|
+
AgentCreateRequestSchema,
|
|
12
|
+
AgentRoleKeySchema,
|
|
13
|
+
AgentSchema,
|
|
14
|
+
AgentUpdateRequestSchema
|
|
15
|
+
} from "bopodev-contracts";
|
|
10
16
|
import {
|
|
11
17
|
appendAuditEvent,
|
|
12
18
|
createAgent,
|
|
@@ -18,7 +24,7 @@ import {
|
|
|
18
24
|
updateAgent
|
|
19
25
|
} from "bopodev-db";
|
|
20
26
|
import type { AppContext } from "../context";
|
|
21
|
-
import { sendError, sendOk } from "../http";
|
|
27
|
+
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
22
28
|
import {
|
|
23
29
|
normalizeRuntimeConfig,
|
|
24
30
|
parseRuntimeConfigFromAgentRow,
|
|
@@ -33,6 +39,7 @@ import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany
|
|
|
33
39
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
34
40
|
import { requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
35
41
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
42
|
+
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
36
43
|
import {
|
|
37
44
|
publishOfficeOccupantForAgent,
|
|
38
45
|
publishOfficeOccupantForApproval
|
|
@@ -84,6 +91,8 @@ const runtimePreflightSchema = z.object({
|
|
|
84
91
|
const UPDATE_AGENT_ALLOWED_KEYS = new Set([
|
|
85
92
|
"managerAgentId",
|
|
86
93
|
"role",
|
|
94
|
+
"roleKey",
|
|
95
|
+
"title",
|
|
87
96
|
"name",
|
|
88
97
|
"providerType",
|
|
89
98
|
"status",
|
|
@@ -129,6 +138,10 @@ function providerRequiresNamedModel(providerType: string) {
|
|
|
129
138
|
return providerType !== "http" && providerType !== "shell";
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
const agentResponseSchema = AgentSchema.extend({
|
|
142
|
+
stateBlob: z.string().optional()
|
|
143
|
+
});
|
|
144
|
+
|
|
132
145
|
function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
|
|
133
146
|
if (!providerRequiresNamedModel(providerType)) {
|
|
134
147
|
return true;
|
|
@@ -142,9 +155,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
142
155
|
|
|
143
156
|
router.get("/", async (req, res) => {
|
|
144
157
|
const rows = await listAgents(ctx.db, req.companyId!);
|
|
145
|
-
return
|
|
158
|
+
return sendOkValidated(
|
|
146
159
|
res,
|
|
147
|
-
|
|
160
|
+
agentResponseSchema.array(),
|
|
161
|
+
rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>)),
|
|
162
|
+
"agents.list"
|
|
148
163
|
);
|
|
149
164
|
});
|
|
150
165
|
|
|
@@ -155,6 +170,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
155
170
|
id: row.id,
|
|
156
171
|
name: row.name,
|
|
157
172
|
role: row.role,
|
|
173
|
+
roleKey: row.roleKey,
|
|
158
174
|
status: row.status,
|
|
159
175
|
canHireAgents: row.canHireAgents
|
|
160
176
|
}))
|
|
@@ -170,8 +186,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
170
186
|
return sendOk(
|
|
171
187
|
res,
|
|
172
188
|
rows.map((row) => {
|
|
173
|
-
const
|
|
174
|
-
const isLeadership = normalizedRole === "ceo" || Boolean(row.canHireAgents);
|
|
189
|
+
const isLeadership = row.roleKey === "ceo" || Boolean(row.canHireAgents);
|
|
175
190
|
const issues: string[] = [];
|
|
176
191
|
const hasBootstrapPrompt = hasText(row.bootstrapPrompt);
|
|
177
192
|
if (isLeadership && !hasBootstrapPrompt) {
|
|
@@ -184,6 +199,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
184
199
|
agentId: row.id,
|
|
185
200
|
name: row.name,
|
|
186
201
|
role: row.role,
|
|
202
|
+
roleKey: row.roleKey,
|
|
187
203
|
providerType: row.providerType,
|
|
188
204
|
canHireAgents: Boolean(row.canHireAgents),
|
|
189
205
|
isLeadership,
|
|
@@ -366,7 +382,8 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
366
382
|
const shouldRequestApproval = (parsed.data.requestApproval || req.actor?.type === "agent") && isApprovalRequired("hire_agent");
|
|
367
383
|
if (shouldRequestApproval) {
|
|
368
384
|
const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
|
|
369
|
-
role: parsed.data.role,
|
|
385
|
+
role: parsed.data.role ?? "",
|
|
386
|
+
roleKey: parsed.data.roleKey ?? null,
|
|
370
387
|
managerAgentId: parsed.data.managerAgentId ?? null
|
|
371
388
|
});
|
|
372
389
|
if (duplicate) {
|
|
@@ -397,6 +414,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
397
414
|
})
|
|
398
415
|
);
|
|
399
416
|
await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approvalId);
|
|
417
|
+
await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
|
|
400
418
|
}
|
|
401
419
|
return sendOk(res, { queuedForApproval: true, approvalId });
|
|
402
420
|
}
|
|
@@ -404,7 +422,9 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
404
422
|
const agent = await createAgent(ctx.db, {
|
|
405
423
|
companyId: req.companyId!,
|
|
406
424
|
managerAgentId: parsed.data.managerAgentId,
|
|
407
|
-
role: parsed.data.role,
|
|
425
|
+
role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
|
|
426
|
+
roleKey: normalizeRoleKey(parsed.data.roleKey),
|
|
427
|
+
title: normalizeTitle(parsed.data.title),
|
|
408
428
|
name: parsed.data.name,
|
|
409
429
|
providerType: parsed.data.providerType,
|
|
410
430
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
@@ -543,7 +563,16 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
543
563
|
companyId: req.companyId!,
|
|
544
564
|
id: req.params.agentId,
|
|
545
565
|
managerAgentId: parsed.data.managerAgentId,
|
|
546
|
-
role:
|
|
566
|
+
role:
|
|
567
|
+
parsed.data.role !== undefined || parsed.data.roleKey !== undefined || parsed.data.title !== undefined
|
|
568
|
+
? resolveAgentRoleText(
|
|
569
|
+
parsed.data.role ?? existingAgent.role,
|
|
570
|
+
parsed.data.roleKey ?? existingAgent.roleKey,
|
|
571
|
+
parsed.data.title ?? existingAgent.title
|
|
572
|
+
)
|
|
573
|
+
: undefined,
|
|
574
|
+
roleKey: parsed.data.roleKey !== undefined ? normalizeRoleKey(parsed.data.roleKey) : undefined,
|
|
575
|
+
title: parsed.data.title !== undefined ? normalizeTitle(parsed.data.title) : undefined,
|
|
547
576
|
name: parsed.data.name,
|
|
548
577
|
providerType: parsed.data.providerType,
|
|
549
578
|
status: parsed.data.status,
|
|
@@ -715,14 +744,15 @@ function enforceRuntimeCwdPolicy(companyId: string, runtime: ReturnType<typeof n
|
|
|
715
744
|
async function findDuplicateHireRequest(
|
|
716
745
|
db: AppContext["db"],
|
|
717
746
|
companyId: string,
|
|
718
|
-
input: { role: string; managerAgentId: string | null }
|
|
747
|
+
input: { role: string; roleKey: string | null; managerAgentId: string | null }
|
|
719
748
|
) {
|
|
720
749
|
const role = input.role.trim();
|
|
750
|
+
const roleKey = normalizeRoleKey(input.roleKey);
|
|
721
751
|
const managerAgentId = input.managerAgentId ?? null;
|
|
722
752
|
const agents = await listAgents(db, companyId);
|
|
723
753
|
const existingAgent = agents.find(
|
|
724
754
|
(agent) =>
|
|
725
|
-
agent.role === role &&
|
|
755
|
+
((roleKey && agent.roleKey === roleKey) || (!roleKey && role.length > 0 && agent.role === role)) &&
|
|
726
756
|
(agent.managerAgentId ?? null) === managerAgentId &&
|
|
727
757
|
agent.status !== "terminated"
|
|
728
758
|
);
|
|
@@ -732,6 +762,10 @@ async function findDuplicateHireRequest(
|
|
|
732
762
|
return false;
|
|
733
763
|
}
|
|
734
764
|
const payload = parseApprovalPayload(approval.payloadJson);
|
|
765
|
+
const payloadRoleKey = normalizeRoleKey(payload.roleKey);
|
|
766
|
+
if (roleKey && payloadRoleKey) {
|
|
767
|
+
return payloadRoleKey === roleKey && (payload.managerAgentId ?? null) === managerAgentId;
|
|
768
|
+
}
|
|
735
769
|
return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
|
|
736
770
|
});
|
|
737
771
|
if (!existingAgent && !pendingApproval) {
|
|
@@ -743,11 +777,12 @@ async function findDuplicateHireRequest(
|
|
|
743
777
|
};
|
|
744
778
|
}
|
|
745
779
|
|
|
746
|
-
function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
|
|
780
|
+
function parseApprovalPayload(payloadJson: string): { role?: string; roleKey?: string | null; managerAgentId?: string | null } {
|
|
747
781
|
try {
|
|
748
782
|
const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
|
|
749
783
|
return {
|
|
750
784
|
role: typeof parsed.role === "string" ? parsed.role : undefined,
|
|
785
|
+
roleKey: typeof parsed.roleKey === "string" ? parsed.roleKey : null,
|
|
751
786
|
managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
|
|
752
787
|
};
|
|
753
788
|
} catch {
|
|
@@ -780,6 +815,40 @@ function providerSupportsSkillInjection(providerType: string) {
|
|
|
780
815
|
return providerType === "codex" || providerType === "cursor" || providerType === "opencode" || providerType === "claude_code";
|
|
781
816
|
}
|
|
782
817
|
|
|
818
|
+
function normalizeRoleKey(input: string | null | undefined) {
|
|
819
|
+
const normalized = input?.trim().toLowerCase();
|
|
820
|
+
if (!normalized) {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
const parsed = AgentRoleKeySchema.safeParse(normalized);
|
|
824
|
+
return parsed.success ? parsed.data : null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function normalizeTitle(input: string | null | undefined) {
|
|
828
|
+
const normalized = input?.trim();
|
|
829
|
+
return normalized ? normalized : null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function resolveAgentRoleText(
|
|
833
|
+
legacyRole: string | undefined,
|
|
834
|
+
roleKeyInput: string | null | undefined,
|
|
835
|
+
titleInput: string | null | undefined
|
|
836
|
+
) {
|
|
837
|
+
const normalizedLegacy = legacyRole?.trim();
|
|
838
|
+
if (normalizedLegacy) {
|
|
839
|
+
return normalizedLegacy;
|
|
840
|
+
}
|
|
841
|
+
const normalizedTitle = normalizeTitle(titleInput);
|
|
842
|
+
if (normalizedTitle) {
|
|
843
|
+
return normalizedTitle;
|
|
844
|
+
}
|
|
845
|
+
const roleKey = normalizeRoleKey(roleKeyInput);
|
|
846
|
+
if (roleKey) {
|
|
847
|
+
return AGENT_ROLE_LABELS[roleKey];
|
|
848
|
+
}
|
|
849
|
+
return AGENT_ROLE_LABELS.general;
|
|
850
|
+
}
|
|
851
|
+
|
|
783
852
|
function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
|
|
784
853
|
if (!actor) {
|
|
785
854
|
return { actorType: "human" as const, actorId: null as string | null };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { sendError, sendOkValidated } from "../http";
|
|
4
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
5
|
+
import type { AppContext } from "../context";
|
|
6
|
+
import {
|
|
7
|
+
clearBoardAttentionDismissed,
|
|
8
|
+
listBoardAttentionItems,
|
|
9
|
+
markBoardAttentionAcknowledged,
|
|
10
|
+
markBoardAttentionDismissed,
|
|
11
|
+
markBoardAttentionResolved,
|
|
12
|
+
markBoardAttentionSeen
|
|
13
|
+
} from "../services/attention-service";
|
|
14
|
+
import { BoardAttentionListResponseSchema } from "bopodev-contracts";
|
|
15
|
+
import { createAttentionRealtimeEvent } from "../realtime/attention";
|
|
16
|
+
|
|
17
|
+
const itemParamsSchema = z.object({
|
|
18
|
+
itemKey: z.string().min(1)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function createAttentionRouter(ctx: AppContext) {
|
|
22
|
+
const router = Router();
|
|
23
|
+
router.use(requireCompanyScope);
|
|
24
|
+
|
|
25
|
+
// Canonical board action queue endpoint for Inbox and board attention UX.
|
|
26
|
+
router.get("/", async (req, res) => {
|
|
27
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
28
|
+
const items = await listBoardAttentionItems(ctx.db, req.companyId!, actorId);
|
|
29
|
+
return sendOkValidated(res, BoardAttentionListResponseSchema, { actorId, items }, "attention.list");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
router.post("/:itemKey/seen", async (req, res) => {
|
|
33
|
+
const parsed = itemParamsSchema.safeParse(req.params);
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
return sendError(res, parsed.error.message, 422);
|
|
36
|
+
}
|
|
37
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
38
|
+
await markBoardAttentionSeen(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
|
|
39
|
+
await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
|
|
40
|
+
return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.seen");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.post("/:itemKey/acknowledge", async (req, res) => {
|
|
44
|
+
const parsed = itemParamsSchema.safeParse(req.params);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
return sendError(res, parsed.error.message, 422);
|
|
47
|
+
}
|
|
48
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
49
|
+
await markBoardAttentionAcknowledged(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
|
|
50
|
+
await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
|
|
51
|
+
return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.acknowledge");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
router.post("/:itemKey/dismiss", async (req, res) => {
|
|
55
|
+
const parsed = itemParamsSchema.safeParse(req.params);
|
|
56
|
+
if (!parsed.success) {
|
|
57
|
+
return sendError(res, parsed.error.message, 422);
|
|
58
|
+
}
|
|
59
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
60
|
+
await markBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
|
|
61
|
+
await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
|
|
62
|
+
return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.dismiss");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
router.post("/:itemKey/undismiss", async (req, res) => {
|
|
66
|
+
const parsed = itemParamsSchema.safeParse(req.params);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
return sendError(res, parsed.error.message, 422);
|
|
69
|
+
}
|
|
70
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
71
|
+
await clearBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
|
|
72
|
+
await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
|
|
73
|
+
return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.undismiss");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
router.post("/:itemKey/resolve", async (req, res) => {
|
|
77
|
+
const parsed = itemParamsSchema.safeParse(req.params);
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
return sendError(res, parsed.error.message, 422);
|
|
80
|
+
}
|
|
81
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
82
|
+
await markBoardAttentionResolved(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
|
|
83
|
+
await publishAttentionResolve(ctx, req.companyId!, actorId, parsed.data.itemKey);
|
|
84
|
+
return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.resolve");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return router;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function publishAttentionUpdate(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
|
|
91
|
+
const items = await listBoardAttentionItems(ctx.db, companyId, actorId);
|
|
92
|
+
const item = items.find((entry) => entry.key === itemKey);
|
|
93
|
+
if (!item) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ctx.realtimeHub?.publish(
|
|
97
|
+
createAttentionRealtimeEvent(companyId, {
|
|
98
|
+
type: "attention.updated",
|
|
99
|
+
item
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function publishAttentionResolve(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
|
|
105
|
+
await publishAttentionUpdate(ctx, companyId, actorId, itemKey);
|
|
106
|
+
ctx.realtimeHub?.publish(
|
|
107
|
+
createAttentionRealtimeEvent(companyId, {
|
|
108
|
+
type: "attention.resolved",
|
|
109
|
+
key: itemKey
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { mkdir } from "node:fs/promises";
|
|
|
2
2
|
import type { NextFunction, Request, Response } from "express";
|
|
3
3
|
import { Router } from "express";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { CompanySchema } from "bopodev-contracts";
|
|
5
6
|
import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
6
7
|
import type { AppContext } from "../context";
|
|
7
|
-
import { sendError, sendOk } from "../http";
|
|
8
|
+
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
8
9
|
import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
|
|
9
10
|
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
10
11
|
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
@@ -37,14 +38,19 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
37
38
|
const router = Router();
|
|
38
39
|
|
|
39
40
|
router.get("/", async (req, res) => {
|
|
40
|
-
const companies = await listCompanies(ctx.db)
|
|
41
|
+
const companies = (await listCompanies(ctx.db)).map((company) => ({
|
|
42
|
+
...company,
|
|
43
|
+
createdAt: company.createdAt instanceof Date ? company.createdAt.toISOString() : String(company.createdAt)
|
|
44
|
+
}));
|
|
41
45
|
if (req.actor?.type === "board") {
|
|
42
|
-
return
|
|
46
|
+
return sendOkValidated(res, CompanySchema.array(), companies, "companies.list");
|
|
43
47
|
}
|
|
44
48
|
const visibleCompanyIds = new Set(req.actor?.companyIds ?? []);
|
|
45
|
-
return
|
|
49
|
+
return sendOkValidated(
|
|
46
50
|
res,
|
|
47
|
-
|
|
51
|
+
CompanySchema.array(),
|
|
52
|
+
companies.filter((company) => visibleCompanyIds.has(company.id)),
|
|
53
|
+
"companies.list.filtered"
|
|
48
54
|
);
|
|
49
55
|
});
|
|
50
56
|
|
|
@@ -91,6 +97,8 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
91
97
|
await createAgent(ctx.db, {
|
|
92
98
|
companyId: company.id,
|
|
93
99
|
role: "CEO",
|
|
100
|
+
roleKey: "ceo",
|
|
101
|
+
title: "CEO",
|
|
94
102
|
name: "CEO",
|
|
95
103
|
providerType,
|
|
96
104
|
heartbeatCron: "*/5 * * * *",
|
package/src/routes/goals.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { GoalSchema } from "bopodev-contracts";
|
|
3
4
|
import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApprovalRequest, listGoals, updateGoal } from "bopodev-db";
|
|
4
5
|
import type { AppContext } from "../context";
|
|
5
|
-
import { sendError, sendOk } from "../http";
|
|
6
|
+
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
6
7
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
7
8
|
import { requirePermission } from "../middleware/request-actor";
|
|
8
9
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
10
|
+
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
9
11
|
import { isApprovalRequired } from "../services/governance-service";
|
|
10
12
|
|
|
11
13
|
const createGoalSchema = z.object({
|
|
@@ -33,7 +35,12 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
33
35
|
router.use(requireCompanyScope);
|
|
34
36
|
|
|
35
37
|
router.get("/", async (req, res) => {
|
|
36
|
-
return
|
|
38
|
+
return sendOkValidated(
|
|
39
|
+
res,
|
|
40
|
+
GoalSchema.array(),
|
|
41
|
+
await listGoals(ctx.db, req.companyId!),
|
|
42
|
+
"goals.list"
|
|
43
|
+
);
|
|
37
44
|
});
|
|
38
45
|
|
|
39
46
|
router.post("/", async (req, res) => {
|
|
@@ -60,6 +67,7 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
60
67
|
approval: serializeStoredApproval(approval)
|
|
61
68
|
})
|
|
62
69
|
);
|
|
70
|
+
await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
|
|
63
71
|
}
|
|
64
72
|
return sendOk(res, { queuedForApproval: true, approvalId });
|
|
65
73
|
}
|
package/src/routes/governance.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
|
+
addIssueComment,
|
|
4
5
|
appendAuditEvent,
|
|
5
6
|
clearApprovalInboxDismissed,
|
|
6
7
|
countPendingApprovalRequests,
|
|
@@ -15,6 +16,7 @@ import { sendError, sendOk } from "../http";
|
|
|
15
16
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
16
17
|
import { requirePermission } from "../middleware/request-actor";
|
|
17
18
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
19
|
+
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
18
20
|
import {
|
|
19
21
|
publishOfficeOccupantForAgent,
|
|
20
22
|
publishOfficeOccupantForApproval
|
|
@@ -34,6 +36,9 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
34
36
|
const router = Router();
|
|
35
37
|
router.use(requireCompanyScope);
|
|
36
38
|
|
|
39
|
+
// Deprecated compatibility shim:
|
|
40
|
+
// board queue consumers should use /attention as the canonical source.
|
|
41
|
+
// Keep this endpoint until all downstream consumers migrate.
|
|
37
42
|
router.get("/approvals", async (req, res) => {
|
|
38
43
|
const approvals = await listApprovalRequests(ctx.db, req.companyId!);
|
|
39
44
|
return sendOk(
|
|
@@ -200,10 +205,41 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
200
205
|
approval: serializeStoredApproval(approval)
|
|
201
206
|
})
|
|
202
207
|
);
|
|
208
|
+
await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
|
|
203
209
|
await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approval.id);
|
|
204
210
|
if (approval.requestedByAgentId) {
|
|
205
211
|
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
|
|
206
212
|
}
|
|
213
|
+
if (parsed.data.status === "approved" && resolution.action === "hire_agent" && resolution.execution.applied) {
|
|
214
|
+
const hireContext = parseHireApprovalCommentContext(approval.payloadJson);
|
|
215
|
+
if (hireContext.issueIds.length > 0) {
|
|
216
|
+
const commentBody = buildHireApprovalIssueComment(hireContext.roleLabel);
|
|
217
|
+
try {
|
|
218
|
+
for (const issueId of hireContext.issueIds) {
|
|
219
|
+
await addIssueComment(ctx.db, {
|
|
220
|
+
companyId: req.companyId!,
|
|
221
|
+
issueId,
|
|
222
|
+
body: commentBody,
|
|
223
|
+
authorType: auditActor.actorType === "agent" ? "agent" : "human",
|
|
224
|
+
authorId: auditActor.actorId
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
await appendAuditEvent(ctx.db, {
|
|
229
|
+
companyId: req.companyId!,
|
|
230
|
+
actorType: "system",
|
|
231
|
+
actorId: null,
|
|
232
|
+
eventType: "governance.hire_approval_comment_failed",
|
|
233
|
+
entityType: "approval_request",
|
|
234
|
+
entityId: approval.id,
|
|
235
|
+
payload: {
|
|
236
|
+
error: String(error),
|
|
237
|
+
issueIds: hireContext.issueIds
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
207
243
|
}
|
|
208
244
|
|
|
209
245
|
if (resolution.execution.entityType === "agent" && resolution.execution.entityId) {
|
|
@@ -226,11 +262,58 @@ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: stri
|
|
|
226
262
|
return { actorType: "human" as const, actorId: actor.id };
|
|
227
263
|
}
|
|
228
264
|
|
|
229
|
-
function parsePayload(payloadJson: string) {
|
|
265
|
+
function parsePayload(payloadJson: string): Record<string, unknown> {
|
|
230
266
|
try {
|
|
231
267
|
const parsed = JSON.parse(payloadJson) as unknown;
|
|
232
|
-
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
268
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
233
269
|
} catch {
|
|
234
270
|
return {};
|
|
235
271
|
}
|
|
236
272
|
}
|
|
273
|
+
|
|
274
|
+
function parseHireApprovalCommentContext(payloadJson: string) {
|
|
275
|
+
const payload = parsePayload(payloadJson);
|
|
276
|
+
const issueIds = normalizeSourceIssueIds(
|
|
277
|
+
typeof payload.sourceIssueId === "string" ? payload.sourceIssueId : undefined,
|
|
278
|
+
Array.isArray(payload.sourceIssueIds) ? payload.sourceIssueIds : undefined
|
|
279
|
+
);
|
|
280
|
+
const roleLabel = resolveHireRoleLabel(payload);
|
|
281
|
+
return { issueIds, roleLabel };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeSourceIssueIds(sourceIssueId?: string, sourceIssueIds?: unknown[]) {
|
|
285
|
+
const normalized = new Set<string>();
|
|
286
|
+
for (const entry of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
|
|
287
|
+
if (typeof entry !== "string") {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const trimmed = entry.trim();
|
|
291
|
+
if (trimmed.length > 0) {
|
|
292
|
+
normalized.add(trimmed);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return Array.from(normalized);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolveHireRoleLabel(payload: Record<string, unknown>) {
|
|
299
|
+
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
300
|
+
if (title.length > 0) {
|
|
301
|
+
return title;
|
|
302
|
+
}
|
|
303
|
+
const role = typeof payload.role === "string" ? payload.role.trim() : "";
|
|
304
|
+
if (role.length > 0) {
|
|
305
|
+
return role;
|
|
306
|
+
}
|
|
307
|
+
const roleKey = typeof payload.roleKey === "string" ? payload.roleKey.trim() : "";
|
|
308
|
+
if (roleKey.length > 0) {
|
|
309
|
+
return roleKey.replace(/_/g, " ");
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function buildHireApprovalIssueComment(roleLabel: string | null) {
|
|
315
|
+
if (roleLabel) {
|
|
316
|
+
return `Approved hiring of ${roleLabel}.`;
|
|
317
|
+
}
|
|
318
|
+
return "Approved hiring request.";
|
|
319
|
+
}
|