bopodev-api 0.1.12 → 0.1.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 BopoDev contributors
3
+ Copyright (c) 2026 Bopo
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,16 +13,18 @@
13
13
  "dotenv": "^17.0.1",
14
14
  "drizzle-orm": "^0.44.5",
15
15
  "express": "^5.1.0",
16
+ "multer": "^2.1.1",
16
17
  "nanoid": "^5.1.5",
17
18
  "ws": "^8.19.0",
18
19
  "zod": "^4.1.5",
19
- "bopodev-contracts": "0.1.12",
20
- "bopodev-db": "0.1.12",
21
- "bopodev-agent-sdk": "0.1.12"
20
+ "bopodev-db": "0.1.14",
21
+ "bopodev-contracts": "0.1.14",
22
+ "bopodev-agent-sdk": "0.1.14"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/cors": "^2.8.19",
25
26
  "@types/express": "^5.0.3",
27
+ "@types/multer": "^2.1.0",
26
28
  "@types/ws": "^8.18.1",
27
29
  "tsx": "^4.20.5"
28
30
  },
package/src/app.ts CHANGED
@@ -13,6 +13,7 @@ import { createHeartbeatRouter } from "./routes/heartbeats";
13
13
  import { createIssuesRouter } from "./routes/issues";
14
14
  import { createObservabilityRouter } from "./routes/observability";
15
15
  import { createProjectsRouter } from "./routes/projects";
16
+ import { createPluginsRouter } from "./routes/plugins";
16
17
  import { sendError } from "./http";
17
18
  import { attachRequestActor } from "./middleware/request-actor";
18
19
 
@@ -62,6 +63,7 @@ export function createApp(ctx: AppContext) {
62
63
  app.use("/governance", createGovernanceRouter(ctx));
63
64
  app.use("/heartbeats", createHeartbeatRouter(ctx));
64
65
  app.use("/observability", createObservabilityRouter(ctx));
66
+ app.use("/plugins", createPluginsRouter(ctx));
65
67
 
66
68
  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
67
69
  if (error instanceof RepositoryValidationError) {
@@ -39,10 +39,39 @@ export function requiresRuntimeCwd(providerType: string) {
39
39
  providerType === "claude_code" ||
40
40
  providerType === "cursor" ||
41
41
  providerType === "opencode" ||
42
+ providerType === "gemini_cli" ||
42
43
  providerType === "shell"
43
44
  );
44
45
  }
45
46
 
47
+ export function resolveDefaultRuntimeModelForProvider(providerType: string | undefined) {
48
+ const normalizedProviderType = providerType?.trim() ?? "";
49
+ if (normalizedProviderType === "claude_code") {
50
+ return "claude-sonnet-4-6";
51
+ }
52
+ if (normalizedProviderType === "codex") {
53
+ return "gpt-5.3-codex";
54
+ }
55
+ if (normalizedProviderType === "opencode") {
56
+ return "opencode/big-pickle";
57
+ }
58
+ if (normalizedProviderType === "gemini_cli") {
59
+ return "gemini-2.5-pro";
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export function resolveRuntimeModelForProvider(
65
+ providerType: string | undefined,
66
+ runtimeModel: string | undefined
67
+ ) {
68
+ const normalizedRuntimeModel = runtimeModel?.trim() || undefined;
69
+ if (normalizedRuntimeModel) {
70
+ return normalizedRuntimeModel;
71
+ }
72
+ return resolveDefaultRuntimeModelForProvider(providerType);
73
+ }
74
+
46
75
  export function normalizeRuntimeConfig(input: {
47
76
  runtimeConfig?: Partial<AgentRuntimeConfig>;
48
77
  legacy?: LegacyRuntimeFields;
@@ -116,12 +145,15 @@ export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>):
116
145
  ? timeoutSecFromColumn
117
146
  : (toSeconds(fallback.timeoutMs) ?? 0);
118
147
 
148
+ const providerType = toText(agent.providerType);
149
+ const runtimeModel = resolveRuntimeModelForProvider(providerType, toText(agent.runtimeModel) ?? fallback.model);
150
+
119
151
  return {
120
152
  runtimeCommand: toText(agent.runtimeCommand) ?? fallback.command,
121
153
  runtimeArgs,
122
154
  runtimeCwd: toText(agent.runtimeCwd) ?? fallback.cwd,
123
155
  runtimeEnv,
124
- runtimeModel: toText(agent.runtimeModel),
156
+ runtimeModel,
125
157
  runtimeThinkingEffort: parseThinkingEffort(agent.runtimeThinkingEffort),
126
158
  bootstrapPrompt: toText(agent.bootstrapPrompt),
127
159
  runtimeTimeoutSec: Math.max(0, timeoutSec),
@@ -165,6 +197,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
165
197
  args?: string[];
166
198
  cwd?: string;
167
199
  env?: Record<string, string>;
200
+ model?: string;
168
201
  timeoutMs?: number;
169
202
  };
170
203
  }
@@ -175,6 +208,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
175
208
  args?: unknown;
176
209
  cwd?: unknown;
177
210
  env?: unknown;
211
+ model?: unknown;
178
212
  timeoutMs?: unknown;
179
213
  };
180
214
  };
@@ -184,6 +218,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
184
218
  args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined,
185
219
  cwd: typeof runtime.cwd === "string" ? runtime.cwd : undefined,
186
220
  env: toRecord(runtime.env),
221
+ model: typeof runtime.model === "string" && runtime.model.trim().length > 0 ? runtime.model.trim() : undefined,
187
222
  timeoutMs: toNumber(runtime.timeoutMs)
188
223
  };
