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.
@@ -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
+ }