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.
- package/package.json +4 -4
- package/src/app.ts +17 -70
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/agents.ts +3 -9
- package/src/routes/companies.ts +18 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +8 -27
- package/src/routes/issues.ts +66 -121
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +5 -7
- package/src/server.ts +35 -276
- package/src/services/attention-service.ts +4 -1
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +6 -2
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +65 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +79 -0
- 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
|
+
}
|