189
224
  } catch {
@@ -62,6 +62,18 @@ export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: st
62
62
  return join(resolveBopoInstanceRoot(), "workspaces", companyId, "agents", agentId);
63
63
  }
64
64
 
65
+ export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
66
+ return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
67
+ }
68
+
69
+ export function resolveAgentDurableMemoryPath(companyId: string, agentId: string) {
70
+ return join(resolveAgentMemoryRootPath(companyId, agentId), "life");
71
+ }
72
+
73
+ export function resolveAgentDailyMemoryPath(companyId: string, agentId: string) {
74
+ return join(resolveAgentMemoryRootPath(companyId, agentId), "memory");
75
+ }
76
+
65
77
  export function resolveStorageRoot() {
66
78
  return join(resolveBopoInstanceRoot(), "data", "storage");
67
79
  }
@@ -0,0 +1,11 @@
1
+ import type { NormalizedRuntimeConfig } from "./agent-config";
2
+
3
+ export async function resolveOpencodeRuntimeModel(
4
+ providerType: string,
5
+ runtimeConfig: NormalizedRuntimeConfig
6
+ ): Promise<string | undefined> {
7
+ if (providerType !== "opencode") {
8
+ return runtimeConfig.runtimeModel?.trim() || undefined;
9
+ }
10
+ return runtimeConfig.runtimeModel?.trim() || undefined;
11
+ }
@@ -5,6 +5,7 @@ import {
5
5
  isInsidePath,
6
6
  normalizeAbsolutePath,
7
7
  resolveAgentFallbackWorkspacePath,
8
+ resolveAgentMemoryRootPath,
8
9
  resolveCompanyProjectsWorkspacePath
9
10
  } from "./instance-paths";
10
11
 
