bopodev-api 0.1.1
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/LICENSE +21 -0
- package/package.json +36 -0
- package/src/app.ts +76 -0
- package/src/context.ts +8 -0
- package/src/http.ts +9 -0
- package/src/middleware/company-scope.ts +15 -0
- package/src/middleware/request-actor.ts +81 -0
- package/src/realtime/governance.ts +66 -0
- package/src/realtime/hub.ts +142 -0
- package/src/realtime/office-space.ts +448 -0
- package/src/routes/agents.ts +305 -0
- package/src/routes/companies.ts +58 -0
- package/src/routes/goals.ts +134 -0
- package/src/routes/governance.ts +208 -0
- package/src/routes/heartbeats.ts +61 -0
- package/src/routes/issues.ts +319 -0
- package/src/routes/observability.ts +47 -0
- package/src/routes/projects.ts +152 -0
- package/src/scripts/db-init.ts +13 -0
- package/src/server.ts +51 -0
- package/src/services/budget-service.ts +31 -0
- package/src/services/governance-service.ts +229 -0
- package/src/services/heartbeat-service.ts +706 -0
- package/src/worker/scheduler.ts +23 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { and, eq } from "drizzle-orm";
|
|
4
|
+
import { agents } from "bopodev-db";
|
|
5
|
+
import type { AppContext } from "../context";
|
|
6
|
+
import { sendError, sendOk } from "../http";
|
|
7
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
8
|
+
import { requirePermission } from "../middleware/request-actor";
|
|
9
|
+
import { runHeartbeatForAgent, runHeartbeatSweep } from "../services/heartbeat-service";
|
|
10
|
+
|
|
11
|
+
const runAgentSchema = z.object({
|
|
12
|
+
agentId: z.string().min(1)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function createHeartbeatRouter(ctx: AppContext) {
|
|
16
|
+
const router = Router();
|
|
17
|
+
router.use(requireCompanyScope);
|
|
18
|
+
|
|
19
|
+
router.post("/run-agent", async (req, res) => {
|
|
20
|
+
requirePermission("heartbeats:run")(req, res, () => {});
|
|
21
|
+
if (res.headersSent) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const parsed = runAgentSchema.safeParse(req.body);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
return sendError(res, parsed.error.message, 422);
|
|
27
|
+
}
|
|
28
|
+
const [agent] = await ctx.db
|
|
29
|
+
.select({ id: agents.id, status: agents.status })
|
|
30
|
+
.from(agents)
|
|
31
|
+
.where(and(eq(agents.companyId, req.companyId!), eq(agents.id, parsed.data.agentId)))
|
|
32
|
+
.limit(1);
|
|
33
|
+
if (!agent) {
|
|
34
|
+
return sendError(res, "Agent not found.", 404);
|
|
35
|
+
}
|
|
36
|
+
if (agent.status === "paused" || agent.status === "terminated") {
|
|
37
|
+
return sendError(res, `Agent is not invokable in status '${agent.status}'.`, 409);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const runId = await runHeartbeatForAgent(ctx.db, req.companyId!, parsed.data.agentId, {
|
|
41
|
+
requestId: req.requestId,
|
|
42
|
+
trigger: "manual",
|
|
43
|
+
realtimeHub: ctx.realtimeHub
|
|
44
|
+
});
|
|
45
|
+
return sendOk(res, { runId, requestId: req.requestId });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.post("/sweep", async (req, res) => {
|
|
49
|
+
requirePermission("heartbeats:sweep")(req, res, () => {});
|
|
50
|
+
if (res.headersSent) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const runIds = await runHeartbeatSweep(ctx.db, req.companyId!, {
|
|
54
|
+
requestId: req.requestId,
|
|
55
|
+
realtimeHub: ctx.realtimeHub
|
|
56
|
+
});
|
|
57
|
+
return sendOk(res, { runIds, requestId: req.requestId });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return router;
|
|
61
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
addIssueComment,
|
|
5
|
+
appendActivity,
|
|
6
|
+
appendAuditEvent,
|
|
7
|
+
createIssue,
|
|
8
|
+
deleteIssueComment,
|
|
9
|
+
deleteIssue,
|
|
10
|
+
listIssueComments,
|
|
11
|
+
listIssues,
|
|
12
|
+
updateIssueComment,
|
|
13
|
+
updateIssue
|
|
14
|
+
} from "bopodev-db";
|
|
15
|
+
import type { AppContext } from "../context";
|
|
16
|
+
import { sendError, sendOk } from "../http";
|
|
17
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
18
|
+
import { requirePermission } from "../middleware/request-actor";
|
|
19
|
+
|
|
20
|
+
const createIssueSchema = z.object({
|
|
21
|
+
projectId: z.string().min(1),
|
|
22
|
+
parentIssueId: z.string().optional(),
|
|
23
|
+
title: z.string().min(1),
|
|
24
|
+
body: z.string().optional(),
|
|
25
|
+
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
|
|
26
|
+
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
|
|
27
|
+
assigneeAgentId: z.string().nullable().optional(),
|
|
28
|
+
labels: z.array(z.string()).default([]),
|
|
29
|
+
tags: z.array(z.string()).default([])
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const createIssueCommentSchema = z.object({
|
|
33
|
+
body: z.string().min(1),
|
|
34
|
+
authorType: z.enum(["human", "agent", "system"]).default("human"),
|
|
35
|
+
authorId: z.string().optional()
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const createIssueCommentLegacySchema = z.object({
|
|
39
|
+
issueId: z.string().min(1),
|
|
40
|
+
body: z.string().min(1),
|
|
41
|
+
authorType: z.enum(["human", "agent", "system"]).default("human"),
|
|
42
|
+
authorId: z.string().optional()
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const updateIssueCommentSchema = z.object({
|
|
46
|
+
body: z.string().min(1)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function parseStringArray(value: unknown) {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return value.map((entry) => String(entry));
|
|
52
|
+
}
|
|
53
|
+
if (typeof value !== "string") {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(value);
|
|
58
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toIssueResponse(issue: Record<string, unknown>) {
|
|
65
|
+
const labels = parseStringArray(issue.labelsJson);
|
|
66
|
+
const tags = parseStringArray(issue.tagsJson);
|
|
67
|
+
const { labelsJson: _labelsJson, tagsJson: _tagsJson, ...rest } = issue;
|
|
68
|
+
return {
|
|
69
|
+
...rest,
|
|
70
|
+
labels,
|
|
71
|
+
tags
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const updateIssueSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
projectId: z.string().min(1).optional(),
|
|
78
|
+
title: z.string().min(1).optional(),
|
|
79
|
+
body: z.string().nullable().optional(),
|
|
80
|
+
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).optional(),
|
|
81
|
+
priority: z.enum(["none", "low", "medium", "high", "urgent"]).optional(),
|
|
82
|
+
assigneeAgentId: z.string().nullable().optional(),
|
|
83
|
+
labels: z.array(z.string()).optional(),
|
|
84
|
+
tags: z.array(z.string()).optional()
|
|
85
|
+
})
|
|
86
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
87
|
+
|
|
88
|
+
export function createIssuesRouter(ctx: AppContext) {
|
|
89
|
+
const router = Router();
|
|
90
|
+
router.use(requireCompanyScope);
|
|
91
|
+
|
|
92
|
+
router.get("/", async (req, res) => {
|
|
93
|
+
const projectId = req.query.projectId?.toString();
|
|
94
|
+
const rows = await listIssues(ctx.db, req.companyId!, projectId);
|
|
95
|
+
return sendOk(
|
|
96
|
+
res,
|
|
97
|
+
rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>))
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.post("/", async (req, res) => {
|
|
102
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
103
|
+
if (res.headersSent) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const parsed = createIssueSchema.safeParse(req.body);
|
|
107
|
+
if (!parsed.success) {
|
|
108
|
+
return sendError(res, parsed.error.message, 422);
|
|
109
|
+
}
|
|
110
|
+
const issue = await createIssue(ctx.db, { companyId: req.companyId!, ...parsed.data });
|
|
111
|
+
await appendActivity(ctx.db, {
|
|
112
|
+
companyId: req.companyId!,
|
|
113
|
+
issueId: issue.id,
|
|
114
|
+
actorType: "human",
|
|
115
|
+
eventType: "issue.created",
|
|
116
|
+
payload: { issue }
|
|
117
|
+
});
|
|
118
|
+
await appendAuditEvent(ctx.db, {
|
|
119
|
+
companyId: req.companyId!,
|
|
120
|
+
actorType: "human",
|
|
121
|
+
eventType: "issue.created",
|
|
122
|
+
entityType: "issue",
|
|
123
|
+
entityId: issue.id,
|
|
124
|
+
payload: issue
|
|
125
|
+
});
|
|
126
|
+
return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
router.get("/:issueId/comments", async (req, res) => {
|
|
130
|
+
const comments = await listIssueComments(ctx.db, req.companyId!, req.params.issueId);
|
|
131
|
+
return sendOk(res, comments);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
router.post("/:issueId/comments", async (req, res) => {
|
|
135
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
136
|
+
if (res.headersSent) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parsed = createIssueCommentSchema.safeParse(req.body);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
return sendError(res, parsed.error.message, 422);
|
|
142
|
+
}
|
|
143
|
+
const comment = await addIssueComment(ctx.db, {
|
|
144
|
+
companyId: req.companyId!,
|
|
145
|
+
issueId: req.params.issueId,
|
|
146
|
+
...parsed.data
|
|
147
|
+
});
|
|
148
|
+
await appendActivity(ctx.db, {
|
|
149
|
+
companyId: req.companyId!,
|
|
150
|
+
issueId: comment.issueId,
|
|
151
|
+
actorType: comment.authorType,
|
|
152
|
+
actorId: comment.authorId,
|
|
153
|
+
eventType: "issue.comment_added",
|
|
154
|
+
payload: { commentId: comment.id }
|
|
155
|
+
});
|
|
156
|
+
await appendAuditEvent(ctx.db, {
|
|
157
|
+
companyId: req.companyId!,
|
|
158
|
+
actorType: comment.authorType,
|
|
159
|
+
actorId: comment.authorId,
|
|
160
|
+
eventType: "issue.comment_added",
|
|
161
|
+
entityType: "issue_comment",
|
|
162
|
+
entityId: comment.id,
|
|
163
|
+
payload: comment
|
|
164
|
+
});
|
|
165
|
+
return sendOk(res, comment);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Backward-compatible endpoint used by older clients.
|
|
169
|
+
router.post("/comment", async (req, res) => {
|
|
170
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
171
|
+
if (res.headersSent) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const parsed = createIssueCommentLegacySchema.safeParse(req.body);
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
return sendError(res, parsed.error.message, 422);
|
|
177
|
+
}
|
|
178
|
+
const comment = await addIssueComment(ctx.db, { companyId: req.companyId!, ...parsed.data });
|
|
179
|
+
await appendActivity(ctx.db, {
|
|
180
|
+
companyId: req.companyId!,
|
|
181
|
+
issueId: comment.issueId,
|
|
182
|
+
actorType: comment.authorType,
|
|
183
|
+
actorId: comment.authorId,
|
|
184
|
+
eventType: "issue.comment_added",
|
|
185
|
+
payload: { commentId: comment.id }
|
|
186
|
+
});
|
|
187
|
+
await appendAuditEvent(ctx.db, {
|
|
188
|
+
companyId: req.companyId!,
|
|
189
|
+
actorType: comment.authorType,
|
|
190
|
+
actorId: comment.authorId,
|
|
191
|
+
eventType: "issue.comment_added",
|
|
192
|
+
entityType: "issue_comment",
|
|
193
|
+
entityId: comment.id,
|
|
194
|
+
payload: comment
|
|
195
|
+
});
|
|
196
|
+
return sendOk(res, comment);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
router.put("/:issueId/comments/:commentId", async (req, res) => {
|
|
200
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
201
|
+
if (res.headersSent) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const parsed = updateIssueCommentSchema.safeParse(req.body);
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
return sendError(res, parsed.error.message, 422);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const comment = await updateIssueComment(ctx.db, {
|
|
210
|
+
companyId: req.companyId!,
|
|
211
|
+
issueId: req.params.issueId,
|
|
212
|
+
id: req.params.commentId,
|
|
213
|
+
body: parsed.data.body
|
|
214
|
+
});
|
|
215
|
+
if (!comment) {
|
|
216
|
+
return sendError(res, "Comment not found.", 404);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await appendActivity(ctx.db, {
|
|
220
|
+
companyId: req.companyId!,
|
|
221
|
+
issueId: req.params.issueId,
|
|
222
|
+
actorType: "human",
|
|
223
|
+
eventType: "issue.comment_updated",
|
|
224
|
+
payload: { commentId: comment.id }
|
|
225
|
+
});
|
|
226
|
+
await appendAuditEvent(ctx.db, {
|
|
227
|
+
companyId: req.companyId!,
|
|
228
|
+
actorType: "human",
|
|
229
|
+
eventType: "issue.comment_updated",
|
|
230
|
+
entityType: "issue_comment",
|
|
231
|
+
entityId: comment.id,
|
|
232
|
+
payload: comment
|
|
233
|
+
});
|
|
234
|
+
return sendOk(res, comment);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
router.delete("/:issueId/comments/:commentId", async (req, res) => {
|
|
238
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
239
|
+
if (res.headersSent) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const deleted = await deleteIssueComment(ctx.db, req.companyId!, req.params.issueId, req.params.commentId);
|
|
243
|
+
if (!deleted) {
|
|
244
|
+
return sendError(res, "Comment not found.", 404);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await appendActivity(ctx.db, {
|
|
248
|
+
companyId: req.companyId!,
|
|
249
|
+
issueId: req.params.issueId,
|
|
250
|
+
actorType: "human",
|
|
251
|
+
eventType: "issue.comment_deleted",
|
|
252
|
+
payload: { commentId: req.params.commentId }
|
|
253
|
+
});
|
|
254
|
+
await appendAuditEvent(ctx.db, {
|
|
255
|
+
companyId: req.companyId!,
|
|
256
|
+
actorType: "human",
|
|
257
|
+
eventType: "issue.comment_deleted",
|
|
258
|
+
entityType: "issue_comment",
|
|
259
|
+
entityId: req.params.commentId,
|
|
260
|
+
payload: { id: req.params.commentId, issueId: req.params.issueId }
|
|
261
|
+
});
|
|
262
|
+
return sendOk(res, { deleted: true });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
router.put("/:issueId", async (req, res) => {
|
|
266
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
267
|
+
if (res.headersSent) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const parsed = updateIssueSchema.safeParse(req.body);
|
|
271
|
+
if (!parsed.success) {
|
|
272
|
+
return sendError(res, parsed.error.message, 422);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...parsed.data });
|
|
276
|
+
if (!issue) {
|
|
277
|
+
return sendError(res, "Issue not found.", 404);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await appendActivity(ctx.db, {
|
|
281
|
+
companyId: req.companyId!,
|
|
282
|
+
issueId: issue.id,
|
|
283
|
+
actorType: "human",
|
|
284
|
+
eventType: "issue.updated",
|
|
285
|
+
payload: { issue }
|
|
286
|
+
});
|
|
287
|
+
await appendAuditEvent(ctx.db, {
|
|
288
|
+
companyId: req.companyId!,
|
|
289
|
+
actorType: "human",
|
|
290
|
+
eventType: "issue.updated",
|
|
291
|
+
entityType: "issue",
|
|
292
|
+
entityId: issue.id,
|
|
293
|
+
payload: issue
|
|
294
|
+
});
|
|
295
|
+
return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
router.delete("/:issueId", async (req, res) => {
|
|
299
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
300
|
+
if (res.headersSent) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const deleted = await deleteIssue(ctx.db, req.companyId!, req.params.issueId);
|
|
304
|
+
if (!deleted) {
|
|
305
|
+
return sendError(res, "Issue not found.", 404);
|
|
306
|
+
}
|
|
307
|
+
await appendAuditEvent(ctx.db, {
|
|
308
|
+
companyId: req.companyId!,
|
|
309
|
+
actorType: "human",
|
|
310
|
+
eventType: "issue.deleted",
|
|
311
|
+
entityType: "issue",
|
|
312
|
+
entityId: req.params.issueId,
|
|
313
|
+
payload: { id: req.params.issueId }
|
|
314
|
+
});
|
|
315
|
+
return sendOk(res, { deleted: true });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return router;
|
|
319
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { listAuditEvents, listCostEntries, listHeartbeatRuns } from "bopodev-db";
|
|
3
|
+
import type { AppContext } from "../context";
|
|
4
|
+
import { sendOk } from "../http";
|
|
5
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
6
|
+
|
|
7
|
+
export function createObservabilityRouter(ctx: AppContext) {
|
|
8
|
+
const router = Router();
|
|
9
|
+
router.use(requireCompanyScope);
|
|
10
|
+
|
|
11
|
+
router.get("/logs", async (req, res) => {
|
|
12
|
+
const rows = await listAuditEvents(ctx.db, req.companyId!);
|
|
13
|
+
return sendOk(
|
|
14
|
+
res,
|
|
15
|
+
rows.map((row) => ({
|
|
16
|
+
...row,
|
|
17
|
+
payload: parsePayload(row.payloadJson)
|
|
18
|
+
}))
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
router.get("/costs", async (req, res) => {
|
|
23
|
+
const rows = await listCostEntries(ctx.db, req.companyId!);
|
|
24
|
+
return sendOk(
|
|
25
|
+
res,
|
|
26
|
+
rows.map((row) => ({
|
|
27
|
+
...row,
|
|
28
|
+
usdCost: typeof row.usdCost === "number" ? row.usdCost : Number(row.usdCost ?? 0)
|
|
29
|
+
}))
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
router.get("/heartbeats", async (req, res) => {
|
|
34
|
+
return sendOk(res, await listHeartbeatRuns(ctx.db, req.companyId!));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return router;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePayload(payloadJson: string) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(payloadJson) as unknown;
|
|
43
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { appendAuditEvent, createProject, deleteProject, listProjects, syncProjectGoals, updateProject } from "bopodev-db";
|
|
4
|
+
import type { AppContext } from "../context";
|
|
5
|
+
import { sendError, sendOk } from "../http";
|
|
6
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
7
|
+
import { requirePermission } from "../middleware/request-actor";
|
|
8
|
+
|
|
9
|
+
const projectStatusSchema = z.enum(["planned", "active", "paused", "blocked", "completed", "archived"]);
|
|
10
|
+
|
|
11
|
+
const createProjectSchema = z.object({
|
|
12
|
+
name: z.string().min(1),
|
|
13
|
+
description: z.string().optional(),
|
|
14
|
+
status: projectStatusSchema.default("planned"),
|
|
15
|
+
plannedStartAt: z.string().optional(),
|
|
16
|
+
workspaceLocalPath: z.string().optional(),
|
|
17
|
+
workspaceGithubRepo: z.string().url().optional(),
|
|
18
|
+
goalIds: z.array(z.string().min(1)).default([])
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const updateProjectSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
name: z.string().min(1).optional(),
|
|
24
|
+
description: z.string().nullable().optional(),
|
|
25
|
+
status: projectStatusSchema.optional(),
|
|
26
|
+
plannedStartAt: z.string().nullable().optional(),
|
|
27
|
+
workspaceLocalPath: z.string().nullable().optional(),
|
|
28
|
+
workspaceGithubRepo: z.string().url().nullable().optional(),
|
|
29
|
+
goalIds: z.array(z.string().min(1)).optional()
|
|
30
|
+
})
|
|
31
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
32
|
+
|
|
33
|
+
function parsePlannedStartAt(value?: string | null) {
|
|
34
|
+
if (!value) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const parsed = new Date(value);
|
|
38
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
39
|
+
throw new Error("Invalid plannedStartAt value.");
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createProjectsRouter(ctx: AppContext) {
|
|
45
|
+
const router = Router();
|
|
46
|
+
router.use(requireCompanyScope);
|
|
47
|
+
|
|
48
|
+
router.get("/", async (req, res) => {
|
|
49
|
+
const projects = await listProjects(ctx.db, req.companyId!);
|
|
50
|
+
return sendOk(res, projects);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
router.post("/", async (req, res) => {
|
|
54
|
+
requirePermission("projects:write")(req, res, () => {});
|
|
55
|
+
if (res.headersSent) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const parsed = createProjectSchema.safeParse(req.body);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
return sendError(res, parsed.error.message, 422);
|
|
61
|
+
}
|
|
62
|
+
const project = await createProject(ctx.db, {
|
|
63
|
+
companyId: req.companyId!,
|
|
64
|
+
name: parsed.data.name,
|
|
65
|
+
description: parsed.data.description,
|
|
66
|
+
status: parsed.data.status,
|
|
67
|
+
plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
68
|
+
workspaceLocalPath: parsed.data.workspaceLocalPath,
|
|
69
|
+
workspaceGithubRepo: parsed.data.workspaceGithubRepo
|
|
70
|
+
});
|
|
71
|
+
await syncProjectGoals(ctx.db, {
|
|
72
|
+
companyId: req.companyId!,
|
|
73
|
+
projectId: project.id,
|
|
74
|
+
goalIds: parsed.data.goalIds
|
|
75
|
+
});
|
|
76
|
+
await appendAuditEvent(ctx.db, {
|
|
77
|
+
companyId: req.companyId!,
|
|
78
|
+
actorType: "human",
|
|
79
|
+
eventType: "project.created",
|
|
80
|
+
entityType: "project",
|
|
81
|
+
entityId: project.id,
|
|
82
|
+
payload: project
|
|
83
|
+
});
|
|
84
|
+
return sendOk(res, project);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
router.put("/:projectId", async (req, res) => {
|
|
88
|
+
requirePermission("projects:write")(req, res, () => {});
|
|
89
|
+
if (res.headersSent) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const parsed = updateProjectSchema.safeParse(req.body);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return sendError(res, parsed.error.message, 422);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const project = await updateProject(ctx.db, {
|
|
98
|
+
companyId: req.companyId!,
|
|
99
|
+
id: req.params.projectId,
|
|
100
|
+
name: parsed.data.name,
|
|
101
|
+
description: parsed.data.description,
|
|
102
|
+
status: parsed.data.status,
|
|
103
|
+
plannedStartAt:
|
|
104
|
+
parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
105
|
+
workspaceLocalPath: parsed.data.workspaceLocalPath,
|
|
106
|
+
workspaceGithubRepo: parsed.data.workspaceGithubRepo
|
|
107
|
+
});
|
|
108
|
+
if (!project) {
|
|
109
|
+
return sendError(res, "Project not found.", 404);
|
|
110
|
+
}
|
|
111
|
+
if (parsed.data.goalIds) {
|
|
112
|
+
await syncProjectGoals(ctx.db, {
|
|
113
|
+
companyId: req.companyId!,
|
|
114
|
+
projectId: project.id,
|
|
115
|
+
goalIds: parsed.data.goalIds
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await appendAuditEvent(ctx.db, {
|
|
120
|
+
companyId: req.companyId!,
|
|
121
|
+
actorType: "human",
|
|
122
|
+
eventType: "project.updated",
|
|
123
|
+
entityType: "project",
|
|
124
|
+
entityId: project.id,
|
|
125
|
+
payload: project
|
|
126
|
+
});
|
|
127
|
+
return sendOk(res, project);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
router.delete("/:projectId", async (req, res) => {
|
|
131
|
+
requirePermission("projects:write")(req, res, () => {});
|
|
132
|
+
if (res.headersSent) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const deleted = await deleteProject(ctx.db, req.companyId!, req.params.projectId);
|
|
136
|
+
if (!deleted) {
|
|
137
|
+
return sendError(res, "Project not found.", 404);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await appendAuditEvent(ctx.db, {
|
|
141
|
+
companyId: req.companyId!,
|
|
142
|
+
actorType: "human",
|
|
143
|
+
eventType: "project.deleted",
|
|
144
|
+
entityType: "project",
|
|
145
|
+
entityId: req.params.projectId,
|
|
146
|
+
payload: { id: req.params.projectId }
|
|
147
|
+
});
|
|
148
|
+
return sendOk(res, { deleted: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return router;
|
|
152
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { bootstrapDatabase } from "bopodev-db";
|
|
2
|
+
|
|
3
|
+
async function main() {
|
|
4
|
+
const { client } = await bootstrapDatabase(process.env.BOPO_DB_PATH);
|
|
5
|
+
const maybeClose = (client as { close?: () => Promise<void> }).close;
|
|
6
|
+
if (maybeClose) {
|
|
7
|
+
await maybeClose.call(client);
|
|
8
|
+
}
|
|
9
|
+
// eslint-disable-next-line no-console
|
|
10
|
+
console.log("Database initialized.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
void main();
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { bootstrapDatabase } from "bopodev-db";
|
|
3
|
+
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
4
|
+
import { createApp } from "./app";
|
|
5
|
+
import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
|
|
6
|
+
import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
|
|
7
|
+
import { attachRealtimeHub } from "./realtime/hub";
|
|
8
|
+
import { createHeartbeatScheduler } from "./worker/scheduler";
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const dbPath = process.env.BOPO_DB_PATH;
|
|
12
|
+
const port = Number(process.env.PORT ?? 4020);
|
|
13
|
+
const { db } = await bootstrapDatabase(dbPath);
|
|
14
|
+
const codexCommand = process.env.BOPO_CODEX_COMMAND ?? "codex";
|
|
15
|
+
const getRuntimeHealth = async () => {
|
|
16
|
+
const codex = await checkRuntimeCommandHealth(codexCommand, {
|
|
17
|
+
timeoutMs: 5_000
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
codex
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
|
|
24
|
+
timeoutMs: 5_000
|
|
25
|
+
});
|
|
26
|
+
if (!startupCodexHealth.available) {
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.warn("[startup] Codex command preflight failed.", startupCodexHealth);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const server = createServer();
|
|
32
|
+
const realtimeHub = attachRealtimeHub(server, {
|
|
33
|
+
bootstrapLoaders: {
|
|
34
|
+
governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
|
|
35
|
+
"office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId)
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const app = createApp({ db, getRuntimeHealth, realtimeHub });
|
|
39
|
+
server.on("request", app);
|
|
40
|
+
server.listen(port, () => {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log(`BopoHQ API running on http://localhost:${port}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
46
|
+
if (defaultCompanyId) {
|
|
47
|
+
createHeartbeatScheduler(db, defaultCompanyId, realtimeHub);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
void main();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
import { agents } from "bopodev-db";
|
|
4
|
+
|
|
5
|
+
export interface BudgetCheckResult {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
hardStopped: boolean;
|
|
8
|
+
utilizationPct: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function checkAgentBudget(db: BopoDb, companyId: string, agentId: string): Promise<BudgetCheckResult> {
|
|
12
|
+
const [agent] = await db
|
|
13
|
+
.select()
|
|
14
|
+
.from(agents)
|
|
15
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
|
|
16
|
+
.limit(1);
|
|
17
|
+
|
|
18
|
+
if (!agent) {
|
|
19
|
+
return { allowed: false, hardStopped: true, utilizationPct: 100 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const monthlyBudget = Number(agent.monthlyBudgetUsd);
|
|
23
|
+
const usedBudget = Number(agent.usedBudgetUsd);
|
|
24
|
+
const utilizationPct = monthlyBudget <= 0 ? 0 : (usedBudget / monthlyBudget) * 100;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
allowed: utilizationPct < 100,
|
|
28
|
+
hardStopped: utilizationPct >= 100,
|
|
29
|
+
utilizationPct
|
|
30
|
+
};
|
|
31
|
+
}
|