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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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.24",
21
- "bopodev-db": "0.1.24",
22
- "bopodev-contracts": "0.1.24"
20
+ "bopodev-db": "0.1.26",
21
+ "bopodev-agent-sdk": "0.1.26",
22
+ "bopodev-contracts": "0.1.26"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
package/src/app.ts CHANGED
@@ -7,6 +7,7 @@ import { nanoid } from "nanoid";
7
7
  import type { AppContext } from "./context";
8
8
  import { createAgentsRouter } from "./routes/agents";
9
9
  import { createAuthRouter } from "./routes/auth";
10
+ import { createAttentionRouter } from "./routes/attention";
10
11
  import { createCompaniesRouter } from "./routes/companies";
11
12
  import { createGoalsRouter } from "./routes/goals";
12
13
  import { createGovernanceRouter } from "./routes/governance";
@@ -46,7 +47,15 @@ export function createApp(ctx: AppContext) {
46
47
  },
47
48
  credentials: true,
48
49
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
49
- allowedHeaders: ["content-type", "x-company-id", "authorization", "x-client-trace-id", "x-bopo-actor-token"]
50
+ allowedHeaders: [
51
+ "content-type",
52
+ "x-company-id",
53
+ "authorization",
54
+ "x-client-trace-id",
55
+ "x-bopo-actor-token",
56
+ "x-request-id",
57
+ "x-bopodev-run-id"
58
+ ]
50
59
  })
51
60
  );
52
61
  app.use(express.json());
@@ -106,6 +115,7 @@ export function createApp(ctx: AppContext) {
106
115
  });
107
116
 
108
117
  app.use("/auth", createAuthRouter(ctx));
118
+ app.use("/attention", createAttentionRouter(ctx));
109
119
  app.use("/companies", createCompaniesRouter(ctx));
110
120
  app.use("/projects", createProjectsRouter(ctx));
111
121
  app.use("/issues", createIssuesRouter(ctx));
package/src/http.ts CHANGED
@@ -4,6 +4,45 @@ export function sendOk<T>(res: Response, data: T) {
4
4
  return res.status(200).json({ ok: true, data });
5
5
  }
6
6
 
7
+ export function sendOkValidated(
8
+ res: Response,
9
+ schema: {
10
+ safeParse: (value: unknown) => {
11
+ success: boolean;
12
+ data?: unknown;
13
+ error?: { issues?: Array<{ path?: unknown[]; message?: string }> };
14
+ };
15
+ },
16
+ data: unknown,
17
+ resourceName: string
18
+ ) {
19
+ const normalized = normalizeContractData(data);
20
+ const parsed = schema.safeParse(normalized);
21
+ if (!parsed.success) {
22
+ return sendError(
23
+ res,
24
+ `Contract mismatch for ${resourceName}: ${(parsed.error?.issues ?? [])
25
+ .map((issue) => `${(issue.path ?? []).join(".") || "<root>"} ${issue.message ?? "Invalid value"}`)
26
+ .join("; ")}`,
27
+ 500
28
+ );
29
+ }
30
+ return sendOk(res, parsed.data ?? normalized);
31
+ }
32
+
7
33
  export function sendError(res: Response, message: string, code = 400) {
8
34
  return res.status(code).json({ ok: false, error: message });
9
35
  }
