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/issues.ts
CHANGED
|
@@ -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
|
|
185
|
+
return sendOkValidated(
|
|
157
186
|
res,
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
|
927
|
+
return normalizeRecipientsForPersistence(deduped);
|
|
697
928
|
}
|
|
698
929
|
|
|
699
930
|
async function validateIssueAssignmentScope(
|
|
@@ -2,9 +2,11 @@ import { Router } from "express";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
4
|
getHeartbeatRun,
|
|
5
|
+
listCompanies,
|
|
5
6
|
listAgents,
|
|
6
7
|
listAuditEvents,
|
|
7
8
|
listCostEntries,
|
|
9
|
+
listGoals,
|
|
8
10
|
listHeartbeatRunMessages,
|
|
9
11
|
listHeartbeatRuns,
|
|
10
12
|
listModelPricing,
|
|
@@ -15,7 +17,7 @@ import type { AppContext } from "../context";
|
|
|
15
17
|
import { sendError, sendOk } from "../http";
|
|
16
18
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
19
|
import { requirePermission } from "../middleware/request-actor";
|
|
18
|
-
import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
|
|
20
|
+
import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
|
|
19
21
|
|
|
20
22
|
export function createObservabilityRouter(ctx: AppContext) {
|
|
21
23
|
const router = Router();
|
|
@@ -266,6 +268,65 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
266
268
|
}
|
|
267
269
|
});
|
|
268
270
|
|
|
271
|
+
router.get("/memory/:agentId/context-preview", async (req, res) => {
|
|
272
|
+
const companyId = req.companyId!;
|
|
273
|
+
const agentId = req.params.agentId;
|
|
274
|
+
const projectIds = typeof req.query.projectIds === "string"
|
|
275
|
+
? req.query.projectIds
|
|
276
|
+
.split(",")
|
|
277
|
+
.map((entry) => entry.trim())
|
|
278
|
+
.filter(Boolean)
|
|
279
|
+
: [];
|
|
280
|
+
const queryText = typeof req.query.query === "string" ? req.query.query.trim() : "";
|
|
281
|
+
const [agents, goals, companies] = await Promise.all([
|
|
282
|
+
listAgents(ctx.db, companyId),
|
|
283
|
+
listGoals(ctx.db, companyId),
|
|
284
|
+
listCompanies(ctx.db)
|
|
285
|
+
]);
|
|
286
|
+
const agent = agents.find((entry) => entry.id === agentId);
|
|
287
|
+
if (!agent) {
|
|
288
|
+
return sendError(res, "Agent not found", 404);
|
|
289
|
+
}
|
|
290
|
+
const company = companies.find((entry) => entry.id === companyId);
|
|
291
|
+
const memoryContext = await loadAgentMemoryContext({
|
|
292
|
+
companyId,
|
|
293
|
+
agentId,
|
|
294
|
+
projectIds,
|
|
295
|
+
queryText: queryText.length > 0 ? queryText : undefined
|
|
296
|
+
});
|
|
297
|
+
const activeCompanyGoals = goals
|
|
298
|
+
.filter((goal) => goal.status === "active" && goal.level === "company")
|
|
299
|
+
.map((goal) => goal.title);
|
|
300
|
+
const activeProjectGoals = goals
|
|
301
|
+
.filter((goal) => goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId))
|
|
302
|
+
.map((goal) => goal.title);
|
|
303
|
+
const activeAgentGoals = goals
|
|
304
|
+
.filter((goal) => goal.status === "active" && goal.level === "agent")
|
|
305
|
+
.map((goal) => goal.title);
|
|
306
|
+
const compiledPreview = [
|
|
307
|
+
`Agent: ${agent.name} (${agent.role})`,
|
|
308
|
+
`Company mission: ${company?.mission ?? "No mission set"}`,
|
|
309
|
+
`Company goals: ${activeCompanyGoals.length > 0 ? activeCompanyGoals.join(" | ") : "None"}`,
|
|
310
|
+
`Project goals: ${activeProjectGoals.length > 0 ? activeProjectGoals.join(" | ") : "None"}`,
|
|
311
|
+
`Agent goals: ${activeAgentGoals.length > 0 ? activeAgentGoals.join(" | ") : "None"}`,
|
|
312
|
+
`Tacit notes: ${memoryContext.tacitNotes ?? "None"}`,
|
|
313
|
+
`Durable facts: ${memoryContext.durableFacts.join(" | ") || "None"}`,
|
|
314
|
+
`Daily notes: ${memoryContext.dailyNotes.join(" | ") || "None"}`
|
|
315
|
+
].join("\n");
|
|
316
|
+
return sendOk(res, {
|
|
317
|
+
agentId,
|
|
318
|
+
projectIds,
|
|
319
|
+
companyMission: company?.mission ?? null,
|
|
320
|
+
goalContext: {
|
|
321
|
+
companyGoals: activeCompanyGoals,
|
|
322
|
+
projectGoals: activeProjectGoals,
|
|
323
|
+
agentGoals: activeAgentGoals
|
|
324
|
+
},
|
|
325
|
+
memoryContext,
|
|
326
|
+
compiledPreview
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
269
330
|
router.get("/plugins/runs", async (req, res) => {
|
|
270
331
|
const companyId = req.companyId!;
|
|
271
332
|
const pluginId = typeof req.query.pluginId === "string" && req.query.pluginId.trim() ? req.query.pluginId.trim() : undefined;
|
package/src/routes/projects.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|
|
2
2
|
import { mkdir, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { ProjectSchema } from "bopodev-contracts";
|
|
5
6
|
import {
|
|
6
7
|
appendAuditEvent,
|
|
7
8
|
createProject,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
updateProjectWorkspace
|
|
16
17
|
} from "bopodev-db";
|
|
17
18
|
import type { AppContext } from "../context";
|
|
18
|
-
import { sendError, sendOk } from "../http";
|
|
19
|
+
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
19
20
|
import { normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
20
21
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
21
22
|
import { requirePermission } from "../middleware/request-actor";
|
|
@@ -50,6 +51,7 @@ const createProjectSchema = z.object({
|
|
|
50
51
|
description: z.string().optional(),
|
|
51
52
|
status: projectStatusSchema.default("planned"),
|
|
52
53
|
plannedStartAt: z.string().optional(),
|
|
54
|
+
monthlyBudgetUsd: z.number().positive().default(100),
|
|
53
55
|
executionWorkspacePolicy: executionWorkspacePolicySchema.optional().nullable(),
|
|
54
56
|
workspace: z
|
|
55
57
|
.object({
|
|
@@ -69,6 +71,7 @@ const updateProjectSchema = z
|
|
|
69
71
|
description: z.string().nullable().optional(),
|
|
70
72
|
status: projectStatusSchema.optional(),
|
|
71
73
|
plannedStartAt: z.string().nullable().optional(),
|
|
74
|
+
monthlyBudgetUsd: z.number().positive().optional(),
|
|
72
75
|
executionWorkspacePolicy: executionWorkspacePolicySchema.nullable().optional(),
|
|
73
76
|
goalIds: z.array(z.string().min(1)).optional()
|
|
74
77
|
})
|
|
@@ -122,7 +125,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
122
125
|
router.get("/", async (req, res) => {
|
|
123
126
|
const projects = await listProjects(ctx.db, req.companyId!);
|
|
124
127
|
const withDiagnostics = await Promise.all(projects.map((project) => enrichProjectDiagnostics(req.companyId!, project)));
|
|
125
|
-
return
|
|
128
|
+
return sendOkValidated(res, ProjectSchema.array(), withDiagnostics, "projects.list");
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
router.post("/", async (req, res) => {
|
|
@@ -140,6 +143,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
140
143
|
description: parsed.data.description,
|
|
141
144
|
status: parsed.data.status,
|
|
142
145
|
plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
146
|
+
monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
|
|
143
147
|
executionWorkspacePolicy: parsed.data.executionWorkspacePolicy ?? null
|
|
144
148
|
});
|
|
145
149
|
if (!project) {
|
|
@@ -216,6 +220,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
216
220
|
status: parsed.data.status,
|
|
217
221
|
plannedStartAt:
|
|
218
222
|
parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
223
|
+
monthlyBudgetUsd: parsed.data.monthlyBudgetUsd === undefined ? undefined : parsed.data.monthlyBudgetUsd.toFixed(4),
|
|
219
224
|
executionWorkspacePolicy: parsed.data.executionWorkspacePolicy
|
|
220
225
|
});
|
|
221
226
|
if (!project) {
|
|
@@ -101,7 +101,7 @@ export async function ensureOnboardingSeed(input: {
|
|
|
101
101
|
const resolvedCompanyName = companyRow.name;
|
|
102
102
|
await ensureCompanyBuiltinTemplateDefaults(db, companyId);
|
|
103
103
|
const agents = await listAgents(db, companyId);
|
|
104
|
-
const existingCeo = agents.find((agent) => agent.role === "CEO" || agent.name === "CEO");
|
|
104
|
+
const existingCeo = agents.find((agent) => agent.roleKey === "ceo" || agent.role === "CEO" || agent.name === "CEO");
|
|
105
105
|
let ceoCreated = false;
|
|
106
106
|
let ceoMigrated = false;
|
|
107
107
|
let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
|
|
@@ -130,6 +130,8 @@ export async function ensureOnboardingSeed(input: {
|
|
|
130
130
|
const ceo = await createAgent(db, {
|
|
131
131
|
companyId,
|
|
132
132
|
role: "CEO",
|
|
133
|
+
roleKey: "ceo",
|
|
134
|
+
title: "CEO",
|
|
133
135
|
name: "CEO",
|
|
134
136
|
providerType: agentProvider,
|
|
135
137
|
heartbeatCron: "*/5 * * * *",
|
package/src/server.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createApp } from "./app";
|
|
|
10
10
|
import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
|
|
11
11
|
import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
|
|
12
12
|
import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
|
|
13
|
+
import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
|
|
13
14
|
import { attachRealtimeHub } from "./realtime/hub";
|
|
14
15
|
import {
|
|
15
16
|
isAuthenticatedMode,
|
|
@@ -106,7 +107,8 @@ async function main() {
|
|
|
106
107
|
bootstrapLoaders: {
|
|
107
108
|
governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
|
|
108
109
|
"office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId),
|
|
109
|
-
"heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
|
|
110
|
+
"heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId),
|
|
111
|
+
attention: (companyId) => loadAttentionRealtimeSnapshot(db, companyId)
|
|
110
112
|
}
|
|
111
113
|
});
|
|
112
114
|
const app = createApp({ db, deploymentMode, allowedOrigins, getRuntimeHealth, realtimeHub });
|