bopodev-api 0.1.27 → 0.1.29

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.
Files changed (50) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -70
  3. package/src/lib/drainable-work.ts +36 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/lib/workspace-policy.ts +1 -2
  6. package/src/middleware/cors-config.ts +36 -0
  7. package/src/middleware/request-actor.ts +10 -16
  8. package/src/middleware/request-id.ts +9 -0
  9. package/src/middleware/request-logging.ts +24 -0
  10. package/src/realtime/office-space.ts +3 -1
  11. package/src/routes/agents.ts +3 -9
  12. package/src/routes/companies.ts +18 -1
  13. package/src/routes/goals.ts +7 -13
  14. package/src/routes/governance.ts +2 -5
  15. package/src/routes/heartbeats.ts +8 -27
  16. package/src/routes/issues.ts +66 -121
  17. package/src/routes/observability.ts +6 -1
  18. package/src/routes/plugins.ts +5 -17
  19. package/src/routes/projects.ts +7 -25
  20. package/src/routes/templates.ts +6 -21
  21. package/src/scripts/onboard-seed.ts +5 -7
  22. package/src/server.ts +35 -276
  23. package/src/services/attention-service.ts +4 -1
  24. package/src/services/budget-service.ts +1 -2
  25. package/src/services/comment-recipient-dispatch-service.ts +39 -2
  26. package/src/services/company-export-service.ts +63 -0
  27. package/src/services/governance-service.ts +6 -2
  28. package/src/services/heartbeat-queue-service.ts +34 -3
  29. package/src/services/heartbeat-service/active-runs.ts +15 -0
  30. package/src/services/heartbeat-service/budget-override.ts +46 -0
  31. package/src/services/heartbeat-service/claims.ts +61 -0
  32. package/src/services/heartbeat-service/cron.ts +58 -0
  33. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  34. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  35. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
  36. package/src/services/heartbeat-service/index.ts +5 -0
  37. package/src/services/heartbeat-service/stop.ts +90 -0
  38. package/src/services/heartbeat-service/sweep.ts +145 -0
  39. package/src/services/heartbeat-service/types.ts +65 -0
  40. package/src/services/memory-file-service.ts +10 -2
  41. package/src/shutdown/graceful-shutdown.ts +77 -0
  42. package/src/startup/database.ts +41 -0
  43. package/src/startup/deployment-validation.ts +37 -0
  44. package/src/startup/env.ts +17 -0
  45. package/src/startup/runtime-health.ts +128 -0
  46. package/src/startup/scheduler-config.ts +39 -0
  47. package/src/types/express.d.ts +13 -0
  48. package/src/types/request-actor.ts +6 -0
  49. package/src/validation/issue-routes.ts +79 -0
  50. package/src/worker/scheduler.ts +20 -4