36
+
37
+ function normalizeContractData(value: unknown) {
38
+ const serialized = JSON.stringify(value, (_key, entry) => {
39
+ if (entry instanceof Date) {
40
+ return entry.toISOString();
41
+ }
42
+ return entry;
43
+ });
44
+ if (serialized === undefined) {
45
+ return value;
46
+ }
47
+ return JSON.parse(serialized);
48
+ }
@@ -0,0 +1,105 @@
1
+ export type CommentRecipientType = "agent" | "board" | "member";
2
+
3
+ export type CommentRecipientInput = {
4
+ recipientType: CommentRecipientType;
5
+ recipientId?: string | null;
6
+ };
7
+
8
+ export type PersistedCommentRecipient = {
9
+ recipientType: CommentRecipientType;
10
+ recipientId: string | null;
11
+ deliveryStatus: "pending" | "dispatched" | "failed" | "skipped";
12
+ dispatchedRunId: string | null;
13
+ dispatchedAt: string | null;
14
+ acknowledgedAt: string | null;
15
+ };
16
+
17
+ export function dedupeCommentRecipients(recipients: CommentRecipientInput[]) {
18
+ const result: Array<{ recipientType: CommentRecipientType; recipientId: string | null }> = [];
19
+ const seen = new Set<string>();
20
+ for (const recipient of recipients) {
21
+ const recipientId = recipient.recipientId?.trim() ? recipient.recipientId.trim() : null;
22
+ if (recipient.recipientType !== "board" && !recipientId) {
23
+ continue;
24
+ }
25
+ const key = `${recipient.recipientType}:${recipientId ?? "__all__"}`;
26
+ if (seen.has(key)) {
27
+ continue;
28
+ }
29
+ seen.add(key);
30
+ result.push({
31
+ recipientType: recipient.recipientType,
32
+ recipientId
33
+ });
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function normalizeRecipientsForPersistence(
39
+ recipients: Array<{ recipientType: CommentRecipientType; recipientId: string | null }>
40
+ ): PersistedCommentRecipient[] {
41
+ return recipients.map((recipient) => ({
42
+ recipientType: recipient.recipientType,
43
+ recipientId: recipient.recipientId ?? null,
44
+ // Non-agent recipients are terminal at creation time; they are not dispatch targets.
45
+ deliveryStatus: recipient.recipientType === "agent" ? "pending" : "skipped",
46
+ dispatchedRunId: null,
47
+ dispatchedAt: null,
48
+ acknowledgedAt: null
49
+ }));
50
+ }
51
+
52
+ export function parseIssueCommentRecipients(raw: string | null): PersistedCommentRecipient[] {
53
+ if (!raw) {
54
+ return [];
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(raw) as unknown;
58
+ if (!Array.isArray(parsed)) {
59
+ return [];
60
+ }
61
+ return parsed
62
+ .map((entry) => {
63
+ if (!entry || typeof entry !== "object") {
64
+ return null;
65
+ }
66
+ const candidate = entry as Record<string, unknown>;
67
+ const recipientTypeRaw = String(candidate.recipientType ?? "").trim();
68
+ if (recipientTypeRaw !== "agent" && recipientTypeRaw !== "board" && recipientTypeRaw !== "member") {
69
+ return null;
70
+ }
71
+ const deliveryStatusRaw = String(candidate.deliveryStatus ?? "").trim();
72
+ const deliveryStatus =
73
+ deliveryStatusRaw === "pending" ||
74
+ deliveryStatusRaw === "dispatched" ||
75
+ deliveryStatusRaw === "failed" ||
76
+ deliveryStatusRaw === "skipped"
77
+ ? deliveryStatusRaw
78
+ : "pending";
79
+ const recipientId =
80
+ typeof candidate.recipientId === "string" && candidate.recipientId.trim().length > 0
81
+ ? candidate.recipientId.trim()
82
+ : null;
83
+ return {
84
+ recipientType: recipientTypeRaw as CommentRecipientType,
85
+ recipientId,
86
+ deliveryStatus,
87
+ dispatchedRunId:
88
+ typeof candidate.dispatchedRunId === "string" && candidate.dispatchedRunId.trim().length > 0
89
+ ? candidate.dispatchedRunId.trim()
90
+ : null,
91
+ dispatchedAt:
92
+ typeof candidate.dispatchedAt === "string" && candidate.dispatchedAt.trim().length > 0
93
+ ? candidate.dispatchedAt.trim()
94
+ : null,
95
+ acknowledgedAt:
96
+ typeof candidate.acknowledgedAt === "string" && candidate.acknowledgedAt.trim().length > 0
97
+ ? candidate.acknowledgedAt.trim()
98
+ : null
99
+ } satisfies PersistedCommentRecipient;
100
+ })
101
+ .filter(Boolean) as PersistedCommentRecipient[];
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
@@ -2,6 +2,7 @@ type AgentLike = {
2
2
  id: string;
3
3
  name: string;
4
4
  role: string;
5
+ roleKey?: string | null;
5
6
  status: string;
6
7
  canHireAgents?: boolean | null;
7
8
  };
@@ -12,6 +13,7 @@ export type HiringDelegateResolution =
12
13
  agentId: string;
13
14
  name: string;
14
15
  role: string;
16
+ roleKey?: string | null;
15
17
  };
16
18
  reason: "ceo_with_hiring_capability" | "first_hiring_capable_agent";
17
19
  }
@@ -32,18 +34,16 @@ export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolu
32
34
  }
33
35
  const normalized = eligible.map((agent) => ({
34
36
  ...agent,
35
- normalizedRole: agent.role.trim().toLowerCase(),
36
37
  normalizedName: agent.name.trim().toLowerCase()
37
38
  }));
38
- const ceo =
39
- normalized.find((agent) => agent.normalizedRole === "ceo") ??
40
- normalized.find((agent) => agent.normalizedName === "ceo");
39
+ const ceo = normalized.find((agent) => agent.roleKey === "ceo") ?? normalized.find((agent) => agent.normalizedName === "ceo");
41
40
  if (ceo) {
42
41
  return {
43
42
  delegate: {
44
43
  agentId: ceo.id,
45
44
  name: ceo.name,
46
- role: ceo.role
45
+ role: ceo.role,
46
+ roleKey: ceo.roleKey ?? null
47
47
  },
48
48
  reason: "ceo_with_hiring_capability"
49
49
  };
@@ -53,7 +53,8 @@ export function resolveHiringDelegate(agents: AgentLike[]): HiringDelegateResolu
53
53
  delegate: {
54
54
  agentId: fallback.id,
55
55
  name: fallback.name,
56
- role: fallback.role
56
+ role: fallback.role,
57
+ roleKey: fallback.roleKey ?? null
57
58
  },
58
59
  reason: "first_hiring_capable_agent"
59
60
  };
@@ -78,6 +78,17 @@ export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
78
78
  return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
79
79
  }
