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.
@@ -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 { runHeartbeatForAgent, runHeartbeatSweep, stopHeartbeatRun } from "../services/heartbeat-service";
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 runId = await runHeartbeatForAgent(ctx.db, req.companyId!, parsed.data.agentId, {
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: invokeStatus,
66
- message: runRow?.message ?? null
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 nextRunId = await runHeartbeatForAgent(ctx.db, input.companyId, run.agentId, {
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
- trigger: "manual",
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: nextRunId,
154
- status: invokeStatus,
155
- message: runRow?.message ?? null
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
  }
@@ -1,9 +1,10 @@
1
1
  import { Router } from "express";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, join, resolve } from "node:path";
4
- import { and, eq } from "drizzle-orm";
4
+ import { and, desc, eq, inArray } from "drizzle-orm";
5
5
  import multer from "multer";
6
6
  import { z } from "zod";
7
+ import { IssueSchema } from "bopodev-contracts";
7
8
  import {
8
9
  addIssueAttachment,
9
10
  addIssueComment,
@@ -14,6 +15,7 @@ import {
14
15
  deleteIssueAttachment,
15
16
  deleteIssueComment,
16
17
  deleteIssue,
18
+ heartbeatRuns,
17
19
  getIssueAttachment,
18
20
  issues,
19
21
  listIssueAttachments,
@@ -27,10 +29,18 @@ import {
27
29
  } from "bopodev-db";
28
30
  import { nanoid } from "nanoid";
29
31
  import type { AppContext } from "../context";
30
- import { sendError, sendOk } from "../http";
32
+ import { sendError, sendOk, sendOkValidated } from "../http";
33
+ import {
34
+ dedupeCommentRecipients,
35
+ normalizeRecipientsForPersistence,
36
+ type CommentRecipientInput,
37
+ type PersistedCommentRecipient
38
+ } from "../lib/comment-recipients";
31
39
  import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
32
40
  import { requireCompanyScope } from "../middleware/company-scope";
33
41
  import { requirePermission } from "../middleware/request-actor";
42
+ import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
43
+ import { publishAttentionSnapshot } from "../realtime/attention";
34
44
 
35
45
  const createIssueSchema = z.object({
36
46
  projectId: z.string().min(1),
@@ -43,6 +53,8 @@ const createIssueSchema = z.object({
43
53
  .object({
44
54
  intentType: z.literal("agent_hiring_request"),
45
55
  requestedRole: z.string().nullable().optional(),
56
+ requestedRoleKey: z.string().nullable().optional(),
57
+ requestedTitle: z.string().nullable().optional(),
46
58
  requestedName: z.string().nullable().optional(),
47
59
  requestedManagerAgentId: z.string().nullable().optional(),
48
60
  requestedProviderType: z.string().nullable().optional(),
@@ -60,6 +72,14 @@ const createIssueSchema = z.object({
60
72
 
61
73
  const createIssueCommentSchema = z.object({
62
74
  body: z.string().min(1),
75
+ recipients: z
76
+ .array(
77
+ z.object({
78
+ recipientType: z.enum(["agent", "board", "member"]),
79
+ recipientId: z.string().nullable().optional()
80
+ })
81
+ )
82
+ .default([]),
63
83
  authorType: z.enum(["human", "agent", "system"]).optional(),
64
84
  authorId: z.string().optional()
65
85
  });
@@ -67,6 +87,14 @@ const createIssueCommentSchema = z.object({
67
87
  const createIssueCommentLegacySchema = z.object({
68
88
  issueId: z.string().min(1),
69
89
  body: z.string().min(1),
90
+ recipients: z
91
+ .array(
92
+ z.object({
93
+ recipientType: z.enum(["agent", "board", "member"]),
94
+ recipientId: z.string().nullable().optional()
95
+ })
96
+ )
97
+ .default([]),
70
98
  authorType: z.enum(["human", "agent", "system"]).optional(),
71
99
  authorId: z.string().optional()
72
100
  });
@@ -99,6 +127,7 @@ const ALLOWED_ATTACHMENT_EXTENSIONS = parseCsvSet(
99
127
  );
100
128
 
101
129
  type IssueAttachmentResponse = Record<string, unknown> & { id: string; downloadPath: string };
130
+ const COMMENT_RUN_ID_HEADER = "x-bopodev-run-id";
102
131
 
103
132
  function parseStringArray(value: unknown) {
104
133
  if (Array.isArray(value)) {
@@ -153,9 +182,11 @@ export function createIssuesRouter(ctx: AppContext) {
153
182
  router.get("/", async (req, res) => {
154
183
  const projectId = req.query.projectId?.toString();
155
184
  const rows = await listIssues(ctx.db, req.companyId!, projectId);
156
- return sendOk(
185
+ return sendOkValidated(
157
186
  res,
158
- rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>))
187
+ IssueSchema.array(),
188
+ rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>)),
189
+ "issues.list"
159
190
  );
160
191
  });
161
192
 
@@ -427,32 +458,13 @@ export function createIssuesRouter(ctx: AppContext) {
427
458
  if (!parsed.success) {
428
459
  return sendError(res, parsed.error.message, 422);
429
460
  }
430
- const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
431
- const comment = await addIssueComment(ctx.db, {
432
- companyId: req.companyId!,
461
+ return createIssueCommentWithRecipients(ctx, req, res, {
433
462
  issueId: req.params.issueId,
434
463
  body: parsed.data.body,
435
- authorType: author.authorType,
436
- authorId: author.authorId
437
- });
438
- await appendActivity(ctx.db, {
439
- companyId: req.companyId!,
440
- issueId: comment.issueId,
441
- actorType: comment.authorType,
442
- actorId: comment.authorId,
443
- eventType: "issue.comment_added",
444
- payload: { commentId: comment.id }
445
- });
446
- await appendAuditEvent(ctx.db, {
447
- companyId: req.companyId!,
448
- actorType: comment.authorType,
449
- actorId: comment.authorId,
450
- eventType: "issue.comment_added",
451
- entityType: "issue_comment",
452
- entityId: comment.id,
453
- payload: comment
464
+ recipients: parsed.data.recipients,
465
+ authorType: parsed.data.authorType,
466
+ authorId: parsed.data.authorId
454
467
  });
455
- return sendOk(res, comment);
456
468
  });
457
469
 
458
470
  // Backward-compatible endpoint used by older clients.
@@ -465,32 +477,13 @@ export function createIssuesRouter(ctx: AppContext) {
465
477
  if (!parsed.success) {
466
478
  return sendError(res, parsed.error.message, 422);
467
479
  }
468
- const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
469
- const comment = await addIssueComment(ctx.db, {
470
- companyId: req.companyId!,
480
+ return createIssueCommentWithRecipients(ctx, req, res, {
471
481
  issueId: parsed.data.issueId,
472
482
  body: parsed.data.body,
473
- authorType: author.authorType,
474
- authorId: author.authorId
475
- });
476
- await appendActivity(ctx.db, {
477
- companyId: req.companyId!,
478
- issueId: comment.issueId,
479
- actorType: comment.authorType,
480
- actorId: comment.authorId,
481
- eventType: "issue.comment_added",
482
- payload: { commentId: comment.id }
483
+ recipients: parsed.data.recipients,
484
+ authorType: parsed.data.authorType,
485
+ authorId: parsed.data.authorId
483
486
  });
484
- await appendAuditEvent(ctx.db, {
485
- companyId: req.companyId!,
486
- actorType: comment.authorType,
487
- actorId: comment.authorId,
488
- eventType: "issue.comment_added",
489
- entityType: "issue_comment",
490
- entityId: comment.id,
491
- payload: comment
492
- });
493
- return sendOk(res, comment);
494
487
  });
495
488
 
496
489
  router.put("/:issueId/comments/:commentId", async (req, res) => {
@@ -503,11 +496,16 @@ export function createIssuesRouter(ctx: AppContext) {
503
496
  return sendError(res, parsed.error.message, 422);
504
497
  }
505
498
 
499
+ const body =
500
+ req.actor?.type === "agent" ? sanitizeCommentBodyForAuthor(parsed.data.body, "agent") : parsed.data.body;
501
+ if (req.actor?.type === "agent" && !body) {
502
+ return sendError(res, "Agent comments must include non-emoji text.", 422);
503
+ }
506
504
  const comment = await updateIssueComment(ctx.db, {
507
505
  companyId: req.companyId!,
508
506
  issueId: req.params.issueId,
509
507
  id: req.params.commentId,
510
- body: parsed.data.body
508
+ body
511
509
  });
512
510
  if (!comment) {
513
511
  return sendError(res, "Comment not found.", 404);
@@ -516,13 +514,15 @@ export function createIssuesRouter(ctx: AppContext) {
516
514
  await appendActivity(ctx.db, {
517
515
  companyId: req.companyId!,
518
516
  issueId: req.params.issueId,
519
- actorType: "human",
517
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
518
+ actorId: req.actor?.id,
520
519
  eventType: "issue.comment_updated",
521
520
  payload: { commentId: comment.id }
522
521
  });
523
522
  await appendAuditEvent(ctx.db, {
524
523
  companyId: req.companyId!,
525
- actorType: "human",
524
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
525
+ actorId: req.actor?.id,
526
526
  eventType: "issue.comment_updated",
527
527
  entityType: "issue_comment",
528
528
  entityId: comment.id,
@@ -544,13 +544,15 @@ export function createIssuesRouter(ctx: AppContext) {
544
544
  await appendActivity(ctx.db, {
545
545
  companyId: req.companyId!,
546
546
  issueId: req.params.issueId,
547
- actorType: "human",
547
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
548
+ actorId: req.actor?.id,
548
549
  eventType: "issue.comment_deleted",
549
550
  payload: { commentId: req.params.commentId }
550
551
  });
551
552
  await appendAuditEvent(ctx.db, {
552
553
  companyId: req.companyId!,
553
- actorType: "human",
554
+ actorType: req.actor?.type === "agent" ? "agent" : "human",
555
+ actorId: req.actor?.id,
554
556
  eventType: "issue.comment_deleted",
555
557
  entityType: "issue_comment",
556
558
  entityId: req.params.commentId,
@@ -680,20 +682,249 @@ function parsePayload(payloadJson: string) {
680
682
  }
681
683
  }
682
684
 
685
+ async function createIssueCommentWithRecipients(
686
+ ctx: AppContext,
687
+ req: {
688
+ companyId?: string;
689
+ actor?: { type?: "board" | "member" | "agent"; id?: string };
690
+ requestId?: string;
691
+ header(name: string): string | undefined;
692
+ },
693
+ res: Parameters<typeof sendOk>[0],
694
+ input: {
695
+ issueId: string;
696
+ body: string;
697
+ recipients: CommentRecipientInput[];
698
+ authorType?: "human" | "agent" | "system";
699
+ authorId?: string;
700
+ }
701
+ ) {
702
+ const spoofValidationError = validateCommentAuthorInput(req.actor?.type, req.actor?.id, {
703
+ authorType: input.authorType,
704
+ authorId: input.authorId
705
+ });
706
+ if (spoofValidationError) {
707
+ return sendError(res, spoofValidationError, 422);
708
+ }
709
+ const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, {
710
+ authorType: input.authorType,
711
+ authorId: input.authorId
712
+ });
713
+ let normalizedRecipients: PersistedCommentRecipient[];
714
+ try {
715
+ normalizedRecipients = await normalizeCommentRecipients(ctx, req.companyId!, input.recipients);
716
+ } catch (error) {
717
+ return sendError(res, error instanceof Error ? error.message : "Invalid recipients.", 422);
718
+ }
719
+ const runId = await resolveIssueCommentRunId(ctx, {
720
+ companyId: req.companyId!,
721
+ actorType: req.actor?.type,
722
+ actorId: req.actor?.id,
723
+ runIdHeader: req.header(COMMENT_RUN_ID_HEADER)
724
+ });
725
+ const sanitizedBody = sanitizeCommentBodyForAuthor(input.body, author.authorType);
726
+ if (!sanitizedBody) {
727
+ return sendError(res, "Agent/system comments must include non-emoji text.", 422);
728
+ }
729
+ const comment = await addIssueComment(ctx.db, {
730
+ companyId: req.companyId!,
731
+ issueId: input.issueId,
732
+ body: sanitizedBody,
733
+ authorType: author.authorType,
734
+ authorId: author.authorId,
735
+ runId,
736
+ recipients: normalizedRecipients
737
+ });
738
+ await appendActivity(ctx.db, {
739
+ companyId: req.companyId!,
740
+ issueId: comment.issueId,
741
+ actorType: coerceActorType(comment.authorType),
742
+ actorId: comment.authorId,
743
+ eventType: "issue.comment_added",
744
+ payload: {
745
+ commentId: comment.id,
746
+ runId: comment.runId ?? null,
747
+ recipientCount: normalizedRecipients.length
748
+ }
749
+ });
750
+ await appendAuditEvent(ctx.db, {
751
+ companyId: req.companyId!,
752
+ actorType: coerceActorType(comment.authorType),
753
+ actorId: comment.authorId,
754
+ eventType: "issue.comment_added",
755
+ entityType: "issue_comment",
756
+ entityId: comment.id,
757
+ payload: comment
758
+ });
759
+ if (normalizedRecipients.some((recipient) => recipient.recipientType === "board")) {
760
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
761
+ }
762
+ triggerIssueCommentDispatchWorker(ctx.db, req.companyId!, {
763
+ requestId: req.requestId,
764
+ realtimeHub: ctx.realtimeHub,
765
+ limit: 10
766
+ });
767
+ return sendOk(res, comment);
768
+ }
769
+
683
770
  function resolveIssueCommentAuthor(
684
771
  actorType: "board" | "member" | "agent" | undefined,
685
772
  actorId: string | undefined,
686
773
  input: { authorType?: "human" | "agent" | "system"; authorId?: string }
687
774
  ) {
688
- const inferredAuthorType = actorType === "agent" ? "agent" : "human";
689
- const authorType = input.authorType ?? inferredAuthorType;
690
- if (input.authorId) {
691
- return { authorType, authorId: input.authorId };
775
+ if ((actorType === "board" || actorType === undefined) && (input.authorType || input.authorId)) {
776
+ return {
777
+ authorType: input.authorType ?? "human",
778
+ authorId: input.authorId
779
+ };
780
+ }
781
+ if (actorType === "agent") {
782
+ return { authorType: "agent" as const, authorId: actorId };
783
+ }
784
+ return { authorType: "human" as const, authorId: actorId };
785
+ }
786
+
787
+ function validateCommentAuthorInput(
788
+ actorType: "board" | "member" | "agent" | undefined,
789
+ actorId: string | undefined,
790
+ input: { authorType?: "human" | "agent" | "system"; authorId?: string }
791
+ ) {
792
+ if (!input.authorType && !input.authorId) {
793
+ return null;
794
+ }
795
+ if (actorType !== "member" && actorType !== "agent") {
796
+ // Board/default channels are trusted for migration/backfill and may set explicit authorship.
797
+ return null;
798
+ }
799
+ const expectedAuthorType = actorType === "agent" ? "agent" : "human";
800
+ if (input.authorType && input.authorType !== expectedAuthorType) {
801
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
802
+ }
803
+ if (input.authorId && actorId && input.authorId !== actorId) {
804
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
805
+ }
806
+ if (input.authorId && !actorId) {
807
+ return "Comment author fields are derived from actor identity and cannot be overridden.";
808
+ }
809
+ return null;
810
+ }
811
+
812
+ function coerceActorType(value: string | null | undefined): "human" | "agent" | "system" {
813
+ if (value === "agent" || value === "system") {
814
+ return value;
815
+ }
816
+ return "human";
817
+ }
818
+
819
+ function sanitizeCommentBodyForAuthor(body: string, authorType: "human" | "agent" | "system") {
820
+ if (authorType === "human") {
821
+ return body;
822
+ }
823
+ const withoutEmoji = body.replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, "");
824
+ const trimmed = withoutEmoji.trim();
825
+ const extractedSummary = extractSummaryFromJsonLikeText(trimmed);
826
+ const isPureJsonLike =
827
+ /^\s*\{[\s\S]*\}\s*$/m.test(trimmed) || /^\s*```(?:json)?[\s\S]*```\s*$/im.test(trimmed);
828
+ if (isPureJsonLike && extractedSummary) {
829
+ return extractedSummary;
830
+ }
831
+ const trailingJsonSummary = trimmed.match(/^(?<main>[\s\S]*?)\n+\{[\s\S]*"summary"\s*:\s*"[\s\S]*?"[\s\S]*\}\s*$/);
832
+ if (trailingJsonSummary?.groups?.main) {
833
+ return trailingJsonSummary.groups.main.trim();
834
+ }
835
+ return trimmed;
836
+ }
837
+
838
+ function extractSummaryFromJsonLikeText(input: string) {
839
+ const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
840
+ const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
841
+ if (!candidate) {
842
+ return null;
843
+ }
844
+ try {
845
+ const parsed = JSON.parse(candidate) as Record<string, unknown>;
846
+ const summary = parsed.summary;
847
+ if (typeof summary === "string" && summary.trim().length > 0) {
848
+ return summary.trim();
849
+ }
850
+ } catch {
851
+ // Fall through to regex extraction for loosely-formatted JSON.
852
+ }
853
+ const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
854
+ const summary = summaryMatch?.[1]
855
+ ?.replace(/\\"/g, "\"")
856
+ .replace(/\\n/g, " ")
857
+ .replace(/\s+/g, " ")
858
+ .trim();
859
+ return summary && summary.length > 0 ? summary : null;
860
+ }
861
+
862
+ async function resolveIssueCommentRunId(
863
+ ctx: AppContext,
864
+ input: {
865
+ companyId: string;
866
+ actorType: "board" | "member" | "agent" | undefined;
867
+ actorId: string | undefined;
868
+ runIdHeader: string | undefined;
869
+ }
870
+ ) {
871
+ const runId = input.runIdHeader?.trim();
872
+ if (runId) {
873
+ const [run] = await ctx.db
874
+ .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId })
875
+ .from(heartbeatRuns)
876
+ .where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, runId)))
877
+ .limit(1);
878
+ if (!run) {
879
+ return null;
880
+ }
881
+ if (input.actorType === "agent" && input.actorId && run.agentId !== input.actorId) {
882
+ return null;
883
+ }
884
+ return run.id;
885
+ }
886
+ if (input.actorType !== "agent" || !input.actorId) {
887
+ return null;
888
+ }
889
+ const [latestRun] = await ctx.db
890
+ .select({ id: heartbeatRuns.id })
891
+ .from(heartbeatRuns)
892
+ .where(
893
+ and(
894
+ eq(heartbeatRuns.companyId, input.companyId),
895
+ eq(heartbeatRuns.agentId, input.actorId),
896
+ eq(heartbeatRuns.status, "started")
897
+ )
898
+ )
899
+ .orderBy(desc(heartbeatRuns.startedAt))
900
+ .limit(1);
901
+ return latestRun?.id ?? null;
902
+ }
903
+
904
+ async function normalizeCommentRecipients(
905
+ ctx: AppContext,
906
+ companyId: string,
907
+ recipients: CommentRecipientInput[]
908
+ ): Promise<PersistedCommentRecipient[]> {
909
+ if (recipients.length === 0) {
910
+ return [] as PersistedCommentRecipient[];
692
911
  }
693
- if (authorType === "agent" && actorId) {
694
- return { authorType, authorId: actorId };
912
+ const deduped = dedupeCommentRecipients(recipients);
913
+ const agentIds = deduped
914
+ .filter((recipient) => recipient.recipientType === "agent" && recipient.recipientId)
915
+ .map((recipient) => recipient.recipientId as string);
916
+ if (agentIds.length > 0) {
917
+ const existingAgents = await ctx.db
918
+ .select({ id: agents.id })
919
+ .from(agents)
920
+ .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)));
921
+ const existingAgentIds = new Set(existingAgents.map((agent) => agent.id));
922
+ const missing = agentIds.find((id) => !existingAgentIds.has(id));
923
+ if (missing) {
924
+ throw new Error(`Recipient agent not found: ${missing}`);
925
+ }
695
926
  }
696
- return { authorType, authorId: undefined };
927
+ return normalizeRecipientsForPersistence(deduped);
697
928
  }
698
929
 
699
930
  async function validateIssueAssignmentScope(