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,39 @@
|
|
|
1
|
+
import { asc, companies, eq } from "bopodev-db";
|
|
2
|
+
import type { BootstrappedDb } from "./database";
|
|
3
|
+
|
|
4
|
+
export async function resolveSchedulerCompanyId(
|
|
5
|
+
db: BootstrappedDb["db"],
|
|
6
|
+
configuredCompanyId: string | null
|
|
7
|
+
) {
|
|
8
|
+
if (configuredCompanyId) {
|
|
9
|
+
const configured = await db
|
|
10
|
+
.select({ id: companies.id })
|
|
11
|
+
.from(companies)
|
|
12
|
+
.where(eq(companies.id, configuredCompanyId))
|
|
13
|
+
.limit(1);
|
|
14
|
+
if (configured.length > 0) {
|
|
15
|
+
return configuredCompanyId;
|
|
16
|
+
}
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fallback = await db
|
|
22
|
+
.select({ id: companies.id })
|
|
23
|
+
.from(companies)
|
|
24
|
+
.orderBy(asc(companies.createdAt))
|
|
25
|
+
.limit(1);
|
|
26
|
+
const id = fallback[0]?.id;
|
|
27
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shouldStartScheduler() {
|
|
31
|
+
const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
|
|
32
|
+
if (rawRole === "off" || rawRole === "follower") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (rawRole === "leader" || rawRole === "auto") {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const createIssueSchema = z.object({
|
|
4
|
+
projectId: z.string().min(1),
|
|
5
|
+
parentIssueId: z.string().optional(),
|
|
6
|
+
title: z.string().min(1),
|
|
7
|
+
body: z.string().optional(),
|
|
8
|
+
metadata: z
|
|
9
|
+
.object({
|
|
10
|
+
delegatedHiringIntent: z
|
|
11
|
+
.object({
|
|
12
|
+
intentType: z.literal("agent_hiring_request"),
|
|
13
|
+
requestedRole: z.string().nullable().optional(),
|
|
14
|
+
requestedRoleKey: z.string().nullable().optional(),
|
|
15
|
+
requestedTitle: z.string().nullable().optional(),
|
|
16
|
+
requestedName: z.string().nullable().optional(),
|
|
17
|
+
requestedManagerAgentId: z.string().nullable().optional(),
|
|
18
|
+
requestedProviderType: z.string().nullable().optional(),
|
|
19
|
+
requestedRuntimeModel: z.string().nullable().optional()
|
|
20
|
+
})
|
|
21
|
+
.optional()
|
|
22
|
+
})
|
|
23
|
+
.optional(),
|
|
24
|
+
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
|
|
25
|
+
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
|
|
26
|
+
assigneeAgentId: z.string().nullable().optional(),
|
|
27
|
+
goalIds: z.array(z.string().min(1)).default([]),
|
|
28
|
+
externalLink: z.string().max(2048).nullable().optional(),
|
|
29
|
+
labels: z.array(z.string()).default([]),
|
|
30
|
+
tags: z.array(z.string()).default([])
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const createIssueCommentSchema = z.object({
|
|
34
|
+
body: z.string().min(1),
|
|
35
|
+
recipients: z
|
|
36
|
+
.array(
|
|
37
|
+
z.object({
|
|
38
|
+
recipientType: z.enum(["agent", "board", "member"]),
|
|
39
|
+
recipientId: z.string().nullable().optional()
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
.default([]),
|
|
43
|
+
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
44
|
+
authorId: z.string().optional()
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const createIssueCommentLegacySchema = z.object({
|
|
48
|
+
issueId: z.string().min(1),
|
|
49
|
+
body: z.string().min(1),
|
|
50
|
+
recipients: z
|
|
51
|
+
.array(
|
|
52
|
+
z.object({
|
|
53
|
+
recipientType: z.enum(["agent", "board", "member"]),
|
|
54
|
+
recipientId: z.string().nullable().optional()
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
.default([]),
|
|
58
|
+
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
59
|
+
authorId: z.string().optional()
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const updateIssueCommentSchema = z.object({
|
|
63
|
+
body: z.string().min(1)
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const updateIssueSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
projectId: z.string().min(1).optional(),
|
|
69
|
+
title: z.string().min(1).optional(),
|
|
70
|
+
body: z.string().nullable().optional(),
|
|
71
|
+
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).optional(),
|
|
72
|
+
priority: z.enum(["none", "low", "medium", "high", "urgent"]).optional(),
|
|
73
|
+
assigneeAgentId: z.string().nullable().optional(),
|
|
74
|
+
goalIds: z.array(z.string().min(1)).optional(),
|
|
75
|
+
externalLink: z.string().max(2048).nullable().optional(),
|
|
76
|
+
labels: z.array(z.string()).optional(),
|
|
77
|
+
tags: z.array(z.string()).optional()
|
|
78
|
+
})
|
|
79
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
package/src/worker/scheduler.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { runHeartbeatSweep } from "../services/heartbeat-service";
|
|
|
4
4
|
import { runHeartbeatQueueSweep } from "../services/heartbeat-queue-service";
|
|
5
5
|
import { runIssueCommentDispatchSweep } from "../services/comment-recipient-dispatch-service";
|
|
6
6
|
|
|
7
|
+
export type HeartbeatSchedulerHandle = {
|
|
8
|
+
stop: () => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
|
|
7
11
|
export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtimeHub?: RealtimeHub) {
|
|
8
12
|
const heartbeatIntervalMs = Number(process.env.BOPO_HEARTBEAT_SWEEP_MS ?? 60_000);
|
|
9
13
|
const queueIntervalMs = Number(process.env.BOPO_HEARTBEAT_QUEUE_SWEEP_MS ?? 2_000);
|
|
@@ -11,18 +15,22 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
|
|
|
11
15
|
let heartbeatRunning = false;
|
|
12
16
|
let queueRunning = false;
|
|
13
17
|
let commentDispatchRunning = false;
|
|
18
|
+
let heartbeatPromise: Promise<unknown> | null = null;
|
|
19
|
+
let queuePromise: Promise<unknown> | null = null;
|
|
20
|
+
let commentDispatchPromise: Promise<unknown> | null = null;
|
|
14
21
|
const heartbeatTimer = setInterval(() => {
|
|
15
22
|
if (heartbeatRunning) {
|
|
16
23
|
return;
|
|
17
24
|
}
|
|
18
25
|
heartbeatRunning = true;
|
|
19
|
-
|
|
26
|
+
heartbeatPromise = runHeartbeatSweep(db, companyId, { realtimeHub })
|
|
20
27
|
.catch((error) => {
|
|
21
28
|
// eslint-disable-next-line no-console
|
|
22
29
|
console.error("[scheduler] heartbeat sweep failed", error);
|
|
23
30
|
})
|
|
24
31
|
.finally(() => {
|
|
25
32
|
heartbeatRunning = false;
|
|
33
|
+
heartbeatPromise = null;
|
|
26
34
|
});
|
|
27
35
|
}, heartbeatIntervalMs);
|
|
28
36
|
const queueTimer = setInterval(() => {
|
|
@@ -30,13 +38,14 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
|
|
|
30
38
|
return;
|
|
31
39
|
}
|
|
32
40
|
queueRunning = true;
|
|
33
|
-
|
|
41
|
+
queuePromise = runHeartbeatQueueSweep(db, companyId, { realtimeHub })
|
|
34
42
|
.catch((error) => {
|
|
35
43
|
// eslint-disable-next-line no-console
|
|
36
44
|
console.error("[scheduler] queue sweep failed", error);
|
|
37
45
|
})
|
|
38
46
|
.finally(() => {
|
|
39
47
|
queueRunning = false;
|
|
48
|
+
queuePromise = null;
|
|
40
49
|
});
|
|
41
50
|
}, queueIntervalMs);
|
|
42
51
|
const commentDispatchTimer = setInterval(() => {
|
|
@@ -44,18 +53,25 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
|
|
|
44
53
|
return;
|
|
45
54
|
}
|
|
46
55
|
commentDispatchRunning = true;
|
|
47
|
-
|
|
56
|
+
commentDispatchPromise = runIssueCommentDispatchSweep(db, companyId, { realtimeHub })
|
|
48
57
|
.catch((error) => {
|
|
49
58
|
// eslint-disable-next-line no-console
|
|
50
59
|
console.error("[scheduler] comment dispatch sweep failed", error);
|
|
51
60
|
})
|
|
52
61
|
.finally(() => {
|
|
53
62
|
commentDispatchRunning = false;
|
|
63
|
+
commentDispatchPromise = null;
|
|
54
64
|
});
|
|
55
65
|
}, commentDispatchIntervalMs);
|
|
56
|
-
|
|
66
|
+
const stop = async () => {
|
|
57
67
|
clearInterval(heartbeatTimer);
|
|
58
68
|
clearInterval(queueTimer);
|
|
59
69
|
clearInterval(commentDispatchTimer);
|
|
70
|
+
await Promise.allSettled(
|
|
71
|
+
[heartbeatPromise, queuePromise, commentDispatchPromise].filter(
|
|
72
|
+
(promise): promise is Promise<unknown> => promise !== null
|
|
73
|
+
)
|
|
74
|
+
);
|
|
60
75
|
};
|
|
76
|
+
return { stop } satisfies HeartbeatSchedulerHandle;
|
|
61
77
|
}
|