bopodev-api 0.1.27 → 0.1.29

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.
Files changed (50) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -70
  3. package/src/lib/drainable-work.ts +36 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/lib/workspace-policy.ts +1 -2
  6. package/src/middleware/cors-config.ts +36 -0
  7. package/src/middleware/request-actor.ts +10 -16
  8. package/src/middleware/request-id.ts +9 -0
  9. package/src/middleware/request-logging.ts +24 -0
  10. package/src/realtime/office-space.ts +3 -1
  11. package/src/routes/agents.ts +3 -9
  12. package/src/routes/companies.ts +18 -1
  13. package/src/routes/goals.ts +7 -13
  14. package/src/routes/governance.ts +2 -5
  15. package/src/routes/heartbeats.ts +8 -27
  16. package/src/routes/issues.ts +66 -121
  17. package/src/routes/observability.ts +6 -1
  18. package/src/routes/plugins.ts +5 -17
  19. package/src/routes/projects.ts +7 -25
  20. package/src/routes/templates.ts +6 -21
  21. package/src/scripts/onboard-seed.ts +5 -7
  22. package/src/server.ts +35 -276
  23. package/src/services/attention-service.ts +4 -1
  24. package/src/services/budget-service.ts +1 -2
  25. package/src/services/comment-recipient-dispatch-service.ts +39 -2
  26. package/src/services/company-export-service.ts +63 -0
  27. package/src/services/governance-service.ts +6 -2
  28. package/src/services/heartbeat-queue-service.ts +34 -3
  29. package/src/services/heartbeat-service/active-runs.ts +15 -0
  30. package/src/services/heartbeat-service/budget-override.ts +46 -0
  31. package/src/services/heartbeat-service/claims.ts +61 -0
  32. package/src/services/heartbeat-service/cron.ts +58 -0
  33. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  34. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  35. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
  36. package/src/services/heartbeat-service/index.ts +5 -0
  37. package/src/services/heartbeat-service/stop.ts +90 -0
  38. package/src/services/heartbeat-service/sweep.ts +145 -0
  39. package/src/services/heartbeat-service/types.ts +65 -0
  40. package/src/services/memory-file-service.ts +10 -2
  41. package/src/shutdown/graceful-shutdown.ts +77 -0
  42. package/src/startup/database.ts +41 -0
  43. package/src/startup/deployment-validation.ts +37 -0
  44. package/src/startup/env.ts +17 -0
  45. package/src/startup/runtime-health.ts +128 -0
  46. package/src/startup/scheduler-config.ts +39 -0
  47. package/src/types/express.d.ts +13 -0
  48. package/src/types/request-actor.ts +6 -0
  49. package/src/validation/issue-routes.ts +79 -0
  50. package/src/worker/scheduler.ts +20 -4
