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.
- package/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +123 -1
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +35 -1
- package/src/services/template-apply-service.ts +33 -0
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/worker/scheduler.ts +26 -1
|
@@ -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
|
+
}
|