bopodev-api 0.1.23 → 0.1.25

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.
@@ -6,7 +6,13 @@ import {
6
6
  getAdapterModels,
7
7
  runAdapterEnvironmentTest
8
8
  } from "bopodev-agent-sdk";
9
- import { AgentCreateRequestSchema, AgentUpdateRequestSchema } from "bopodev-contracts";
9
+ import {
10
+ AGENT_ROLE_LABELS,
11
+ AgentCreateRequestSchema,
12
+ AgentRoleKeySchema,
13
+ AgentSchema,
14
+ AgentUpdateRequestSchema
15
+ } from "bopodev-contracts";
10
16
  import {
11
17
  appendAuditEvent,
12
18
  createAgent,
@@ -18,7 +24,7 @@ import {
18
24
  updateAgent
19
25
  } from "bopodev-db";
20
26
  import type { AppContext } from "../context";
21
- import { sendError, sendOk } from "../http";
27
+ import { sendError, sendOk, sendOkValidated } from "../http";
22
28
  import {
23
29
  normalizeRuntimeConfig,
24
30
  parseRuntimeConfigFromAgentRow,
@@ -33,6 +39,7 @@ import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany
33
39
  import { requireCompanyScope } from "../middleware/company-scope";
34
40
  import { requireBoardRole, requirePermission } from "../middleware/request-actor";
35
41
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
42
+ import { publishAttentionSnapshot } from "../realtime/attention";
36
43
  import {
37
44
  publishOfficeOccupantForAgent,
38
45
  publishOfficeOccupantForApproval
@@ -84,6 +91,8 @@ const runtimePreflightSchema = z.object({
84
91
  const UPDATE_AGENT_ALLOWED_KEYS = new Set([
85
92
  "managerAgentId",
86
93
  "role",
94
+ "roleKey",
95
+ "title",
87
96
  "name",
88
97
  "providerType",
89
98
  "status",
@@ -129,6 +138,10 @@ function providerRequiresNamedModel(providerType: string) {
129
138
  return providerType !== "http" && providerType !== "shell";
130
139
  }
131
140
 
141
+ const agentResponseSchema = AgentSchema.extend({
142
+ stateBlob: z.string().optional()
143
+ });
144
+
132
145
  function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
133
146
  if (!providerRequiresNamedModel(providerType)) {
134
147
  return true;
@@ -142,9 +155,11 @@ export function createAgentsRouter(ctx: AppContext) {
142
155
 
143
156
  router.get("/", async (req, res) => {
144
157
  const rows = await listAgents(ctx.db, req.companyId!);
145
- return sendOk(
158
+ return sendOkValidated(
146
159
  res,
147
- rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>))
160
+ agentResponseSchema.array(),
161
+ rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>)),
162
+ "agents.list"
148
163
  );
149
164
  });
150
165
 
@@ -155,6 +170,7 @@ export function createAgentsRouter(ctx: AppContext) {
155
170
  id: row.id,
156
171
  name: row.name,
157
172
  role: row.role,
173
+ roleKey: row.roleKey,
158
174
  status: row.status,
159
175
  canHireAgents: row.canHireAgents
160
176
  }))
@@ -170,8 +186,7 @@ export function createAgentsRouter(ctx: AppContext) {
170
186
  return sendOk(
171
187
  res,
172
188
  rows.map((row) => {
173
- const normalizedRole = row.role.trim().toLowerCase();
174
- const isLeadership = normalizedRole === "ceo" || Boolean(row.canHireAgents);
189
+ const isLeadership = row.roleKey === "ceo" || Boolean(row.canHireAgents);
175
190
  const issues: string[] = [];
176
191
  const hasBootstrapPrompt = hasText(row.bootstrapPrompt);
177
192
  if (isLeadership && !hasBootstrapPrompt) {
@@ -184,6 +199,7 @@ export function createAgentsRouter(ctx: AppContext) {
184
199
  agentId: row.id,
185
200
  name: row.name,
186
201
  role: row.role,
202
+ roleKey: row.roleKey,
187
203
  providerType: row.providerType,
188
204
  canHireAgents: Boolean(row.canHireAgents),
189
205
  isLeadership,
@@ -366,7 +382,8 @@ export function createAgentsRouter(ctx: AppContext) {
366
382
  const shouldRequestApproval = (parsed.data.requestApproval || req.actor?.type === "agent") && isApprovalRequired("hire_agent");
367
383
  if (shouldRequestApproval) {
368
384
  const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
369
- role: parsed.data.role,
385
+ role: parsed.data.role ?? "",
386
+ roleKey: parsed.data.roleKey ?? null,
370
387
  managerAgentId: parsed.data.managerAgentId ?? null
371
388
  });
372
389
  if (duplicate) {
@@ -397,6 +414,7 @@ export function createAgentsRouter(ctx: AppContext) {
397
414
  })
398
415
  );
399
416
  await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approvalId);
417
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
400
418
  }
401
419
  return sendOk(res, { queuedForApproval: true, approvalId });
402
420
  }
@@ -404,7 +422,9 @@ export function createAgentsRouter(ctx: AppContext) {
404
422
  const agent = await createAgent(ctx.db, {
405
423
  companyId: req.companyId!,
406
424
  managerAgentId: parsed.data.managerAgentId,
407
- role: parsed.data.role,
425
+ role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
426
+ roleKey: normalizeRoleKey(parsed.data.roleKey),
427
+ title: normalizeTitle(parsed.data.title),
408
428
  name: parsed.data.name,
409
429
  providerType: parsed.data.providerType,
410
430
  heartbeatCron: parsed.data.heartbeatCron,
@@ -543,7 +563,16 @@ export function createAgentsRouter(ctx: AppContext) {
543
563
  companyId: req.companyId!,
544
564
  id: req.params.agentId,
545
565
  managerAgentId: parsed.data.managerAgentId,
546
- role: parsed.data.role,
566
+ role:
567
+ parsed.data.role !== undefined || parsed.data.roleKey !== undefined || parsed.data.title !== undefined
568
+ ? resolveAgentRoleText(
569
+ parsed.data.role ?? existingAgent.role,
570
+ parsed.data.roleKey ?? existingAgent.roleKey,
571
+ parsed.data.title ?? existingAgent.title
572
+ )
573
+ : undefined,
574
+ roleKey: parsed.data.roleKey !== undefined ? normalizeRoleKey(parsed.data.roleKey) : undefined,
575
+ title: parsed.data.title !== undefined ? normalizeTitle(parsed.data.title) : undefined,
547
576
  name: parsed.data.name,
548
577
  providerType: parsed.data.providerType,
549
578
  status: parsed.data.status,
@@ -715,14 +744,15 @@ function enforceRuntimeCwdPolicy(companyId: string, runtime: ReturnType<typeof n
715
744
  async function findDuplicateHireRequest(
716
745
  db: AppContext["db"],
717
746
  companyId: string,
718
- input: { role: string; managerAgentId: string | null }
747
+ input: { role: string; roleKey: string | null; managerAgentId: string | null }
719
748
  ) {
720
749
  const role = input.role.trim();
750
+ const roleKey = normalizeRoleKey(input.roleKey);
721
751
  const managerAgentId = input.managerAgentId ?? null;
722
752
  const agents = await listAgents(db, companyId);
723
753
  const existingAgent = agents.find(
724
754
  (agent) =>
725
- agent.role === role &&
755
+ ((roleKey && agent.roleKey === roleKey) || (!roleKey && role.length > 0 && agent.role === role)) &&
726
756
  (agent.managerAgentId ?? null) === managerAgentId &&
727
757
  agent.status !== "terminated"
728
758
  );
@@ -732,6 +762,10 @@ async function findDuplicateHireRequest(
732
762
  return false;
733
763
  }
734
764
  const payload = parseApprovalPayload(approval.payloadJson);
765
+ const payloadRoleKey = normalizeRoleKey(payload.roleKey);
766
+ if (roleKey && payloadRoleKey) {
767
+ return payloadRoleKey === roleKey && (payload.managerAgentId ?? null) === managerAgentId;
768
+ }
735
769
  return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
736
770
  });
737
771
  if (!existingAgent && !pendingApproval) {
@@ -743,11 +777,12 @@ async function findDuplicateHireRequest(
743
777
  };
744
778
  }
745
779
 
746
- function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
780
+ function parseApprovalPayload(payloadJson: string): { role?: string; roleKey?: string | null; managerAgentId?: string | null } {
747
781
  try {
748
782
  const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
749
783
  return {
750
784
  role: typeof parsed.role === "string" ? parsed.role : undefined,
785
+ roleKey: typeof parsed.roleKey === "string" ? parsed.roleKey : null,
751
786
  managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
752
787
  };
753
788
  } catch {
@@ -780,6 +815,40 @@ function providerSupportsSkillInjection(providerType: string) {
780
815
  return providerType === "codex" || providerType === "cursor" || providerType === "opencode" || providerType === "claude_code";
781
816
  }
782
817
 
818
+ function normalizeRoleKey(input: string | null | undefined) {
819
+ const normalized = input?.trim().toLowerCase();
820
+ if (!normalized) {
821
+ return null;
822
+ }
823
+ const parsed = AgentRoleKeySchema.safeParse(normalized);
824
+ return parsed.success ? parsed.data : null;
825
+ }
826
+
827
+ function normalizeTitle(input: string | null | undefined) {
828
+ const normalized = input?.trim();
829
+ return normalized ? normalized : null;
830
+ }
831
+
832
+ function resolveAgentRoleText(
833
+ legacyRole: string | undefined,
834
+ roleKeyInput: string | null | undefined,
835
+ titleInput: string | null | undefined
836
+ ) {
837
+ const normalizedLegacy = legacyRole?.trim();
838
+ if (normalizedLegacy) {
839
+ return normalizedLegacy;
840
+ }
841
+ const normalizedTitle = normalizeTitle(titleInput);
842
+ if (normalizedTitle) {
843
+ return normalizedTitle;
844
+ }
845
+ const roleKey = normalizeRoleKey(roleKeyInput);
846
+ if (roleKey) {
847
+ return AGENT_ROLE_LABELS[roleKey];
848
+ }
849
+ return AGENT_ROLE_LABELS.general;
850
+ }
851
+
783
852
  function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
784
853
  if (!actor) {
785
854
  return { actorType: "human" as const, actorId: null as string | null };
@@ -0,0 +1,112 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { sendError, sendOkValidated } from "../http";
4
+ import { requireCompanyScope } from "../middleware/company-scope";
5
+ import type { AppContext } from "../context";
6
+ import {
7
+ clearBoardAttentionDismissed,
8
+ listBoardAttentionItems,
9
+ markBoardAttentionAcknowledged,
10
+ markBoardAttentionDismissed,
11
+ markBoardAttentionResolved,
12
+ markBoardAttentionSeen
13
+ } from "../services/attention-service";
14
+ import { BoardAttentionListResponseSchema } from "bopodev-contracts";
15
+ import { createAttentionRealtimeEvent } from "../realtime/attention";
16
+
17
+ const itemParamsSchema = z.object({
18
+ itemKey: z.string().min(1)
19
+ });
20
+
21
+ export function createAttentionRouter(ctx: AppContext) {
22
+ const router = Router();
23
+ router.use(requireCompanyScope);
24
+
25
+ // Canonical board action queue endpoint for Inbox and board attention UX.
26
+ router.get("/", async (req, res) => {
27
+ const actorId = req.actor?.id ?? "local-board";
28
+ const items = await listBoardAttentionItems(ctx.db, req.companyId!, actorId);
29
+ return sendOkValidated(res, BoardAttentionListResponseSchema, { actorId, items }, "attention.list");
30
+ });
31
+
32
+ router.post("/:itemKey/seen", async (req, res) => {
33
+ const parsed = itemParamsSchema.safeParse(req.params);
34
+ if (!parsed.success) {
35
+ return sendError(res, parsed.error.message, 422);
36
+ }
37
+ const actorId = req.actor?.id ?? "local-board";
38
+ await markBoardAttentionSeen(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
39
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
40
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.seen");
41
+ });
42
+
43
+ router.post("/:itemKey/acknowledge", async (req, res) => {
44
+ const parsed = itemParamsSchema.safeParse(req.params);
45
+ if (!parsed.success) {
46
+ return sendError(res, parsed.error.message, 422);
47
+ }
48
+ const actorId = req.actor?.id ?? "local-board";
49
+ await markBoardAttentionAcknowledged(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
50
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
51
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.acknowledge");
52
+ });
53
+
54
+ router.post("/:itemKey/dismiss", async (req, res) => {
55
+ const parsed = itemParamsSchema.safeParse(req.params);
56
+ if (!parsed.success) {
57
+ return sendError(res, parsed.error.message, 422);
58
+ }
59
+ const actorId = req.actor?.id ?? "local-board";
60
+ await markBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
61
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
62
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.dismiss");
63
+ });
64
+
65
+ router.post("/:itemKey/undismiss", async (req, res) => {
66
+ const parsed = itemParamsSchema.safeParse(req.params);
67
+ if (!parsed.success) {
68
+ return sendError(res, parsed.error.message, 422);
69
+ }
70
+ const actorId = req.actor?.id ?? "local-board";
71
+ await clearBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
72
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
73
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.undismiss");
74
+ });
75
+
76
+ router.post("/:itemKey/resolve", async (req, res) => {
77
+ const parsed = itemParamsSchema.safeParse(req.params);
78
+ if (!parsed.success) {
79
+ return sendError(res, parsed.error.message, 422);
80
+ }
81
+ const actorId = req.actor?.id ?? "local-board";
82
+ await markBoardAttentionResolved(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
83
+ await publishAttentionResolve(ctx, req.companyId!, actorId, parsed.data.itemKey);
84
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.resolve");
85
+ });
86
+
87
+ return router;
88
+ }
89
+
90
+ async function publishAttentionUpdate(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
91
+ const items = await listBoardAttentionItems(ctx.db, companyId, actorId);
92
+ const item = items.find((entry) => entry.key === itemKey);
93
+ if (!item) {
94
+ return;
95
+ }
96
+ ctx.realtimeHub?.publish(
97
+ createAttentionRealtimeEvent(companyId, {
98
+ type: "attention.updated",
99
+ item
100
+ })
101
+ );
102
+ }
103
+
104
+ async function publishAttentionResolve(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
105
+ await publishAttentionUpdate(ctx, companyId, actorId, itemKey);
106
+ ctx.realtimeHub?.publish(
107
+ createAttentionRealtimeEvent(companyId, {
108
+ type: "attention.resolved",
109
+ key: itemKey
110
+ })
111
+ );
112
+ }
@@ -2,9 +2,10 @@ import { mkdir } from "node:fs/promises";
2
2
  import type { NextFunction, Request, Response } from "express";
3
3
  import { Router } from "express";
4
4
  import { z } from "zod";
5
+ import { CompanySchema } from "bopodev-contracts";
5
6
  import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
6
7
  import type { AppContext } from "../context";
7
- import { sendError, sendOk } from "../http";
8
+ import { sendError, sendOk, sendOkValidated } from "../http";
8
9
  import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
9
10
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
10
11
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
@@ -37,14 +38,19 @@ export function createCompaniesRouter(ctx: AppContext) {
37
38
  const router = Router();
38
39
 
39
40
  router.get("/", async (req, res) => {
40
- const companies = await listCompanies(ctx.db);
41
+ const companies = (await listCompanies(ctx.db)).map((company) => ({
42
+ ...company,
43
+ createdAt: company.createdAt instanceof Date ? company.createdAt.toISOString() : String(company.createdAt)
44
+ }));
41
45
  if (req.actor?.type === "board") {
42
- return sendOk(res, companies);
46
+ return sendOkValidated(res, CompanySchema.array(), companies, "companies.list");
43
47
  }
44
48
  const visibleCompanyIds = new Set(req.actor?.companyIds ?? []);
45
- return sendOk(
49
+ return sendOkValidated(
46
50
  res,
47
- companies.filter((company) => visibleCompanyIds.has(company.id))
51
+ CompanySchema.array(),
52
+ companies.filter((company) => visibleCompanyIds.has(company.id)),
53
+ "companies.list.filtered"
48
54
  );
49
55
  });
50
56
 
@@ -91,6 +97,8 @@ export function createCompaniesRouter(ctx: AppContext) {
91
97
  await createAgent(ctx.db, {
92
98
  companyId: company.id,
93
99
  role: "CEO",
100
+ roleKey: "ceo",
101
+ title: "CEO",
94
102
  name: "CEO",
95
103
  providerType,
96
104
  heartbeatCron: "*/5 * * * *",
@@ -1,11 +1,13 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
+ import { GoalSchema } from "bopodev-contracts";
3
4
  import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApprovalRequest, listGoals, updateGoal } from "bopodev-db";
4
5
  import type { AppContext } from "../context";
5
- import { sendError, sendOk } from "../http";
6
+ import { sendError, sendOk, sendOkValidated } from "../http";
6
7
  import { requireCompanyScope } from "../middleware/company-scope";
7
8
  import { requirePermission } from "../middleware/request-actor";
8
9
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
10
+ import { publishAttentionSnapshot } from "../realtime/attention";
9
11
  import { isApprovalRequired } from "../services/governance-service";
10
12
 
11
13
  const createGoalSchema = z.object({
@@ -33,7 +35,12 @@ export function createGoalsRouter(ctx: AppContext) {
33
35
  router.use(requireCompanyScope);
34
36
 
35
37
  router.get("/", async (req, res) => {
36
- return sendOk(res, await listGoals(ctx.db, req.companyId!));
38
+ return sendOkValidated(
39
+ res,
40
+ GoalSchema.array(),
41
+ await listGoals(ctx.db, req.companyId!),
42
+ "goals.list"
43
+ );
37
44
  });
38
45
 
39
46
  router.post("/", async (req, res) => {
@@ -60,6 +67,7 @@ export function createGoalsRouter(ctx: AppContext) {
60
67
  approval: serializeStoredApproval(approval)
61
68
  })
62
69
  );
70
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
63
71
  }
64
72
  return sendOk(res, { queuedForApproval: true, approvalId });
65
73
  }
@@ -15,6 +15,7 @@ import { sendError, sendOk } from "../http";
15
15
  import { requireCompanyScope } from "../middleware/company-scope";
16
16
  import { requirePermission } from "../middleware/request-actor";
17
17
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
18
+ import { publishAttentionSnapshot } from "../realtime/attention";
18
19
  import {
19
20
  publishOfficeOccupantForAgent,
20
21
  publishOfficeOccupantForApproval
@@ -34,6 +35,9 @@ export function createGovernanceRouter(ctx: AppContext) {
34
35
  const router = Router();
35
36
  router.use(requireCompanyScope);
36
37
 
38
+ // Deprecated compatibility shim:
39
+ // board queue consumers should use /attention as the canonical source.
40
+ // Keep this endpoint until all downstream consumers migrate.
37
41
  router.get("/approvals", async (req, res) => {
38
42
  const approvals = await listApprovalRequests(ctx.db, req.companyId!);
39
43
  return sendOk(
@@ -200,6 +204,7 @@ export function createGovernanceRouter(ctx: AppContext) {
200
204
  approval: serializeStoredApproval(approval)
201
205
  })
202
206
  );
207
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
203
208
  await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approval.id);
204
209
  if (approval.requestedByAgentId) {
205
210
  await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
@@ -1,12 +1,17 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import { and, eq } from "drizzle-orm";
4
- import { agents, heartbeatRuns } from "bopodev-db";
4
+ import { agents, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
5
5
  import type { AppContext } from "../context";
6
6
  import { sendError, sendOk } from "../http";
7
7
  import { requireCompanyScope } from "../middleware/company-scope";
8
8
  import { requirePermission } from "../middleware/request-actor";
9
- import { runHeartbeatForAgent, runHeartbeatSweep, stopHeartbeatRun } from "../services/heartbeat-service";
9
+ import {
10
+ findPendingProjectBudgetOverrideBlocksForAgent,
11
+ runHeartbeatSweep,
12
+ stopHeartbeatRun
13
+ } from "../services/heartbeat-service";
14
+ import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "../services/heartbeat-queue-service";
10
15
 
11
16
  const runAgentSchema = z.object({
12
17
  agentId: z.string().min(1)
@@ -14,6 +19,12 @@ const runAgentSchema = z.object({
14
19
  const runIdParamsSchema = z.object({
15
20
  runId: z.string().min(1)
16
21
  });
22
+ const queueQuerySchema = z.object({
23
+ status: z.enum(["pending", "running", "completed", "failed", "dead_letter", "canceled"]).optional(),
24
+ agentId: z.string().min(1).optional(),
25
+ jobType: z.enum(["manual", "scheduler", "resume", "redo", "comment_dispatch"]).optional(),
26
+ limit: z.coerce.number().int().min(1).max(1000).optional()
27
+ });
17
28
 
18
29
  export function createHeartbeatRouter(ctx: AppContext) {
19
30
  const router = Router();
@@ -39,31 +50,37 @@ export function createHeartbeatRouter(ctx: AppContext) {
39
50
  if (agent.status === "paused" || agent.status === "terminated") {
40
51
  return sendError(res, `Agent is not invokable in status '${agent.status}'.`, 409);
41
52
  }
53
+ const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(
54
+ ctx.db,
55
+ req.companyId!,
56
+ parsed.data.agentId
57
+ );
58
+ if (blockedProjectIds.length > 0) {
59
+ return sendError(
60
+ res,
61
+ `Agent is blocked by pending project budget approval for project(s): ${blockedProjectIds.join(", ")}.`,
62
+ 423
63
+ );
64
+ }
42
65
 
43
- const runId = await runHeartbeatForAgent(ctx.db, req.companyId!, parsed.data.agentId, {
66
+ const job = await enqueueHeartbeatQueueJob(ctx.db, {
67
+ companyId: req.companyId!,
68
+ agentId: parsed.data.agentId,
69
+ jobType: "manual",
70
+ priority: 30,
71
+ idempotencyKey: req.requestId ? `manual:${parsed.data.agentId}:${req.requestId}` : null,
72
+ payload: {}
73
+ });
74
+ triggerHeartbeatQueueWorker(ctx.db, req.companyId!, {
44
75
  requestId: req.requestId,
45
- trigger: "manual",
46
76
  realtimeHub: ctx.realtimeHub
47
77
  });
48
- if (!runId) {
49
- return sendError(res, "Heartbeat could not be started for this agent.", 409);
50
- }
51
- const [runRow] = await ctx.db
52
- .select({ id: heartbeatRuns.id, status: heartbeatRuns.status, message: heartbeatRuns.message })
53
- .from(heartbeatRuns)
54
- .where(and(eq(heartbeatRuns.companyId, req.companyId!), eq(heartbeatRuns.id, runId)))
55
- .limit(1);
56
- const invokeStatus =
57
- runRow?.status === "skipped" && String(runRow.message ?? "").includes("already in progress")
58
- ? "skipped_overlap"
59
- : runRow?.status === "skipped"
60
- ? "skipped"
61
- : "started";
62
78
  return sendOk(res, {
63
- runId,
79
+ runId: null,
80
+ jobId: job.id,
64
81
  requestId: req.requestId,
65
- status: invokeStatus,
66
- message: runRow?.message ?? null
82
+ status: "queued",
83
+ message: "Heartbeat queued."
67
84
  });
68
85
  });
69
86
 
@@ -127,32 +144,32 @@ export function createHeartbeatRouter(ctx: AppContext) {
127
144
  if (agent.status === "paused" || agent.status === "terminated") {
128
145
  return { ok: false as const, statusCode: 409, message: `Agent is not invokable in status '${agent.status}'.` };
129
146
  }
130
- const nextRunId = await runHeartbeatForAgent(ctx.db, input.companyId, run.agentId, {
147
+ const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(ctx.db, input.companyId, run.agentId);
148
+ if (blockedProjectIds.length > 0) {
149
+ return {
150
+ ok: false as const,
151
+ statusCode: 423,
152
+ message: `Agent is blocked by pending project budget approval for project(s): ${blockedProjectIds.join(", ")}.`
153
+ };
154
+ }
155
+ const job = await enqueueHeartbeatQueueJob(ctx.db, {
156
+ companyId: input.companyId,
157
+ agentId: run.agentId,
158
+ jobType: input.mode,
159
+ priority: 30,
160
+ idempotencyKey: input.requestId ? `${input.mode}:${run.agentId}:${run.id}:${input.requestId}` : null,
161
+ payload: { sourceRunId: run.id }
162
+ });
163
+ triggerHeartbeatQueueWorker(ctx.db, input.companyId, {
131
164
  requestId: input.requestId,
132
- trigger: "manual",
133
- realtimeHub: ctx.realtimeHub,
134
- mode: input.mode,
135
- sourceRunId: run.id
165
+ realtimeHub: ctx.realtimeHub
136
166
  });
137
- if (!nextRunId) {
138
- return { ok: false as const, statusCode: 409, message: "Heartbeat could not be started for this agent." };
139
- }
140
- const [runRow] = await ctx.db
141
- .select({ id: heartbeatRuns.id, status: heartbeatRuns.status, message: heartbeatRuns.message })
142
- .from(heartbeatRuns)
143
- .where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, nextRunId)))
144
- .limit(1);
145
- const invokeStatus =
146
- runRow?.status === "skipped" && String(runRow.message ?? "").includes("already in progress")
147
- ? "skipped_overlap"
148
- : runRow?.status === "skipped"
149
- ? "skipped"
150
- : "started";
151
167
  return {
152
168
  ok: true as const,
153
- runId: nextRunId,
154
- status: invokeStatus,
155
- message: runRow?.message ?? null
169
+ runId: null,
170
+ jobId: job.id,
171
+ status: "queued" as const,
172
+ message: "Heartbeat queued."
156
173
  };
157
174
  }
158
175
 
@@ -176,6 +193,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
176
193
  }
177
194
  return sendOk(res, {
178
195
  runId: result.runId,
196
+ jobId: result.jobId,
179
197
  requestId: req.requestId,
180
198
  status: result.status,
181
199
  message: result.message
@@ -202,6 +220,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
202
220
  }
203
221
  return sendOk(res, {
204
222
  runId: result.runId,
223
+ jobId: result.jobId,
205
224
  requestId: req.requestId,
206
225
  status: result.status,
207
226
  message: result.message
@@ -220,5 +239,24 @@ export function createHeartbeatRouter(ctx: AppContext) {
220
239
  return sendOk(res, { runIds, requestId: req.requestId });
221
240
  });
222
241
 
242
+ router.get("/queue", async (req, res) => {
243
+ requirePermission("heartbeats:run")(req, res, () => {});
244
+ if (res.headersSent) {
245
+ return;
246
+ }
247
+ const parsed = queueQuerySchema.safeParse(req.query);
248
+ if (!parsed.success) {
249
+ return sendError(res, parsed.error.message, 422);
250
+ }
251
+ const jobs = await listHeartbeatQueueJobs(ctx.db, {
252
+ companyId: req.companyId!,
253
+ status: parsed.data.status,
254
+ agentId: parsed.data.agentId,
255
+ jobType: parsed.data.jobType,
256
+ limit: parsed.data.limit
257
+ });
258
+ return sendOk(res, { items: jobs });
259
+ });
260
+
223
261
  return router;
224
262
  }