bopodev-api 0.1.30 → 0.1.31

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,665 @@
1
+ import { nanoid } from "nanoid";
2
+ import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm";
3
+ import {
4
+ agents,
5
+ appendAuditEvent,
6
+ assertProjectBelongsToCompany,
7
+ issues,
8
+ type BopoDb,
9
+ syncIssueGoals,
10
+ workLoopRuns,
11
+ workLoops,
12
+ workLoopTriggers
13
+ } from "bopodev-db";
14
+ import type { RealtimeHub } from "../../realtime/hub";
15
+ import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "../heartbeat-queue-service";
16
+ import {
17
+ assertValidTimeZone,
18
+ dailyCronAtLocalTime,
19
+ floorToUtcMinute,
20
+ nextCronFireAfter,
21
+ validateCronExpression,
22
+ weeklyCronAtLocalTime
23
+ } from "./loop-cron";
24
+
25
+ export const MAX_LOOP_CATCH_UP_RUNS = 25;
26
+
27
+ const OPEN_ISSUE_STATUSES = ["todo", "in_progress", "blocked", "in_review"] as const;
28
+
29
+ export type WorkLoopConcurrencyPolicy = "coalesce_if_active" | "skip_if_active" | "always_enqueue";
30
+ export type WorkLoopCatchUpPolicy = "skip_missed" | "enqueue_missed_with_cap";
31
+
32
+ export async function createWorkLoop(
33
+ db: BopoDb,
34
+ input: {
35
+ companyId: string;
36
+ projectId: string;
37
+ parentIssueId?: string | null;
38
+ goalIds?: string[];
39
+ title: string;
40
+ description?: string | null;
41
+ assigneeAgentId: string;
42
+ priority?: string;
43
+ status?: string;
44
+ concurrencyPolicy?: WorkLoopConcurrencyPolicy;
45
+ catchUpPolicy?: WorkLoopCatchUpPolicy;
46
+ }
47
+ ) {
48
+ const id = nanoid(14);
49
+ await db.insert(workLoops).values({
50
+ id,
51
+ companyId: input.companyId,
52
+ projectId: input.projectId,
53
+ parentIssueId: input.parentIssueId ?? null,
54
+ goalIdsJson: JSON.stringify(input.goalIds ?? []),
55
+ title: input.title,
56
+ description: input.description ?? null,
57
+ assigneeAgentId: input.assigneeAgentId,
58
+ priority: input.priority ?? "medium",
59
+ status: input.status ?? "active",
60
+ concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
61
+ catchUpPolicy: input.catchUpPolicy ?? "skip_missed"
62
+ });
63
+ const [row] = await db.select().from(workLoops).where(eq(workLoops.id, id)).limit(1);
64
+ return row ?? null;
65
+ }
66
+
67
+ export async function updateWorkLoop(
68
+ db: BopoDb,
69
+ companyId: string,
70
+ loopId: string,
71
+ patch: Partial<{
72
+ title: string;
73
+ description: string | null;
74
+ assigneeAgentId: string;
75
+ priority: string;
76
+ status: string;
77
+ concurrencyPolicy: WorkLoopConcurrencyPolicy;
78
+ catchUpPolicy: WorkLoopCatchUpPolicy;
79
+ parentIssueId: string | null;
80
+ goalIds: string[];
81
+ projectId: string;
82
+ }>
83
+ ) {
84
+ const [existing] = await db
85
+ .select()
86
+ .from(workLoops)
87
+ .where(and(eq(workLoops.id, loopId), eq(workLoops.companyId, companyId)))
88
+ .limit(1);
89
+ if (!existing) {
90
+ return null;
91
+ }
92
+ await db
93
+ .update(workLoops)
94
+ .set({
95
+ ...("title" in patch ? { title: patch.title } : {}),
96
+ ...("description" in patch ? { description: patch.description } : {}),
97
+ ...("assigneeAgentId" in patch ? { assigneeAgentId: patch.assigneeAgentId } : {}),
98
+ ...("priority" in patch ? { priority: patch.priority } : {}),
99
+ ...("status" in patch ? { status: patch.status } : {}),
100
+ ...("concurrencyPolicy" in patch ? { concurrencyPolicy: patch.concurrencyPolicy } : {}),
101
+ ...("catchUpPolicy" in patch ? { catchUpPolicy: patch.catchUpPolicy } : {}),
102
+ ...("parentIssueId" in patch ? { parentIssueId: patch.parentIssueId } : {}),
103
+ ...("goalIds" in patch ? { goalIdsJson: JSON.stringify(patch.goalIds ?? []) } : {}),
104
+ ...("projectId" in patch ? { projectId: patch.projectId } : {}),
105
+ updatedAt: new Date()
106
+ })
107
+ .where(eq(workLoops.id, loopId));
108
+ const [row] = await db.select().from(workLoops).where(eq(workLoops.id, loopId)).limit(1);
109
+ return row ?? null;
110
+ }
111
+
112
+ export async function getWorkLoop(db: BopoDb, companyId: string, loopId: string) {
113
+ const [row] = await db
114
+ .select()
115
+ .from(workLoops)
116
+ .where(and(eq(workLoops.id, loopId), eq(workLoops.companyId, companyId)))
117
+ .limit(1);
118
+ return row ?? null;
119
+ }
120
+
121
+ export async function listWorkLoops(db: BopoDb, companyId: string) {
122
+ return db
123
+ .select()
124
+ .from(workLoops)
125
+ .where(eq(workLoops.companyId, companyId))
126
+ .orderBy(desc(workLoops.updatedAt), asc(workLoops.title));
127
+ }
128
+
129
+ export async function addWorkLoopTrigger(
130
+ db: BopoDb,
131
+ input: {
132
+ companyId: string;
133
+ workLoopId: string;
134
+ cronExpression: string;
135
+ timezone?: string;
136
+ label?: string | null;
137
+ enabled?: boolean;
138
+ }
139
+ ) {
140
+ const err = validateCronExpression(input.cronExpression);
141
+ if (err) {
142
+ throw new Error(err);
143
+ }
144
+ const tz = input.timezone?.trim() || "UTC";
145
+ assertValidTimeZone(tz);
146
+ const id = nanoid(14);
147
+ const start = nextCronFireAfter(input.cronExpression, tz, new Date(Date.now() - 60_000));
148
+ await db.insert(workLoopTriggers).values({
149
+ id,
150
+ companyId: input.companyId,
151
+ workLoopId: input.workLoopId,
152
+ kind: "schedule",
153
+ label: input.label ?? null,
154
+ enabled: input.enabled ?? true,
155
+ cronExpression: input.cronExpression.trim(),
156
+ timezone: tz,
157
+ nextRunAt: start ?? new Date()
158
+ });
159
+ const [row] = await db.select().from(workLoopTriggers).where(eq(workLoopTriggers.id, id)).limit(1);
160
+ return row ?? null;
161
+ }
162
+
163
+ export async function addWorkLoopTriggerFromPreset(
164
+ db: BopoDb,
165
+ input: {
166
+ companyId: string;
167
+ workLoopId: string;
168
+ preset: "daily" | "weekly";
169
+ hour24: number;
170
+ minute: number;
171
+ timezone?: string;
172
+ dayOfWeek?: number;
173
+ label?: string | null;
174
+ enabled?: boolean;
175
+ }
176
+ ) {
177
+ const tz = input.timezone?.trim() || "UTC";
178
+ assertValidTimeZone(tz);
179
+ const cron =
180
+ input.preset === "daily"
181
+ ? dailyCronAtLocalTime(input.hour24, input.minute)
182
+ : weeklyCronAtLocalTime(input.dayOfWeek ?? 1, input.hour24, input.minute);
183
+ return addWorkLoopTrigger(db, {
184
+ companyId: input.companyId,
185
+ workLoopId: input.workLoopId,
186
+ cronExpression: cron,
187
+ timezone: tz,
188
+ label: input.label,
189
+ enabled: input.enabled
190
+ });
191
+ }
192
+
193
+ export async function updateWorkLoopTrigger(
194
+ db: BopoDb,
195
+ companyId: string,
196
+ triggerId: string,
197
+ patch: Partial<{
198
+ cronExpression: string;
199
+ timezone: string;
200
+ label: string | null;
201
+ enabled: boolean;
202
+ }>
203
+ ) {
204
+ const [existing] = await db
205
+ .select()
206
+ .from(workLoopTriggers)
207
+ .where(and(eq(workLoopTriggers.id, triggerId), eq(workLoopTriggers.companyId, companyId)))
208
+ .limit(1);
209
+ if (!existing) {
210
+ return null;
211
+ }
212
+ if (patch.cronExpression) {
213
+ const verr = validateCronExpression(patch.cronExpression);
214
+ if (verr) {
215
+ throw new Error(verr);
216
+ }
217
+ }
218
+ if (patch.timezone) {
219
+ assertValidTimeZone(patch.timezone);
220
+ }
221
+ const cronExpr = patch.cronExpression?.trim() ?? existing.cronExpression;
222
+ const tz = patch.timezone?.trim() ?? existing.timezone;
223
+ let nextRunAt: Date | undefined;
224
+ if (patch.cronExpression || patch.timezone) {
225
+ nextRunAt = nextCronFireAfter(cronExpr, tz, new Date(Date.now() - 60_000)) ?? undefined;
226
+ }
227
+ await db
228
+ .update(workLoopTriggers)
229
+ .set({
230
+ ...("cronExpression" in patch && patch.cronExpression ? { cronExpression: cronExpr } : {}),
231
+ ...("timezone" in patch && patch.timezone ? { timezone: tz } : {}),
232
+ ...("label" in patch ? { label: patch.label } : {}),
233
+ ...("enabled" in patch ? { enabled: patch.enabled } : {}),
234
+ ...(nextRunAt ? { nextRunAt } : {}),
235
+ updatedAt: new Date()
236
+ })
237
+ .where(eq(workLoopTriggers.id, triggerId));
238
+ const [row] = await db.select().from(workLoopTriggers).where(eq(workLoopTriggers.id, triggerId)).limit(1);
239
+ return row ?? null;
240
+ }
241
+
242
+ export async function deleteWorkLoopTrigger(
243
+ db: BopoDb,
244
+ companyId: string,
245
+ workLoopId: string,
246
+ triggerId: string
247
+ ): Promise<boolean> {
248
+ const [existing] = await db
249
+ .select()
250
+ .from(workLoopTriggers)
251
+ .where(
252
+ and(
253
+ eq(workLoopTriggers.id, triggerId),
254
+ eq(workLoopTriggers.companyId, companyId),
255
+ eq(workLoopTriggers.workLoopId, workLoopId)
256
+ )
257
+ )
258
+ .limit(1);
259
+ if (!existing) {
260
+ return false;
261
+ }
262
+ await db.delete(workLoopTriggers).where(eq(workLoopTriggers.id, triggerId));
263
+ return true;
264
+ }
265
+
266
+ export async function listWorkLoopTriggers(db: BopoDb, companyId: string, workLoopId: string) {
267
+ return db
268
+ .select()
269
+ .from(workLoopTriggers)
270
+ .where(and(eq(workLoopTriggers.companyId, companyId), eq(workLoopTriggers.workLoopId, workLoopId)))
271
+ .orderBy(asc(workLoopTriggers.createdAt));
272
+ }
273
+
274
+ export async function listWorkLoopRuns(db: BopoDb, companyId: string, workLoopId: string, limit = 100) {
275
+ return db
276
+ .select()
277
+ .from(workLoopRuns)
278
+ .where(and(eq(workLoopRuns.companyId, companyId), eq(workLoopRuns.workLoopId, workLoopId)))
279
+ .orderBy(desc(workLoopRuns.triggeredAt), desc(workLoopRuns.id))
280
+ .limit(Math.min(500, Math.max(1, limit)));
281
+ }
282
+
283
+ async function findOpenIssueForLoop(db: BopoDb, companyId: string, loopId: string) {
284
+ const rows = await db
285
+ .select()
286
+ .from(issues)
287
+ .where(
288
+ and(
289
+ eq(issues.companyId, companyId),
290
+ eq(issues.loopId, loopId),
291
+ inArray(issues.status, [...OPEN_ISSUE_STATUSES])
292
+ )
293
+ )
294
+ .orderBy(desc(issues.updatedAt), desc(issues.createdAt))
295
+ .limit(5);
296
+ return rows[0] ?? null;
297
+ }
298
+
299
+ function nextResultText(status: string, issueId?: string | null) {
300
+ if (status === "issue_created" && issueId) {
301
+ return `Created execution issue ${issueId}`;
302
+ }
303
+ if (status === "coalesced") {
304
+ return "Coalesced into an existing open issue";
305
+ }
306
+ if (status === "skipped") {
307
+ return "Skipped while an open issue exists";
308
+ }
309
+ if (status === "failed") {
310
+ return "Execution failed";
311
+ }
312
+ return status;
313
+ }
314
+
315
+ export async function dispatchLoopRun(
316
+ db: BopoDb,
317
+ input: {
318
+ companyId: string;
319
+ loopId: string;
320
+ triggerId: string | null;
321
+ source: "schedule" | "manual";
322
+ idempotencyKey?: string | null;
323
+ realtimeHub?: RealtimeHub;
324
+ requestId?: string | null;
325
+ /** Advance schedule from this instant (missed-tick catch-up). Defaults to actual fire time. */
326
+ anchorForScheduleAdvance?: Date;
327
+ }
328
+ ): Promise<(typeof workLoopRuns.$inferSelect) | null> {
329
+ const run = await db.transaction(async (tx) => {
330
+ const txDb = tx as unknown as BopoDb;
331
+ await tx.execute(sql`select id from ${workLoops} where ${workLoops.id} = ${input.loopId} for update`);
332
+
333
+ const [loop] = await txDb
334
+ .select()
335
+ .from(workLoops)
336
+ .where(and(eq(workLoops.id, input.loopId), eq(workLoops.companyId, input.companyId)))
337
+ .limit(1);
338
+ if (!loop || loop.status !== "active") {
339
+ return null;
340
+ }
341
+
342
+ await assertProjectBelongsToCompany(txDb, input.companyId, loop.projectId);
343
+
344
+ if (input.idempotencyKey) {
345
+ const [existing] = await txDb
346
+ .select()
347
+ .from(workLoopRuns)
348
+ .where(
349
+ and(
350
+ eq(workLoopRuns.companyId, input.companyId),
351
+ eq(workLoopRuns.workLoopId, input.loopId),
352
+ eq(workLoopRuns.source, input.source),
353
+ eq(workLoopRuns.idempotencyKey, input.idempotencyKey),
354
+ input.triggerId ? eq(workLoopRuns.triggerId, input.triggerId) : isNull(workLoopRuns.triggerId)
355
+ )
356
+ )
357
+ .orderBy(desc(workLoopRuns.createdAt))
358
+ .limit(1);
359
+ if (existing) {
360
+ return existing;
361
+ }
362
+ }
363
+
364
+ const [agent] = await txDb
365
+ .select({ id: agents.id, status: agents.status })
366
+ .from(agents)
367
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, loop.assigneeAgentId)))
368
+ .limit(1);
369
+ if (!agent || agent.status === "terminated" || agent.status === "paused") {
370
+ const runId = nanoid(14);
371
+ await txDb.insert(workLoopRuns).values({
372
+ id: runId,
373
+ companyId: input.companyId,
374
+ workLoopId: loop.id,
375
+ triggerId: input.triggerId,
376
+ source: input.source,
377
+ status: "failed",
378
+ triggeredAt: new Date(),
379
+ idempotencyKey: input.idempotencyKey ?? null,
380
+ failureReason: "Assignee agent is not runnable",
381
+ completedAt: new Date()
382
+ });
383
+ const [failedRow] = await txDb.select().from(workLoopRuns).where(eq(workLoopRuns.id, runId)).limit(1);
384
+ return failedRow ?? null;
385
+ }
386
+
387
+ const triggeredAt = new Date();
388
+ const scheduleAnchor = input.anchorForScheduleAdvance ?? triggeredAt;
389
+ const runId = nanoid(14);
390
+ await txDb.insert(workLoopRuns).values({
391
+ id: runId,
392
+ companyId: input.companyId,
393
+ workLoopId: loop.id,
394
+ triggerId: input.triggerId,
395
+ source: input.source,
396
+ status: "received",
397
+ triggeredAt,
398
+ idempotencyKey: input.idempotencyKey ?? null,
399
+ payloadJson: "{}"
400
+ });
401
+
402
+ const policy = loop.concurrencyPolicy as WorkLoopConcurrencyPolicy;
403
+ const activeIssue =
404
+ policy === "always_enqueue" ? null : await findOpenIssueForLoop(txDb, input.companyId, loop.id);
405
+
406
+ if (activeIssue && policy !== "always_enqueue") {
407
+ const status = policy === "skip_if_active" ? "skipped" : "coalesced";
408
+ await txDb
409
+ .update(workLoopRuns)
410
+ .set({
411
+ status,
412
+ linkedIssueId: activeIssue.id,
413
+ coalescedIntoRunId: activeIssue.loopRunId,
414
+ completedAt: triggeredAt,
415
+ updatedAt: new Date()
416
+ })
417
+ .where(eq(workLoopRuns.id, runId));
418
+ await txDb
419
+ .update(workLoops)
420
+ .set({
421
+ lastTriggeredAt: triggeredAt,
422
+ updatedAt: new Date()
423
+ })
424
+ .where(eq(workLoops.id, loop.id));
425
+ if (input.triggerId) {
426
+ const [tr] = await txDb
427
+ .select()
428
+ .from(workLoopTriggers)
429
+ .where(eq(workLoopTriggers.id, input.triggerId))
430
+ .limit(1);
431
+ if (tr) {
432
+ const nextAt = nextCronFireAfter(tr.cronExpression, tr.timezone, scheduleAnchor);
433
+ await txDb
434
+ .update(workLoopTriggers)
435
+ .set({
436
+ lastFiredAt: triggeredAt,
437
+ lastResult: nextResultText(status, activeIssue.id),
438
+ nextRunAt: nextAt ?? null,
439
+ updatedAt: new Date()
440
+ })
441
+ .where(eq(workLoopTriggers.id, input.triggerId));
442
+ }
443
+ }
444
+ const [updated] = await txDb.select().from(workLoopRuns).where(eq(workLoopRuns.id, runId)).limit(1);
445
+ return updated ?? null;
446
+ }
447
+
448
+ let goalIds: string[] = [];
449
+ try {
450
+ goalIds = JSON.parse(loop.goalIdsJson || "[]") as string[];
451
+ } catch {
452
+ goalIds = [];
453
+ }
454
+
455
+ const issueId = nanoid(12);
456
+ await txDb.insert(issues).values({
457
+ id: issueId,
458
+ companyId: input.companyId,
459
+ projectId: loop.projectId,
460
+ parentIssueId: loop.parentIssueId,
461
+ title: loop.title,
462
+ body: loop.description,
463
+ status: "todo",
464
+ priority: loop.priority,
465
+ assigneeAgentId: loop.assigneeAgentId,
466
+ labelsJson: JSON.stringify(["work-loop"]),
467
+ tagsJson: "[]",
468
+ loopId: loop.id,
469
+ loopRunId: runId
470
+ });
471
+ if (goalIds.length > 0) {
472
+ await syncIssueGoals(txDb, {
473
+ companyId: input.companyId,
474
+ issueId,
475
+ projectId: loop.projectId,
476
+ goalIds
477
+ });
478
+ }
479
+
480
+ await txDb
481
+ .update(workLoopRuns)
482
+ .set({
483
+ status: "issue_created",
484
+ linkedIssueId: issueId,
485
+ updatedAt: new Date()
486
+ })
487
+ .where(eq(workLoopRuns.id, runId));
488
+
489
+ await txDb
490
+ .update(workLoops)
491
+ .set({
492
+ lastTriggeredAt: triggeredAt,
493
+ updatedAt: new Date()
494
+ })
495
+ .where(eq(workLoops.id, loop.id));
496
+
497
+ if (input.triggerId) {
498
+ const [tr] = await txDb
499
+ .select()
500
+ .from(workLoopTriggers)
501
+ .where(eq(workLoopTriggers.id, input.triggerId))
502
+ .limit(1);
503
+ if (tr) {
504
+ const nextAt = nextCronFireAfter(tr.cronExpression, tr.timezone, scheduleAnchor);
505
+ await txDb
506
+ .update(workLoopTriggers)
507
+ .set({
508
+ lastFiredAt: triggeredAt,
509
+ lastResult: nextResultText("issue_created", issueId),
510
+ nextRunAt: nextAt ?? null,
511
+ updatedAt: new Date()
512
+ })
513
+ .where(eq(workLoopTriggers.id, input.triggerId));
514
+ }
515
+ }
516
+
517
+ const [finalRun] = await txDb.select().from(workLoopRuns).where(eq(workLoopRuns.id, runId)).limit(1);
518
+ return finalRun ?? null;
519
+ });
520
+
521
+ if (!run || run.status !== "issue_created" || !run.linkedIssueId) {
522
+ return run;
523
+ }
524
+
525
+ try {
526
+ const [loopRow] = await db
527
+ .select({ assigneeAgentId: workLoops.assigneeAgentId })
528
+ .from(workLoops)
529
+ .where(eq(workLoops.id, input.loopId))
530
+ .limit(1);
531
+ if (!loopRow) {
532
+ return run;
533
+ }
534
+ await enqueueHeartbeatQueueJob(db, {
535
+ companyId: input.companyId,
536
+ agentId: loopRow.assigneeAgentId,
537
+ jobType: "manual",
538
+ priority: 35,
539
+ idempotencyKey: `loop_wake:${run.linkedIssueId}:${run.id}`,
540
+ payload: {
541
+ wakeContext: {
542
+ reason: "loop_execution",
543
+ issueIds: [run.linkedIssueId]
544
+ }
545
+ }
546
+ });
547
+ triggerHeartbeatQueueWorker(db, input.companyId, {
548
+ requestId: input.requestId ?? undefined,
549
+ realtimeHub: input.realtimeHub
550
+ });
551
+ } catch {
552
+ // leave issue created; queue failure is observable via missing run
553
+ }
554
+
555
+ return run;
556
+ }
557
+
558
+ export async function runLoopSweep(
559
+ db: BopoDb,
560
+ companyId: string,
561
+ options?: { requestId?: string | null; realtimeHub?: RealtimeHub }
562
+ ) {
563
+ const now = new Date();
564
+ const due = await db
565
+ .select({
566
+ trigger: workLoopTriggers,
567
+ loop: workLoops
568
+ })
569
+ .from(workLoopTriggers)
570
+ .innerJoin(workLoops, eq(workLoopTriggers.workLoopId, workLoops.id))
571
+ .where(
572
+ and(
573
+ eq(workLoopTriggers.companyId, companyId),
574
+ eq(workLoops.companyId, companyId),
575
+ eq(workLoopTriggers.enabled, true),
576
+ eq(workLoopTriggers.kind, "schedule"),
577
+ isNotNull(workLoopTriggers.nextRunAt),
578
+ lte(workLoopTriggers.nextRunAt, now),
579
+ eq(workLoops.status, "active")
580
+ )
581
+ )
582
+ .orderBy(asc(workLoopTriggers.nextRunAt));
583
+
584
+ let processed = 0;
585
+ for (const row of due) {
586
+ const trigger = row.trigger;
587
+ const loop = row.loop;
588
+ const catchUp = loop.catchUpPolicy as WorkLoopCatchUpPolicy;
589
+ const nextRunAt = trigger.nextRunAt;
590
+ if (!nextRunAt) {
591
+ continue;
592
+ }
593
+
594
+ if (catchUp === "skip_missed") {
595
+ const lateMs = now.getTime() - nextRunAt.getTime();
596
+ if (lateMs > 90_000) {
597
+ const bumped =
598
+ nextCronFireAfter(trigger.cronExpression, trigger.timezone, new Date(now.getTime() - 60_000)) ??
599
+ nextCronFireAfter(trigger.cronExpression, trigger.timezone, now);
600
+ await db
601
+ .update(workLoopTriggers)
602
+ .set({
603
+ nextRunAt: bumped,
604
+ lastResult: "Skipped missed window (catch-up: skip missed)",
605
+ updatedAt: new Date()
606
+ })
607
+ .where(eq(workLoopTriggers.id, trigger.id));
608
+ continue;
609
+ }
610
+ }
611
+
612
+ if (catchUp === "enqueue_missed_with_cap") {
613
+ let cursor = new Date(nextRunAt.getTime());
614
+ let n = 0;
615
+ while (cursor <= now && n < MAX_LOOP_CATCH_UP_RUNS) {
616
+ const tickKey = floorToUtcMinute(cursor).toISOString();
617
+ await dispatchLoopRun(db, {
618
+ companyId,
619
+ loopId: loop.id,
620
+ triggerId: trigger.id,
621
+ source: "schedule",
622
+ idempotencyKey: `schedule:${trigger.id}:${tickKey}`,
623
+ anchorForScheduleAdvance: cursor,
624
+ realtimeHub: options?.realtimeHub,
625
+ requestId: options?.requestId
626
+ });
627
+ processed += 1;
628
+ n += 1;
629
+ const next = nextCronFireAfter(trigger.cronExpression, trigger.timezone, cursor);
630
+ if (!next || next.getTime() <= cursor.getTime()) {
631
+ break;
632
+ }
633
+ cursor = next;
634
+ }
635
+ continue;
636
+ }
637
+
638
+ const minuteKey = floorToUtcMinute(now).toISOString();
639
+ await dispatchLoopRun(db, {
640
+ companyId,
641
+ loopId: loop.id,
642
+ triggerId: trigger.id,
643
+ source: "schedule",
644
+ idempotencyKey: `schedule:${trigger.id}:${minuteKey}`,
645
+ realtimeHub: options?.realtimeHub,
646
+ requestId: options?.requestId
647
+ });
648
+ processed += 1;
649
+ }
650
+
651
+ await appendAuditEvent(db, {
652
+ companyId,
653
+ actorType: "system",
654
+ eventType: "work_loop.sweep.completed",
655
+ entityType: "company",
656
+ entityId: companyId,
657
+ correlationId: options?.requestId ?? null,
658
+ payload: {
659
+ processed,
660
+ requestId: options?.requestId ?? null
661
+ }
662
+ });
663
+
664
+ return { processed };
665
+ }