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,305 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { appendAuditEvent, createAgent, createApprovalRequest, deleteAgent, getApprovalRequest, listAgents, updateAgent } from "bopodev-db";
|
|
4
|
+
import type { AppContext } from "../context";
|
|
5
|
+
import { sendError, sendOk } from "../http";
|
|
6
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
7
|
+
import { requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
8
|
+
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
9
|
+
import {
|
|
10
|
+
publishOfficeOccupantForAgent,
|
|
11
|
+
publishOfficeOccupantForApproval
|
|
12
|
+
} from "../realtime/office-space";
|
|
13
|
+
import { isApprovalRequired } from "../services/governance-service";
|
|
14
|
+
|
|
15
|
+
const createAgentSchema = z.object({
|
|
16
|
+
managerAgentId: z.string().optional(),
|
|
17
|
+
role: z.string().min(1),
|
|
18
|
+
name: z.string().min(1),
|
|
19
|
+
providerType: z.enum(["claude_code", "codex", "http", "shell"]),
|
|
20
|
+
heartbeatCron: z.string().default("*/5 * * * *"),
|
|
21
|
+
monthlyBudgetUsd: z.number().nonnegative().default(30),
|
|
22
|
+
canHireAgents: z.boolean().default(false),
|
|
23
|
+
requestApproval: z.boolean().default(true),
|
|
24
|
+
runtimeCommand: z.string().optional(),
|
|
25
|
+
runtimeArgs: z.array(z.string()).optional(),
|
|
26
|
+
runtimeCwd: z.string().optional(),
|
|
27
|
+
runtimeTimeoutMs: z.number().int().positive().max(600000).optional()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const updateAgentSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
managerAgentId: z.string().nullable().optional(),
|
|
33
|
+
role: z.string().min(1).optional(),
|
|
34
|
+
name: z.string().min(1).optional(),
|
|
35
|
+
providerType: z.enum(["claude_code", "codex", "http", "shell"]).optional(),
|
|
36
|
+
status: z.enum(["idle", "running", "paused", "terminated"]).optional(),
|
|
37
|
+
heartbeatCron: z.string().min(1).optional(),
|
|
38
|
+
monthlyBudgetUsd: z.number().nonnegative().optional(),
|
|
39
|
+
canHireAgents: z.boolean().optional(),
|
|
40
|
+
runtimeCommand: z.string().optional(),
|
|
41
|
+
runtimeArgs: z.array(z.string()).optional(),
|
|
42
|
+
runtimeCwd: z.string().optional(),
|
|
43
|
+
runtimeTimeoutMs: z.number().int().positive().max(600000).optional()
|
|
44
|
+
})
|
|
45
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
46
|
+
|
|
47
|
+
function toAgentResponse(agent: Record<string, unknown>) {
|
|
48
|
+
return {
|
|
49
|
+
...agent,
|
|
50
|
+
monthlyBudgetUsd:
|
|
51
|
+
typeof agent.monthlyBudgetUsd === "number" ? agent.monthlyBudgetUsd : Number(agent.monthlyBudgetUsd ?? 0),
|
|
52
|
+
usedBudgetUsd: typeof agent.usedBudgetUsd === "number" ? agent.usedBudgetUsd : Number(agent.usedBudgetUsd ?? 0)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createAgentsRouter(ctx: AppContext) {
|
|
57
|
+
const router = Router();
|
|
58
|
+
router.use(requireCompanyScope);
|
|
59
|
+
|
|
60
|
+
router.get("/", async (req, res) => {
|
|
61
|
+
const rows = await listAgents(ctx.db, req.companyId!);
|
|
62
|
+
return sendOk(
|
|
63
|
+
res,
|
|
64
|
+
rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>))
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
router.post("/", async (req, res) => {
|
|
69
|
+
const requireCreate = requirePermission("agents:write");
|
|
70
|
+
requireCreate(req, res, () => {});
|
|
71
|
+
if (res.headersSent) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const parsed = createAgentSchema.safeParse(req.body);
|
|
75
|
+
if (!parsed.success) {
|
|
76
|
+
return sendError(res, parsed.error.message, 422);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (parsed.data.requestApproval && isApprovalRequired("hire_agent")) {
|
|
80
|
+
const approvalId = await createApprovalRequest(ctx.db, {
|
|
81
|
+
companyId: req.companyId!,
|
|
82
|
+
action: "hire_agent",
|
|
83
|
+
payload: parsed.data
|
|
84
|
+
});
|
|
85
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, approvalId);
|
|
86
|
+
if (approval) {
|
|
87
|
+
ctx.realtimeHub?.publish(
|
|
88
|
+
createGovernanceRealtimeEvent(req.companyId!, {
|
|
89
|
+
type: "approval.created",
|
|
90
|
+
approval: serializeStoredApproval(approval)
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approvalId);
|
|
94
|
+
}
|
|
95
|
+
return sendOk(res, { queuedForApproval: true, approvalId });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const agent = await createAgent(ctx.db, {
|
|
99
|
+
companyId: req.companyId!,
|
|
100
|
+
managerAgentId: parsed.data.managerAgentId,
|
|
101
|
+
role: parsed.data.role,
|
|
102
|
+
name: parsed.data.name,
|
|
103
|
+
providerType: parsed.data.providerType,
|
|
104
|
+
heartbeatCron: parsed.data.heartbeatCron,
|
|
105
|
+
monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
|
|
106
|
+
canHireAgents: parsed.data.canHireAgents,
|
|
107
|
+
initialState: {
|
|
108
|
+
runtime: {
|
|
109
|
+
command: parsed.data.runtimeCommand,
|
|
110
|
+
args: parsed.data.runtimeArgs,
|
|
111
|
+
cwd: parsed.data.runtimeCwd,
|
|
112
|
+
timeoutMs: parsed.data.runtimeTimeoutMs
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await appendAuditEvent(ctx.db, {
|
|
118
|
+
companyId: req.companyId!,
|
|
119
|
+
actorType: "human",
|
|
120
|
+
eventType: "agent.hired",
|
|
121
|
+
entityType: "agent",
|
|
122
|
+
entityId: agent.id,
|
|
123
|
+
payload: agent
|
|
124
|
+
});
|
|
125
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
126
|
+
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
router.put("/:agentId", async (req, res) => {
|
|
130
|
+
const requireUpdate = requirePermission("agents:write");
|
|
131
|
+
requireUpdate(req, res, () => {});
|
|
132
|
+
if (res.headersSent) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const parsed = updateAgentSchema.safeParse(req.body);
|
|
136
|
+
if (!parsed.success) {
|
|
137
|
+
return sendError(res, parsed.error.message, 422);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const existingAgent = (await listAgents(ctx.db, req.companyId!)).find((row) => row.id === req.params.agentId);
|
|
141
|
+
if (!existingAgent) {
|
|
142
|
+
return sendError(res, "Agent not found.", 404);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hasRuntimePatch =
|
|
146
|
+
parsed.data.runtimeCommand !== undefined ||
|
|
147
|
+
parsed.data.runtimeArgs !== undefined ||
|
|
148
|
+
parsed.data.runtimeCwd !== undefined ||
|
|
149
|
+
parsed.data.runtimeTimeoutMs !== undefined;
|
|
150
|
+
|
|
151
|
+
let stateBlobPatch: Record<string, unknown> | undefined;
|
|
152
|
+
if (hasRuntimePatch) {
|
|
153
|
+
const existingState = (() => {
|
|
154
|
+
try {
|
|
155
|
+
const parsedState = JSON.parse(existingAgent.stateBlob ?? "{}") as Record<string, unknown>;
|
|
156
|
+
return typeof parsedState === "object" && parsedState !== null ? parsedState : {};
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
const existingRuntime =
|
|
162
|
+
typeof existingState.runtime === "object" && existingState.runtime !== null
|
|
163
|
+
? (existingState.runtime as Record<string, unknown>)
|
|
164
|
+
: {};
|
|
165
|
+
stateBlobPatch = {
|
|
166
|
+
...existingState,
|
|
167
|
+
runtime: {
|
|
168
|
+
...existingRuntime,
|
|
169
|
+
...(parsed.data.runtimeCommand === undefined ? {} : { command: parsed.data.runtimeCommand }),
|
|
170
|
+
...(parsed.data.runtimeArgs === undefined ? {} : { args: parsed.data.runtimeArgs }),
|
|
171
|
+
...(parsed.data.runtimeCwd === undefined ? {} : { cwd: parsed.data.runtimeCwd }),
|
|
172
|
+
...(parsed.data.runtimeTimeoutMs === undefined ? {} : { timeoutMs: parsed.data.runtimeTimeoutMs })
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const agent = await updateAgent(ctx.db, {
|
|
178
|
+
companyId: req.companyId!,
|
|
179
|
+
id: req.params.agentId,
|
|
180
|
+
managerAgentId: parsed.data.managerAgentId,
|
|
181
|
+
role: parsed.data.role,
|
|
182
|
+
name: parsed.data.name,
|
|
183
|
+
providerType: parsed.data.providerType,
|
|
184
|
+
status: parsed.data.status,
|
|
185
|
+
heartbeatCron: parsed.data.heartbeatCron,
|
|
186
|
+
monthlyBudgetUsd:
|
|
187
|
+
typeof parsed.data.monthlyBudgetUsd === "number" ? parsed.data.monthlyBudgetUsd.toFixed(4) : undefined,
|
|
188
|
+
canHireAgents: parsed.data.canHireAgents,
|
|
189
|
+
stateBlob: stateBlobPatch
|
|
190
|
+
});
|
|
191
|
+
if (!agent) {
|
|
192
|
+
return sendError(res, "Agent not found.", 404);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await appendAuditEvent(ctx.db, {
|
|
196
|
+
companyId: req.companyId!,
|
|
197
|
+
actorType: "human",
|
|
198
|
+
eventType: "agent.updated",
|
|
199
|
+
entityType: "agent",
|
|
200
|
+
entityId: agent.id,
|
|
201
|
+
payload: agent
|
|
202
|
+
});
|
|
203
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
204
|
+
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
router.delete("/:agentId", async (req, res) => {
|
|
208
|
+
requireBoardRole(req, res, () => {});
|
|
209
|
+
if (res.headersSent) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const deleted = await deleteAgent(ctx.db, req.companyId!, req.params.agentId);
|
|
213
|
+
if (!deleted) {
|
|
214
|
+
return sendError(res, "Agent not found.", 404);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await appendAuditEvent(ctx.db, {
|
|
218
|
+
companyId: req.companyId!,
|
|
219
|
+
actorType: "human",
|
|
220
|
+
eventType: "agent.deleted",
|
|
221
|
+
entityType: "agent",
|
|
222
|
+
entityId: req.params.agentId,
|
|
223
|
+
payload: { id: req.params.agentId }
|
|
224
|
+
});
|
|
225
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, req.params.agentId);
|
|
226
|
+
return sendOk(res, { deleted: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
router.post("/:agentId/pause", async (req, res) => {
|
|
230
|
+
requirePermission("agents:lifecycle")(req, res, () => {});
|
|
231
|
+
if (res.headersSent) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const agent = await updateAgent(ctx.db, {
|
|
235
|
+
companyId: req.companyId!,
|
|
236
|
+
id: req.params.agentId,
|
|
237
|
+
status: "paused"
|
|
238
|
+
});
|
|
239
|
+
if (!agent) {
|
|
240
|
+
return sendError(res, "Agent not found.", 404);
|
|
241
|
+
}
|
|
242
|
+
await appendAuditEvent(ctx.db, {
|
|
243
|
+
companyId: req.companyId!,
|
|
244
|
+
actorType: "human",
|
|
245
|
+
eventType: "agent.paused",
|
|
246
|
+
entityType: "agent",
|
|
247
|
+
entityId: agent.id,
|
|
248
|
+
payload: { id: agent.id, status: agent.status }
|
|
249
|
+
});
|
|
250
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
251
|
+
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
router.post("/:agentId/resume", async (req, res) => {
|
|
255
|
+
requirePermission("agents:lifecycle")(req, res, () => {});
|
|
256
|
+
if (res.headersSent) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const agent = await updateAgent(ctx.db, {
|
|
260
|
+
companyId: req.companyId!,
|
|
261
|
+
id: req.params.agentId,
|
|
262
|
+
status: "idle"
|
|
263
|
+
});
|
|
264
|
+
if (!agent) {
|
|
265
|
+
return sendError(res, "Agent not found.", 404);
|
|
266
|
+
}
|
|
267
|
+
await appendAuditEvent(ctx.db, {
|
|
268
|
+
companyId: req.companyId!,
|
|
269
|
+
actorType: "human",
|
|
270
|
+
eventType: "agent.resumed",
|
|
271
|
+
entityType: "agent",
|
|
272
|
+
entityId: agent.id,
|
|
273
|
+
payload: { id: agent.id, status: agent.status }
|
|
274
|
+
});
|
|
275
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
276
|
+
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
router.post("/:agentId/terminate", async (req, res) => {
|
|
280
|
+
requireBoardRole(req, res, () => {});
|
|
281
|
+
if (res.headersSent) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const agent = await updateAgent(ctx.db, {
|
|
285
|
+
companyId: req.companyId!,
|
|
286
|
+
id: req.params.agentId,
|
|
287
|
+
status: "terminated"
|
|
288
|
+
});
|
|
289
|
+
if (!agent) {
|
|
290
|
+
return sendError(res, "Agent not found.", 404);
|
|
291
|
+
}
|
|
292
|
+
await appendAuditEvent(ctx.db, {
|
|
293
|
+
companyId: req.companyId!,
|
|
294
|
+
actorType: "human",
|
|
295
|
+
eventType: "agent.terminated",
|
|
296
|
+
entityType: "agent",
|
|
297
|
+
entityId: agent.id,
|
|
298
|
+
payload: { id: agent.id, status: agent.status }
|
|
299
|
+
});
|
|
300
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, agent.id);
|
|
301
|
+
return sendOk(res, toAgentResponse(agent as unknown as Record<string, unknown>));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return router;
|
|
305
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
4
|
+
import type { AppContext } from "../context";
|
|
5
|
+
import { sendError, sendOk } from "../http";
|
|
6
|
+
|
|
7
|
+
const createCompanySchema = z.object({
|
|
8
|
+
name: z.string().min(1),
|
|
9
|
+
mission: z.string().optional()
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const updateCompanySchema = z
|
|
13
|
+
.object({
|
|
14
|
+
name: z.string().min(1).optional(),
|
|
15
|
+
mission: z.string().optional()
|
|
16
|
+
})
|
|
17
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
18
|
+
|
|
19
|
+
export function createCompaniesRouter(ctx: AppContext) {
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
router.get("/", async (_req, res) => {
|
|
23
|
+
const companies = await listCompanies(ctx.db);
|
|
24
|
+
return sendOk(res, companies);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
router.post("/", async (req, res) => {
|
|
28
|
+
const parsed = createCompanySchema.safeParse(req.body);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return sendError(res, parsed.error.message, 422);
|
|
31
|
+
}
|
|
32
|
+
const company = await createCompany(ctx.db, parsed.data);
|
|
33
|
+
return sendOk(res, company);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
router.put("/:companyId", async (req, res) => {
|
|
37
|
+
const parsed = updateCompanySchema.safeParse(req.body);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
return sendError(res, parsed.error.message, 422);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const company = await updateCompany(ctx.db, { id: req.params.companyId, ...parsed.data });
|
|
43
|
+
if (!company) {
|
|
44
|
+
return sendError(res, "Company not found.", 404);
|
|
45
|
+
}
|
|
46
|
+
return sendOk(res, company);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
router.delete("/:companyId", async (req, res) => {
|
|
50
|
+
const deleted = await deleteCompany(ctx.db, req.params.companyId);
|
|
51
|
+
if (!deleted) {
|
|
52
|
+
return sendError(res, "Company not found.", 404);
|
|
53
|
+
}
|
|
54
|
+
return sendOk(res, { deleted: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return router;
|
|
58
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApprovalRequest, listGoals, updateGoal } 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
|
+
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
9
|
+
import { isApprovalRequired } from "../services/governance-service";
|
|
10
|
+
|
|
11
|
+
const createGoalSchema = z.object({
|
|
12
|
+
projectId: z.string().optional(),
|
|
13
|
+
parentGoalId: z.string().optional(),
|
|
14
|
+
level: z.enum(["company", "project", "agent"]),
|
|
15
|
+
title: z.string().min(1),
|
|
16
|
+
description: z.string().optional(),
|
|
17
|
+
activateNow: z.boolean().default(false)
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const updateGoalSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
projectId: z.string().nullable().optional(),
|
|
23
|
+
parentGoalId: z.string().nullable().optional(),
|
|
24
|
+
level: z.enum(["company", "project", "agent"]).optional(),
|
|
25
|
+
title: z.string().min(1).optional(),
|
|
26
|
+
description: z.string().nullable().optional(),
|
|
27
|
+
status: z.enum(["draft", "active", "completed", "archived"]).optional()
|
|
28
|
+
})
|
|
29
|
+
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
30
|
+
|
|
31
|
+
export function createGoalsRouter(ctx: AppContext) {
|
|
32
|
+
const router = Router();
|
|
33
|
+
router.use(requireCompanyScope);
|
|
34
|
+
|
|
35
|
+
router.get("/", async (req, res) => {
|
|
36
|
+
return sendOk(res, await listGoals(ctx.db, req.companyId!));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.post("/", async (req, res) => {
|
|
40
|
+
requirePermission("goals:write")(req, res, () => {});
|
|
41
|
+
if (res.headersSent) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const parsed = createGoalSchema.safeParse(req.body);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
return sendError(res, parsed.error.message, 422);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (parsed.data.activateNow && isApprovalRequired("activate_goal")) {
|
|
50
|
+
const approvalId = await createApprovalRequest(ctx.db, {
|
|
51
|
+
companyId: req.companyId!,
|
|
52
|
+
action: "activate_goal",
|
|
53
|
+
payload: parsed.data
|
|
54
|
+
});
|
|
55
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, approvalId);
|
|
56
|
+
if (approval) {
|
|
57
|
+
ctx.realtimeHub?.publish(
|
|
58
|
+
createGovernanceRealtimeEvent(req.companyId!, {
|
|
59
|
+
type: "approval.created",
|
|
60
|
+
approval: serializeStoredApproval(approval)
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return sendOk(res, { queuedForApproval: true, approvalId });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const goal = await createGoal(ctx.db, {
|
|
68
|
+
companyId: req.companyId!,
|
|
69
|
+
projectId: parsed.data.projectId,
|
|
70
|
+
parentGoalId: parsed.data.parentGoalId,
|
|
71
|
+
level: parsed.data.level,
|
|
72
|
+
title: parsed.data.title,
|
|
73
|
+
description: parsed.data.description
|
|
74
|
+
});
|
|
75
|
+
await appendAuditEvent(ctx.db, {
|
|
76
|
+
companyId: req.companyId!,
|
|
77
|
+
actorType: "human",
|
|
78
|
+
eventType: "goal.created",
|
|
79
|
+
entityType: "goal",
|
|
80
|
+
entityId: goal.id,
|
|
81
|
+
payload: goal
|
|
82
|
+
});
|
|
83
|
+
return sendOk(res, goal);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
router.put("/:goalId", async (req, res) => {
|
|
87
|
+
requirePermission("goals:write")(req, res, () => {});
|
|
88
|
+
if (res.headersSent) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const parsed = updateGoalSchema.safeParse(req.body);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
return sendError(res, parsed.error.message, 422);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const goal = await updateGoal(ctx.db, { companyId: req.companyId!, id: req.params.goalId, ...parsed.data });
|
|
97
|
+
if (!goal) {
|
|
98
|
+
return sendError(res, "Goal not found.", 404);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await appendAuditEvent(ctx.db, {
|
|
102
|
+
companyId: req.companyId!,
|
|
103
|
+
actorType: "human",
|
|
104
|
+
eventType: "goal.updated",
|
|
105
|
+
entityType: "goal",
|
|
106
|
+
entityId: goal.id,
|
|
107
|
+
payload: goal
|
|
108
|
+
});
|
|
109
|
+
return sendOk(res, goal);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
router.delete("/:goalId", async (req, res) => {
|
|
113
|
+
requirePermission("goals:write")(req, res, () => {});
|
|
114
|
+
if (res.headersSent) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const deleted = await deleteGoal(ctx.db, req.companyId!, req.params.goalId);
|
|
118
|
+
if (!deleted) {
|
|
119
|
+
return sendError(res, "Goal not found.", 404);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await appendAuditEvent(ctx.db, {
|
|
123
|
+
companyId: req.companyId!,
|
|
124
|
+
actorType: "human",
|
|
125
|
+
eventType: "goal.deleted",
|
|
126
|
+
entityType: "goal",
|
|
127
|
+
entityId: req.params.goalId,
|
|
128
|
+
payload: { id: req.params.goalId }
|
|
129
|
+
});
|
|
130
|
+
return sendOk(res, { deleted: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return router;
|
|
134
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
appendAuditEvent,
|
|
5
|
+
clearApprovalInboxDismissed,
|
|
6
|
+
getApprovalRequest,
|
|
7
|
+
listApprovalInboxStates,
|
|
8
|
+
listApprovalRequests,
|
|
9
|
+
markApprovalInboxDismissed,
|
|
10
|
+
markApprovalInboxSeen
|
|
11
|
+
} from "bopodev-db";
|
|
12
|
+
import type { AppContext } from "../context";
|
|
13
|
+
import { sendError, sendOk } from "../http";
|
|
14
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
15
|
+
import { requirePermission } from "../middleware/request-actor";
|
|
16
|
+
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
17
|
+
import {
|
|
18
|
+
publishOfficeOccupantForAgent,
|
|
19
|
+
publishOfficeOccupantForApproval
|
|
20
|
+
} from "../realtime/office-space";
|
|
21
|
+
import { GovernanceError, resolveApproval } from "../services/governance-service";
|
|
22
|
+
|
|
23
|
+
const resolveSchema = z.object({
|
|
24
|
+
approvalId: z.string().min(1),
|
|
25
|
+
status: z.enum(["approved", "rejected", "overridden"])
|
|
26
|
+
});
|
|
27
|
+
const inboxMutationSchema = z.object({
|
|
28
|
+
approvalId: z.string().min(1)
|
|
29
|
+
});
|
|
30
|
+
const RESOLVED_APPROVAL_INBOX_WINDOW_DAYS = 30;
|
|
31
|
+
|
|
32
|
+
export function createGovernanceRouter(ctx: AppContext) {
|
|
33
|
+
const router = Router();
|
|
34
|
+
router.use(requireCompanyScope);
|
|
35
|
+
|
|
36
|
+
router.get("/approvals", async (req, res) => {
|
|
37
|
+
const approvals = await listApprovalRequests(ctx.db, req.companyId!);
|
|
38
|
+
return sendOk(
|
|
39
|
+
res,
|
|
40
|
+
approvals.map((approval) => ({
|
|
41
|
+
...approval,
|
|
42
|
+
payload: parsePayload(approval.payloadJson)
|
|
43
|
+
}))
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
router.get("/inbox", async (req, res) => {
|
|
48
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
49
|
+
const [approvals, inboxStates] = await Promise.all([
|
|
50
|
+
listApprovalRequests(ctx.db, req.companyId!),
|
|
51
|
+
listApprovalInboxStates(ctx.db, req.companyId!, actorId)
|
|
52
|
+
]);
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const resolvedWindowMs = RESOLVED_APPROVAL_INBOX_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
55
|
+
const inboxStateByApprovalId = new Map(inboxStates.map((state) => [state.approvalId, state]));
|
|
56
|
+
const items = approvals
|
|
57
|
+
.filter((approval) => {
|
|
58
|
+
if (approval.status === "pending") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (!approval.resolvedAt) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return now - approval.resolvedAt.getTime() <= resolvedWindowMs;
|
|
65
|
+
})
|
|
66
|
+
.map((approval) => {
|
|
67
|
+
const inboxState = inboxStateByApprovalId.get(approval.id);
|
|
68
|
+
return {
|
|
69
|
+
approval: serializeStoredApproval(approval),
|
|
70
|
+
seenAt: inboxState?.seenAt?.toISOString() ?? null,
|
|
71
|
+
dismissedAt: inboxState?.dismissedAt?.toISOString() ?? null,
|
|
72
|
+
isPending: approval.status === "pending"
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return sendOk(res, {
|
|
77
|
+
actorId,
|
|
78
|
+
resolvedWindowDays: RESOLVED_APPROVAL_INBOX_WINDOW_DAYS,
|
|
79
|
+
items
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.post("/inbox/:approvalId/seen", async (req, res) => {
|
|
84
|
+
const parsed = inboxMutationSchema.safeParse(req.params);
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
return sendError(res, parsed.error.message, 422);
|
|
87
|
+
}
|
|
88
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, parsed.data.approvalId);
|
|
89
|
+
if (!approval) {
|
|
90
|
+
return sendError(res, "Approval request not found.", 404);
|
|
91
|
+
}
|
|
92
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
93
|
+
await markApprovalInboxSeen(ctx.db, {
|
|
94
|
+
companyId: req.companyId!,
|
|
95
|
+
actorId,
|
|
96
|
+
approvalId: approval.id
|
|
97
|
+
});
|
|
98
|
+
return sendOk(res, { ok: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.post("/inbox/:approvalId/dismiss", async (req, res) => {
|
|
102
|
+
const parsed = inboxMutationSchema.safeParse(req.params);
|
|
103
|
+
if (!parsed.success) {
|
|
104
|
+
return sendError(res, parsed.error.message, 422);
|
|
105
|
+
}
|
|
106
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, parsed.data.approvalId);
|
|
107
|
+
if (!approval) {
|
|
108
|
+
return sendError(res, "Approval request not found.", 404);
|
|
109
|
+
}
|
|
110
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
111
|
+
await markApprovalInboxDismissed(ctx.db, {
|
|
112
|
+
companyId: req.companyId!,
|
|
113
|
+
actorId,
|
|
114
|
+
approvalId: approval.id
|
|
115
|
+
});
|
|
116
|
+
return sendOk(res, { ok: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
router.post("/inbox/:approvalId/undismiss", async (req, res) => {
|
|
120
|
+
const parsed = inboxMutationSchema.safeParse(req.params);
|
|
121
|
+
if (!parsed.success) {
|
|
122
|
+
return sendError(res, parsed.error.message, 422);
|
|
123
|
+
}
|
|
124
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, parsed.data.approvalId);
|
|
125
|
+
if (!approval) {
|
|
126
|
+
return sendError(res, "Approval request not found.", 404);
|
|
127
|
+
}
|
|
128
|
+
const actorId = req.actor?.id ?? "local-board";
|
|
129
|
+
await clearApprovalInboxDismissed(ctx.db, {
|
|
130
|
+
companyId: req.companyId!,
|
|
131
|
+
actorId,
|
|
132
|
+
approvalId: approval.id
|
|
133
|
+
});
|
|
134
|
+
return sendOk(res, { ok: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
router.post("/resolve", async (req, res) => {
|
|
138
|
+
requirePermission("governance:resolve")(req, res, () => {});
|
|
139
|
+
if (res.headersSent) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const parsed = resolveSchema.safeParse(req.body);
|
|
143
|
+
if (!parsed.success) {
|
|
144
|
+
return sendError(res, parsed.error.message, 422);
|
|
145
|
+
}
|
|
146
|
+
let resolution;
|
|
147
|
+
try {
|
|
148
|
+
resolution = await resolveApproval(ctx.db, req.companyId!, parsed.data.approvalId, parsed.data.status);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof GovernanceError) {
|
|
151
|
+
return sendError(res, error.message, 422);
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await appendAuditEvent(ctx.db, {
|
|
157
|
+
companyId: req.companyId!,
|
|
158
|
+
actorType: "human",
|
|
159
|
+
eventType: "governance.approval_resolved",
|
|
160
|
+
entityType: "approval_request",
|
|
161
|
+
entityId: parsed.data.approvalId,
|
|
162
|
+
payload: resolution
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (resolution.execution.applied && resolution.execution.entityType && resolution.execution.entityId) {
|
|
166
|
+
await appendAuditEvent(ctx.db, {
|
|
167
|
+
companyId: req.companyId!,
|
|
168
|
+
actorType: "human",
|
|
169
|
+
eventType:
|
|
170
|
+
resolution.execution.entityType === "agent" ? "agent.hired_from_approval" : "goal.activated_from_approval",
|
|
171
|
+
entityType: resolution.execution.entityType,
|
|
172
|
+
entityId: resolution.execution.entityId,
|
|
173
|
+
payload: resolution.execution.entity ?? { id: resolution.execution.entityId }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const approval = await getApprovalRequest(ctx.db, req.companyId!, parsed.data.approvalId);
|
|
178
|
+
if (approval) {
|
|
179
|
+
ctx.realtimeHub?.publish(
|
|
180
|
+
createGovernanceRealtimeEvent(req.companyId!, {
|
|
181
|
+
type: "approval.resolved",
|
|
182
|
+
approval: serializeStoredApproval(approval)
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approval.id);
|
|
186
|
+
if (approval.requestedByAgentId) {
|
|
187
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (resolution.execution.entityType === "agent" && resolution.execution.entityId) {
|
|
192
|
+
await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, resolution.execution.entityId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return sendOk(res, resolution);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return router;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parsePayload(payloadJson: string) {
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(payloadJson) as unknown;
|
|
204
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
205
|
+
} catch {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
}
|