@@ -73,3 +74,7 @@ export function ensureRuntimeWorkspaceCompatible(projectWorkspacePath: string, r
73
74
  export function resolveAgentFallbackWorkspace(companyId: string, agentId: string) {
74
75
  return resolveAgentFallbackWorkspacePath(companyId, agentId);
75
76
  }
77
+
78
+ export function resolveAgentMemoryRoot(companyId: string, agentId: string) {
79
+ return resolveAgentMemoryRootPath(companyId, agentId);
80
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ HeartbeatRunRealtimeEvent,
3
+ RealtimeEventEnvelope,
4
+ RealtimeMessage
5
+ } from "bopodev-contracts";
6
+ import { listHeartbeatRunMessagesForRuns, listHeartbeatRuns, type BopoDb } from "bopodev-db";
7
+
8
+ export function createHeartbeatRunsRealtimeEvent(
9
+ companyId: string,
10
+ event: HeartbeatRunRealtimeEvent
11
+ ): Extract<RealtimeMessage, { kind: "event" }> {
12
+ return createRealtimeEvent(companyId, {
13
+ channel: "heartbeat-runs",
14
+ event
15
+ });
16
+ }
17
+
18
+ export async function loadHeartbeatRunsRealtimeSnapshot(
19
+ db: BopoDb,
20
+ companyId: string
21
+ ): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
22
+ const runs = await listHeartbeatRuns(db, companyId, 8);
23
+ const transcriptsByRunId = await listHeartbeatRunMessagesForRuns(db, {
24
+ companyId,
25
+ runIds: runs.map((run) => run.id),
26
+ perRunLimit: 60
27
+ });
28
+ const transcripts = runs.map((run) => {
29
+ const result = transcriptsByRunId.get(run.id) ?? { items: [], nextCursor: null };
30
+ return {
31
+ runId: run.id,
32
+ messages: result.items.map((message) => ({
33
+ id: message.id,
34
+ runId: message.runId,
35
+ sequence: message.sequence,
36
+ kind: message.kind as
37
+ | "system"
38
+ | "assistant"
39
+ | "thinking"
40
+ | "tool_call"
41
+ | "tool_result"
42
+ | "result"
43
+ | "stderr",
44
+ label: message.label,
45
+ text: message.text,
46
+ payload: message.payloadJson,
47
+ signalLevel: (message.signalLevel as "high" | "medium" | "low" | "noise" | null) ?? undefined,
48
+ groupKey: message.groupKey,
49
+ source: (message.source as "stdout" | "stderr" | "trace_fallback" | null) ?? undefined,
50
+ createdAt: message.createdAt.toISOString()
51
+ })),
52
+ nextCursor: result.nextCursor
53
+ };
54
+ });
55
+
56
+ return createHeartbeatRunsRealtimeEvent(companyId, {
57
+ type: "runs.snapshot",
58
+ runs: runs.map((run) => ({
59
+ runId: run.id,
60
+ status: run.status as "started" | "completed" | "failed" | "skipped",
61
+ message: run.message ?? null,
62
+ startedAt: run.startedAt.toISOString(),
63
+ finishedAt: run.finishedAt?.toISOString() ?? null
64
+ })),
65
+ transcripts
66
+ });
67
+ }
68
+
69
+ function createRealtimeEvent(
70
+ companyId: string,
71
+ envelope: Extract<RealtimeEventEnvelope, { channel: "heartbeat-runs" }>
72
+ ): Extract<RealtimeMessage, { kind: "event" }> {
73
+ return {
74
+ kind: "event",
75
+ companyId,
76
+ ...envelope
77
+ };
78
+ }
@@ -25,7 +25,7 @@ export function attachRealtimeHub(
25
25
 
26
26
  wss.on("connection", async (socket, request) => {
27
27
  const subscription = getSubscription(request.url);
28
- if (!subscription) {
28
+ if (!subscription || !canSubscribeToCompany(request.headers, subscription.companyId)) {
29
29
  socket.close(1008, "Invalid realtime subscription");
30
30
  return;
31
31
  }
@@ -131,6 +131,42 @@ function getSubscription(requestUrl: string | undefined) {
131
131
  };
132
132
  }
133
133
 
134
+ function canSubscribeToCompany(
135
+ headers: Record<string, string | string[] | undefined>,
136
+ companyId: string
137
+ ) {
138
+ const actorType = readHeader(headers, "x-actor-type")?.toLowerCase();
139
+ const actorCompanies = parseCommaList(readHeader(headers, "x-actor-companies"));
140
+ const hasActorHeaders = Boolean(actorType || actorCompanies.length > 0);
141
+ const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
142
+
143
+ if (!hasActorHeaders) {
144
+ return allowLocalBoardFallback;
145
+ }
146
+ if (actorType === "board") {
147
+ return true;
148
+ }
149
+ return actorCompanies.includes(companyId);
150
+ }
151
+
152
+ function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
153
+ const value = headers[name];
154
+ if (Array.isArray(value)) {
155
+ return value[0];
156
+ }
157
+ return value;
158
+ }
159
+
160
+ function parseCommaList(value: string | undefined) {
161
+ if (!value) {
162
+ return [] as string[];
163
+ }
164
+ return value
165
+ .split(",")
166
+ .map((entry) => entry.trim())
167
+ .filter((entry) => entry.length > 0);
168
+ }
169
+
134
170
  function buildSubscriptionKey(companyId: string, channel: RealtimeChannel) {
135
171
  return `${companyId}:${channel}`;
136
172
  }
@@ -427,7 +427,16 @@ function sortOccupants(occupants: OfficeOccupant[]) {
427
427
  }
428
428
 
429
429
  function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
430
- return value === "claude_code" || value === "codex" || value === "http" || value === "shell" ? value : null;
430
+ return value === "claude_code" ||
431
+ value === "codex" ||
432
+ value === "cursor" ||
433
+ value === "opencode" ||
434
+ value === "openai_api" ||
435
+ value === "anthropic_api" ||
436
+ value === "http" ||
437
+ value === "shell"
438
+ ? value
439
+ : null;
431
440
  }
432
441
 
433
442
  function formatActionLabel(action: string) {
@@ -14,6 +14,7 @@ import {
14
14
  deleteAgent,
15
15
  getApprovalRequest,
16
16
  listAgents,
17
+ listApprovalRequests,
17
18
  updateAgent
18
19
  } from "bopodev-db";
19
20
  import type { AppContext } from "../context";
@@ -21,10 +22,12 @@ import { sendError, sendOk } from "../http";
21
22
  import {
22
23
  normalizeRuntimeConfig,
23
24
  parseRuntimeConfigFromAgentRow,
25
+ resolveRuntimeModelForProvider,
24
26
  requiresRuntimeCwd,
25
27
  runtimeConfigToDb,
26
28
  runtimeConfigToStateBlobPatch
27
29
  } from "../lib/agent-config";
30
+ import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
28
31
  import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
29
32
  import { requireCompanyScope } from "../middleware/company-scope";
30
33
  import { requireBoardRole, requirePermission } from "../middleware/request-actor";
@@ -63,7 +66,17 @@ const updateAgentSchema = AgentUpdateRequestSchema.extend({
63
66
  });
64
67
 
65
68
  const runtimePreflightSchema = z.object({
66
- providerType: z.enum(["claude_code", "codex", "cursor", "opencode", "http", "shell"]),
69
+ providerType: z.enum([
70
+ "claude_code",
71
+ "codex",
72
+ "cursor",
73
+ "opencode",
74
+ "gemini_cli",
75
+ "openai_api",
76
+ "anthropic_api",
77
+ "http",
78
+ "shell"
79
+ ]),
67
80
  runtimeConfig: z.record(z.string(), z.unknown()).optional(),
68
81
  ...legacyRuntimeConfigSchema.shape
69
82
  });
@@ -111,6 +124,17 @@ function toAgentResponse(agent: Record<string, unknown>) {
111
124
  };
112
125
  }
113
126
 
127
+ function providerRequiresNamedModel(providerType: string) {
128
+ return providerType !== "http" && providerType !== "shell";
129
+ }
130
+
131
+ function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
132
+ if (!providerRequiresNamedModel(providerType)) {
133
+ return true;
134
+ }
135
+ return hasText(runtimeModel);
136
+ }
137
+
114
138
  export function createAgentsRouter(ctx: AppContext) {
115
139
  const router = Router();
116
140
  router.use(requireCompanyScope);
@@ -143,7 +167,16 @@ export function createAgentsRouter(ctx: AppContext) {
143
167
  runtimeConfig: req.body?.runtimeConfig,
144
168
  defaultRuntimeCwd
145
169
  });