@@ -1,6 +1,5 @@
1
1
  import { mkdir, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
- import { and, desc, eq, inArray, sql } from "drizzle-orm";
4
3
  import { nanoid } from "nanoid";
5
4
  import { resolveAdapter } from "bopodev-agent-sdk";
6
5
  import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
@@ -19,294 +18,65 @@ import {
19
18
  import type { BopoDb } from "bopodev-db";
20
19
  import {
21
20
  addIssueComment,
21
+ and,
22
22
  approvalRequests,
23
23
  agents,
24
24
  appendActivity,
25
25
  appendHeartbeatRunMessages,
26
+ auditEvents,
26
27
  companies,
27
28
  createApprovalRequest,
29
+ desc,
30
+ eq,
28
31
  goals,
29
32
  heartbeatRuns,
33
+ inArray,
30
34
  issueComments,
31
35
  issueAttachments,
32
36
  issues,
33
- projects
37
+ listIssueGoalIdsBatch,
38
+ projects,
39
+ sql
34
40
  } from "bopodev-db";
35
41
  import { appendAuditEvent, appendCost } from "bopodev-db";
36
- import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
37
- import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
42
+ import { parseRuntimeConfigFromAgentRow } from "../../lib/agent-config";
43
+ import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../../lib/git-runtime";
38
44
  import {
39
45
  isInsidePath,
40
46
  normalizeCompanyWorkspacePath,
41
47
  resolveCompanyWorkspaceRootPath,
42
48
  resolveProjectWorkspacePath
43
- } from "../lib/instance-paths";
44
- import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
49
+ } from "../../lib/instance-paths";
50
+ import { resolveRunArtifactAbsolutePath } from "../../lib/run-artifact-paths";
45
51
  import {
46
52
  assertRuntimeCwdForCompany,
47
53
  getProjectWorkspaceContextMap,
48
54
  hasText,
49
55
  resolveAgentFallbackWorkspace
50
- } from "../lib/workspace-policy";
51
- import type { RealtimeHub } from "../realtime/hub";
52
- import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
53
- import { publishAttentionSnapshot } from "../realtime/attention";
54
- import { publishOfficeOccupantForAgent } from "../realtime/office-space";
55
- import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "./budget-service";
56
- import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "./memory-file-service";
57
- import { calculateModelPricedUsdCost } from "./model-pricing";
58
- import { runPluginHook } from "./plugin-runtime";
59
-
60
- type HeartbeatRunTrigger = "manual" | "scheduler";
61
- type HeartbeatRunMode = "default" | "resume" | "redo";
62
- type HeartbeatProviderType =
63
- | "claude_code"
64
- | "codex"
65
- | "cursor"
66
- | "opencode"
67
- | "gemini_cli"
68
- | "openai_api"
69
- | "anthropic_api"
70
- | "http"
71
- | "shell";
72
-
73
- type ActiveHeartbeatRun = {
74
- companyId: string;
75
- agentId: string;
76
- abortController: AbortController;
77
- cancelReason?: string | null;
78
- cancelRequestedAt?: string | null;
79
- cancelRequestedBy?: string | null;
80
- };
81
-
82
- const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
83
- type HeartbeatWakeContext = {
84
- reason?: string | null;
85
- commentId?: string | null;
86
- commentBody?: string | null;
87
- issueIds?: string[];
88
- };
89
-
90
- const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
91
-
92
- type RunDigestSignal = {
93
- sequence: number;
94
- kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
95
- label: string | null;
96
- text: string | null;
97
- payload: string | null;
98
- signalLevel: "high" | "medium" | "low" | "noise";
99
- groupKey: string | null;
100
- source: "stdout" | "stderr" | "trace_fallback";
101
- };
102
-
103
- type RunDigest = {
104
- status: "completed" | "failed" | "skipped";
105
- headline: string;
106
- summary: string;
107
- successes: string[];
108
- failures: string[];
109
- blockers: string[];
110
- nextAction: string;
111
- evidence: {
112
- transcriptSignalCount: number;
113
- outcomeActionCount: number;
114
- outcomeBlockerCount: number;
115
- failureType: string | null;
116
- };
117
- };
118
-
119
- type RunTerminalPresentation = {
120
- internalStatus: "completed" | "failed" | "skipped";
121
- publicStatus: "completed" | "failed";
122
- completionReason: RunCompletionReason;
123
- };
124
-
125
- export async function claimIssuesForAgent(
126
- db: BopoDb,
127
- companyId: string,
128
- agentId: string,
129
- heartbeatRunId: string,
130
- maxItems = 5
131
- ) {
132
- const result = await db.execute(sql`
133
- WITH candidate AS (
134
- SELECT id
135
- FROM issues
136
- WHERE company_id = ${companyId}
137
- AND assignee_agent_id = ${agentId}
138
- AND status IN ('todo', 'in_progress')
139
- AND is_claimed = false
140
- ORDER BY
141
- CASE priority
142
- WHEN 'urgent' THEN 0
143
- WHEN 'high' THEN 1
144
- WHEN 'medium' THEN 2
145
- WHEN 'low' THEN 3
146
- ELSE 4
147
- END ASC,
148
- updated_at ASC
149
- LIMIT ${maxItems}
150
- FOR UPDATE SKIP LOCKED
151
- )
152
- UPDATE issues i
153
- SET is_claimed = true,
154
- claimed_by_heartbeat_run_id = ${heartbeatRunId},
155
- updated_at = CURRENT_TIMESTAMP
156
- FROM candidate c
157
- WHERE i.id = c.id
158
- RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
159
- `);
160
-
161
- return (result.rows ?? []) as Array<{
162
- id: string;
163
- project_id: string;
164
- parent_issue_id: string | null;
165
- title: string;
166
- body: string | null;
167
- status: string;
168
- priority: string;
169
- labels_json: string;
170
- tags_json: string;
171
- }>;
172
- }
173
-
174
- export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
175
- if (issueIds.length === 0) {
176
- return;
177
- }
178
- await db
179
- .update(issues)
180
- .set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
181
- .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
182
- }
183
-
184
- export async function stopHeartbeatRun(
185
- db: BopoDb,
186
- companyId: string,
187
- runId: string,
188
- options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
189
- ) {
190
- const runTrigger = options?.trigger ?? "manual";
191
- const [run] = await db
192
- .select({
193
- id: heartbeatRuns.id,
194
- status: heartbeatRuns.status,
195
- agentId: heartbeatRuns.agentId
196
- })
197
- .from(heartbeatRuns)
198
- .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
199
- .limit(1);
200
- if (!run) {
201
- return { ok: false as const, reason: "not_found" as const };
202
- }
203
- if (run.status !== "started") {
204
- return { ok: false as const, reason: "invalid_status" as const, status: run.status };
205
- }
206
- const active = activeHeartbeatRuns.get(runId);
207
- const cancelReason = "cancelled by stop request";
208
- const cancelRequestedAt = new Date().toISOString();
209
- if (active) {
210
- active.cancelReason = cancelReason;
211
- active.cancelRequestedAt = cancelRequestedAt;
212
- active.cancelRequestedBy = options?.actorId ?? null;
213
- active.abortController.abort(cancelReason);
214
- } else {
215
- const finishedAt = new Date();
216
- await db
217
- .update(heartbeatRuns)
218
- .set({
219
- status: "failed",
220
- finishedAt,
221
- message: "Heartbeat cancelled by stop request."
222
- })
223
- .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
224
- publishHeartbeatRunStatus(options?.realtimeHub, {
225
- companyId,
226
- runId,
227
- status: "failed",
228
- message: "Heartbeat cancelled by stop request.",
229
- finishedAt
230
- });
231
- }
232
- await appendAuditEvent(db, {
233
- companyId,
234
- actorType: "system",
235
- eventType: "heartbeat.cancel_requested",
236
- entityType: "heartbeat_run",
237
- entityId: runId,
238
- correlationId: options?.requestId ?? runId,
239
- payload: {
240
- agentId: run.agentId,
241
- trigger: runTrigger,
242
- requestId: options?.requestId ?? null,
243
- actorId: options?.actorId ?? null,
244
- inMemoryAbortRegistered: Boolean(active)
245
- }
246
- });
247
- if (!active) {
248
- await appendAuditEvent(db, {
249
- companyId,
250
- actorType: "system",
251
- eventType: "heartbeat.cancelled",
252
- entityType: "heartbeat_run",
253
- entityId: runId,
254
- correlationId: options?.requestId ?? runId,
255
- payload: {
256
- agentId: run.agentId,
257
- reason: cancelReason,
258
- trigger: runTrigger,
259
- requestId: options?.requestId ?? null,
260
- actorId: options?.actorId ?? null
261
- }
262
- });
263
- }
264
- return { ok: true as const, runId, agentId: run.agentId, status: run.status };
265
- }
266
-
267
- export async function findPendingProjectBudgetOverrideBlocksForAgent(
268
- db: BopoDb,
269
- companyId: string,
270
- agentId: string
271
- ) {
272
- const assignedRows = await db
273
- .select({ projectId: issues.projectId })
274
- .from(issues)
275
- .where(
276
- and(
277
- eq(issues.companyId, companyId),
278
- eq(issues.assigneeAgentId, agentId),
279
- inArray(issues.status, ["todo", "in_progress"])
280
- )
281
- );
282
- const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
283
- if (assignedProjectIds.size === 0) {
284
- return [] as string[];
285
- }
286
- const pendingOverrides = await db
287
- .select({ payloadJson: approvalRequests.payloadJson })
288
- .from(approvalRequests)
289
- .where(
290
- and(
291
- eq(approvalRequests.companyId, companyId),
292
- eq(approvalRequests.action, "override_budget"),
293
- eq(approvalRequests.status, "pending")
294
- )
295
- );
296
- const blockedProjectIds = new Set<string>();
297
- for (const approval of pendingOverrides) {
298
- try {
299
- const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
300
- const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
301
- if (projectId && assignedProjectIds.has(projectId)) {
302
- blockedProjectIds.add(projectId);
303
- }
304
- } catch {
305
- // Ignore malformed payloads to keep enforcement resilient.
306
- }
307
- }
308
- return Array.from(blockedProjectIds);
309
- }
56
+ } from "../../lib/workspace-policy";
57
+ import type { RealtimeHub } from "../../realtime/hub";
58
+ import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "../../realtime/heartbeat-runs";
59
+ import { publishAttentionSnapshot } from "../../realtime/attention";
60
+ import { publishOfficeOccupantForAgent } from "../../realtime/office-space";
61
+ import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "../budget-service";
62
+ import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "../memory-file-service";
63
+ import { calculateModelPricedUsdCost } from "../model-pricing";
64
+ import { runPluginHook } from "../plugin-runtime";
65
+ import { registerActiveHeartbeatRun, unregisterActiveHeartbeatRun } from "./active-runs";
66
+ import { claimIssuesForAgent, releaseClaimedIssues } from "./claims";
67
+ import { publishHeartbeatRunStatus } from "./heartbeat-realtime";
68
+ import { extractNaturalRunUpdate, sanitizeAgentSummaryCommentBody } from "./heartbeat-run-summary-text";
69
+ import type {
70
+ ActiveHeartbeatRun,
71
+ HeartbeatIdlePolicy,
72
+ HeartbeatProviderType,
73
+ HeartbeatRunMode,
74
+ HeartbeatRunTrigger,
75
+ HeartbeatWakeContext,
76
+ RunDigest,
77
+ RunDigestSignal,
78
+ RunTerminalPresentation
79
+ } from "./types";
310
80
 