@@ -0,0 +1,46 @@
1
+ import { and, eq, inArray, approvalRequests, issues } from "bopodev-db";
2
+ import type { BopoDb } from "bopodev-db";
3
+
4
+ export async function findPendingProjectBudgetOverrideBlocksForAgent(
5
+ db: BopoDb,
6
+ companyId: string,
7
+ agentId: string
8
+ ) {
9
+ const assignedRows = await db
10
+ .select({ projectId: issues.projectId })
11
+ .from(issues)
12
+ .where(
13
+ and(
14
+ eq(issues.companyId, companyId),
15
+ eq(issues.assigneeAgentId, agentId),
16
+ inArray(issues.status, ["todo", "in_progress"])
17
+ )
18
+ );
19
+ const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
20
+ if (assignedProjectIds.size === 0) {
21
+ return [] as string[];
22
+ }
23
+ const pendingOverrides = await db
24
+ .select({ payloadJson: approvalRequests.payloadJson })
25
+ .from(approvalRequests)
26
+ .where(
27
+ and(
28
+ eq(approvalRequests.companyId, companyId),
29
+ eq(approvalRequests.action, "override_budget"),
30
+ eq(approvalRequests.status, "pending")
31
+ )
32
+ );
33
+ const blockedProjectIds = new Set<string>();
34
+ for (const approval of pendingOverrides) {
35
+ try {
36
+ const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
37
+ const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
38
+ if (projectId && assignedProjectIds.has(projectId)) {
39
+ blockedProjectIds.add(projectId);
40
+ }
41
+ } catch {
42
+ // Ignore malformed payloads to keep enforcement resilient.
43
+ }
44
+ }
45
+ return Array.from(blockedProjectIds);
46
+ }
@@ -0,0 +1,61 @@
1
+ import { and, eq, inArray, issues, sql } from "bopodev-db";
2
+ import type { BopoDb } from "bopodev-db";
3
+
4
+ export async function claimIssuesForAgent(
5
+ db: BopoDb,
6
+ companyId: string,
7
+ agentId: string,
8
+ heartbeatRunId: string,
9
+ maxItems = 5
10
+ ) {
11
+ const result = await db.execute(sql`
12
+ WITH candidate AS (
13
+ SELECT id
14
+ FROM issues
15
+ WHERE company_id = ${companyId}
16
+ AND assignee_agent_id = ${agentId}
17
+ AND status IN ('todo', 'in_progress')
18
+ AND is_claimed = false
19
+ ORDER BY
20
+ CASE priority
21
+ WHEN 'urgent' THEN 0
22
+ WHEN 'high' THEN 1
23
+ WHEN 'medium' THEN 2
24
+ WHEN 'low' THEN 3
25
+ ELSE 4
26
+ END ASC,
27
+ updated_at ASC
28
+ LIMIT ${maxItems}
29
+ FOR UPDATE SKIP LOCKED
30
+ )
31
+ UPDATE issues i
32
+ SET is_claimed = true,
33
+ claimed_by_heartbeat_run_id = ${heartbeatRunId},
34
+ updated_at = CURRENT_TIMESTAMP
35
+ FROM candidate c
36
+ WHERE i.id = c.id
37
+ RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
38
+ `);
39
+
40
+ return result as unknown as Array<{
41
+ id: string;
42
+ project_id: string;
43
+ parent_issue_id: string | null;
44
+ title: string;
45
+ body: string | null;
46
+ status: string;
47
+ priority: string;
48
+ labels_json: string;
49
+ tags_json: string;
50
+ }>;
51
+ }
52
+
53
+ export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
54
+ if (issueIds.length === 0) {
55
+ return;
56
+ }
57
+ await db
58
+ .update(issues)
59
+ .set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
60
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
61
+ }
@@ -0,0 +1,58 @@
1
+ export function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
2
+ const normalizedNow = truncateToMinute(now);
3
+ if (!matchesCronExpression(cronExpression, normalizedNow)) {
4
+ return false;
5
+ }
6
+ if (!lastRunAt) {
7
+ return true;
8
+ }
9
+ return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
10
+ }
11
+
12
+ function truncateToMinute(date: Date) {
13
+ const clone = new Date(date);
14
+ clone.setSeconds(0, 0);
15
+ return clone;
16
+ }
17
+
18
+ function matchesCronExpression(expression: string, date: Date) {
19
+ const parts = expression.trim().split(/\s+/);
20
+ if (parts.length !== 5) {
21
+ return false;
22
+ }
23
+
24
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
25
+ return (
26
+ matchesCronField(minute, date.getMinutes(), 0, 59) &&
27
+ matchesCronField(hour, date.getHours(), 0, 23) &&
28
+ matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
29
+ matchesCronField(month, date.getMonth() + 1, 1, 12) &&
30
+ matchesCronField(dayOfWeek, date.getDay(), 0, 6)
31
+ );
32
+ }
33
+
34
+ function matchesCronField(field: string, value: number, min: number, max: number) {
35
+ return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
36
+ }
37
+
38
+ function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
39
+ if (part === "*") {
40
+ return true;
41
+ }
42
+
43
+ const stepMatch = part.match(/^\*\/(\d+)$/);
44
+ if (stepMatch) {
45
+ const step = Number(stepMatch[1]);
46
+ return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
47
+ }
48
+
49
+ const rangeMatch = part.match(/^(\d+)-(\d+)$/);
50
+ if (rangeMatch) {
51
+ const start = Number(rangeMatch[1]);
52
+ const end = Number(rangeMatch[2]);
53
+ return start <= value && value <= end;
54
+ }
55
+
56
+ const exact = Number(part);
57
+ return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
58
+ }
@@ -0,0 +1,28 @@
1
+ import type { RealtimeHub } from "../../realtime/hub";
2
+ import { createHeartbeatRunsRealtimeEvent } from "../../realtime/heartbeat-runs";
3
+
4
+ export function publishHeartbeatRunStatus(
5
+ realtimeHub: RealtimeHub | undefined,
6
+ input: {
7
+ companyId: string;
8
+ runId: string;
9
+ status: "started" | "completed" | "failed" | "skipped";
10
+ message?: string | null;
11
+ startedAt?: Date;
12
+ finishedAt?: Date;
13
+ }
14
+ ) {
15
+ if (!realtimeHub) {
16
+ return;
17
+ }
18
+ realtimeHub.publish(
19
+ createHeartbeatRunsRealtimeEvent(input.companyId, {
20
+ type: "run.status.updated",
21
+ runId: input.runId,
22
+ status: input.status,
23
+ message: input.message ?? null,
24
+ startedAt: input.startedAt?.toISOString(),
25
+ finishedAt: input.finishedAt?.toISOString() ?? null
26
+ })
27
+ );
28
+ }
@@ -0,0 +1,53 @@
1
+ const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
2
+
3
+ export function sanitizeAgentSummaryCommentBody(body: string) {
4
+ const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
5
+ return sanitized.length > 0 ? sanitized : "Run update.";
6
+ }
7
+
8
+ function extractSummaryFromJsonLikeText(input: string) {
9
+ const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
10
+ const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
11
+ if (!candidate) {
12
+ return null;
13
+ }
14
+ try {
15
+ const parsed = JSON.parse(candidate) as Record<string, unknown>;
16
+ const summary = parsed.summary;
17
+ if (typeof summary === "string" && summary.trim().length > 0) {
18
+ return summary.trim();
19
+ }
20
+ } catch {
21
+ // Fall through to regex extraction for loosely-formatted JSON.
22
+ }
23
+ const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
24
+ const summary = summaryMatch?.[1]
25
+ ?.replace(/\\"/g, "\"")
26
+ .replace(/\\n/g, " ")
27
+ .replace(/\s+/g, " ")
28
+ .trim();
29
+ return summary && summary.length > 0 ? summary : null;
30
+ }
31
+
32
+ export function extractNaturalRunUpdate(executionSummary: string) {
33
+ const normalized = executionSummary.trim();
34
+ const jsonSummary = extractSummaryFromJsonLikeText(normalized);
35
+ const source = jsonSummary ?? normalized;
36
+ const lines = source
37
+ .split("\n")
38
+ .map((line) => line.trim())
39
+ .filter((line) => line.length > 0)
40
+ .filter((line) => !line.startsWith("{") && !line.startsWith("}"));
41
+ const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
42
+ .replace(/^run (failure )?summary\s*:\s*/i, "")
43
+ .replace(/^completed all assigned issue steps\s*:\s*/i, "")
44
+ .replace(/^issue status\s*:\s*/i, "")
45
+ .replace(/`+/g, "")
46
+ .replace(/\s+/g, " ")
47
+ .trim();
48
+ const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
49
+ if (!bounded) {
50
+ return "Run update.";
51
+ }
52
+ return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
53
+ }