146
- const typedProviderType = providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
170
+ const typedProviderType = providerType as
171
+ | "claude_code"
172
+ | "codex"
173
+ | "cursor"
174
+ | "opencode"
175
+ | "gemini_cli"
176
+ | "openai_api"
177
+ | "anthropic_api"
178
+ | "http"
179
+ | "shell";
147
180
  const models = await getAdapterModels(typedProviderType, {
148
181
  command: runtimeConfig.runtimeCommand,
149
182
  args: runtimeConfig.runtimeArgs,
@@ -245,6 +278,11 @@ export function createAgentsRouter(ctx: AppContext) {
245
278
  },
246
279
  defaultRuntimeCwd
247
280
  });
281
+ runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
282
+ runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
283
+ if (!ensureNamedRuntimeModel(parsed.data.providerType, runtimeConfig.runtimeModel)) {
284
+ return sendError(res, "A named runtime model is required for this provider.", 422);
285
+ }
248
286
  if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
249
287
  return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
250
288
  }
@@ -253,6 +291,19 @@ export function createAgentsRouter(ctx: AppContext) {
253
291
  }
254
292
 
255
293
  if (parsed.data.requestApproval && isApprovalRequired("hire_agent")) {
294
+ const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
295
+ role: parsed.data.role,
296
+ managerAgentId: parsed.data.managerAgentId ?? null
297
+ });
298
+ if (duplicate) {
299
+ return sendOk(res, {
300
+ queuedForApproval: false,
301
+ duplicate: true,
302
+ existingAgentId: duplicate.existingAgentId ?? null,
303
+ pendingApprovalId: duplicate.pendingApprovalId ?? null,
304
+ message: duplicateMessage(duplicate)
305
+ });
306
+ }
256
307
  const approvalId = await createApprovalRequest(ctx.db, {
257
308
  companyId: req.companyId!,
258
309
  action: "hire_agent",
@@ -363,6 +414,11 @@ export function createAgentsRouter(ctx: AppContext) {
363
414
  })
364
415
  : {})