311
81
  export async function runHeartbeatForAgent(
312
82
  db: BopoDb,
@@ -713,6 +483,8 @@ export async function runHeartbeatForAgent(
713
483
 
714
484
  let issueIds: string[] = [];
715
485
  let claimedIssueIds: string[] = [];
486
+ /** After transcript flush: remove DB row + audit noise for idle heartbeats with no issues. */
487
+ let discardIdleNoWorkRunAfterFlush = false;
716
488
  let executionWorkItemsForBudget: Array<{ issueId: string; projectId: string }> = [];
717
489
  let state: AgentState & {
718
490
  runtime?: {
@@ -901,7 +673,11 @@ export async function runHeartbeatForAgent(
901
673
  const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
902
674
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
903
675
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
904
- const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
676
+ const contextWorkItems = await hydrateIssueWorkItemsWithGoalIds(
677
+ db,
678
+ companyId,
679
+ resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext)
680
+ );
905
681
  executionWorkItemsForBudget = contextWorkItems.map((item) => ({ issueId: item.id, projectId: item.project_id }));
906
682
  claimedIssueIds = workItems.map((item) => item.id);
907
683
  issueIds = contextWorkItems.map((item) => item.id);
@@ -1293,7 +1069,8 @@ export async function runHeartbeatForAgent(
1293
1069
  mission: context.company.mission ?? null,
1294
1070
  goalContext: {
1295
1071
  companyGoals: context.goalContext?.companyGoals ?? [],
1296
- projectGoals: context.goalContext?.projectGoals ?? []
1072
+ projectGoals: context.goalContext?.projectGoals ?? [],
1073
+ agentGoals: context.goalContext?.agentGoals ?? []
1297
1074
  }
1298
1075
  });
1299
1076
  await appendAuditEvent(db, {
@@ -1337,7 +1114,8 @@ export async function runHeartbeatForAgent(
1337
1114
  summary: executionSummary,
1338
1115
  mission: context.company.mission ?? null,
1339
1116
  companyGoals: context.goalContext?.companyGoals ?? [],
1340
- projectGoals: context.goalContext?.projectGoals ?? []
1117
+ projectGoals: context.goalContext?.projectGoals ?? [],
1118
+ agentGoals: context.goalContext?.agentGoals ?? []
1341
1119
  });
1342
1120
  await appendAuditEvent(db, {
1343
1121
  companyId,
@@ -1716,6 +1494,11 @@ export async function runHeartbeatForAgent(
1716
1494
  }
1717
1495
  }
1718
1496
  });
1497
+ discardIdleNoWorkRunAfterFlush =
1498
+ issueIds.length === 0 &&
1499
+ !isCommentOrderWake &&
1500
+ (terminalPresentation.completionReason === "no_assigned_work" ||
1501
+ (isIdleNoWork && heartbeatIdlePolicy === "skip_adapter" && persistedRunStatus === "completed"));
1719
1502
  } catch (error) {
1720
1503
  const classified = classifyHeartbeatError(error);
1721
1504
  executionSummary =
@@ -1951,6 +1734,17 @@ export async function runHeartbeatForAgent(
1951
1734
  }
1952
1735
  } finally {
1953
1736
  await transcriptWriteQueue;
1737
+ if (discardIdleNoWorkRunAfterFlush) {
1738
+ try {
1739
+ await purgeIdleNoWorkHeartbeatRun(db, companyId, runId);
1740
+ if (options?.realtimeHub) {
1741
+ options.realtimeHub.publish(await loadHeartbeatRunsRealtimeSnapshot(db, companyId));
1742
+ }
1743
+ } catch (purgeError) {
1744
+ // eslint-disable-next-line no-console
1745
+ console.error("[heartbeat] failed to purge idle no-work run", runId, purgeError);
1746
+ }
1747
+ }
1954
1748
  unregisterActiveHeartbeatRun(runId);
1955
1749
  try {
1956
1750
  await releaseClaimedIssues(db, companyId, claimedIssueIds);
@@ -1971,7 +1765,7 @@ export async function runHeartbeatForAgent(
1971
1765
  }
1972
1766
  await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
1973
1767
  try {
1974
- const queueModule = await import("./heartbeat-queue-service");
1768
+ const queueModule = await import("../heartbeat-queue-service");
1975
1769
  queueModule.triggerHeartbeatQueueWorker(db, companyId, {
1976
1770
  requestId: options?.requestId,
1977
1771
  realtimeHub: options?.realtimeHub
@@ -1984,17 +1778,31 @@ export async function runHeartbeatForAgent(
1984
1778
  return runId;
1985
1779
  }
1986
1780
 
1781
+ async function purgeIdleNoWorkHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
1782
+ await db
1783
+ .delete(auditEvents)
1784
+ .where(
1785
+ and(eq(auditEvents.companyId, companyId), eq(auditEvents.entityType, "heartbeat_run"), eq(auditEvents.entityId, runId))
1786
+ );
1787
+ await db.delete(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
1788
+ }
1789
+
1987
1790
  async function insertStartedRunAtomic(
1988
1791
  db: BopoDb,
1989
1792
  input: { id: string; companyId: string; agentId: string; message: string }
1990
1793
  ) {
1991
- const result = await db.execute(sql`
1992
- INSERT INTO heartbeat_runs (id, company_id, agent_id, status, message)
1993
- VALUES (${input.id}, ${input.companyId}, ${input.agentId}, 'started', ${input.message})
1994
- ON CONFLICT DO NOTHING
1995
- RETURNING id
1996
- `);
1997
- return (result.rows ?? []).length > 0;
1794
+ const inserted = await db
1795
+ .insert(heartbeatRuns)
1796
+ .values({
1797
+ id: input.id,
1798
+ companyId: input.companyId,
1799
+ agentId: input.agentId,
1800
+ status: "started",
1801
+ message: input.message
1802
+ })
1803
+ .onConflictDoNothing()
1804
+ .returning({ id: heartbeatRuns.id });
1805
+ return inserted.length > 0;
1998
1806
  }
1999
1807
 
2000
1808
  async function recoverStaleHeartbeatRuns(
@@ -2054,129 +1862,43 @@ async function recoverStaleHeartbeatRuns(
2054
1862
  }
2055
1863
  }
2056
1864
 
2057
- export async function runHeartbeatSweep(
1865
+ type IssueWorkItemRow = {
1866
+ id: string;
1867
+ project_id: string;
1868
+ parent_issue_id: string | null;
1869
+ title: string;
1870
+ body: string | null;
1871
+ status: string;
1872
+ priority: string;
1873
+ labels_json: string;
1874
+ tags_json: string;
1875
+ };
1876
+
1877
+ type IssueWorkItemRowWithGoals = IssueWorkItemRow & { goal_ids: string[] };
1878
+
1879
+ async function hydrateIssueWorkItemsWithGoalIds(
2058
1880
  db: BopoDb,
2059
1881
  companyId: string,
2060
- options?: { requestId?: string; realtimeHub?: RealtimeHub }
2061
- ) {
2062
- const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
2063
- const latestRunByAgent = await listLatestRunByAgent(db, companyId);
2064
-
2065
- const now = new Date();
2066
- const enqueuedJobIds: string[] = [];
2067
- const dueAgents: Array<{ id: string }> = [];
2068
- let skippedNotDue = 0;
2069
- let skippedStatus = 0;
2070
- let skippedBudgetBlocked = 0;
2071
- let failedStarts = 0;
2072
- const sweepStartedAt = Date.now();
2073
- for (const agent of companyAgents) {
2074
- if (agent.status !== "idle" && agent.status !== "running") {
2075
- skippedStatus += 1;
2076
- continue;
2077
- }
2078
- if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
2079
- skippedNotDue += 1;
2080
- continue;
2081
- }
2082
- const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
2083
- if (blockedProjectIds.length > 0) {
2084
- skippedBudgetBlocked += 1;
2085
- continue;
2086
- }
2087
- dueAgents.push({ id: agent.id });
1882
+ items: IssueWorkItemRow[]
1883
+ ): Promise<IssueWorkItemRowWithGoals[]> {
1884
+ if (items.length === 0) {
1885
+ return [];
2088
1886
  }
2089
- const sweepConcurrency = resolveHeartbeatSweepConcurrency(dueAgents.length);
2090
- const queueModule = await import("./heartbeat-queue-service");
2091
- await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
2092
- try {
2093
- const job = await queueModule.enqueueHeartbeatQueueJob(db, {
2094
- companyId,
2095
- agentId: agent.id,
2096
- jobType: "scheduler",
2097
- priority: 80,
2098
- idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
2099
- payload: {}
2100
- });
2101
- enqueuedJobIds.push(job.id);
2102
- queueModule.triggerHeartbeatQueueWorker(db, companyId, {
2103
- requestId: options?.requestId,
2104
- realtimeHub: options?.realtimeHub
2105
- });
2106
- } catch {
2107
- failedStarts += 1;
2108
- }
2109
- });
2110
- await appendAuditEvent(db, {
1887
+ const map = await listIssueGoalIdsBatch(
1888
+ db,
2111
1889
  companyId,
2112
- actorType: "system",
2113
- eventType: "heartbeat.sweep.completed",
2114
- entityType: "company",
2115
- entityId: companyId,
2116
- correlationId: options?.requestId ?? null,
2117
- payload: {
2118
- runIds: enqueuedJobIds,
2119
- startedCount: enqueuedJobIds.length,
2120
- dueCount: dueAgents.length,
2121
- failedStarts,
2122
- skippedStatus,
2123
- skippedNotDue,
2124
- skippedBudgetBlocked,
2125
- concurrency: sweepConcurrency,
2126
- elapsedMs: Date.now() - sweepStartedAt,
2127
- requestId: options?.requestId ?? null
2128
- }
2129
- });
2130
- return enqueuedJobIds;
2131
- }
2132
-
2133
- async function listLatestRunByAgent(db: BopoDb, companyId: string) {
2134
- const result = await db.execute(sql`
2135
- SELECT agent_id, MAX(started_at) AS latest_started_at
2136
- FROM heartbeat_runs
2137
- WHERE company_id = ${companyId}
2138
- GROUP BY agent_id
2139
- `);
2140
- const latestRunByAgent = new Map<string, Date>();
2141
- for (const row of result.rows ?? []) {
2142
- const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
2143
- if (!agentId) {
2144
- continue;
2145
- }
2146
- const startedAt = coerceDate(row.latest_started_at);
2147
- if (!startedAt) {
2148
- continue;
2149
- }
2150
- latestRunByAgent.set(agentId, startedAt);
2151
- }
2152
- return latestRunByAgent;
2153
- }
2154
-
2155
- function coerceDate(value: unknown) {
2156
- if (value instanceof Date) {
2157
- return Number.isNaN(value.getTime()) ? null : value;
2158
- }
2159
- if (typeof value === "string" || typeof value === "number") {
2160
- const parsed = new Date(value);
2161
- return Number.isNaN(parsed.getTime()) ? null : parsed;
2162
- }
2163
- return null;
1890
+ items.map((item) => item.id)
1891
+ );
1892
+ return items.map((item) => ({
1893
+ ...item,
1894
+ goal_ids: map.get(item.id) ?? []
1895
+ }));
2164
1896
  }
2165
1897
 
2166
1898
  async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssueIds?: string[]) {
2167
1899
  const normalizedIds = Array.from(new Set((wakeIssueIds ?? []).filter((id) => id.trim().length > 0)));
2168
1900
  if (normalizedIds.length === 0) {
2169
- return [] as Array<{
2170
- id: string;
2171
- project_id: string;
2172
- parent_issue_id: string | null;
2173
- title: string;
2174
- body: string | null;
2175
- status: string;
2176
- priority: string;
2177
- labels_json: string;
2178
- tags_json: string;
2179
- }>;
1901
+ return [] as IssueWorkItemRow[];
2180
1902
  }
2181
1903
  const rows = await db
2182
1904
  .select({
@@ -2196,32 +1918,9 @@ async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssue
2196
1918
  return rows.sort((a, b) => (sortOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (sortOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
2197
1919
  }
2198
1920
 
2199
- function mergeContextWorkItems(
2200
- assigned: Array<{
2201
- id: string;
2202
- project_id: string;
2203
- parent_issue_id: string | null;
2204
- title: string;
2205
- body: string | null;
2206
- status: string;
2207
- priority: string;
2208
- labels_json: string;
2209
- tags_json: string;
2210
- }>,
2211
- wakeContext: Array<{
2212
- id: string;
2213
- project_id: string;
2214
- parent_issue_id: string | null;
2215
- title: string;
2216
- body: string | null;
2217
- status: string;
2218
- priority: string;
2219
- labels_json: string;
2220
- tags_json: string;
2221
- }>
2222
- ) {
1921
+ function mergeContextWorkItems(assigned: IssueWorkItemRow[], wakeContext: IssueWorkItemRow[]) {
2223
1922
  const seen = new Set<string>();
2224
- const merged: typeof assigned = [];
1923
+ const merged: IssueWorkItemRow[] = [];
2225
1924
  for (const item of assigned) {
2226
1925
  if (!seen.has(item.id)) {
2227
1926
  seen.add(item.id);
@@ -2238,28 +1937,8 @@ function mergeContextWorkItems(
2238
1937
  }
2239
1938
 
2240
1939
  function resolveExecutionWorkItems(
2241
- assigned: Array<{
2242
- id: string;
2243
- project_id: string;
2244
- parent_issue_id: string | null;
2245
- title: string;
2246
- body: string | null;
2247
- status: string;
2248
- priority: string;
2249
- labels_json: string;
2250
- tags_json: string;
2251
- }>,
2252
- wakeContextItems: Array<{
2253
- id: string;
2254
- project_id: string;
2255
- parent_issue_id: string | null;
2256
- title: string;
2257
- body: string | null;
2258
- status: string;
2259
- priority: string;
2260
- labels_json: string;
2261
- tags_json: string;
2262
- }>,
1940
+ assigned: IssueWorkItemRow[],
1941
+ wakeContextItems: IssueWorkItemRow[],
2263
1942
  wakeContext?: HeartbeatWakeContext
2264
1943
  ) {
2265
1944
  if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
@@ -2297,6 +1976,62 @@ async function loadWakeContextCommentBody(db: BopoDb, companyId: string, comment
2297
1976
  return body && body.length > 0 ? body : null;
2298
1977
  }
2299
1978
 
1979
+ const GOAL_CONTEXT_DESC_MAX_CHARS = 280;
1980
+ const GOAL_ANCESTRY_NODE_DESC_MAX_CHARS = 120;
1981
+ const GOAL_ANCESTRY_MAX_DEPTH = 16;
1982
+
1983
+ type GoalRowForHeartbeat = {
1984
+ id: string;
1985
+ title: string;
1986
+ description: string | null;
1987
+ parentGoalId: string | null;
1988
+ level: string;
1989
+ projectId: string | null;
1990
+ status: string;
1991
+ ownerAgentId: string | null;
1992
+ };
1993
+
1994
+ function clipGoalDescription(text: string, max: number) {
1995
+ const t = text.trim();
1996
+ if (t.length <= max) {
1997
+ return t;
1998
+ }
1999
+ return `${t.slice(0, Math.max(0, max - 1))}…`;
2000
+ }
2001
+
2002
+ function formatGoalContextLine(goal: Pick<GoalRowForHeartbeat, "title" | "description">, descMax: number) {
2003
+ const title = goal.title.trim();
2004
+ const desc = goal.description?.trim();
2005
+ if (!desc) {
2006
+ return title;
2007
+ }
2008
+ return `${title} — ${clipGoalDescription(desc, descMax)}`;
2009
+ }
2010
+
2011
+ function buildGoalAncestryLines(
2012
+ goalId: string | null | undefined,
2013
+ byId: Map<string, GoalRowForHeartbeat>,
2014
+ nodeDescMax: number
2015
+ ) {
2016
+ if (!goalId) {
2017
+ return [] as string[];
2018
+ }
2019
+ const chain: string[] = [];
2020
+ let current: GoalRowForHeartbeat | undefined = byId.get(goalId);
2021
+ const visited = new Set<string>();
2022
+ let depth = 0;
2023
+ while (current && depth < GOAL_ANCESTRY_MAX_DEPTH) {
2024
+ if (visited.has(current.id)) {
2025
+ break;
2026
+ }
2027
+ visited.add(current.id);
2028
+ chain.unshift(formatGoalContextLine(current, nodeDescMax));
2029
+ current = current.parentGoalId ? byId.get(current.parentGoalId) : undefined;
2030
+ depth += 1;
2031
+ }
2032
+ return chain;
2033
+ }
2034
+
2300
2035
  async function buildHeartbeatContext(
2301
2036
  db: BopoDb,
2302
2037
  companyId: string,
@@ -2311,17 +2046,7 @@ async function buildHeartbeatContext(
2311
2046
  memoryContext?: HeartbeatContext["memoryContext"];
2312
2047
  runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
2313
2048
  wakeContext?: HeartbeatWakeContext;
2314
- workItems: Array<{
2315
- id: string;
2316
- project_id: string;
2317
- parent_issue_id: string | null;
2318
- title: string;
2319
- body: string | null;
2320
- status: string;
2321
- priority: string;
2322
- labels_json: string;
2323
- tags_json: string;
2324
- }>;
2049
+ workItems: IssueWorkItemRowWithGoals[];
2325
2050
  }
2326
2051
  ): Promise<HeartbeatContext> {
2327
2052
  const [company] = await db
@@ -2412,24 +2137,48 @@ async function buildHeartbeatContext(
2412
2137
  id: goals.id,
2413
2138
  level: goals.level,
2414
2139
  title: goals.title,
2140
+ description: goals.description,
2415
2141
  status: goals.status,
2416
- projectId: goals.projectId
2142
+ projectId: goals.projectId,
2143
+ parentGoalId: goals.parentGoalId,
2144
+ ownerAgentId: goals.ownerAgentId
2417
2145
  })
2418
2146
  .from(goals)
2419
2147
  .where(eq(goals.companyId, companyId));
2420
2148
 
2149
+ const goalById = new Map<string, GoalRowForHeartbeat>(
2150
+ goalRows.map((row) => [
2151
+ row.id,
2152
+ {
2153
+ id: row.id,
2154
+ title: row.title,
2155
+ description: row.description,
2156
+ parentGoalId: row.parentGoalId,
2157
+ level: row.level,
2158
+ projectId: row.projectId,
2159
+ status: row.status,
2160
+ ownerAgentId: row.ownerAgentId
2161
+ }
2162
+ ])
2163
+ );
2164
+
2421
2165
  const activeCompanyGoals = goalRows
2422
2166
  .filter((goal) => goal.status === "active" && goal.level === "company")
2423
- .map((goal) => goal.title);
2167
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2424
2168
  const activeProjectGoals = goalRows
2425
2169
  .filter(
2426
2170
  (goal) =>
2427
2171
  goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
2428
2172
  )
2429
- .map((goal) => goal.title);
2173
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2430
2174
  const activeAgentGoals = goalRows
2431
- .filter((goal) => goal.status === "active" && goal.level === "agent")
2432
- .map((goal) => goal.title);
2175
+ .filter(
2176
+ (goal) =>
2177
+ goal.status === "active" &&
2178
+ goal.level === "agent" &&
2179
+ (!goal.ownerAgentId || goal.ownerAgentId === input.agentId)
2180
+ )
2181
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2433
2182
  const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2434
2183
  const promptMode = resolveHeartbeatPromptMode();
2435
2184
 
@@ -2468,6 +2217,11 @@ async function buildHeartbeatContext(
2468
2217
  issueId: item.id,
2469
2218
  projectId: item.project_id,
2470
2219
  parentIssueId: item.parent_issue_id,
2220
+ goalIds: item.goal_ids,
2221
+ goalAncestryChains: item.goal_ids.map((gid) => {
2222
+ const chain = buildGoalAncestryLines(gid, goalById, GOAL_ANCESTRY_NODE_DESC_MAX_CHARS);
2223
+ return chain.length > 0 ? chain : [gid];
2224
+ }),
2471
2225
  childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
2472
2226
  projectName: projectNameById.get(item.project_id) ?? null,
2473
2227
  title: item.title,
@@ -2499,10 +2253,13 @@ function computeMissionAlignmentSignal(input: {
2499
2253
  mission: string | null;
2500
2254
  companyGoals: string[];
2501
2255
  projectGoals: string[];
2256
+ agentGoals: string[];
2502
2257
  }) {
2503
2258
  const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
2504
2259
  const missionTokens = tokenizeAlignmentText(input.mission ?? "");
2505
- const goalTokens = tokenizeAlignmentText([...input.companyGoals, ...input.projectGoals].join(" "));
2260
+ const goalTokens = tokenizeAlignmentText(
2261
+ [...input.companyGoals, ...input.projectGoals, ...input.agentGoals].join(" ")
2262
+ );
2506
2263
  const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
2507
2264
  const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
2508
2265
  const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
@@ -2733,34 +2490,6 @@ async function ensureProjectBudgetOverrideApprovalRequest(
2733
2490
  return approvalId;
2734
2491
  }
2735
2492
 
2736
- function sanitizeAgentSummaryCommentBody(body: string) {
2737
- const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
2738
- return sanitized.length > 0 ? sanitized : "Run update.";
2739
- }
2740
-
2741
- function extractNaturalRunUpdate(executionSummary: string) {
2742
- const normalized = executionSummary.trim();
2743
- const jsonSummary = extractSummaryFromJsonLikeText(normalized);
2744
- const source = jsonSummary ?? normalized;
2745
- const lines = source
2746
- .split("\n")
2747
- .map((line) => line.trim())
2748
- .filter((line) => line.length > 0)
2749
- .filter((line) => !line.startsWith("{") && !line.startsWith("}"));
2750
- const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
2751
- .replace(/^run (failure )?summary\s*:\s*/i, "")
2752
- .replace(/^completed all assigned issue steps\s*:\s*/i, "")
2753
- .replace(/^issue status\s*:\s*/i, "")
2754
- .replace(/`+/g, "")
2755
- .replace(/\s+/g, " ")
2756
- .trim();
2757
- const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
2758
- if (!bounded) {
2759
- return "Run update.";
2760
- }
2761
- return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
2762
- }
2763
-
2764
2493
  function buildRunDigest(input: {
2765
2494
  status: "completed" | "failed" | "skipped";
2766
2495
  executionSummary: string;
@@ -3156,6 +2885,11 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
3156
2885
  const [, agentId, suffix = ""] = directMatch;
3157
2886
  return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3158
2887
  }
2888
+ const projectAgentsMatch = normalized.match(/^projects\/agents\/([^/]+)\/operating(\/.*)?$/);
2889
+ if (projectAgentsMatch) {
2890
+ const [, agentId, suffix = ""] = projectAgentsMatch;
2891
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
2892
+ }
3159
2893
  const issueScopedMatch = normalized.match(
3160
2894
  /^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
3161
2895
  );
@@ -3452,30 +3186,6 @@ function isMachineNoiseLine(text: string) {
3452
3186
  return patterns.some((pattern) => pattern.test(normalized));
3453
3187
  }
3454
3188
 
3455
- function extractSummaryFromJsonLikeText(input: string) {
3456
- const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
3457
- const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
3458
- if (!candidate) {
3459
- return null;
3460
- }
3461
- try {
3462
- const parsed = JSON.parse(candidate) as Record<string, unknown>;
3463
- const summary = parsed.summary;
3464
- if (typeof summary === "string" && summary.trim().length > 0) {
3465
- return summary.trim();
3466
- }
3467
- } catch {
3468
- // Fall through to regex extraction for loosely-formatted JSON.
3469
- }
3470
- const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
3471
- const summary = summaryMatch?.[1]
3472
- ?.replace(/\\"/g, "\"")
3473
- .replace(/\\n/g, " ")
3474
- .replace(/\s+/g, " ")
3475
- .trim();
3476
- return summary && summary.length > 0 ? summary : null;
3477
- }
3478
-
3479
3189
  async function appendRunSummaryComments(
3480
3190
  db: BopoDb,
3481
3191
  input: {
@@ -3849,32 +3559,6 @@ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
3849
3559
  return level === "high" || level === "medium";
3850
3560
  }
3851
3561
 
3852
- function publishHeartbeatRunStatus(
3853
- realtimeHub: RealtimeHub | undefined,
3854
- input: {
3855
- companyId: string;
3856
- runId: string;
3857
- status: "started" | "completed" | "failed" | "skipped";
3858
- message?: string | null;
3859
- startedAt?: Date;
3860
- finishedAt?: Date;
3861
- }
3862
- ) {
3863
- if (!realtimeHub) {
3864
- return;
3865
- }
3866
- realtimeHub.publish(
3867
- createHeartbeatRunsRealtimeEvent(input.companyId, {
3868
- type: "run.status.updated",
3869
- runId: input.runId,
3870
- status: input.status,
3871
- message: input.message ?? null,
3872
- startedAt: input.startedAt?.toISOString(),
3873
- finishedAt: input.finishedAt?.toISOString() ?? null
3874
- })
3875
- );
3876
- }
3877
-
3878
3562
  async function resolveRuntimeWorkspaceForWorkItems(
3879
3563
  db: BopoDb,
3880
3564
  companyId: string,
@@ -4039,39 +3723,6 @@ function resolveStaleRunThresholdMs() {
4039
3723
  return parsed;
4040
3724
  }
4041
3725
 
4042
- function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
4043
- const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
4044
- const fallback = 4;
4045
- const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
4046
- if (normalized < 1) {
4047
- return 1;
4048
- }
4049
- // Prevent scheduler bursts from starving the API event loop.
4050
- const bounded = Math.min(normalized, 16);
4051
- return Math.min(bounded, Math.max(1, dueAgentsCount));
4052
- }
4053
-
4054
- async function runWithConcurrency<T>(
4055
- items: T[],
4056
- concurrency: number,
4057
- worker: (item: T, index: number) => Promise<void>
4058
- ) {
4059
- if (items.length === 0) {
4060
- return;
4061
- }
4062
- const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
4063
- let cursor = 0;
4064
- await Promise.all(
4065
- Array.from({ length: workerCount }, async () => {
4066
- while (cursor < items.length) {
4067
- const index = cursor;
4068
- cursor += 1;
4069
- await worker(items[index] as T, index);
4070
- }
4071
- })
4072
- );
4073
- }
4074
-
4075
3726
  function resolveEffectiveStaleRunThresholdMs(input: {
4076
3727
  baseThresholdMs: number;
4077
3728
  runtimeTimeoutSec: number;
@@ -4291,14 +3942,6 @@ function mergeRuntimeForExecution(
4291
3942
  };
4292
3943
  }
4293
3944
 
4294
- function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
4295
- activeHeartbeatRuns.set(runId, run);
4296
- }
4297
-
4298
- function unregisterActiveHeartbeatRun(runId: string) {
4299
- activeHeartbeatRuns.delete(runId);
4300
- }
4301
-
4302
3945
  function clearResumeState(
4303
3946
  state: AgentState & {
4304
3947
  runtime?: {
@@ -4350,8 +3993,6 @@ function resolveHeartbeatPromptMode(): "full" | "compact" {
4350
3993
  return raw === "compact" ? "compact" : "full";
4351
3994
  }
4352
3995
 
4353
- type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4354
-
4355
3996
  function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4356
3997
  const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4357
3998
  if (raw === "skip_adapter") {
@@ -4738,62 +4379,3 @@ async function appendFinishedRunCostEntry(input: {
4738
4379
  usdCostStatus
4739
4380
  };
4740
4381
  }
4741
-
4742
- function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
4743
- const normalizedNow = truncateToMinute(now);
4744
- if (!matchesCronExpression(cronExpression, normalizedNow)) {
4745
- return false;
4746
- }
4747
- if (!lastRunAt) {
4748
- return true;
4749
- }
4750
- return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
4751
- }
4752
-
4753
- function truncateToMinute(date: Date) {
4754
- const clone = new Date(date);
4755
- clone.setSeconds(0, 0);
4756
- return clone;
4757
- }
4758
-
4759
- function matchesCronExpression(expression: string, date: Date) {
4760
- const parts = expression.trim().split(/\s+/);
4761
- if (parts.length !== 5) {
4762
- return false;
4763
- }
4764
-
4765
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
4766
- return (
4767
- matchesCronField(minute, date.getMinutes(), 0, 59) &&
4768
- matchesCronField(hour, date.getHours(), 0, 23) &&
4769
- matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
4770
- matchesCronField(month, date.getMonth() + 1, 1, 12) &&
4771
- matchesCronField(dayOfWeek, date.getDay(), 0, 6)
4772
- );
4773
- }
4774
-
4775
- function matchesCronField(field: string, value: number, min: number, max: number) {
4776
- return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
4777
- }
4778
-
4779
- function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
4780
- if (part === "*") {
4781
- return true;
4782
- }
4783
-
4784
- const stepMatch = part.match(/^\*\/(\d+)$/);
4785
- if (stepMatch) {
4786
- const step = Number(stepMatch[1]);
4787
- return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
4788
- }
4789
-
4790
- const rangeMatch = part.match(/^(\d+)-(\d+)$/);
4791
- if (rangeMatch) {
4792
- const start = Number(rangeMatch[1]);
4793
- const end = Number(rangeMatch[2]);
4794
- return start <= value && value <= end;
4795
- }
4796
-
4797
- const exact = Number(part);
4798
- return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
4799
- }