@electric-ax/agents-server 0.3.0

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 (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,788 @@
1
+ import { getNextCronFireAt } from '@electric-ax/agents-runtime'
2
+ import { DEFAULT_TENANT_ID, isUnregisteredTenantError } from './tenant.js'
3
+ import { serverLog } from './utils/log.js'
4
+ import type { PgClient } from './db/index.js'
5
+
6
+ export interface DelayedSendPayload {
7
+ entityUrl: string
8
+ from?: string
9
+ payload: unknown
10
+ key?: string
11
+ type?: string
12
+ producerId?: string
13
+ manifest?: {
14
+ ownerEntityUrl: string
15
+ key: string
16
+ entry: Record<string, unknown>
17
+ }
18
+ }
19
+
20
+ export interface CronTickPayload {
21
+ streamPath: string
22
+ }
23
+
24
+ type SchedulerTaskKind = `delayed_send` | `cron_tick`
25
+ type TenantIdsProvider = () => Iterable<string>
26
+ const POSTGRES_TEXT_OID = 25
27
+
28
+ interface ScheduledTaskRow {
29
+ id: number | string
30
+ tenant_id: string
31
+ kind: SchedulerTaskKind
32
+ payload: DelayedSendPayload | CronTickPayload
33
+ fire_at: Date | string
34
+ cron_expression: string | null
35
+ cron_timezone: string | null
36
+ cron_tick_number: number | null
37
+ owner_entity_url: string | null
38
+ manifest_key: string | null
39
+ }
40
+
41
+ export interface SchedulerOptions {
42
+ pgClient: PgClient
43
+ instanceId: string
44
+ tenantId?: string | null
45
+ tenantIds?: TenantIdsProvider
46
+ claimExpiryMs?: number
47
+ safetyPollMs?: number
48
+ listen?: boolean
49
+ executors: {
50
+ delayed_send: (
51
+ payload: DelayedSendPayload,
52
+ taskId: number,
53
+ tenantId: string
54
+ ) => Promise<void>
55
+ cron_tick: (
56
+ payload: CronTickPayload,
57
+ tickNumber: number,
58
+ taskId: number,
59
+ tenantId: string
60
+ ) => Promise<void>
61
+ }
62
+ }
63
+
64
+ export interface SchedulerClient {
65
+ enqueueDelayedSend(
66
+ payload: DelayedSendPayload,
67
+ fireAt: Date,
68
+ opts?: { ownerEntityUrl?: string; manifestKey?: string }
69
+ ): Promise<void>
70
+ syncManifestDelayedSend(
71
+ ownerEntityUrl: string,
72
+ manifestKey: string,
73
+ payload: DelayedSendPayload,
74
+ fireAt: Date
75
+ ): Promise<void>
76
+ cancelManifestDelayedSend(
77
+ ownerEntityUrl: string,
78
+ manifestKey: string
79
+ ): Promise<void>
80
+ enqueueCronTick(
81
+ expression: string,
82
+ timezone: string,
83
+ tickNumber: number,
84
+ streamPath: string,
85
+ fireAt: Date
86
+ ): Promise<void>
87
+ }
88
+
89
+ export class PostgresSchedulerClient implements SchedulerClient {
90
+ constructor(
91
+ private readonly pgClient: PgClient,
92
+ private readonly tenantId: string,
93
+ private readonly wake?: () => void
94
+ ) {}
95
+
96
+ async enqueueDelayedSend(
97
+ payload: DelayedSendPayload,
98
+ fireAt: Date,
99
+ opts?: { ownerEntityUrl?: string; manifestKey?: string }
100
+ ): Promise<void> {
101
+ await this.pgClient`
102
+ insert into scheduled_tasks (
103
+ tenant_id,
104
+ kind,
105
+ payload,
106
+ fire_at,
107
+ owner_entity_url,
108
+ manifest_key
109
+ )
110
+ values (
111
+ ${this.tenantId},
112
+ 'delayed_send',
113
+ ${JSON.stringify(payload)}::jsonb,
114
+ ${fireAt.toISOString()}::timestamptz,
115
+ ${opts?.ownerEntityUrl ?? null},
116
+ ${opts?.manifestKey ?? null}
117
+ )
118
+ `
119
+ this.wake?.()
120
+ }
121
+
122
+ async syncManifestDelayedSend(
123
+ ownerEntityUrl: string,
124
+ manifestKey: string,
125
+ payload: DelayedSendPayload,
126
+ fireAt: Date
127
+ ): Promise<void> {
128
+ await this.pgClient.begin(async (sql) => {
129
+ await sql`
130
+ update scheduled_tasks
131
+ set completed_at = now(), claimed_at = null, claimed_by = null
132
+ where tenant_id = ${this.tenantId}
133
+ and kind = 'delayed_send'
134
+ and owner_entity_url = ${ownerEntityUrl}
135
+ and manifest_key = ${manifestKey}
136
+ and completed_at is null
137
+ `
138
+
139
+ await sql`
140
+ insert into scheduled_tasks (
141
+ tenant_id,
142
+ kind,
143
+ payload,
144
+ fire_at,
145
+ owner_entity_url,
146
+ manifest_key
147
+ )
148
+ values (
149
+ ${this.tenantId},
150
+ 'delayed_send',
151
+ ${JSON.stringify(payload)}::jsonb,
152
+ ${fireAt.toISOString()}::timestamptz,
153
+ ${ownerEntityUrl},
154
+ ${manifestKey}
155
+ )
156
+ `
157
+ })
158
+ this.wake?.()
159
+ }
160
+
161
+ async cancelManifestDelayedSend(
162
+ ownerEntityUrl: string,
163
+ manifestKey: string
164
+ ): Promise<void> {
165
+ await this.pgClient`
166
+ update scheduled_tasks
167
+ set completed_at = now(), claimed_at = null, claimed_by = null
168
+ where tenant_id = ${this.tenantId}
169
+ and kind = 'delayed_send'
170
+ and owner_entity_url = ${ownerEntityUrl}
171
+ and manifest_key = ${manifestKey}
172
+ and completed_at is null
173
+ `
174
+ this.wake?.()
175
+ }
176
+
177
+ async enqueueCronTick(
178
+ expression: string,
179
+ timezone: string,
180
+ tickNumber: number,
181
+ streamPath: string,
182
+ fireAt: Date
183
+ ): Promise<void> {
184
+ await this.pgClient`
185
+ insert into scheduled_tasks (
186
+ tenant_id,
187
+ kind,
188
+ payload,
189
+ fire_at,
190
+ cron_expression,
191
+ cron_timezone,
192
+ cron_tick_number
193
+ )
194
+ values (
195
+ ${this.tenantId},
196
+ 'cron_tick',
197
+ ${JSON.stringify({ streamPath })}::jsonb,
198
+ ${fireAt.toISOString()}::timestamptz,
199
+ ${expression},
200
+ ${timezone},
201
+ ${tickNumber}
202
+ )
203
+ on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
204
+ `
205
+ this.wake?.()
206
+ }
207
+ }
208
+
209
+ export function isPermanentElectricAgentsError(err: unknown): boolean {
210
+ const status =
211
+ typeof err === `object` && err !== null && `status` in err
212
+ ? (err as { status?: unknown }).status
213
+ : undefined
214
+ const name =
215
+ typeof err === `object` && err !== null && `name` in err
216
+ ? (err as { name?: unknown }).name
217
+ : undefined
218
+
219
+ return (
220
+ name === `ElectricAgentsError` &&
221
+ typeof status === `number` &&
222
+ status >= 400 &&
223
+ status < 500
224
+ )
225
+ }
226
+
227
+ function normalizeTask(row: ScheduledTaskRow): {
228
+ id: number
229
+ tenantId: string
230
+ kind: SchedulerTaskKind
231
+ payload: DelayedSendPayload | CronTickPayload
232
+ fireAt: Date
233
+ cronExpression: string | null
234
+ cronTimezone: string | null
235
+ cronTickNumber: number | null
236
+ ownerEntityUrl: string | null
237
+ manifestKey: string | null
238
+ } {
239
+ return {
240
+ id: Number(row.id),
241
+ tenantId: row.tenant_id,
242
+ kind: row.kind,
243
+ payload: row.payload,
244
+ fireAt: row.fire_at instanceof Date ? row.fire_at : new Date(row.fire_at),
245
+ cronExpression: row.cron_expression,
246
+ cronTimezone: row.cron_timezone,
247
+ cronTickNumber: row.cron_tick_number,
248
+ ownerEntityUrl: row.owner_entity_url,
249
+ manifestKey: row.manifest_key,
250
+ }
251
+ }
252
+
253
+ export class Scheduler implements SchedulerClient {
254
+ private readonly claimExpiryMs: number
255
+ private readonly safetyPollMs: number
256
+ private readonly listenEnabled: boolean
257
+ private readonly pgClient: PgClient
258
+ private readonly instanceId: string
259
+ private readonly tenantId: string | null
260
+ private readonly tenantIds?: TenantIdsProvider
261
+ private running = false
262
+ private loopPromise: Promise<void> | null = null
263
+ private currentSleepResolve: (() => void) | null = null
264
+ private currentSleepTimer: NodeJS.Timeout | null = null
265
+ private listenerMeta: { unlisten: () => Promise<void> } | null = null
266
+
267
+ constructor(private readonly options: SchedulerOptions) {
268
+ this.pgClient = options.pgClient
269
+ this.instanceId = options.instanceId
270
+ this.tenantId =
271
+ options.tenantId === undefined ? DEFAULT_TENANT_ID : options.tenantId
272
+ this.tenantIds = options.tenantIds
273
+ this.claimExpiryMs = options.claimExpiryMs ?? 30_000
274
+ this.safetyPollMs = options.safetyPollMs ?? 10_000
275
+ this.listenEnabled = options.listen !== false
276
+ }
277
+
278
+ private resolveTenantId(tenantId?: string): string {
279
+ if (tenantId) return tenantId
280
+ if (this.tenantId) return this.tenantId
281
+ throw new Error(`Scheduler tenantId is required in shared mode`)
282
+ }
283
+
284
+ async start(): Promise<void> {
285
+ if (this.running) return
286
+ this.running = true
287
+
288
+ if (this.listenEnabled) {
289
+ this.listenerMeta = await this.pgClient.listen(
290
+ `scheduled_tasks_wake`,
291
+ () => {
292
+ this.wakeEarly()
293
+ }
294
+ )
295
+ }
296
+
297
+ this.loopPromise = this.runLoop().catch((err) => {
298
+ console.error(`[agent-server] scheduler loop failed:`, err)
299
+ this.running = false
300
+ this.wakeEarly()
301
+ })
302
+ }
303
+
304
+ async stop(): Promise<void> {
305
+ this.running = false
306
+ this.wakeEarly()
307
+ if (this.loopPromise) {
308
+ await this.loopPromise
309
+ this.loopPromise = null
310
+ }
311
+ if (this.listenerMeta) {
312
+ await this.listenerMeta.unlisten()
313
+ this.listenerMeta = null
314
+ }
315
+ }
316
+
317
+ wake(): void {
318
+ this.wakeEarly()
319
+ }
320
+
321
+ async enqueueDelayedSend(
322
+ payload: DelayedSendPayload,
323
+ fireAt: Date,
324
+ opts?: { ownerEntityUrl?: string; manifestKey?: string }
325
+ ): Promise<void> {
326
+ const tenantId = this.resolveTenantId()
327
+ await this.pgClient`
328
+ insert into scheduled_tasks (
329
+ tenant_id,
330
+ kind,
331
+ payload,
332
+ fire_at,
333
+ owner_entity_url,
334
+ manifest_key
335
+ )
336
+ values (
337
+ ${tenantId},
338
+ 'delayed_send',
339
+ ${JSON.stringify(payload)}::jsonb,
340
+ ${fireAt.toISOString()}::timestamptz,
341
+ ${opts?.ownerEntityUrl ?? null},
342
+ ${opts?.manifestKey ?? null}
343
+ )
344
+ `
345
+ this.wakeEarly()
346
+ }
347
+
348
+ async syncManifestDelayedSend(
349
+ ownerEntityUrl: string,
350
+ manifestKey: string,
351
+ payload: DelayedSendPayload,
352
+ fireAt: Date
353
+ ): Promise<void> {
354
+ const tenantId = this.resolveTenantId()
355
+ await this.pgClient.begin(async (sql) => {
356
+ await sql`
357
+ update scheduled_tasks
358
+ set completed_at = now(), claimed_at = null, claimed_by = null
359
+ where tenant_id = ${tenantId}
360
+ and kind = 'delayed_send'
361
+ and owner_entity_url = ${ownerEntityUrl}
362
+ and manifest_key = ${manifestKey}
363
+ and completed_at is null
364
+ `
365
+
366
+ await sql`
367
+ insert into scheduled_tasks (
368
+ tenant_id,
369
+ kind,
370
+ payload,
371
+ fire_at,
372
+ owner_entity_url,
373
+ manifest_key
374
+ )
375
+ values (
376
+ ${tenantId},
377
+ 'delayed_send',
378
+ ${JSON.stringify(payload)}::jsonb,
379
+ ${fireAt.toISOString()}::timestamptz,
380
+ ${ownerEntityUrl},
381
+ ${manifestKey}
382
+ )
383
+ `
384
+ })
385
+ this.wakeEarly()
386
+ }
387
+
388
+ async cancelManifestDelayedSend(
389
+ ownerEntityUrl: string,
390
+ manifestKey: string
391
+ ): Promise<void> {
392
+ const tenantId = this.resolveTenantId()
393
+ await this.pgClient`
394
+ update scheduled_tasks
395
+ set completed_at = now(), claimed_at = null, claimed_by = null
396
+ where tenant_id = ${tenantId}
397
+ and kind = 'delayed_send'
398
+ and owner_entity_url = ${ownerEntityUrl}
399
+ and manifest_key = ${manifestKey}
400
+ and completed_at is null
401
+ `
402
+ this.wakeEarly()
403
+ }
404
+
405
+ async enqueueCronTick(
406
+ expression: string,
407
+ timezone: string,
408
+ tickNumber: number,
409
+ streamPath: string,
410
+ fireAt: Date
411
+ ): Promise<void> {
412
+ const tenantId = this.resolveTenantId()
413
+ await this.pgClient`
414
+ insert into scheduled_tasks (
415
+ tenant_id,
416
+ kind,
417
+ payload,
418
+ fire_at,
419
+ cron_expression,
420
+ cron_timezone,
421
+ cron_tick_number
422
+ )
423
+ values (
424
+ ${tenantId},
425
+ 'cron_tick',
426
+ ${JSON.stringify({ streamPath })}::jsonb,
427
+ ${fireAt.toISOString()}::timestamptz,
428
+ ${expression},
429
+ ${timezone},
430
+ ${tickNumber}
431
+ )
432
+ on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
433
+ `
434
+ this.wakeEarly()
435
+ }
436
+
437
+ private async runLoop(): Promise<void> {
438
+ while (this.running) {
439
+ try {
440
+ await this.reclaimStaleClaims()
441
+ await this.fireReadyTasks()
442
+
443
+ const nextFireAt = await this.getNextFireAt()
444
+ const sleepTargetMs = nextFireAt
445
+ ? Math.max(0, nextFireAt.getTime() - Date.now())
446
+ : this.safetyPollMs
447
+
448
+ await this.sleepOrWake(Math.min(sleepTargetMs, this.safetyPollMs))
449
+ } catch (err) {
450
+ console.error(`[agent-server] scheduler iteration failed:`, err)
451
+ await this.sleepOrWake(this.safetyPollMs)
452
+ }
453
+ }
454
+ }
455
+
456
+ private async reclaimStaleClaims(): Promise<void> {
457
+ if (this.tenantId === null) {
458
+ const tenantIds = this.sharedTenantIds()
459
+ if (tenantIds && tenantIds.length === 0) return
460
+ if (tenantIds) {
461
+ await this.pgClient`
462
+ update scheduled_tasks
463
+ set claimed_by = null, claimed_at = null
464
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
465
+ and completed_at is null
466
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
467
+ `
468
+ return
469
+ }
470
+
471
+ await this.pgClient`
472
+ update scheduled_tasks
473
+ set claimed_by = null, claimed_at = null
474
+ where completed_at is null
475
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
476
+ `
477
+ return
478
+ }
479
+
480
+ await this.pgClient`
481
+ update scheduled_tasks
482
+ set claimed_by = null, claimed_at = null
483
+ where tenant_id = ${this.tenantId}
484
+ and completed_at is null
485
+ and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
486
+ `
487
+ }
488
+
489
+ private async fireReadyTasks(): Promise<void> {
490
+ while (this.running) {
491
+ const tasks = await this.claimReadyTasks()
492
+ if (tasks.length === 0) return
493
+
494
+ for (const task of tasks) {
495
+ await this.executeTask(task)
496
+ }
497
+ }
498
+ }
499
+
500
+ private async claimReadyTasks(): Promise<
501
+ Array<ReturnType<typeof normalizeTask>>
502
+ > {
503
+ if (this.tenantId === null) {
504
+ const tenantIds = this.sharedTenantIds()
505
+ if (tenantIds && tenantIds.length === 0) return []
506
+ if (tenantIds) {
507
+ const rows = await this.pgClient<Array<ScheduledTaskRow>>`
508
+ update scheduled_tasks
509
+ set claimed_by = ${this.instanceId}, claimed_at = now()
510
+ where id in (
511
+ select id
512
+ from scheduled_tasks
513
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
514
+ and completed_at is null
515
+ and claimed_at is null
516
+ and fire_at <= now()
517
+ order by fire_at, id
518
+ for update skip locked
519
+ limit 50
520
+ )
521
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
522
+ , owner_entity_url, manifest_key
523
+ `
524
+
525
+ return rows.map(normalizeTask)
526
+ }
527
+
528
+ const rows = await this.pgClient<Array<ScheduledTaskRow>>`
529
+ update scheduled_tasks
530
+ set claimed_by = ${this.instanceId}, claimed_at = now()
531
+ where id in (
532
+ select id
533
+ from scheduled_tasks
534
+ where completed_at is null
535
+ and claimed_at is null
536
+ and fire_at <= now()
537
+ order by fire_at, id
538
+ for update skip locked
539
+ limit 50
540
+ )
541
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
542
+ , owner_entity_url, manifest_key
543
+ `
544
+
545
+ return rows.map(normalizeTask)
546
+ }
547
+
548
+ const rows = await this.pgClient<Array<ScheduledTaskRow>>`
549
+ update scheduled_tasks
550
+ set claimed_by = ${this.instanceId}, claimed_at = now()
551
+ where tenant_id = ${this.tenantId}
552
+ and id in (
553
+ select id
554
+ from scheduled_tasks
555
+ where tenant_id = ${this.tenantId}
556
+ and completed_at is null
557
+ and claimed_at is null
558
+ and fire_at <= now()
559
+ order by fire_at, id
560
+ for update skip locked
561
+ limit 50
562
+ )
563
+ returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
564
+ , owner_entity_url, manifest_key
565
+ `
566
+
567
+ return rows.map(normalizeTask)
568
+ }
569
+
570
+ private async executeTask(
571
+ task: ReturnType<typeof normalizeTask>
572
+ ): Promise<void> {
573
+ try {
574
+ if (task.kind === `delayed_send`) {
575
+ await this.options.executors.delayed_send(
576
+ task.payload as DelayedSendPayload,
577
+ task.id,
578
+ task.tenantId
579
+ )
580
+ await this.markTaskComplete(task.id, task.tenantId)
581
+ return
582
+ }
583
+
584
+ const tickNumber = task.cronTickNumber
585
+ if (tickNumber == null || !task.cronExpression || !task.cronTimezone) {
586
+ throw new Error(`cron task ${task.id} is missing cron metadata`)
587
+ }
588
+
589
+ await this.options.executors.cron_tick(
590
+ task.payload as CronTickPayload,
591
+ tickNumber,
592
+ task.id,
593
+ task.tenantId
594
+ )
595
+ await this.completeAndRescheduleCron(task)
596
+ } catch (err) {
597
+ const message = err instanceof Error ? err.message : String(err)
598
+ if (isUnregisteredTenantError(err)) {
599
+ await this.releaseClaim(task.id, message, task.tenantId)
600
+ serverLog.warn(
601
+ `[scheduler] skipped ${task.kind} task ${task.id} for unregistered tenant "${task.tenantId}": ${message}`
602
+ )
603
+ return
604
+ }
605
+ if (isPermanentElectricAgentsError(err)) {
606
+ await this.markTaskPermanentFailure(task.id, message, task.tenantId)
607
+ return
608
+ }
609
+ await this.releaseClaim(task.id, message, task.tenantId)
610
+ }
611
+ }
612
+
613
+ private async markTaskComplete(
614
+ taskId: number,
615
+ tenantId = this.resolveTenantId()
616
+ ): Promise<void> {
617
+ await this.pgClient`
618
+ update scheduled_tasks
619
+ set completed_at = now(), last_error = null
620
+ where tenant_id = ${tenantId}
621
+ and id = ${taskId}
622
+ and claimed_by = ${this.instanceId}
623
+ and completed_at is null
624
+ `
625
+ }
626
+
627
+ private async markTaskPermanentFailure(
628
+ taskId: number,
629
+ message: string,
630
+ tenantId = this.resolveTenantId()
631
+ ): Promise<void> {
632
+ await this.pgClient`
633
+ update scheduled_tasks
634
+ set completed_at = now(), last_error = ${message}
635
+ where tenant_id = ${tenantId}
636
+ and id = ${taskId}
637
+ and claimed_by = ${this.instanceId}
638
+ and completed_at is null
639
+ `
640
+ }
641
+
642
+ private async releaseClaim(
643
+ taskId: number,
644
+ message: string,
645
+ tenantId = this.resolveTenantId()
646
+ ): Promise<void> {
647
+ await this.pgClient`
648
+ update scheduled_tasks
649
+ set claimed_at = null, claimed_by = null, last_error = ${message}
650
+ where tenant_id = ${tenantId}
651
+ and id = ${taskId}
652
+ and claimed_by = ${this.instanceId}
653
+ and completed_at is null
654
+ `
655
+ }
656
+
657
+ private async completeAndRescheduleCron(
658
+ task: ReturnType<typeof normalizeTask>
659
+ ): Promise<void> {
660
+ const tenantId = task.tenantId ?? this.resolveTenantId()
661
+ await this.pgClient.begin(async (sql) => {
662
+ const completed = await sql<Array<{ id: number | string }>>`
663
+ update scheduled_tasks
664
+ set completed_at = now(), last_error = null
665
+ where tenant_id = ${tenantId}
666
+ and id = ${task.id}
667
+ and claimed_by = ${this.instanceId}
668
+ and completed_at is null
669
+ returning id
670
+ `
671
+ if (completed.length === 0) return
672
+
673
+ const nextFireAt = getNextCronFireAt(
674
+ task.cronExpression!,
675
+ task.cronTimezone!,
676
+ task.fireAt
677
+ )
678
+
679
+ await sql`
680
+ insert into scheduled_tasks (
681
+ tenant_id,
682
+ kind,
683
+ payload,
684
+ fire_at,
685
+ cron_expression,
686
+ cron_timezone,
687
+ cron_tick_number
688
+ )
689
+ values (
690
+ ${tenantId},
691
+ 'cron_tick',
692
+ ${JSON.stringify(task.payload)}::jsonb,
693
+ ${nextFireAt.toISOString()}::timestamptz,
694
+ ${task.cronExpression},
695
+ ${task.cronTimezone},
696
+ ${task.cronTickNumber! + 1}
697
+ )
698
+ on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
699
+ `
700
+ })
701
+ }
702
+
703
+ private async getNextFireAt(): Promise<Date | null> {
704
+ if (this.tenantId === null) {
705
+ const tenantIds = this.sharedTenantIds()
706
+ if (tenantIds && tenantIds.length === 0) return null
707
+ if (tenantIds) {
708
+ const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
709
+ select fire_at
710
+ from scheduled_tasks
711
+ where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
712
+ and completed_at is null
713
+ and claimed_at is null
714
+ order by fire_at, id
715
+ limit 1
716
+ `
717
+
718
+ if (rows.length === 0) return null
719
+ const fireAt = rows[0]!.fire_at
720
+ return fireAt instanceof Date ? fireAt : new Date(fireAt)
721
+ }
722
+
723
+ const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
724
+ select fire_at
725
+ from scheduled_tasks
726
+ where completed_at is null
727
+ and claimed_at is null
728
+ order by fire_at, id
729
+ limit 1
730
+ `
731
+
732
+ if (rows.length === 0) return null
733
+ const fireAt = rows[0]!.fire_at
734
+ return fireAt instanceof Date ? fireAt : new Date(fireAt)
735
+ }
736
+
737
+ const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
738
+ select fire_at
739
+ from scheduled_tasks
740
+ where tenant_id = ${this.tenantId}
741
+ and completed_at is null
742
+ and claimed_at is null
743
+ order by fire_at, id
744
+ limit 1
745
+ `
746
+
747
+ if (rows.length === 0) return null
748
+ const fireAt = rows[0]!.fire_at
749
+ return fireAt instanceof Date ? fireAt : new Date(fireAt)
750
+ }
751
+
752
+ private async sleepOrWake(durationMs: number): Promise<void> {
753
+ if (!this.running) return
754
+
755
+ await new Promise<void>((resolve) => {
756
+ const finish = (): void => {
757
+ if (this.currentSleepTimer) {
758
+ clearTimeout(this.currentSleepTimer)
759
+ this.currentSleepTimer = null
760
+ }
761
+ this.currentSleepResolve = null
762
+ resolve()
763
+ }
764
+
765
+ this.currentSleepResolve = finish
766
+ this.currentSleepTimer = setTimeout(finish, Math.max(durationMs, 0))
767
+ })
768
+ }
769
+
770
+ private wakeEarly(): void {
771
+ const resolve = this.currentSleepResolve
772
+ this.currentSleepResolve = null
773
+ if (this.currentSleepTimer) {
774
+ clearTimeout(this.currentSleepTimer)
775
+ this.currentSleepTimer = null
776
+ }
777
+ resolve?.()
778
+ }
779
+
780
+ private sharedTenantIds(): Array<string> | null {
781
+ if (this.tenantId !== null || !this.tenantIds) return null
782
+ return [...new Set(this.tenantIds())]
783
+ }
784
+
785
+ private sharedTenantIdsParameter(tenantIds: Array<string>) {
786
+ return this.pgClient.array(tenantIds, POSTGRES_TEXT_OID)
787
+ }
788
+ }