80
80
 
81
+ export function resolveCompanyMemoryRootPath(companyId: string) {
82
+ const safeCompanyId = assertPathSegment(companyId, "companyId");
83
+ return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "memory");
84
+ }
85
+
86
+ export function resolveProjectMemoryRootPath(companyId: string, projectId: string) {
87
+ const safeCompanyId = assertPathSegment(companyId, "companyId");
88
+ const safeProjectId = assertPathSegment(projectId, "projectId");
89
+ return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "projects", safeProjectId, "memory");
90
+ }
91
+
81
92
  export function resolveAgentDurableMemoryPath(companyId: string, agentId: string) {
82
93
  return join(resolveAgentMemoryRootPath(companyId, agentId), "life");
83
94
  }
@@ -0,0 +1,47 @@
1
+ import type { RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
2
+ import type { BopoDb } from "bopodev-db";
3
+ import { listBoardAttentionItems } from "../services/attention-service";
4
+ import type { RealtimeHub } from "./hub";
5
+
6
+ const DEFAULT_ACTOR_ID = "local-board";
7
+
8
+ export async function loadAttentionRealtimeSnapshot(
9
+ db: BopoDb,
10
+ companyId: string
11
+ ): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
12
+ const items = await listBoardAttentionItems(db, companyId, DEFAULT_ACTOR_ID);
13
+ return createAttentionRealtimeEvent(companyId, {
14
+ type: "attention.snapshot",
15
+ items
16
+ });
17
+ }
18
+
19
+ export function createAttentionRealtimeEvent(
20
+ companyId: string,
21
+ event: Extract<RealtimeEventEnvelope, { channel: "attention" }>["event"]
22
+ ): Extract<RealtimeMessage, { kind: "event" }> {
23
+ return {
24
+ kind: "event",
25
+ companyId,
26
+ channel: "attention",
27
+ event
28
+ };
29
+ }
30
+
31
+ export async function publishAttentionSnapshot(
32
+ db: BopoDb,
33
+ realtimeHub: RealtimeHub | undefined,
34
+ companyId: string,
35
+ actorId = DEFAULT_ACTOR_ID
36
+ ) {
37
+ if (!realtimeHub) {
38
+ return;
39
+ }
40
+ const items = await listBoardAttentionItems(db, companyId, actorId);
41
+ realtimeHub.publish(
42
+ createAttentionRealtimeEvent(companyId, {
43
+ type: "attention.snapshot",
44
+ items
45
+ })
46
+ );
47
+ }
@@ -1,4 +1,10 @@
1
- import type { ApprovalRequest, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
1
+ import {
2
+ ApprovalActionSchema,
3
+ ApprovalRequestSchema,
4
+ type ApprovalRequest,
5
+ type RealtimeEventEnvelope,
6
+ type RealtimeMessage
7
+ } from "bopodev-contracts";
2
8
  import { listApprovalRequests, type BopoDb } from "bopodev-db";
3
9
 
4
10
  export async function loadGovernanceRealtimeSnapshot(db: BopoDb, companyId: string): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
@@ -33,13 +39,15 @@ export function serializeStoredApproval(approval: {
33
39
  createdAt: Date;
34
40
  resolvedAt: Date | null;
35
41
  }): ApprovalRequest {
42
+ const actionParsed = ApprovalActionSchema.safeParse(approval.action);
43
+ const statusParsed = ApprovalRequestSchema.shape.status.safeParse(approval.status);
36
44
  return {
37
45
  id: approval.id,
38
46
  companyId: approval.companyId,
39
47
  requestedByAgentId: approval.requestedByAgentId,
40
- action: approval.action as ApprovalRequest["action"],
48
+ action: actionParsed.success ? actionParsed.data : "override_budget",
41
49
  payload: parsePayload(approval.payloadJson),
42
- status: approval.status as ApprovalRequest["status"],
50
+ status: statusParsed.success ? statusParsed.data : "pending",
43
51
  createdAt: approval.createdAt.toISOString(),
44
52
  resolvedAt: approval.resolvedAt?.toISOString() ?? null
45
53
  };
@@ -1,8 +1,17 @@
1
1
  import type {
2
2
  HeartbeatRunRealtimeEvent,
3
+ HeartbeatRunTranscriptEventKind,
4
+ HeartbeatRunTranscriptSignalLevel,
5
+ HeartbeatRunTranscriptSource,
3
6
  RealtimeEventEnvelope,
4
7
  RealtimeMessage
5
8
  } from "bopodev-contracts";
9
+ import {
10
+ HeartbeatRunSchema,
11
+ HeartbeatRunTranscriptEventKindSchema,
12
+ HeartbeatRunTranscriptSignalLevelSchema,
13
+ HeartbeatRunTranscriptSourceSchema
14
+ } from "bopodev-contracts";
6
15
  import { listHeartbeatRunMessagesForRuns, listHeartbeatRuns, type BopoDb } from "bopodev-db";
7
16
 
8
17
  export function createHeartbeatRunsRealtimeEvent(
@@ -33,20 +42,13 @@ export async function loadHeartbeatRunsRealtimeSnapshot(
33
42
  id: message.id,
34
43
  runId: message.runId,
35
44
  sequence: message.sequence,
36
- kind: message.kind as
37
- | "system"
38
- | "assistant"
39
- | "thinking"
40
- | "tool_call"
41
- | "tool_result"
42
- | "result"
43
- | "stderr",
45
+ kind: parseTranscriptKind(message.kind),
44
46
  label: message.label,
45
47
  text: message.text,
46
48
  payload: message.payloadJson,
47
- signalLevel: (message.signalLevel as "high" | "medium" | "low" | "noise" | null) ?? undefined,
49
+ signalLevel: parseTranscriptSignalLevel(message.signalLevel),
48
50
  groupKey: message.groupKey,
49
- source: (message.source as "stdout" | "stderr" | "trace_fallback" | null) ?? undefined,
51
+ source: parseTranscriptSource(message.source),
50
52
  createdAt: message.createdAt.toISOString()
51
53
  })),
52
54
  nextCursor: result.nextCursor
@@ -57,7 +59,7 @@ export async function loadHeartbeatRunsRealtimeSnapshot(
57
59
  type: "runs.snapshot",
58
60
  runs: runs.map((run) => ({
59
61
  runId: run.id,
60
- status: run.status as "started" | "completed" | "failed" | "skipped",
62
+ status: parseRunStatus(run.status),
61
63
  message: run.message ?? null,
62
64
  startedAt: run.startedAt.toISOString(),
63
65
  finishedAt: run.finishedAt?.toISOString() ?? null
@@ -76,3 +78,23 @@ function createRealtimeEvent(
76
78
  ...envelope
77
79
  };
78
80
  }
81
+
82
+ function parseRunStatus(value: string) {
83
+ const parsed = HeartbeatRunSchema.shape.status.safeParse(value);
84
+ return parsed.success ? parsed.data : "failed";
85
+ }
86
+
87
+ function parseTranscriptKind(value: string): HeartbeatRunTranscriptEventKind {
88
+ const parsed = HeartbeatRunTranscriptEventKindSchema.safeParse(value);
89
+ return parsed.success ? parsed.data : "system";
90
+ }
91
+
92
+ function parseTranscriptSignalLevel(value: string | null): HeartbeatRunTranscriptSignalLevel | undefined {
93
+ const parsed = HeartbeatRunTranscriptSignalLevelSchema.safeParse(value);
94
+ return parsed.success ? parsed.data : undefined;
95
+ }
96
+
97
+ function parseTranscriptSource(value: string | null): HeartbeatRunTranscriptSource | undefined {
98
+ const parsed = HeartbeatRunTranscriptSourceSchema.safeParse(value);
99
+ return parsed.success ? parsed.data : undefined;
100
+ }
@@ -140,7 +140,7 @@ function canSubscribeToCompany(
140
140
  ) {
141
141
  const deploymentMode = resolveDeploymentMode();
142
142
  const tokenSecret = process.env.BOPO_AUTH_TOKEN_SECRET?.trim() || "";
143
- const tokenIdentity = tokenSecret ? verifyActorToken(readRealtimeTokenFromUrl(requestUrl), tokenSecret) : null;
143
+ const tokenIdentity = tokenSecret ? verifyActorToken(readRealtimeToken(requestUrl, headers), tokenSecret) : null;
144
144
  if (tokenIdentity) {
145
145
  if (tokenIdentity.type === "board") {
146
146
  return true;
@@ -169,7 +169,11 @@ function canSubscribeToCompany(
169
169
  return actorCompanies.includes(companyId);
170
170
  }
171
171
 
172
- function readRealtimeTokenFromUrl(requestUrl: string | undefined) {
172
+ function readRealtimeToken(requestUrl: string | undefined, headers: Record<string, string | string[] | undefined>) {
173
+ const fromProtocol = readRealtimeTokenFromSubprotocolHeader(readHeader(headers, "sec-websocket-protocol"));
174
+ if (fromProtocol) {
175
+ return fromProtocol;
176
+ }
173
177
  if (!requestUrl) {
174
178
  return null;
175
179
  }
@@ -178,6 +182,34 @@ function readRealtimeTokenFromUrl(requestUrl: string | undefined) {
178
182
  return token && token.length > 0 ? token : null;
179
183
  }
180
184
 
185
+ function readRealtimeTokenFromSubprotocolHeader(value: string | undefined) {
186
+ if (!value) {
187
+ return null;
188
+ }
189
+ const protocolEntries = value
190
+ .split(",")
191
+ .map((entry) => entry.trim())
192
+ .filter((entry) => entry.length > 0);
193
+ for (const protocol of protocolEntries) {
194
+ if (!protocol.startsWith("bopo-token.")) {
195
+ continue;
196
+ }
197
+ const encodedToken = protocol.slice("bopo-token.".length);
198
+ if (!encodedToken) {
199
+ continue;
200
+ }
201
+ try {
202
+ const decoded = decodeURIComponent(encodedToken).trim();
203
+ if (decoded) {
204
+ return decoded;
205
+ }
206
+ } catch {
207
+ continue;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
181
213
  function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
182
214
  const value = headers[name];
183
215
  if (Array.isArray(value)) {
@@ -1,5 +1,6 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import type { OfficeOccupant, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
3
+ import { AGENT_ROLE_LABELS, AgentRoleKeySchema } from "bopodev-contracts";
3
4
  import {
4
5
  agents,
5
6
  approvalRequests,
@@ -376,7 +377,7 @@ function deriveHireCandidateOccupant(approval: {
376
377
 
377
378
  const payload = parsePayload(approval.payloadJson);
378
379
  const name = typeof payload.name === "string" ? payload.name : "Pending hire";
379
- const role = typeof payload.role === "string" ? payload.role : null;
380
+ const role = resolvePayloadRoleLabel(payload);
380
381
  const providerType =
381
382
  typeof payload.providerType === "string" ? normalizeProviderType(payload.providerType) : null;
382
383
 
@@ -431,6 +432,7 @@ function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
431
432
  value === "codex" ||
432
433
  value === "cursor" ||
433
434
  value === "opencode" ||
435
+ value === "gemini_cli" ||
434
436
  value === "openai_api" ||
435
437
  value === "anthropic_api" ||
436
438
  value === "http" ||
@@ -446,6 +448,20 @@ function formatActionLabel(action: string) {
446
448
  .join(" ");
447
449
  }
448
450
 
451
+ function resolvePayloadRoleLabel(payload: Record<string, unknown>) {
452
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
453
+ if (title) {
454
+ return title;
455
+ }
456
+ const roleKeyValue = typeof payload.roleKey === "string" ? payload.roleKey.trim().toLowerCase() : "";
457
+ const parsedRoleKey = roleKeyValue ? AgentRoleKeySchema.safeParse(roleKeyValue) : null;
458
+ if (parsedRoleKey?.success) {
459
+ return AGENT_ROLE_LABELS[parsedRoleKey.data];
460
+ }
461
+ const role = typeof payload.role === "string" ? payload.role.trim() : "";
462
+ return role || null;
463
+ }
464
+
449
465
  function parsePayload(payloadJson: string): Record<string, unknown> {
450
466
  try {
451
467
  const parsed = JSON.parse(payloadJson) as unknown;