bopodev-api 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,706 @@
1
+ import { and, desc, eq, inArray, sql } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { resolveAdapter } from "bopodev-agent-sdk";
4
+ import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
5
+ import type { BopoDb } from "bopodev-db";
6
+ import { agents, appendActivity, companies, goals, heartbeatRuns, issues, projects } from "bopodev-db";
7
+ import { appendAuditEvent, appendCost } from "bopodev-db";
8
+ import type { RealtimeHub } from "../realtime/hub";
9
+ import { publishOfficeOccupantForAgent } from "../realtime/office-space";
10
+ import { checkAgentBudget } from "./budget-service";
11
+
12
+ export async function claimIssuesForAgent(
13
+ db: BopoDb,
14
+ companyId: string,
15
+ agentId: string,
16
+ heartbeatRunId: string,
17
+ maxItems = 5
18
+ ) {
19
+ const result = await db.execute(sql`
20
+ WITH candidate AS (
21
+ SELECT id
22
+ FROM issues
23
+ WHERE company_id = ${companyId}
24
+ AND assignee_agent_id = ${agentId}
25
+ AND status IN ('todo', 'in_progress')
26
+ AND is_claimed = false
27
+ ORDER BY updated_at ASC
28
+ LIMIT ${maxItems}
29
+ FOR UPDATE SKIP LOCKED
30
+ )
31
+ UPDATE issues i
32
+ SET is_claimed = true,
33
+ claimed_by_heartbeat_run_id = ${heartbeatRunId},
34
+ updated_at = CURRENT_TIMESTAMP
35
+ FROM candidate c
36
+ WHERE i.id = c.id
37
+ RETURNING i.id, i.project_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
38
+ `);
39
+
40
+ return (result.rows ?? []) as Array<{
41
+ id: string;
42
+ project_id: string;
43
+ title: string;
44
+ body: string | null;
45
+ status: string;
46
+ priority: string;
47
+ labels_json: string;
48
+ tags_json: string;
49
+ }>;
50
+ }
51
+
52
+ export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
53
+ if (issueIds.length === 0) {
54
+ return;
55
+ }
56
+ await db
57
+ .update(issues)
58
+ .set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
59
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
60
+ }
61
+
62
+ export async function runHeartbeatForAgent(
63
+ db: BopoDb,
64
+ companyId: string,
65
+ agentId: string,
66
+ options?: { requestId?: string; trigger?: "manual" | "scheduler"; realtimeHub?: RealtimeHub }
67
+ ) {
68
+ const [agent] = await db
69
+ .select()
70
+ .from(agents)
71
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
72
+ .limit(1);
73
+
74
+ if (!agent || agent.status === "paused" || agent.status === "terminated") {
75
+ return null;
76
+ }
77
+
78
+ const startedRuns = await db
79
+ .select({ id: heartbeatRuns.id, startedAt: heartbeatRuns.startedAt })
80
+ .from(heartbeatRuns)
81
+ .where(
82
+ and(
83
+ eq(heartbeatRuns.companyId, companyId),
84
+ eq(heartbeatRuns.agentId, agentId),
85
+ eq(heartbeatRuns.status, "started")
86
+ )
87
+ );
88
+ const staleRunThresholdMs = resolveStaleRunThresholdMs();
89
+ const nowTs = Date.now();
90
+ const staleRuns = startedRuns.filter((run) => {
91
+ const startedAt = run.startedAt.getTime();
92
+ return nowTs - startedAt >= staleRunThresholdMs;
93
+ });
94
+
95
+ if (staleRuns.length > 0) {
96
+ await recoverStaleHeartbeatRuns(db, companyId, agentId, staleRuns, {
97
+ requestId: options?.requestId,
98
+ trigger: options?.trigger ?? "manual",
99
+ staleRunThresholdMs
100
+ });
101
+ }
102
+
103
+ const budgetCheck = await checkAgentBudget(db, companyId, agentId);
104
+ const runId = nanoid(14);
105
+ if (budgetCheck.allowed) {
106
+ const claimed = await insertStartedRunAtomic(db, {
107
+ id: runId,
108
+ companyId,
109
+ agentId,
110
+ message: "Heartbeat started."
111
+ });
112
+ if (!claimed) {
113
+ const skippedRunId = nanoid(14);
114
+ await db.insert(heartbeatRuns).values({
115
+ id: skippedRunId,
116
+ companyId,
117
+ agentId,
118
+ status: "skipped",
119
+ finishedAt: new Date(),
120
+ message: "Heartbeat skipped: another run is already in progress for this agent."
121
+ });
122
+ await appendAuditEvent(db, {
123
+ companyId,
124
+ actorType: "system",
125
+ eventType: "heartbeat.skipped_overlap",
126
+ entityType: "heartbeat_run",
127
+ entityId: skippedRunId,
128
+ correlationId: options?.requestId ?? skippedRunId,
129
+ payload: { agentId, requestId: options?.requestId, trigger: options?.trigger ?? "manual" }
130
+ });
131
+ return skippedRunId;
132
+ }
133
+ } else {
134
+ await db.insert(heartbeatRuns).values({
135
+ id: runId,
136
+ companyId,
137
+ agentId,
138
+ status: "skipped",
139
+ message: "Heartbeat skipped due to budget hard-stop."
140
+ });
141
+ }
142
+
143
+ if (budgetCheck.allowed) {
144
+ await appendAuditEvent(db, {
145
+ companyId,
146
+ actorType: "system",
147
+ eventType: "heartbeat.started",
148
+ entityType: "heartbeat_run",
149
+ entityId: runId,
150
+ correlationId: options?.requestId ?? runId,
151
+ payload: {
152
+ agentId,
153
+ requestId: options?.requestId ?? null,
154
+ trigger: options?.trigger ?? "manual"
155
+ }
156
+ });
157
+ }
158
+
159
+ if (!budgetCheck.allowed) {
160
+ await appendAuditEvent(db, {
161
+ companyId,
162
+ actorType: "system",
163
+ eventType: "budget.hard_stop",
164
+ entityType: "agent",
165
+ entityId: agentId,
166
+ payload: { utilizationPct: budgetCheck.utilizationPct }
167
+ });
168
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
169
+ return runId;
170
+ }
171
+
172
+ if (budgetCheck.utilizationPct >= 80) {
173
+ await appendAuditEvent(db, {
174
+ companyId,
175
+ actorType: "system",
176
+ eventType: "budget.soft_warning",
177
+ entityType: "agent",
178
+ entityId: agentId,
179
+ payload: { utilizationPct: budgetCheck.utilizationPct }
180
+ });
181
+ }
182
+
183
+ let issueIds: string[] = [];
184
+ let state: AgentState & {
185
+ runtime?: {
186
+ command?: string;
187
+ args?: string[];
188
+ cwd?: string;
189
+ timeoutMs?: number;
190
+ retryCount?: number;
191
+ retryBackoffMs?: number;
192
+ };
193
+ } = {};
194
+ let executionSummary = "";
195
+ let executionTrace: unknown = null;
196
+ let stateParseError: string | null = null;
197
+
198
+ try {
199
+ const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
200
+ issueIds = workItems.map((item) => item.id);
201
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
202
+ const adapter = resolveAdapter(agent.providerType as "claude_code" | "codex" | "http" | "shell");
203
+ const parsedState = parseAgentState(agent.stateBlob);
204
+ state = parsedState.state;
205
+ stateParseError = parsedState.parseError;
206
+
207
+ const context = await buildHeartbeatContext(db, companyId, {
208
+ agentId,
209
+ agentName: agent.name,
210
+ agentRole: agent.role,
211
+ managerAgentId: agent.managerAgentId,
212
+ providerType: agent.providerType as "claude_code" | "codex" | "http" | "shell",
213
+ heartbeatRunId: runId,
214
+ state,
215
+ runtime: state.runtime,
216
+ workItems
217
+ });
218
+
219
+ const execution = await adapter.execute(context);
220
+ executionSummary = execution.summary;
221
+ executionTrace = execution.trace ?? null;
222
+
223
+ if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
224
+ await appendCost(db, {
225
+ companyId,
226
+ providerType: agent.providerType,
227
+ tokenInput: execution.tokenInput,
228
+ tokenOutput: execution.tokenOutput,
229
+ usdCost: execution.usdCost.toFixed(6),
230
+ issueId: workItems[0]?.id ?? null,
231
+ projectId: workItems[0]?.project_id ?? null,
232
+ agentId
233
+ });
234
+ }
235
+
236
+ if (
237
+ execution.nextState ||
238
+ execution.usdCost > 0 ||
239
+ execution.tokenInput > 0 ||
240
+ execution.tokenOutput > 0 ||
241
+ execution.status !== "skipped"
242
+ ) {
243
+ await db
244
+ .update(agents)
245
+ .set({
246
+ stateBlob: JSON.stringify(execution.nextState ?? state),
247
+ usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${execution.usdCost}`,
248
+ tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
249
+ updatedAt: new Date()
250
+ })
251
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
252
+ }
253
+
254
+ if (issueIds.length > 0 && execution.status === "ok") {
255
+ await db
256
+ .update(issues)
257
+ .set({ status: "in_review", updatedAt: new Date() })
258
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
259
+
260
+ for (const issueId of issueIds) {
261
+ await appendActivity(db, {
262
+ companyId,
263
+ issueId,
264
+ actorType: "system",
265
+ eventType: "issue.sent_to_review",
266
+ payload: { heartbeatRunId: runId, agentId }
267
+ });
268
+ }
269
+ }
270
+
271
+ await db
272
+ .update(heartbeatRuns)
273
+ .set({
274
+ status: execution.status === "failed" ? "failed" : "completed",
275
+ finishedAt: new Date(),
276
+ message: execution.summary
277
+ })
278
+ .where(eq(heartbeatRuns.id, runId));
279
+
280
+ await appendAuditEvent(db, {
281
+ companyId,
282
+ actorType: "system",
283
+ eventType: "heartbeat.completed",
284
+ entityType: "heartbeat_run",
285
+ entityId: runId,
286
+ correlationId: options?.requestId ?? runId,
287
+ payload: {
288
+ agentId,
289
+ result: execution.summary,
290
+ issueIds,
291
+ usage: {
292
+ tokenInput: execution.tokenInput,
293
+ tokenOutput: execution.tokenOutput,
294
+ usdCost: execution.usdCost
295
+ },
296
+ trace: execution.trace ?? null,
297
+ diagnostics: {
298
+ stateParseError,
299
+ requestId: options?.requestId,
300
+ trigger: options?.trigger ?? "manual"
301
+ }
302
+ }
303
+ });
304
+ } catch (error) {
305
+ const classified = classifyHeartbeatError(error);
306
+ executionSummary = `Heartbeat failed (${classified.type}): ${classified.message}`;
307
+ await db
308
+ .update(heartbeatRuns)
309
+ .set({
310
+ status: "failed",
311
+ finishedAt: new Date(),
312
+ message: executionSummary
313
+ })
314
+ .where(eq(heartbeatRuns.id, runId));
315
+ await appendAuditEvent(db, {
316
+ companyId,
317
+ actorType: "system",
318
+ eventType: "heartbeat.failed",
319
+ entityType: "heartbeat_run",
320
+ entityId: runId,
321
+ correlationId: options?.requestId ?? runId,
322
+ payload: {
323
+ agentId,
324
+ issueIds,
325
+ errorType: classified.type,
326
+ errorMessage: classified.message,
327
+ trace: executionTrace,
328
+ diagnostics: {
329
+ stateParseError,
330
+ requestId: options?.requestId,
331
+ trigger: options?.trigger ?? "manual"
332
+ }
333
+ }
334
+ });
335
+ } finally {
336
+ try {
337
+ await releaseClaimedIssues(db, companyId, issueIds);
338
+ } catch (releaseError) {
339
+ await appendAuditEvent(db, {
340
+ companyId,
341
+ actorType: "system",
342
+ eventType: "heartbeat.release_failed",
343
+ entityType: "heartbeat_run",
344
+ entityId: runId,
345
+ correlationId: options?.requestId ?? runId,
346
+ payload: {
347
+ agentId,
348
+ issueIds,
349
+ error: String(releaseError)
350
+ }
351
+ });
352
+ }
353
+ await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
354
+ }
355
+
356
+ return runId;
357
+ }
358
+
359
+ async function insertStartedRunAtomic(
360
+ db: BopoDb,
361
+ input: { id: string; companyId: string; agentId: string; message: string }
362
+ ) {
363
+ const result = await db.execute(sql`
364
+ INSERT INTO heartbeat_runs (id, company_id, agent_id, status, message)
365
+ VALUES (${input.id}, ${input.companyId}, ${input.agentId}, 'started', ${input.message})
366
+ ON CONFLICT DO NOTHING
367
+ RETURNING id
368
+ `);
369
+ return (result.rows ?? []).length > 0;
370
+ }
371
+
372
+ async function recoverStaleHeartbeatRuns(
373
+ db: BopoDb,
374
+ companyId: string,
375
+ agentId: string,
376
+ staleRuns: Array<{ id: string; startedAt: Date }>,
377
+ input: { requestId?: string; trigger: "manual" | "scheduler"; staleRunThresholdMs: number }
378
+ ) {
379
+ const staleRunIds = staleRuns.map((run) => run.id);
380
+ if (staleRunIds.length === 0) {
381
+ return;
382
+ }
383
+
384
+ await db
385
+ .update(heartbeatRuns)
386
+ .set({
387
+ status: "failed",
388
+ finishedAt: new Date(),
389
+ message: "Heartbeat auto-failed after stale in-progress timeout."
390
+ })
391
+ .where(
392
+ and(
393
+ eq(heartbeatRuns.companyId, companyId),
394
+ eq(heartbeatRuns.agentId, agentId),
395
+ inArray(heartbeatRuns.id, staleRunIds),
396
+ eq(heartbeatRuns.status, "started")
397
+ )
398
+ );
399
+
400
+ const claimedIssueRows = await db
401
+ .select({ id: issues.id })
402
+ .from(issues)
403
+ .where(and(eq(issues.companyId, companyId), inArray(issues.claimedByHeartbeatRunId, staleRunIds), eq(issues.isClaimed, true)));
404
+ await releaseClaimedIssues(
405
+ db,
406
+ companyId,
407
+ claimedIssueRows.map((row) => row.id)
408
+ );
409
+
410
+ for (const staleRun of staleRuns) {
411
+ await appendAuditEvent(db, {
412
+ companyId,
413
+ actorType: "system",
414
+ eventType: "heartbeat.stale_recovered",
415
+ entityType: "heartbeat_run",
416
+ entityId: staleRun.id,
417
+ correlationId: input.requestId ?? staleRun.id,
418
+ payload: {
419
+ agentId,
420
+ trigger: input.trigger,
421
+ requestId: input.requestId ?? null,
422
+ staleRunThresholdMs: input.staleRunThresholdMs,
423
+ staleForMs: Date.now() - staleRun.startedAt.getTime()
424
+ }
425
+ });
426
+ }
427
+ }
428
+
429
+ export async function runHeartbeatSweep(
430
+ db: BopoDb,
431
+ companyId: string,
432
+ options?: { requestId?: string; realtimeHub?: RealtimeHub }
433
+ ) {
434
+ const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
435
+ const recentRuns = await db
436
+ .select({ agentId: heartbeatRuns.agentId, startedAt: heartbeatRuns.startedAt })
437
+ .from(heartbeatRuns)
438
+ .where(eq(heartbeatRuns.companyId, companyId))
439
+ .orderBy(desc(heartbeatRuns.startedAt));
440
+ const latestRunByAgent = new Map<string, Date>();
441
+ for (const run of recentRuns) {
442
+ if (!latestRunByAgent.has(run.agentId)) {
443
+ latestRunByAgent.set(run.agentId, run.startedAt);
444
+ }
445
+ }
446
+
447
+ const now = new Date();
448
+ const runs: string[] = [];
449
+ let skippedNotDue = 0;
450
+ let skippedStatus = 0;
451
+ let failedStarts = 0;
452
+ const sweepStartedAt = Date.now();
453
+ for (const agent of companyAgents) {
454
+ if (agent.status !== "idle" && agent.status !== "running") {
455
+ skippedStatus += 1;
456
+ continue;
457
+ }
458
+ if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
459
+ skippedNotDue += 1;
460
+ continue;
461
+ }
462
+ try {
463
+ const runId = await runHeartbeatForAgent(db, companyId, agent.id, {
464
+ trigger: "scheduler",
465
+ requestId: options?.requestId,
466
+ realtimeHub: options?.realtimeHub
467
+ });
468
+ if (runId) {
469
+ runs.push(runId);
470
+ }
471
+ } catch {
472
+ failedStarts += 1;
473
+ }
474
+ }
475
+ await appendAuditEvent(db, {
476
+ companyId,
477
+ actorType: "system",
478
+ eventType: "heartbeat.sweep.completed",
479
+ entityType: "company",
480
+ entityId: companyId,
481
+ correlationId: options?.requestId ?? null,
482
+ payload: {
483
+ runIds: runs,
484
+ startedCount: runs.length,
485
+ failedStarts,
486
+ skippedStatus,
487
+ skippedNotDue,
488
+ elapsedMs: Date.now() - sweepStartedAt,
489
+ requestId: options?.requestId ?? null
490
+ }
491
+ });
492
+ return runs;
493
+ }
494
+
495
+ async function buildHeartbeatContext(
496
+ db: BopoDb,
497
+ companyId: string,
498
+ input: {
499
+ agentId: string;
500
+ agentName: string;
501
+ agentRole: string;
502
+ managerAgentId: string | null;
503
+ providerType: "claude_code" | "codex" | "http" | "shell";
504
+ heartbeatRunId: string;
505
+ state: AgentState;
506
+ runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
507
+ workItems: Array<{
508
+ id: string;
509
+ project_id: string;
510
+ title: string;
511
+ body: string | null;
512
+ status: string;
513
+ priority: string;
514
+ labels_json: string;
515
+ tags_json: string;
516
+ }>;
517
+ }
518
+ ): Promise<HeartbeatContext> {
519
+ const [company] = await db
520
+ .select({ name: companies.name, mission: companies.mission })
521
+ .from(companies)
522
+ .where(eq(companies.id, companyId))
523
+ .limit(1);
524
+ const projectIds = Array.from(new Set(input.workItems.map((item) => item.project_id)));
525
+ const projectRows =
526
+ projectIds.length > 0
527
+ ? await db
528
+ .select({ id: projects.id, name: projects.name })
529
+ .from(projects)
530
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
531
+ : [];
532
+ const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
533
+ const goalRows = await db
534
+ .select({
535
+ id: goals.id,
536
+ level: goals.level,
537
+ title: goals.title,
538
+ status: goals.status,
539
+ projectId: goals.projectId
540
+ })
541
+ .from(goals)
542
+ .where(eq(goals.companyId, companyId));
543
+
544
+ const activeCompanyGoals = goalRows
545
+ .filter((goal) => goal.status === "active" && goal.level === "company")
546
+ .map((goal) => goal.title);
547
+ const activeProjectGoals = goalRows
548
+ .filter(
549
+ (goal) =>
550
+ goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
551
+ )
552
+ .map((goal) => goal.title);
553
+ const activeAgentGoals = goalRows
554
+ .filter((goal) => goal.status === "active" && goal.level === "agent")
555
+ .map((goal) => goal.title);
556
+
557
+ return {
558
+ companyId,
559
+ agentId: input.agentId,
560
+ providerType: input.providerType,
561
+ heartbeatRunId: input.heartbeatRunId,
562
+ company: {
563
+ name: company?.name ?? "Unknown company",
564
+ mission: company?.mission ?? null
565
+ },
566
+ agent: {
567
+ name: input.agentName,
568
+ role: input.agentRole,
569
+ managerAgentId: input.managerAgentId
570
+ },
571
+ state: input.state,
572
+ runtime: input.runtime,
573
+ goalContext: {
574
+ companyGoals: activeCompanyGoals,
575
+ projectGoals: activeProjectGoals,
576
+ agentGoals: activeAgentGoals
577
+ },
578
+ workItems: input.workItems.map((item) => ({
579
+ issueId: item.id,
580
+ projectId: item.project_id,
581
+ projectName: projectNameById.get(item.project_id) ?? null,
582
+ title: item.title,
583
+ body: item.body,
584
+ status: item.status,
585
+ priority: item.priority,
586
+ labels: parseStringArray(item.labels_json),
587
+ tags: parseStringArray(item.tags_json)
588
+ }))
589
+ };
590
+ }
591
+
592
+ function parseStringArray(value: string | null) {
593
+ if (!value) {
594
+ return [];
595
+ }
596
+ try {
597
+ const parsed = JSON.parse(value) as unknown;
598
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
599
+ } catch {
600
+ return [];
601
+ }
602
+ }
603
+
604
+ function parseAgentState(stateBlob: string | null) {
605
+ if (!stateBlob) {
606
+ return { state: {} as AgentState, parseError: null };
607
+ }
608
+ try {
609
+ return {
610
+ state: JSON.parse(stateBlob) as AgentState & {
611
+ runtime?: {
612
+ command?: string;
613
+ args?: string[];
614
+ cwd?: string;
615
+ timeoutMs?: number;
616
+ retryCount?: number;
617
+ retryBackoffMs?: number;
618
+ };
619
+ },
620
+ parseError: null
621
+ };
622
+ } catch (error) {
623
+ return {
624
+ state: {} as AgentState,
625
+ parseError: String(error)
626
+ };
627
+ }
628
+ }
629
+
630
+ function classifyHeartbeatError(error: unknown) {
631
+ const message = String(error);
632
+ if (message.includes("ENOENT")) {
633
+ return { type: "runtime_missing", message };
634
+ }
635
+ if (message.includes("timeout")) {
636
+ return { type: "timeout", message };
637
+ }
638
+ return { type: "unknown", message };
639
+ }
640
+
641
+ function resolveStaleRunThresholdMs() {
642
+ const parsed = Number(process.env.BOPO_HEARTBEAT_STALE_RUN_MS ?? 10 * 60 * 1000);
643
+ if (!Number.isFinite(parsed) || parsed < 1_000) {
644
+ return 10 * 60 * 1000;
645
+ }
646
+ return parsed;
647
+ }
648
+
649
+ function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
650
+ const normalizedNow = truncateToMinute(now);
651
+ if (!matchesCronExpression(cronExpression, normalizedNow)) {
652
+ return false;
653
+ }
654
+ if (!lastRunAt) {
655
+ return true;
656
+ }
657
+ return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
658
+ }
659
+
660
+ function truncateToMinute(date: Date) {
661
+ const clone = new Date(date);
662
+ clone.setSeconds(0, 0);
663
+ return clone;
664
+ }
665
+
666
+ function matchesCronExpression(expression: string, date: Date) {
667
+ const parts = expression.trim().split(/\s+/);
668
+ if (parts.length !== 5) {
669
+ return false;
670
+ }
671
+
672
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
673
+ return (
674
+ matchesCronField(minute, date.getMinutes(), 0, 59) &&
675
+ matchesCronField(hour, date.getHours(), 0, 23) &&
676
+ matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
677
+ matchesCronField(month, date.getMonth() + 1, 1, 12) &&
678
+ matchesCronField(dayOfWeek, date.getDay(), 0, 6)
679
+ );
680
+ }
681
+
682
+ function matchesCronField(field: string, value: number, min: number, max: number) {
683
+ return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
684
+ }
685
+
686
+ function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
687
+ if (part === "*") {
688
+ return true;
689
+ }
690
+
691
+ const stepMatch = part.match(/^\*\/(\d+)$/);
692
+ if (stepMatch) {
693
+ const step = Number(stepMatch[1]);
694
+ return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
695
+ }
696
+
697
+ const rangeMatch = part.match(/^(\d+)-(\d+)$/);
698
+ if (rangeMatch) {
699
+ const start = Number(rangeMatch[1]);
700
+ const end = Number(rangeMatch[2]);
701
+ return start <= value && value <= end;
702
+ }
703
+
704
+ const exact = Number(part);
705
+ return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
706
+ }