bopodev-api 0.1.23 → 0.1.25
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 +5 -0
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +62 -1
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +3 -1
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +391 -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 +233 -9
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +930 -49
- package/src/services/memory-file-service.ts +513 -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
|
@@ -15,6 +15,7 @@ import { sendError, sendOk } from "../http";
|
|
|
15
15
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
16
16
|
import { requirePermission } from "../middleware/request-actor";
|
|
17
17
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
18
|
+
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
18
19
|
import {
|
|
19
20
|
publishOfficeOccupantForAgent,
|
|
20
21
|
publishOfficeOccupantForApproval
|
|
@@ -34,6 +35,9 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
34
35
|
const router = Router();
|
|
35
36
|
router.use(requireCompanyScope);
|
|
36
37
|
|
|
38
|
+
// Deprecated compatibility shim:
|
|
39
|
+
// board queue consumers should use /attention as the canonical source.
|
|
40
|
+
// Keep this endpoint until all downstream consumers migrate.
|
|
37
41
|
router.get("/approvals", async (req, res) => {
|
|
38
42
|
const approvals = await listApprovalRequests(ctx.db, req.companyId!);
|
|
39
43
|
return sendOk(
|
|
@@ -200,6 +204,7 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
200
204
|
approval: serializeStoredApproval(approval)
|
|
201
205
|
})
|
|
202
206
|
);
|
|
207
|
+
await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
|
|
203
208
|
await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approval.id);
|
|
204
209
|
if (approval.requestedByAgentId) {
|
|
205
210
|
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
|
package/src/routes/heartbeats.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { and, eq } from "drizzle-orm";
|
|
4
|
-
import { agents, heartbeatRuns } from "bopodev-db";
|
|
4
|
+
import { agents, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
|
|
5
5
|
import type { AppContext } from "../context";
|
|
6
6
|
import { sendError, sendOk } from "../http";
|
|
7
7
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
8
8
|
import { requirePermission } from "../middleware/request-actor";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
findPendingProjectBudgetOverrideBlocksForAgent,
|
|
11
|
+
runHeartbeatSweep,
|
|
12
|
+
stopHeartbeatRun
|
|
13
|
+
} from "../services/heartbeat-service";
|
|
14
|
+
import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "../services/heartbeat-queue-service";
|
|
10
15
|
|
|
11
16
|
const runAgentSchema = z.object({
|
|
12
17
|
agentId: z.string().min(1)
|
|
@@ -14,6 +19,12 @@ const runAgentSchema = z.object({
|
|
|
14
19
|
const runIdParamsSchema = z.object({
|
|
15
20
|
runId: z.string().min(1)
|
|
16
21
|
});
|
|
22
|
+
const queueQuerySchema = z.object({
|
|
23
|
+
status: z.enum(["pending", "running", "completed", "failed", "dead_letter", "canceled"]).optional(),
|
|
24
|
+
agentId: z.string().min(1).optional(),
|
|
25
|
+
jobType: z.enum(["manual", "scheduler", "resume", "redo", "comment_dispatch"]).optional(),
|
|
26
|
+
limit: z.coerce.number().int().min(1).max(1000).optional()
|
|
27
|
+
});
|
|
17
28
|
|
|
18
29
|
export function createHeartbeatRouter(ctx: AppContext) {
|
|
19
30
|
const router = Router();
|
|
@@ -39,31 +50,37 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
39
50
|
if (agent.status === "paused" || agent.status === "terminated") {
|
|
40
51
|
return sendError(res, `Agent is not invokable in status '${agent.status}'.`, 409);
|
|
41
52
|
}
|
|
53
|
+
const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(
|
|
54
|
+
ctx.db,
|
|
55
|
+
req.companyId!,
|
|
56
|
+
parsed.data.agentId
|
|
57
|
+
);
|
|
58
|
+
if (blockedProjectIds.length > 0) {
|
|
59
|
+
return sendError(
|
|
60
|
+
res,
|
|
61
|
+
`Agent is blocked by pending project budget approval for project(s): ${blockedProjectIds.join(", ")}.`,
|
|
62
|
+
423
|
|
63
|
+
);
|
|
64
|
+
}
|
|
42
65
|
|
|
43
|
-
const
|
|
66
|
+
const job = await enqueueHeartbeatQueueJob(ctx.db, {
|
|
67
|
+
companyId: req.companyId!,
|
|
68
|
+
agentId: parsed.data.agentId,
|
|
69
|
+
jobType: "manual",
|
|
70
|
+
priority: 30,
|
|
71
|
+
idempotencyKey: req.requestId ? `manual:${parsed.data.agentId}:${req.requestId}` : null,
|
|
72
|
+
payload: {}
|
|
73
|
+
});
|
|
74
|
+
triggerHeartbeatQueueWorker(ctx.db, req.companyId!, {
|
|
44
75
|
requestId: req.requestId,
|
|
45
|
-
trigger: "manual",
|
|
46
76
|
realtimeHub: ctx.realtimeHub
|
|
47
77
|
});
|
|
48
|
-
if (!runId) {
|
|
49
|
-
return sendError(res, "Heartbeat could not be started for this agent.", 409);
|
|
50
|
-
}
|
|
51
|
-
const [runRow] = await ctx.db
|
|
52
|
-
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status, message: heartbeatRuns.message })
|
|
53
|
-
.from(heartbeatRuns)
|
|
54
|
-
.where(and(eq(heartbeatRuns.companyId, req.companyId!), eq(heartbeatRuns.id, runId)))
|
|
55
|
-
.limit(1);
|
|
56
|
-
const invokeStatus =
|
|
57
|
-
runRow?.status === "skipped" && String(runRow.message ?? "").includes("already in progress")
|
|
58
|
-
? "skipped_overlap"
|
|
59
|
-
: runRow?.status === "skipped"
|
|
60
|
-
? "skipped"
|
|
61
|
-
: "started";
|
|
62
78
|
return sendOk(res, {
|
|
63
|
-
runId,
|
|
79
|
+
runId: null,
|
|
80
|
+
jobId: job.id,
|
|
64
81
|
requestId: req.requestId,
|
|
65
|
-
status:
|
|
66
|
-
message:
|
|
82
|
+
status: "queued",
|
|
83
|
+
message: "Heartbeat queued."
|
|
67
84
|
});
|
|
68
85
|
});
|
|
69
86
|
|
|
@@ -127,32 +144,32 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
127
144
|
if (agent.status === "paused" || agent.status === "terminated") {
|
|
128
145
|
return { ok: false as const, statusCode: 409, message: `Agent is not invokable in status '${agent.status}'.` };
|
|
129
146
|
}
|
|
130
|
-
const
|
|
147
|
+
const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(ctx.db, input.companyId, run.agentId);
|
|
148
|
+
if (blockedProjectIds.length > 0) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false as const,
|
|
151
|
+
statusCode: 423,
|
|
152
|
+
message: `Agent is blocked by pending project budget approval for project(s): ${blockedProjectIds.join(", ")}.`
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const job = await enqueueHeartbeatQueueJob(ctx.db, {
|
|
156
|
+
companyId: input.companyId,
|
|
157
|
+
agentId: run.agentId,
|
|
158
|
+
jobType: input.mode,
|
|
159
|
+
priority: 30,
|
|
160
|
+
idempotencyKey: input.requestId ? `${input.mode}:${run.agentId}:${run.id}:${input.requestId}` : null,
|
|
161
|
+
payload: { sourceRunId: run.id }
|
|
162
|
+
});
|
|
163
|
+
triggerHeartbeatQueueWorker(ctx.db, input.companyId, {
|
|
131
164
|
requestId: input.requestId,
|
|
132
|
-
|
|
133
|
-
realtimeHub: ctx.realtimeHub,
|
|
134
|
-
mode: input.mode,
|
|
135
|
-
sourceRunId: run.id
|
|
165
|
+
realtimeHub: ctx.realtimeHub
|
|
136
166
|
});
|
|
137
|
-
if (!nextRunId) {
|
|
138
|
-
return { ok: false as const, statusCode: 409, message: "Heartbeat could not be started for this agent." };
|
|
139
|
-
}
|
|
140
|
-
const [runRow] = await ctx.db
|
|
141
|
-
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status, message: heartbeatRuns.message })
|
|
142
|
-
.from(heartbeatRuns)
|
|
143
|
-
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, nextRunId)))
|
|
144
|
-
.limit(1);
|
|
145
|
-
const invokeStatus =
|
|
146
|
-
runRow?.status === "skipped" && String(runRow.message ?? "").includes("already in progress")
|
|
147
|
-
? "skipped_overlap"
|
|
148
|
-
: runRow?.status === "skipped"
|
|
149
|
-
? "skipped"
|
|
150
|
-
: "started";
|
|
151
167
|
return {
|
|
152
168
|
ok: true as const,
|
|
153
|
-
runId:
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
runId: null,
|
|
170
|
+
jobId: job.id,
|
|
171
|
+
status: "queued" as const,
|
|
172
|
+
message: "Heartbeat queued."
|
|
156
173
|
};
|
|
157
174
|
}
|
|
158
175
|
|
|
@@ -176,6 +193,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
176
193
|
}
|
|
177
194
|
return sendOk(res, {
|
|
178
195
|
runId: result.runId,
|
|
196
|
+
jobId: result.jobId,
|
|
179
197
|
requestId: req.requestId,
|
|
180
198
|
status: result.status,
|
|
181
199
|
message: result.message
|
|
@@ -202,6 +220,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
202
220
|
}
|
|
203
221
|
return sendOk(res, {
|
|
204
222
|
runId: result.runId,
|
|
223
|
+
jobId: result.jobId,
|
|
205
224
|
requestId: req.requestId,
|
|
206
225
|
status: result.status,
|
|
207
226
|
message: result.message
|
|
@@ -220,5 +239,24 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
220
239
|
return sendOk(res, { runIds, requestId: req.requestId });
|
|
221
240
|
});
|
|
222
241
|
|
|
242
|
+
router.get("/queue", async (req, res) => {
|
|
243
|
+
requirePermission("heartbeats:run")(req, res, () => {});
|
|
244
|
+
if (res.headersSent) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const parsed = queueQuerySchema.safeParse(req.query);
|
|
248
|
+
if (!parsed.success) {
|
|
249
|
+
return sendError(res, parsed.error.message, 422);
|
|
250
|
+
}
|
|
251
|
+
const jobs = await listHeartbeatQueueJobs(ctx.db, {
|
|
252
|
+
companyId: req.companyId!,
|
|
253
|
+
status: parsed.data.status,
|
|
254
|
+
agentId: parsed.data.agentId,
|
|
255
|
+
jobType: parsed.data.jobType,
|
|
256
|
+
limit: parsed.data.limit
|
|
257
|
+
});
|
|
258
|
+
return sendOk(res, { items: jobs });
|
|
259
|
+
});
|
|
260
|
+
|
|
223
261
|
return router;
|
|
224
262
|
}
|