@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
package/src/runtime.ts ADDED
@@ -0,0 +1,537 @@
1
+ import { parseCronStreamPath } from '@electric-ax/agents-runtime'
2
+ import { and, eq } from 'drizzle-orm'
3
+ import { consumerCallbacks, wakeRegistrations } from './db/schema.js'
4
+ import { ClaimWriteTokenStore } from './claim-write-token-store.js'
5
+ import { PostgresRegistry } from './entity-registry.js'
6
+ import { EntityManager } from './entity-manager.js'
7
+ import {
8
+ buildManifestWakeRegistration,
9
+ extractManifestCronSpec,
10
+ } from './manifest-side-effects.js'
11
+ import { SchemaValidator } from './electric-agents/schema-validator.js'
12
+ import { serverLog } from './utils/log.js'
13
+ import { isPermanentElectricAgentsError } from './scheduler.js'
14
+ import { StreamClient, durableStreamsServiceUrl } from './stream-client.js'
15
+ import { DEFAULT_TENANT_ID } from './tenant.js'
16
+ import type { DrizzleDB } from './db/index.js'
17
+ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
18
+ import type { DurableStreamsBearerProvider } from './stream-client.js'
19
+ import type {
20
+ CronTickPayload,
21
+ DelayedSendPayload,
22
+ SchedulerClient,
23
+ } from './scheduler.js'
24
+ import type { WakeRegistry } from './wake-registry.js'
25
+
26
+ function omitUndefined<T extends Record<string, unknown>>(value: T): T {
27
+ return Object.fromEntries(
28
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
29
+ ) as T
30
+ }
31
+
32
+ export interface ElectricAgentsTenantRuntimeOptions {
33
+ service?: string
34
+ tenantId?: string
35
+ db: DrizzleDB
36
+ registry?: PostgresRegistry
37
+ durableStreamsUrl?: string
38
+ durableStreamsBearer?: DurableStreamsBearerProvider
39
+ streamClient?: StreamClient
40
+ wakeRegistry: WakeRegistry
41
+ scheduler: SchedulerClient
42
+ entityBridgeManager: EntityBridgeCoordinator
43
+ claimWriteTokens?: ClaimWriteTokenStore
44
+ stopWakeRegistryOnShutdown?: boolean
45
+ }
46
+
47
+ export class ElectricAgentsTenantRuntime {
48
+ readonly serviceId: string
49
+ readonly service: string
50
+ readonly db: DrizzleDB
51
+ readonly streamClient: StreamClient
52
+ readonly registry: PostgresRegistry
53
+ readonly wakeRegistry: WakeRegistry
54
+ readonly scheduler: SchedulerClient
55
+ readonly entityBridgeManager: EntityBridgeCoordinator
56
+ readonly claimWriteTokens: ClaimWriteTokenStore
57
+ readonly manager: EntityManager
58
+
59
+ constructor(options: ElectricAgentsTenantRuntimeOptions) {
60
+ this.serviceId = options.service ?? options.tenantId ?? DEFAULT_TENANT_ID
61
+ this.service = this.serviceId
62
+ this.db = options.db
63
+ if (options.streamClient) {
64
+ this.streamClient = options.streamClient
65
+ } else if (options.durableStreamsUrl) {
66
+ this.streamClient = new StreamClient(
67
+ durableStreamsServiceUrl(options.durableStreamsUrl, this.serviceId),
68
+ { bearer: options.durableStreamsBearer }
69
+ )
70
+ } else {
71
+ throw new Error(`Either durableStreamsUrl or streamClient is required`)
72
+ }
73
+
74
+ this.registry =
75
+ options.registry ?? new PostgresRegistry(this.db, this.serviceId)
76
+ this.wakeRegistry = options.wakeRegistry
77
+ this.scheduler = options.scheduler
78
+ this.entityBridgeManager = options.entityBridgeManager
79
+ this.claimWriteTokens =
80
+ options.claimWriteTokens ?? new ClaimWriteTokenStore()
81
+ this.manager = new EntityManager({
82
+ registry: this.registry,
83
+ streamClient: this.streamClient,
84
+ validator: new SchemaValidator(),
85
+ wakeRegistry: this.wakeRegistry,
86
+ scheduler: this.scheduler,
87
+ entityBridgeManager: this.entityBridgeManager,
88
+ writeTokenValidator: (entity, token) =>
89
+ this.claimWriteTokens.isValid(
90
+ this.serviceId,
91
+ entity.streams.main,
92
+ token
93
+ ),
94
+ stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false,
95
+ })
96
+ }
97
+
98
+ async stop(): Promise<void> {
99
+ await this.manager.shutdown()
100
+ }
101
+
102
+ async rehydrateCronSchedules(): Promise<void> {
103
+ const rows = await this.db
104
+ .select({ sourceUrl: wakeRegistrations.sourceUrl })
105
+ .from(wakeRegistrations)
106
+ .where(eq(wakeRegistrations.tenantId, this.serviceId))
107
+ const cronSpecs = new Map<
108
+ string,
109
+ { expression: string; timezone: string }
110
+ >()
111
+
112
+ for (const row of rows) {
113
+ if (!row.sourceUrl.startsWith(`/_cron/`)) continue
114
+ try {
115
+ const spec = parseCronStreamPath(row.sourceUrl, { fallback: `utc` })
116
+ cronSpecs.set(JSON.stringify(spec), spec)
117
+ } catch (err) {
118
+ serverLog.warn(`[agent-server] invalid cron wake registration:`, err)
119
+ }
120
+ }
121
+
122
+ for (const spec of cronSpecs.values()) {
123
+ try {
124
+ await this.manager.getOrCreateCronStream(spec.expression, spec.timezone)
125
+ } catch (err) {
126
+ serverLog.warn(`[agent-server] cron rehydration failed:`, err)
127
+ }
128
+ }
129
+
130
+ const { entities } = await this.manager.registry.listEntities({
131
+ limit: 10_000,
132
+ })
133
+ await this.manager.registry.clearEntityManifestSources()
134
+
135
+ for (const entity of entities) {
136
+ try {
137
+ const events = await this.streamClient.readJson<
138
+ Record<string, unknown>
139
+ >(entity.streams.main)
140
+ const manifestEvents = new Map<string, Record<string, unknown>>()
141
+
142
+ for (const event of events) {
143
+ if (event.type !== `manifest` || typeof event.key !== `string`) {
144
+ continue
145
+ }
146
+ manifestEvents.set(event.key, event)
147
+ }
148
+
149
+ for (const [manifestKey, event] of manifestEvents) {
150
+ const headers = event.headers as Record<string, unknown> | undefined
151
+ const operation = headers?.operation as string | undefined
152
+ const value = event.value as Record<string, unknown> | undefined
153
+ await this.applyManifestEntitySource(
154
+ entity.url,
155
+ manifestKey,
156
+ operation,
157
+ value
158
+ )
159
+ await this.applyManifestFutureSendSchedule(
160
+ entity.url,
161
+ manifestKey,
162
+ operation,
163
+ value
164
+ )
165
+ }
166
+ } catch (err) {
167
+ serverLog.warn(
168
+ `[agent-server] manifest future_send rehydration failed for ${entity.url}:`,
169
+ err
170
+ )
171
+ }
172
+ }
173
+ }
174
+
175
+ async evaluateWakePayload(
176
+ sourceUrl: string,
177
+ event: Record<string, unknown> | Array<Record<string, unknown>>
178
+ ): Promise<void> {
179
+ if (Array.isArray(event)) {
180
+ await Promise.all(
181
+ event.map((item) => this.manager.evaluateWakes(sourceUrl, item))
182
+ )
183
+ return
184
+ }
185
+
186
+ await this.manager.evaluateWakes(sourceUrl, event)
187
+ }
188
+
189
+ checkRunFinished(
190
+ sourceUrl: string,
191
+ event: Record<string, unknown> | Array<Record<string, unknown>>
192
+ ): void {
193
+ const events = Array.isArray(event) ? event : [event]
194
+ for (const item of events) {
195
+ if (item.type !== `run`) continue
196
+ const value = item.value as Record<string, unknown> | undefined
197
+ const headers = item.headers as Record<string, unknown> | undefined
198
+ const status = value?.status as string | undefined
199
+ const operation = headers?.operation as string | undefined
200
+ if (
201
+ operation === `update` &&
202
+ (status === `completed` || status === `failed`)
203
+ ) {
204
+ void this.maybeMarkEntityIdleAfterRunFinished(sourceUrl)
205
+ return
206
+ }
207
+ }
208
+ }
209
+
210
+ async syncManifestWakes(
211
+ subscriberUrl: string,
212
+ event: Record<string, unknown> | Array<Record<string, unknown>>
213
+ ): Promise<void> {
214
+ const events = Array.isArray(event) ? event : [event]
215
+ for (const item of events) {
216
+ const eventType = item.type as string | undefined
217
+ if (eventType !== `manifest`) continue
218
+
219
+ const headers = item.headers as Record<string, unknown> | undefined
220
+ const operation = headers?.operation as string | undefined
221
+ const manifestKey = item.key as string | undefined
222
+ const value = item.value as Record<string, unknown> | undefined
223
+
224
+ if (!manifestKey) continue
225
+
226
+ if (operation === `delete`) {
227
+ await this.manager.wakeRegistry.unregisterByManifestKey(
228
+ subscriberUrl,
229
+ manifestKey,
230
+ this.serviceId
231
+ )
232
+ continue
233
+ }
234
+
235
+ await this.manager.wakeRegistry.unregisterByManifestKey(
236
+ subscriberUrl,
237
+ manifestKey,
238
+ this.serviceId
239
+ )
240
+
241
+ if (value) {
242
+ const reg = buildManifestWakeRegistration(
243
+ subscriberUrl,
244
+ value,
245
+ manifestKey
246
+ )
247
+ if (reg) {
248
+ reg.tenantId = this.serviceId
249
+ await this.manager.wakeRegistry.register(reg)
250
+ }
251
+
252
+ const cronSpec = extractManifestCronSpec(value)
253
+ if (cronSpec) {
254
+ void this.manager
255
+ .getOrCreateCronStream(cronSpec.expression, cronSpec.timezone)
256
+ .catch((err) =>
257
+ serverLog.warn(`[agent-server] cron schedule failed:`, err)
258
+ )
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ async syncManifestEntitySources(
265
+ ownerEntityUrl: string,
266
+ event: Record<string, unknown> | Array<Record<string, unknown>>
267
+ ): Promise<void> {
268
+ const events = Array.isArray(event) ? event : [event]
269
+ for (const item of events) {
270
+ if (item.type !== `manifest`) continue
271
+
272
+ const manifestKey = item.key as string | undefined
273
+ const headers = item.headers as Record<string, unknown> | undefined
274
+ const operation = headers?.operation as string | undefined
275
+ const value = item.value as Record<string, unknown> | undefined
276
+
277
+ if (!manifestKey) continue
278
+ await this.applyManifestEntitySource(
279
+ ownerEntityUrl,
280
+ manifestKey,
281
+ operation,
282
+ value
283
+ )
284
+ }
285
+ }
286
+
287
+ async syncManifestSchedules(
288
+ ownerEntityUrl: string,
289
+ event: Record<string, unknown> | Array<Record<string, unknown>>
290
+ ): Promise<void> {
291
+ const events = Array.isArray(event) ? event : [event]
292
+ for (const item of events) {
293
+ if (item.type !== `manifest`) continue
294
+
295
+ const manifestKey = item.key as string | undefined
296
+ const headers = item.headers as Record<string, unknown> | undefined
297
+ const operation = headers?.operation as string | undefined
298
+ const value = item.value as Record<string, unknown> | undefined
299
+
300
+ if (!manifestKey) continue
301
+ await this.applyManifestFutureSendSchedule(
302
+ ownerEntityUrl,
303
+ manifestKey,
304
+ operation,
305
+ value
306
+ )
307
+ }
308
+ }
309
+
310
+ async executeDelayedSend(
311
+ payload: DelayedSendPayload,
312
+ taskId: number
313
+ ): Promise<void> {
314
+ const producerId = payload.producerId ?? `scheduler-task-${taskId}`
315
+ try {
316
+ await this.manager.send(
317
+ payload.entityUrl,
318
+ {
319
+ from: payload.from,
320
+ payload: payload.payload,
321
+ key: payload.key ?? `scheduled-task-${taskId}`,
322
+ type: payload.type,
323
+ },
324
+ {
325
+ producerId,
326
+ }
327
+ )
328
+
329
+ if (payload.manifest) {
330
+ await this.manager.writeManifestEntry(
331
+ payload.manifest.ownerEntityUrl,
332
+ payload.manifest.key,
333
+ `update`,
334
+ omitUndefined({
335
+ ...payload.manifest.entry,
336
+ status: `sent`,
337
+ sentAt: new Date().toISOString(),
338
+ failedAt: undefined,
339
+ lastError: undefined,
340
+ }),
341
+ {
342
+ producerId: `manifest-status-${producerId}-sent`,
343
+ }
344
+ )
345
+ }
346
+ } catch (err) {
347
+ if (payload.manifest && isPermanentElectricAgentsError(err)) {
348
+ await this.manager.writeManifestEntry(
349
+ payload.manifest.ownerEntityUrl,
350
+ payload.manifest.key,
351
+ `update`,
352
+ omitUndefined({
353
+ ...payload.manifest.entry,
354
+ status: `failed`,
355
+ failedAt: new Date().toISOString(),
356
+ sentAt: undefined,
357
+ lastError: err instanceof Error ? err.message : String(err),
358
+ }),
359
+ {
360
+ producerId: `manifest-status-${producerId}-failed`,
361
+ }
362
+ )
363
+ }
364
+ throw err
365
+ }
366
+ }
367
+
368
+ async executeCronTick(
369
+ payload: CronTickPayload,
370
+ tickNumber: number
371
+ ): Promise<void> {
372
+ const streamPath = payload.streamPath
373
+ const encodedExpression = streamPath.split(`/`).at(-1)
374
+ const spec = parseCronStreamPath(streamPath, {
375
+ fallback: `utc`,
376
+ })
377
+ const tickEvent = {
378
+ type: `cron_tick`,
379
+ key: `tick-${tickNumber}`,
380
+ value: {
381
+ expression: spec.expression,
382
+ timezone: spec.timezone,
383
+ firedAt: new Date().toISOString(),
384
+ tickNumber,
385
+ },
386
+ headers: {
387
+ operation: `insert`,
388
+ timestamp: new Date().toISOString(),
389
+ },
390
+ }
391
+ await this.streamClient.appendIdempotent(
392
+ streamPath,
393
+ new TextEncoder().encode(JSON.stringify(tickEvent)),
394
+ {
395
+ producerId: `scheduler-cron-${encodedExpression}-${tickNumber}`,
396
+ }
397
+ )
398
+ await this.manager.evaluateWakes(streamPath, tickEvent)
399
+ }
400
+
401
+ private async applyManifestFutureSendSchedule(
402
+ ownerEntityUrl: string,
403
+ manifestKey: string,
404
+ operation: string | undefined,
405
+ value: Record<string, unknown> | undefined
406
+ ): Promise<void> {
407
+ if (operation === `delete`) {
408
+ await this.scheduler.cancelManifestDelayedSend(
409
+ ownerEntityUrl,
410
+ manifestKey
411
+ )
412
+ return
413
+ }
414
+
415
+ if (
416
+ !value ||
417
+ value.kind !== `schedule` ||
418
+ value.scheduleType !== `future_send`
419
+ ) {
420
+ await this.scheduler.cancelManifestDelayedSend(
421
+ ownerEntityUrl,
422
+ manifestKey
423
+ )
424
+ return
425
+ }
426
+
427
+ if (value.status !== undefined && value.status !== `pending`) {
428
+ await this.scheduler.cancelManifestDelayedSend(
429
+ ownerEntityUrl,
430
+ manifestKey
431
+ )
432
+ return
433
+ }
434
+
435
+ const fireAtRaw = value.fireAt
436
+ const producerId = value.producerId
437
+ const targetUrl = value.targetUrl
438
+ if (
439
+ typeof fireAtRaw !== `string` ||
440
+ typeof producerId !== `string` ||
441
+ typeof targetUrl !== `string`
442
+ ) {
443
+ serverLog.warn(
444
+ `[agent-server] invalid future_send manifest entry for ${ownerEntityUrl}/${manifestKey}`
445
+ )
446
+ return
447
+ }
448
+
449
+ const fireAt = new Date(fireAtRaw)
450
+ if (Number.isNaN(fireAt.getTime())) {
451
+ serverLog.warn(
452
+ `[agent-server] invalid future_send fireAt for ${ownerEntityUrl}/${manifestKey}: ${fireAtRaw}`
453
+ )
454
+ return
455
+ }
456
+
457
+ await this.scheduler.syncManifestDelayedSend(
458
+ ownerEntityUrl,
459
+ manifestKey,
460
+ {
461
+ entityUrl: targetUrl,
462
+ from: typeof value.from === `string` ? value.from : ownerEntityUrl,
463
+ payload: value.payload,
464
+ key: `scheduled-${producerId}`,
465
+ type:
466
+ typeof value.messageType === `string` ? value.messageType : undefined,
467
+ producerId,
468
+ manifest: {
469
+ ownerEntityUrl,
470
+ key: manifestKey,
471
+ entry: omitUndefined({
472
+ ...value,
473
+ key: manifestKey,
474
+ kind: `schedule`,
475
+ scheduleType: `future_send`,
476
+ targetUrl,
477
+ fireAt: fireAt.toISOString(),
478
+ producerId,
479
+ status: `pending`,
480
+ }),
481
+ },
482
+ },
483
+ fireAt
484
+ )
485
+ }
486
+
487
+ private async applyManifestEntitySource(
488
+ ownerEntityUrl: string,
489
+ manifestKey: string,
490
+ operation: string | undefined,
491
+ value: Record<string, unknown> | undefined
492
+ ): Promise<void> {
493
+ const sourceRef =
494
+ operation === `delete` ? undefined : this.extractEntitiesSourceRef(value)
495
+ await this.manager.registry.replaceEntityManifestSource(
496
+ ownerEntityUrl,
497
+ manifestKey,
498
+ sourceRef
499
+ )
500
+ }
501
+
502
+ private extractEntitiesSourceRef(
503
+ manifest: Record<string, unknown> | undefined
504
+ ): string | undefined {
505
+ if (
506
+ manifest?.kind === `source` &&
507
+ manifest.sourceType === `entities` &&
508
+ typeof manifest.sourceRef === `string`
509
+ ) {
510
+ return manifest.sourceRef
511
+ }
512
+ return undefined
513
+ }
514
+
515
+ private async maybeMarkEntityIdleAfterRunFinished(
516
+ entityUrl: string
517
+ ): Promise<void> {
518
+ const primaryStream = `${entityUrl}/main`
519
+ const callbacks = await this.db
520
+ .select()
521
+ .from(consumerCallbacks)
522
+ .where(
523
+ and(
524
+ eq(consumerCallbacks.tenantId, this.serviceId),
525
+ eq(consumerCallbacks.primaryStream, primaryStream)
526
+ )
527
+ )
528
+ .limit(1)
529
+
530
+ if (callbacks.length > 0) {
531
+ return
532
+ }
533
+
534
+ await this.manager.registry.updateStatus(entityUrl, `idle`)
535
+ await this.entityBridgeManager.onEntityChanged(entityUrl)
536
+ }
537
+ }