365
416
  };
417
+ nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
418
+ nextRuntime.runtimeModel = resolveRuntimeModelForProvider(effectiveProviderType, nextRuntime.runtimeModel);
419
+ if (!ensureNamedRuntimeModel(effectiveProviderType, nextRuntime.runtimeModel)) {
420
+ return sendError(res, "A named runtime model is required for this provider.", 422);
421
+ }
366
422
  if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
367
423
  nextRuntime.runtimeCwd = defaultRuntimeCwd;
368
424
  }
@@ -522,3 +578,56 @@ function listUnsupportedAgentUpdateKeys(payload: unknown) {
522
578
  }
523
579
  return unsupported;
524
580
  }
581
+
582
+ async function findDuplicateHireRequest(
583
+ db: AppContext["db"],
584
+ companyId: string,
585
+ input: { role: string; managerAgentId: string | null }
586
+ ) {
587
+ const role = input.role.trim();
588
+ const managerAgentId = input.managerAgentId ?? null;
589
+ const agents = await listAgents(db, companyId);
590
+ const existingAgent = agents.find(
591
+ (agent) =>
592
+ agent.role === role &&
593
+ (agent.managerAgentId ?? null) === managerAgentId &&
594
+ agent.status !== "terminated"
595
+ );
596
+ const approvals = await listApprovalRequests(db, companyId);
597
+ const pendingApproval = approvals.find((approval) => {
598
+ if (approval.status !== "pending" || approval.action !== "hire_agent") {
599
+ return false;
600
+ }
601
+ const payload = parseApprovalPayload(approval.payloadJson);
602
+ return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
603
+ });
604
+ if (!existingAgent && !pendingApproval) {
605
+ return null;
606
+ }
607
+ return {
608
+ existingAgentId: existingAgent?.id ?? null,
609
+ pendingApprovalId: pendingApproval?.id ?? null
610
+ };
611
+ }
612
+
613
+ function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
614
+ try {
615
+ const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
616
+ return {
617
+ role: typeof parsed.role === "string" ? parsed.role : undefined,
618
+ managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
619
+ };
620
+ } catch {
621
+ return {};
622
+ }
623
+ }
624
+
625
+ function duplicateMessage(input: { existingAgentId: string | null; pendingApprovalId: string | null }) {
626
+ if (input.existingAgentId && input.pendingApprovalId) {
627
+ return `Duplicate hire request blocked: existing agent ${input.existingAgentId} and pending approval ${input.pendingApprovalId}.`;
628
+ }
629
+ if (input.existingAgentId) {
630
+ return `Duplicate hire request blocked: existing agent ${input.existingAgentId}.`;
631
+ }
632
+ return `Duplicate hire request blocked: pending approval ${input.pendingApprovalId}.`;
633
+ }
@@ -3,6 +3,8 @@ import { z } from "zod";
3
3
  import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
