bopodev-api 0.1.12 → 0.1.13

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.
@@ -1,8 +1,17 @@
1
1
  import { Router } from "express";
2
- import { listAuditEvents, listCostEntries, listHeartbeatRuns } from "bopodev-db";
2
+ import {
3
+ getHeartbeatRun,
4
+ listAgents,
5
+ listAuditEvents,
6
+ listCostEntries,
7
+ listHeartbeatRunMessages,
8
+ listHeartbeatRuns,
9
+ listPluginRuns
10
+ } from "bopodev-db";
3
11
  import type { AppContext } from "../context";
4
- import { sendOk } from "../http";
12
+ import { sendError, sendOk } from "../http";
5
13
  import { requireCompanyScope } from "../middleware/company-scope";
14
+ import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
6
15
 
7
16
  export function createObservabilityRouter(ctx: AppContext) {
8
17
  const router = Router();
@@ -32,27 +41,189 @@ export function createObservabilityRouter(ctx: AppContext) {
32
41
 
33
42
  router.get("/heartbeats", async (req, res) => {
34
43
  const companyId = req.companyId!;
44
+ const rawLimit = Number(req.query.limit ?? 100);
45
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
46
+ const statusFilter = typeof req.query.status === "string" && req.query.status.trim().length > 0 ? req.query.status.trim() : null;
47
+ const agentFilter = typeof req.query.agentId === "string" && req.query.agentId.trim().length > 0 ? req.query.agentId.trim() : null;
35
48
  const [runs, auditRows] = await Promise.all([
36
- listHeartbeatRuns(ctx.db, companyId),
49
+ listHeartbeatRuns(ctx.db, companyId, limit),
37
50
  listAuditEvents(ctx.db, companyId)
38
51
  ]);
39
- const outcomeByRunId = new Map<string, unknown>();
40
- for (const row of auditRows) {
41
- if (
42
- row.entityType === "heartbeat_run" &&
43
- (row.eventType === "heartbeat.completed" || row.eventType === "heartbeat.failed")
44
- ) {
45
- const payload = parsePayload(row.payloadJson);
46
- if (payload && typeof payload === "object" && "outcome" in payload) {
47
- outcomeByRunId.set(row.entityId, (payload as Record<string, unknown>).outcome ?? null);
48
- }
52
+ const runDetailsByRunId = buildRunDetailsMap(auditRows);
53
+ return sendOk(
54
+ res,
55
+ runs
56
+ .filter((run) => (statusFilter ? run.status === statusFilter : true))
57
+ .filter((run) => (agentFilter ? run.agentId === agentFilter : true))
58
+ .map((run) => {
59
+ const details = runDetailsByRunId.get(run.id);
60
+ const outcome = details?.outcome ?? null;
61
+ return {
62
+ ...serializeRunRow(run, outcome),
63
+ outcome
64
+ };
65
+ })
66
+ );
67
+ });
68
+
69
+ router.get("/heartbeats/:runId", async (req, res) => {
70
+ const companyId = req.companyId!;
71
+ const runId = req.params.runId;
72
+ const [run, auditRows, transcriptResult] = await Promise.all([
73
+ getHeartbeatRun(ctx.db, companyId, runId),
74
+ listAuditEvents(ctx.db, companyId, 500),
75
+ listHeartbeatRunMessages(ctx.db, { companyId, runId, limit: 20 })
76
+ ]);
77
+ if (!run) {
78
+ return sendError(res, "Run not found", 404);
79
+ }
80
+ const runDetailsByRunId = buildRunDetailsMap(auditRows);
81
+ const details = runDetailsByRunId.get(runId) ?? null;
82
+ const trace = toRecord(details?.trace);
83
+ const traceTranscript = Array.isArray(trace?.transcript) ? trace.transcript : [];
84
+ return sendOk(res, {
85
+ run: serializeRunRow(run, details?.outcome ?? null),
86
+ details,
87
+ transcript: {
88
+ hasPersistedMessages: transcriptResult.items.length > 0,
89
+ fallbackFromTrace: transcriptResult.items.length === 0 && traceTranscript.length > 0,
90
+ truncated: traceTranscript.length >= 120
49
91
  }
92
+ });
93
+ });
94
+
95
+ router.get("/heartbeats/:runId/messages", async (req, res) => {
96
+ const companyId = req.companyId!;
97
+ const runId = req.params.runId;
98
+ const rawLimit = Number(req.query.limit ?? 200);
99
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 200;
100
+ const afterRaw = typeof req.query.cursor === "string" ? Number(req.query.cursor) : NaN;
101
+ const afterSequence = Number.isFinite(afterRaw) ? Math.floor(afterRaw) : undefined;
102
+ const signalOnly = req.query.signalOnly !== "false";
103
+ const requestedKinds =
104
+ typeof req.query.kinds === "string"
105
+ ? req.query.kinds
106
+ .split(",")
107
+ .map((value) => value.trim())
108
+ .filter(Boolean)
109
+ : [];
110
+ const allowedKinds = new Set(["system", "assistant", "thinking", "tool_call", "tool_result", "result", "stderr"]);
111
+ const kindFilter = requestedKinds.filter((kind) => allowedKinds.has(kind));
112
+ const [run, result] = await Promise.all([
113
+ getHeartbeatRun(ctx.db, companyId, runId),
114
+ listHeartbeatRunMessages(ctx.db, { companyId, runId, limit, afterSequence })
115
+ ]);
116
+ if (!run) {
117
+ return sendError(res, "Run not found", 404);
50
118
  }
119
+ const filteredItems = result.items
120
+ .filter((message) => (kindFilter.length > 0 ? kindFilter.includes(message.kind) : true))
121
+ .filter((message) => {
122
+ if (!signalOnly) {
123
+ return true;
124
+ }
125
+ if (message.kind === "tool_call" || message.kind === "tool_result" || message.kind === "result") {
126
+ return true;
127
+ }
128
+ return message.signalLevel === "high" || message.signalLevel === "medium";
129
+ });
130
+ const derivedItems = deriveRelevantMessagesFromRawTranscript(result.items, run, kindFilter, signalOnly);
131
+ const responseItems = [...filteredItems, ...derivedItems]
132
+ .sort((a, b) => a.sequence - b.sequence)
133
+ .filter((message, index, array) => {
134
+ const previous = array[index - 1];
135
+ if (!previous) {
136
+ return true;
137
+ }
138
+ return !(
139
+ previous.kind === message.kind &&
140
+ previous.text === message.text &&
141
+ previous.label === message.label &&
142
+ previous.sequence === message.sequence
143
+ );
144
+ });
145
+ return sendOk(res, {
146
+ runId,
147
+ items: responseItems.map((message) => ({
148
+ id: message.id,
149
+ companyId: message.companyId,
150
+ runId: message.runId,
151
+ sequence: message.sequence,
152
+ kind: message.kind,
153
+ label: message.label,
154
+ text: message.text,
155
+ payload: message.payloadJson,
156
+ signalLevel: message.signalLevel ?? undefined,
157
+ groupKey: message.groupKey,
158
+ source: message.source ?? undefined,
159
+ createdAt: message.createdAt.toISOString()
160
+ })),
161
+ nextCursor: result.nextCursor
162
+ });
163
+ });
164
+
165
+ router.get("/memory", async (req, res) => {
166
+ const companyId = req.companyId!;
167
+ const agentIdFilter = typeof req.query.agentId === "string" && req.query.agentId.trim() ? req.query.agentId.trim() : null;
168
+ const rawLimit = Number(req.query.limit ?? 100);
169
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
170
+ const agents = await listAgents(ctx.db, companyId);
171
+ const targetAgents = agentIdFilter ? agents.filter((agent) => agent.id === agentIdFilter) : agents;
172
+ const fileRows = await Promise.all(
173
+ targetAgents.map(async (agent) => ({
174
+ agentId: agent.id,
175
+ files: await listAgentMemoryFiles({
176
+ companyId,
177
+ agentId: agent.id,
178
+ maxFiles: limit
179
+ })
180
+ }))
181
+ );
182
+ const flattened = fileRows
183
+ .flatMap((row) =>
184
+ row.files.map((file) => ({
185
+ agentId: row.agentId,
186
+ relativePath: file.relativePath,
187
+ path: file.path
188
+ }))
189
+ )
190
+ .slice(0, limit);
191
+ return sendOk(res, {
192
+ items: flattened
193
+ });
194
+ });
195
+
196
+ router.get("/memory/:agentId/file", async (req, res) => {
197
+ const companyId = req.companyId!;
198
+ const agentId = req.params.agentId;
199
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
200
+ if (!relativePath) {
201
+ return sendError(res, "Query parameter 'path' is required.", 422);
202
+ }
203
+ try {
204
+ const file = await readAgentMemoryFile({
205
+ companyId,
206
+ agentId,
207
+ relativePath
208
+ });
209
+ return sendOk(res, file);
210
+ } catch (error) {
211
+ return sendError(res, String(error), 422);
212
+ }
213
+ });
214
+
215
+ router.get("/plugins/runs", async (req, res) => {
216
+ const companyId = req.companyId!;
217
+ const pluginId = typeof req.query.pluginId === "string" && req.query.pluginId.trim() ? req.query.pluginId.trim() : undefined;
218
+ const runId = typeof req.query.runId === "string" && req.query.runId.trim() ? req.query.runId.trim() : undefined;
219
+ const rawLimit = Number(req.query.limit ?? 200);
220
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 1000) : 200;
221
+ const rows = await listPluginRuns(ctx.db, { companyId, pluginId, runId, limit });
51
222
  return sendOk(
52
223
  res,
53
- runs.map((run) => ({
54
- ...run,
55
- outcome: outcomeByRunId.get(run.id) ?? null
224
+ rows.map((row) => ({
225
+ ...row,
226
+ diagnostics: parsePayload(row.diagnosticsJson)
56
227
  }))
57
228
  );
58
229
  });
@@ -60,11 +231,368 @@ export function createObservabilityRouter(ctx: AppContext) {
60
231
  return router;
61
232
  }
62
233
 
63
- function parsePayload(payloadJson: string) {
234
+ function parsePayload(payloadJson: string): Record<string, unknown> {
64
235
  try {
65
236
  const parsed = JSON.parse(payloadJson) as unknown;
66
- return typeof parsed === "object" && parsed !== null ? parsed : {};
237
+ return toRecord(parsed) ?? {};
67
238
  } catch {
68
239
  return {};
69
240
  }
70
241
  }
242
+
243
+ function toRecord(value: unknown) {
244
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
245
+ }
246
+
247
+ function serializeRunRow(
248
+ run: {
249
+ id: string;
250
+ companyId: string;
251
+ agentId: string;
252
+ status: string;
253
+ startedAt: Date;
254
+ finishedAt: Date | null;
255
+ message: string | null;
256
+ },
257
+ outcome: unknown
258
+ ) {
259
+ const runType = resolveRunType(run, outcome);
260
+ return {
261
+ id: run.id,
262
+ companyId: run.companyId,
263
+ agentId: run.agentId,
264
+ status: run.status,
265
+ startedAt: run.startedAt.toISOString(),
266
+ finishedAt: run.finishedAt?.toISOString() ?? null,
267
+ message: run.message ?? null,
268
+ runType
269
+ };
270
+ }
271
+
272
+ function resolveRunType(
273
+ run: {
274
+ status: string;
275
+ message: string | null;
276
+ },
277
+ outcome: unknown
278
+ ): "work" | "no_assigned_work" | "budget_skip" | "overlap_skip" | "other_skip" | "failed" | "running" {
279
+ if (run.status === "started") {
280
+ return "running";
281
+ }
282
+ if (run.status === "failed") {
283
+ return "failed";
284
+ }
285
+ const normalizedMessage = (run.message ?? "").toLowerCase();
286
+ if (normalizedMessage.includes("already in progress")) {
287
+ return "overlap_skip";
288
+ }
289
+ if (normalizedMessage.includes("budget hard-stop")) {
290
+ return "budget_skip";
291
+ }
292
+ if (isNoAssignedWorkMessage(run.message)) {
293
+ return "no_assigned_work";
294
+ }
295
+ if (isNoAssignedWorkOutcome(outcome)) {
296
+ return "no_assigned_work";
297
+ }
298
+ if (run.status === "skipped") {
299
+ return "other_skip";
300
+ }
301
+ return "work";
302
+ }
303
+
304
+ function isNoAssignedWorkMessage(message: string | null) {
305
+ return /\bno assigned work found\b/i.test(message ?? "");
306
+ }
307
+
308
+ function isNoAssignedWorkOutcome(outcome: unknown) {
309
+ const record = toRecord(outcome);
310
+ if (!record) {
311
+ return false;
312
+ }
313
+ if (record.kind !== "skipped") {
314
+ return false;
315
+ }
316
+ const issueIdsTouched = Array.isArray(record.issueIdsTouched)
317
+ ? record.issueIdsTouched.filter((value) => typeof value === "string")
318
+ : [];
319
+ if (issueIdsTouched.length === 0) {
320
+ return true;
321
+ }
322
+ const actions = Array.isArray(record.actions)
323
+ ? record.actions.filter((value) => typeof value === "object" && value !== null)
324
+ : [];
325
+ return actions.some((action) => {
326
+ const parsed = action as Record<string, unknown>;
327
+ return parsed.type === "heartbeat.skip";
328
+ });
329
+ }
330
+
331
+ function buildRunDetailsMap(
332
+ auditRows: Array<{
333
+ entityType: string;
334
+ eventType: string;
335
+ entityId: string;
336
+ payloadJson: string;
337
+ createdAt: Date;
338
+ }>
339
+ ) {
340
+ const detailsByRunId = new Map<string, Record<string, unknown>>();
341
+ const relevantRows = auditRows
342
+ .filter(
343
+ (row) =>
344
+ row.entityType === "heartbeat_run" &&
345
+ (row.eventType === "heartbeat.completed" || row.eventType === "heartbeat.failed")
346
+ )
347
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
348
+ for (const row of relevantRows) {
349
+ if (detailsByRunId.has(row.entityId)) {
350
+ continue;
351
+ }
352
+ detailsByRunId.set(row.entityId, parsePayload(row.payloadJson));
353
+ }
354
+ return detailsByRunId;
355
+ }
356
+
357
+ function deriveRelevantMessagesFromRawTranscript(
358
+ items: Array<{
359
+ id: string;
360
+ companyId: string;
361
+ runId: string;
362
+ sequence: number;
363
+ kind: string;
364
+ label: string | null;
365
+ text: string | null;
366
+ payloadJson: string | null;
367
+ signalLevel: string | null;
368
+ groupKey: string | null;
369
+ source: string | null;
370
+ createdAt: Date;
371
+ }>,
372
+ run: {
373
+ id: string;
374
+ companyId: string;
375
+ status: string;
376
+ message: string | null;
377
+ finishedAt: Date | null;
378
+ },
379
+ kindFilter: string[],
380
+ signalOnly: boolean
381
+ ) {
382
+ const derived: Array<{
383
+ id: string;
384
+ companyId: string;
385
+ runId: string;
386
+ sequence: number;
387
+ kind: "assistant" | "tool_call" | "tool_result" | "result";
388
+ label: string | null;
389
+ text: string | null;
390
+ payloadJson: string | null;
391
+ signalLevel: "high" | "medium";
392
+ groupKey: string | null;
393
+ source: "stderr";
394
+ createdAt: Date;
395
+ }> = [];
396
+
397
+ let inPromptBlock = true;
398
+ let assistantAfterCodex = false;
399
+ let pendingTool:
400
+ | {
401
+ sequence: number;
402
+ createdAt: Date;
403
+ command: string;
404
+ }
405
+ | undefined;
406
+ let pendingResult:
407
+ | {
408
+ sequence: number;
409
+ createdAt: Date;
410
+ command: string;
411
+ statusLine: string;
412
+ output: string[];
413
+ }
414
+ | undefined;
415
+
416
+ const flushPendingResult = () => {
417
+ if (!pendingResult) {
418
+ return;
419
+ }
420
+ const body = [pendingResult.statusLine, ...(pendingResult.output.length > 0 ? ["", ...pendingResult.output] : [])]
421
+ .join("\n")
422
+ .trim();
423
+ derived.push({
424
+ id: `derived-${run.id}-${pendingResult.sequence}-result`,
425
+ companyId: run.companyId,
426
+ runId: run.id,
427
+ sequence: pendingResult.sequence * 10 + 1,
428
+ kind: "tool_result",
429
+ label: pendingResult.command,
430
+ text: body || pendingResult.command,
431
+ payloadJson: null,
432
+ signalLevel: "high",
433
+ groupKey: `tool:${pendingResult.command}`,
434
+ source: "stderr",
435
+ createdAt: pendingResult.createdAt
436
+ });
437
+ pendingResult = undefined;
438
+ };
439
+
440
+ for (const item of items) {
441
+ const text = (item.text ?? "").trim();
442
+ if (!text) {
443
+ continue;
444
+ }
445
+ if (inPromptBlock) {
446
+ if (text === "mcp startup: no servers" || text === "codex") {
447
+ inPromptBlock = false;
448
+ } else {
449
+ continue;
450
+ }
451
+ }
452
+
453
+ if (text === "codex") {
454
+ flushPendingResult();
455
+ assistantAfterCodex = true;
456
+ continue;
457
+ }
458
+ if (text === "exec") {
459
+ flushPendingResult();
460
+ assistantAfterCodex = false;
461
+ continue;
462
+ }
463
+
464
+ const inlineCommandMatch = /^(\/bin\/.+?) in .+? (succeeded|failed|exited .+?):$/i.exec(text);
465
+ if (inlineCommandMatch) {
466
+ const command = inlineCommandMatch[1]!.trim();
467
+ derived.push({
468
+ id: `derived-${run.id}-${item.sequence}-call`,
469
+ companyId: run.companyId,
470
+ runId: run.id,
471
+ sequence: item.sequence * 10,
472
+ kind: "tool_call",
473
+ label: "command_execution",
474
+ text: command,
475
+ payloadJson: JSON.stringify({ command }),
476
+ signalLevel: "high",
477
+ groupKey: `tool:${command}`,
478
+ source: "stderr",
479
+ createdAt: item.createdAt
480
+ });
481
+ pendingResult = {
482
+ sequence: item.sequence,
483
+ createdAt: item.createdAt,
484
+ command,
485
+ statusLine: text.slice(command.length).trim(),
486
+ output: []
487
+ };
488
+ assistantAfterCodex = false;
489
+ continue;
490
+ }
491
+
492
+ if (text.startsWith("/bin/")) {
493
+ flushPendingResult();
494
+ pendingTool = {
495
+ sequence: item.sequence,
496
+ createdAt: item.createdAt,
497
+ command: text
498
+ };
499
+ derived.push({
500
+ id: `derived-${run.id}-${item.sequence}-call`,
501
+ companyId: run.companyId,
502
+ runId: run.id,
503
+ sequence: item.sequence * 10,
504
+ kind: "tool_call",
505
+ label: "command_execution",
506
+ text,
507
+ payloadJson: JSON.stringify({ command: text }),
508
+ signalLevel: "high",
509
+ groupKey: `tool:${text}`,
510
+ source: "stderr",
511
+ createdAt: item.createdAt
512
+ });
513
+ assistantAfterCodex = false;
514
+ continue;
515
+ }
516
+
517
+ if (/^(succeeded in \d+ms:|failed in \d+ms:|exited \d+ in \d+ms:)$/i.test(text) && pendingTool) {
518
+ pendingResult = {
519
+ sequence: pendingTool.sequence,
520
+ createdAt: pendingTool.createdAt,
521
+ command: pendingTool.command,
522
+ statusLine: text,
523
+ output: []
524
+ };
525
+ pendingTool = undefined;
526
+ continue;
527
+ }
528
+
529
+ if (pendingResult) {
530
+ if (text === "---" || text === "--------") {
531
+ continue;
532
+ }
533
+ pendingResult.output.push(text);
534
+ continue;
535
+ }
536
+
537
+ if (assistantAfterCodex && looksLikeUsefulAssistantText(text)) {
538
+ derived.push({
539
+ id: `derived-${run.id}-${item.sequence}-assistant`,
540
+ companyId: run.companyId,
541
+ runId: run.id,
542
+ sequence: item.sequence * 10,
543
+ kind: "assistant",
544
+ label: null,
545
+ text,
546
+ payloadJson: null,
547
+ signalLevel: "medium",
548
+ groupKey: "assistant",
549
+ source: "stderr",
550
+ createdAt: item.createdAt
551
+ });
552
+ assistantAfterCodex = false;
553
+ }
554
+ }
555
+
556
+ flushPendingResult();
557
+
558
+ const runMessage = run.message?.trim();
559
+ const shouldAppendRunSummary = Boolean(runMessage) && run.status !== "started";
560
+ if (!derived.some((item) => item.kind === "result") && shouldAppendRunSummary && runMessage) {
561
+ derived.push({
562
+ id: `derived-${run.id}-final-result`,
563
+ companyId: run.companyId,
564
+ runId: run.id,
565
+ sequence: (items[items.length - 1]?.sequence ?? 0) * 10 + 9,
566
+ kind: "result",
567
+ label: null,
568
+ text: runMessage,
569
+ payloadJson: null,
570
+ signalLevel: "high",
571
+ groupKey: "result",
572
+ source: "stderr",
573
+ createdAt: run.finishedAt ?? items[items.length - 1]?.createdAt ?? new Date()
574
+ });
575
+ }
576
+
577
+ return derived
578
+ .filter((item) => (kindFilter.length > 0 ? kindFilter.includes(item.kind) : true))
579
+ .filter(() => (signalOnly ? true : true));
580
+ }
581
+
582
+ function looksLikeUsefulAssistantText(text: string) {
583
+ const normalized = text.toLowerCase();
584
+ if (normalized.length > 320) {
585
+ return false;
586
+ }
587
+ return (
588
+ normalized.includes("i’m ") ||
589
+ normalized.includes("i'm ") ||
590
+ normalized.includes("next i") ||
591
+ normalized.includes("using `") ||
592
+ normalized.includes("switching to") ||
593
+ normalized.includes("the workspace already") ||
594
+ normalized.includes("i have the") ||
595
+ normalized.includes("i still need") ||
596
+ normalized.includes("restoring")
597
+ );
598
+ }