bopodev-api 0.1.1

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.
@@ -0,0 +1,448 @@
1
+ import { and, desc, eq } from "drizzle-orm";
2
+ import type { OfficeOccupant, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
3
+ import {
4
+ agents,
5
+ approvalRequests,
6
+ getApprovalRequest,
7
+ heartbeatRuns,
8
+ issues,
9
+ listAgents,
10
+ listApprovalRequests,
11
+ listHeartbeatRuns,
12
+ type BopoDb
13
+ } from "bopodev-db";
14
+ import type { RealtimeHub } from "./hub";
15
+
16
+ export async function loadOfficeSpaceRealtimeSnapshot(
17
+ db: BopoDb,
18
+ companyId: string
19
+ ): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
20
+ return createRealtimeEvent(companyId, {
21
+ channel: "office-space",
22
+ event: {
23
+ type: "office.snapshot",
24
+ occupants: await listOfficeOccupants(db, companyId)
25
+ }
26
+ });
27
+ }
28
+
29
+ export function createOfficeSpaceRealtimeEvent(
30
+ companyId: string,
31
+ event: Extract<RealtimeEventEnvelope, { channel: "office-space" }>["event"]
32
+ ): Extract<RealtimeMessage, { kind: "event" }> {
33
+ return createRealtimeEvent(companyId, {
34
+ channel: "office-space",
35
+ event
36
+ });
37
+ }
38
+
39
+ export async function publishOfficeOccupantForAgent(
40
+ db: BopoDb,
41
+ realtimeHub: RealtimeHub | undefined,
42
+ companyId: string,
43
+ agentId: string
44
+ ) {
45
+ if (!realtimeHub) {
46
+ return;
47
+ }
48
+
49
+ const occupant = await loadOfficeOccupantForAgent(db, companyId, agentId);
50
+ if (!occupant) {
51
+ realtimeHub.publish(
52
+ createOfficeSpaceRealtimeEvent(companyId, {
53
+ type: "office.occupant.left",
54
+ occupantId: buildAgentOccupantId(agentId)
55
+ })
56
+ );
57
+ return;
58
+ }
59
+
60
+ realtimeHub.publish(
61
+ createOfficeSpaceRealtimeEvent(companyId, {
62
+ type: "office.occupant.updated",
63
+ occupant
64
+ })
65
+ );
66
+ }
67
+
68
+ export async function publishOfficeOccupantForApproval(
69
+ db: BopoDb,
70
+ realtimeHub: RealtimeHub | undefined,
71
+ companyId: string,
72
+ approvalId: string
73
+ ) {
74
+ if (!realtimeHub) {
75
+ return;
76
+ }
77
+
78
+ const occupant = await loadOfficeOccupantForApproval(db, companyId, approvalId);
79
+ if (!occupant) {
80
+ realtimeHub.publish(
81
+ createOfficeSpaceRealtimeEvent(companyId, {
82
+ type: "office.occupant.left",
83
+ occupantId: buildHireCandidateOccupantId(approvalId)
84
+ })
85
+ );
86
+ return;
87
+ }
88
+
89
+ realtimeHub.publish(
90
+ createOfficeSpaceRealtimeEvent(companyId, {
91
+ type: "office.occupant.updated",
92
+ occupant
93
+ })
94
+ );
95
+ }
96
+
97
+ async function listOfficeOccupants(db: BopoDb, companyId: string): Promise<OfficeOccupant[]> {
98
+ const [agentRows, heartbeatRows, approvalRows, issueRows] = await Promise.all([
99
+ listAgents(db, companyId),
100
+ listHeartbeatRuns(db, companyId, 500),
101
+ listApprovalRequests(db, companyId),
102
+ db
103
+ .select({
104
+ id: issues.id,
105
+ assigneeAgentId: issues.assigneeAgentId,
106
+ claimedByHeartbeatRunId: issues.claimedByHeartbeatRunId,
107
+ isClaimed: issues.isClaimed,
108
+ title: issues.title,
109
+ status: issues.status,
110
+ updatedAt: issues.updatedAt
111
+ })
112
+ .from(issues)
113
+ .where(eq(issues.companyId, companyId))
114
+ ]);
115
+
116
+ return sortOccupants([
117
+ ...agentRows
118
+ .map((agent) =>
119
+ deriveAgentOccupant(agent, {
120
+ pendingApprovals: approvalRows,
121
+ heartbeatRows,
122
+ issueRows
123
+ })
124
+ )
125
+ .filter((occupant): occupant is OfficeOccupant => occupant !== null),
126
+ ...approvalRows
127
+ .map((approval) => deriveHireCandidateOccupant(approval))
128
+ .filter((occupant): occupant is OfficeOccupant => occupant !== null)
129
+ ]);
130
+ }
131
+
132
+ async function loadOfficeOccupantForAgent(db: BopoDb, companyId: string, agentId: string): Promise<OfficeOccupant | null> {
133
+ const [agent] = await db
134
+ .select({
135
+ id: agents.id,
136
+ companyId: agents.companyId,
137
+ name: agents.name,
138
+ role: agents.role,
139
+ status: agents.status,
140
+ providerType: agents.providerType,
141
+ updatedAt: agents.updatedAt
142
+ })
143
+ .from(agents)
144
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
145
+ .limit(1);
146
+
147
+ if (!agent) {
148
+ return null;
149
+ }
150
+
151
+ const [pendingApprovals, startedRuns, assignedIssues] = await Promise.all([
152
+ db
153
+ .select({
154
+ id: approvalRequests.id,
155
+ companyId: approvalRequests.companyId,
156
+ requestedByAgentId: approvalRequests.requestedByAgentId,
157
+ action: approvalRequests.action,
158
+ payloadJson: approvalRequests.payloadJson,
159
+ status: approvalRequests.status,
160
+ createdAt: approvalRequests.createdAt,
161
+ resolvedAt: approvalRequests.resolvedAt
162
+ })
163
+ .from(approvalRequests)
164
+ .where(
165
+ and(
166
+ eq(approvalRequests.companyId, companyId),
167
+ eq(approvalRequests.requestedByAgentId, agentId),
168
+ eq(approvalRequests.status, "pending")
169
+ )
170
+ )
171
+ .orderBy(desc(approvalRequests.createdAt)),
172
+ db
173
+ .select({
174
+ id: heartbeatRuns.id,
175
+ agentId: heartbeatRuns.agentId,
176
+ status: heartbeatRuns.status,
177
+ startedAt: heartbeatRuns.startedAt,
178
+ finishedAt: heartbeatRuns.finishedAt
179
+ })
180
+ .from(heartbeatRuns)
181
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "started")))
182
+ .orderBy(desc(heartbeatRuns.startedAt)),
183
+ db
184
+ .select({
185
+ id: issues.id,
186
+ assigneeAgentId: issues.assigneeAgentId,
187
+ claimedByHeartbeatRunId: issues.claimedByHeartbeatRunId,
188
+ isClaimed: issues.isClaimed,
189
+ title: issues.title,
190
+ status: issues.status,
191
+ updatedAt: issues.updatedAt
192
+ })
193
+ .from(issues)
194
+ .where(and(eq(issues.companyId, companyId), eq(issues.assigneeAgentId, agentId)))
195
+ ]);
196
+
197
+ const claimedIssues =
198
+ startedRuns[0] && startedRuns[0].status === "started"
199
+ ? assignedIssues.filter((issue) => issue.isClaimed && issue.claimedByHeartbeatRunId === startedRuns[0]?.id)
200
+ : [];
201
+
202
+ return deriveAgentOccupant(agent, {
203
+ pendingApprovals,
204
+ heartbeatRows: startedRuns,
205
+ issueRows: assignedIssues,
206
+ claimedIssuesOverride: claimedIssues
207
+ });
208
+ }
209
+
210
+ async function loadOfficeOccupantForApproval(db: BopoDb, companyId: string, approvalId: string): Promise<OfficeOccupant | null> {
211
+ const approval = await getApprovalRequest(db, companyId, approvalId);
212
+ return approval ? deriveHireCandidateOccupant(approval) : null;
213
+ }
214
+
215
+ function deriveAgentOccupant(
216
+ agent: {
217
+ id: string;
218
+ companyId: string;
219
+ name: string;
220
+ role: string;
221
+ status: string;
222
+ providerType: string;
223
+ updatedAt: Date;
224
+ },
225
+ input: {
226
+ pendingApprovals: Array<{
227
+ id: string;
228
+ requestedByAgentId: string | null;
229
+ action: string;
230
+ payloadJson: string;
231
+ status: string;
232
+ createdAt: Date;
233
+ resolvedAt: Date | null;
234
+ }>;
235
+ heartbeatRows: Array<{
236
+ id: string;
237
+ agentId: string;
238
+ status: string;
239
+ startedAt: Date;
240
+ finishedAt: Date | null;
241
+ }>;
242
+ issueRows: Array<{
243
+ id: string;
244
+ assigneeAgentId: string | null;
245
+ claimedByHeartbeatRunId: string | null;
246
+ isClaimed: boolean;
247
+ title: string;
248
+ status: string;
249
+ updatedAt: Date;
250
+ }>;
251
+ claimedIssuesOverride?: Array<{
252
+ id: string;
253
+ assigneeAgentId: string | null;
254
+ claimedByHeartbeatRunId: string | null;
255
+ isClaimed: boolean;
256
+ title: string;
257
+ status: string;
258
+ updatedAt: Date;
259
+ }>;
260
+ }
261
+ ): OfficeOccupant | null {
262
+ if (agent.status === "terminated") {
263
+ return null;
264
+ }
265
+
266
+ const pendingApproval = input.pendingApprovals
267
+ .filter((approval) => approval.status === "pending" && approval.requestedByAgentId === agent.id)
268
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
269
+
270
+ const activeRun = input.heartbeatRows
271
+ .filter((run) => run.status === "started" && run.agentId === agent.id && !run.finishedAt)
272
+ .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())[0];
273
+
274
+ const claimedIssues =
275
+ input.claimedIssuesOverride ??
276
+ input.issueRows
277
+ .filter((issue) => issue.isClaimed && issue.claimedByHeartbeatRunId === activeRun?.id)
278
+ .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime());
279
+
280
+ const nextAssignedIssue = input.issueRows
281
+ .filter((issue) => issue.assigneeAgentId === agent.id && issue.status !== "done" && issue.status !== "canceled")
282
+ .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime())[0];
283
+
284
+ if (pendingApproval) {
285
+ return {
286
+ id: buildAgentOccupantId(agent.id),
287
+ kind: "agent",
288
+ companyId: agent.companyId,
289
+ agentId: agent.id,
290
+ approvalId: pendingApproval.id,
291
+ displayName: agent.name,
292
+ role: agent.role,
293
+ room: "security",
294
+ status: "waiting_for_approval",
295
+ taskLabel: `${formatActionLabel(pendingApproval.action)} approval`,
296
+ providerType: normalizeProviderType(agent.providerType),
297
+ focusEntityType: "approval",
298
+ focusEntityId: pendingApproval.id,
299
+ updatedAt: pendingApproval.createdAt.toISOString()
300
+ };
301
+ }
302
+
303
+ if (activeRun) {
304
+ return {
305
+ id: buildAgentOccupantId(agent.id),
306
+ kind: "agent",
307
+ companyId: agent.companyId,
308
+ agentId: agent.id,
309
+ approvalId: null,
310
+ displayName: agent.name,
311
+ role: agent.role,
312
+ room: "work_space",
313
+ status: "working",
314
+ taskLabel: claimedIssues[0]?.title ?? "Checking in on work",
315
+ providerType: normalizeProviderType(agent.providerType),
316
+ focusEntityType: claimedIssues[0] ? "issue" : "agent",
317
+ focusEntityId: claimedIssues[0]?.id ?? agent.id,
318
+ updatedAt: activeRun.startedAt.toISOString()
319
+ };
320
+ }
321
+
322
+ if (agent.status === "paused") {
323
+ return {
324
+ id: buildAgentOccupantId(agent.id),
325
+ kind: "agent",
326
+ companyId: agent.companyId,
327
+ agentId: agent.id,
328
+ approvalId: null,
329
+ displayName: agent.name,
330
+ role: agent.role,
331
+ room: "waiting_room",
332
+ status: "paused",
333
+ taskLabel: "Paused",
334
+ providerType: normalizeProviderType(agent.providerType),
335
+ focusEntityType: "agent",
336
+ focusEntityId: agent.id,
337
+ updatedAt: agent.updatedAt.toISOString()
338
+ };
339
+ }
340
+
341
+ return {
342
+ id: buildAgentOccupantId(agent.id),
343
+ kind: "agent",
344
+ companyId: agent.companyId,
345
+ agentId: agent.id,
346
+ approvalId: null,
347
+ displayName: agent.name,
348
+ role: agent.role,
349
+ room: "waiting_room",
350
+ status: "idle",
351
+ taskLabel: nextAssignedIssue ? `Up next: ${nextAssignedIssue.title}` : "Waiting for work",
352
+ providerType: normalizeProviderType(agent.providerType),
353
+ focusEntityType: nextAssignedIssue ? "issue" : "agent",
354
+ focusEntityId: nextAssignedIssue?.id ?? agent.id,
355
+ updatedAt: nextAssignedIssue?.updatedAt.toISOString() ?? agent.updatedAt.toISOString()
356
+ };
357
+ }
358
+
359
+ function deriveHireCandidateOccupant(approval: {
360
+ id: string;
361
+ companyId: string;
362
+ action: string;
363
+ payloadJson: string;
364
+ status: string;
365
+ createdAt: Date;
366
+ }): OfficeOccupant | null {
367
+ if (approval.status !== "pending" || approval.action !== "hire_agent") {
368
+ return null;
369
+ }
370
+
371
+ const payload = parsePayload(approval.payloadJson);
372
+ const name = typeof payload.name === "string" ? payload.name : "Pending hire";
373
+ const role = typeof payload.role === "string" ? payload.role : null;
374
+ const providerType =
375
+ typeof payload.providerType === "string" ? normalizeProviderType(payload.providerType) : null;
376
+
377
+ return {
378
+ id: buildHireCandidateOccupantId(approval.id),
379
+ kind: "hire_candidate",
380
+ companyId: approval.companyId,
381
+ agentId: null,
382
+ approvalId: approval.id,
383
+ displayName: name,
384
+ role,
385
+ room: "security",
386
+ status: "waiting_for_approval",
387
+ taskLabel: "Awaiting hire approval",
388
+ providerType,
389
+ focusEntityType: "approval",
390
+ focusEntityId: approval.id,
391
+ updatedAt: approval.createdAt.toISOString()
392
+ };
393
+ }
394
+
395
+ function createRealtimeEvent(
396
+ companyId: string,
397
+ envelope: Extract<RealtimeEventEnvelope, { channel: "office-space" }>
398
+ ): Extract<RealtimeMessage, { kind: "event" }> {
399
+ return {
400
+ kind: "event",
401
+ companyId,
402
+ ...envelope
403
+ };
404
+ }
405
+
406
+ function sortOccupants(occupants: OfficeOccupant[]) {
407
+ const roomOrder: Record<OfficeOccupant["room"], number> = {
408
+ waiting_room: 0,
409
+ work_space: 1,
410
+ security: 2
411
+ };
412
+
413
+ return [...occupants].sort((a, b) => {
414
+ const roomComparison = roomOrder[a.room] - roomOrder[b.room];
415
+ if (roomComparison !== 0) {
416
+ return roomComparison;
417
+ }
418
+ return a.displayName.localeCompare(b.displayName);
419
+ });
420
+ }
421
+
422
+ function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
423
+ return value === "claude_code" || value === "codex" || value === "http" || value === "shell" ? value : null;
424
+ }
425
+
426
+ function formatActionLabel(action: string) {
427
+ return action
428
+ .split("_")
429
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
430
+ .join(" ");
431
+ }
432
+
433
+ function parsePayload(payloadJson: string): Record<string, unknown> {
434
+ try {
435
+ const parsed = JSON.parse(payloadJson) as unknown;
436
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
437
+ } catch {
438
+ return {};
439
+ }
440
+ }
441
+
442
+ function buildAgentOccupantId(agentId: string) {
443
+ return `agent:${agentId}`;
444
+ }
445
+
446
+ function buildHireCandidateOccupantId(approvalId: string) {
447
+ return `hire-candidate:${approvalId}`;
448
+ }