4
4
  import type { AppContext } from "../context";
5
5
  import { sendError, sendOk } from "../http";
6
+ import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
7
+ import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
6
8
 
7
9
  const createCompanySchema = z.object({
8
10
  name: z.string().min(1),
@@ -30,6 +32,8 @@ export function createCompaniesRouter(ctx: AppContext) {
30
32
  return sendError(res, parsed.error.message, 422);
31
33
  }
32
34
  const company = await createCompany(ctx.db, parsed.data);
35
+ await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
36
+ await ensureCompanyModelPricingDefaults(ctx.db, company.id);
33
37
  return sendOk(res, company);
34
38
  });
35
39
 
@@ -163,11 +163,18 @@ export function createGovernanceRouter(ctx: AppContext) {
163
163
  });
164
164
 
165
165
  if (resolution.execution.applied && resolution.execution.entityType && resolution.execution.entityId) {
166
+ const eventType =
167
+ resolution.action === "grant_plugin_capabilities"
168
+ ? "plugin.capabilities_granted_from_approval"
169
+ : resolution.execution.entityType === "agent"
170
+ ? "agent.hired_from_approval"
171
+ : resolution.execution.entityType === "goal"
172
+ ? "goal.activated_from_approval"
173
+ : "memory.promoted_from_approval";
166
174
  await appendAuditEvent(ctx.db, {
167
175
  companyId: req.companyId!,
168
176
  actorType: "human",
169
- eventType:
170
- resolution.execution.entityType === "agent" ? "agent.hired_from_approval" : "goal.activated_from_approval",
177
+ eventType,
171
178
  entityType: resolution.execution.entityType,
172
179
  entityId: resolution.execution.entityId,
173
180
  payload: resolution.execution.entity ?? { id: resolution.execution.entityId }
@@ -79,7 +79,8 @@ export function createHeartbeatRouter(ctx: AppContext) {
79
79
  const stopResult = await stopHeartbeatRun(ctx.db, req.companyId!, parsed.data.runId, {
80
80
  requestId: req.requestId,
81
81
  trigger: "manual",
82
- actorId: req.actor?.id ?? undefined
82
+ actorId: req.actor?.id ?? undefined,
83
+ realtimeHub: ctx.realtimeHub
83
84
  });
84
85
  if (!stopResult.ok) {
85
86
  if (stopResult.reason === "not_found") {