@electric-ax/agents-server 0.4.5 → 0.4.7

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.
@@ -15,6 +15,7 @@ import {
15
15
  assertEntityStatus,
16
16
  assertRunnerAdminStatus,
17
17
  assertRunnerKind,
18
+ isTerminalEntityStatus,
18
19
  } from './electric-agents-types.js'
19
20
  import { DEFAULT_TENANT_ID } from './tenant.js'
20
21
  import type { DrizzleDB } from './db/index.js'
@@ -360,11 +361,17 @@ export class PostgresRegistry {
360
361
  input: MaterializeHeartbeatClaimInput
361
362
  ): Promise<void> {
362
363
  const heartbeatAt = input.heartbeatAt ?? new Date()
364
+ // Only touch leaseExpiresAt when the caller explicitly provides one.
365
+ // The lease was set at materializeActiveClaim time from the upstream
366
+ // lease_ttl_ms and remains the authoritative expiry; heartbeats are
367
+ // alive-pings, not lease extensions.
363
368
  await this.db
364
369
  .update(consumerClaims)
365
370
  .set({
366
371
  lastHeartbeatAt: heartbeatAt,
367
- leaseExpiresAt: input.leaseExpiresAt ?? null,
372
+ ...(input.leaseExpiresAt !== undefined
373
+ ? { leaseExpiresAt: input.leaseExpiresAt }
374
+ : {}),
368
375
  updatedAt: heartbeatAt,
369
376
  })
370
377
  .where(
@@ -378,7 +385,7 @@ export class PostgresRegistry {
378
385
 
379
386
  async materializeReleasedClaim(
380
387
  input: MaterializeReleasedClaimInput
381
- ): Promise<ConsumerClaim | null> {
388
+ ): Promise<{ claim: ConsumerClaim | null; entityCleared: boolean }> {
382
389
  const releasedAt = input.releasedAt ?? new Date()
383
390
  const rows = await this.db
384
391
  .update(consumerClaims)
@@ -398,8 +405,13 @@ export class PostgresRegistry {
398
405
  .returning()
399
406
 
400
407
  const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null
408
+ let entityCleared = false
401
409
  if (claim) {
402
- await this.db
410
+ // entityCleared distinguishes "we were the active dispatch and now it's
411
+ // empty" from "a newer claim was already active for this entity." The
412
+ // WHERE clause matches our (consumerId, epoch) so an evicted-by-newer
413
+ // case correctly returns zero rows.
414
+ const cleared = await this.db
403
415
  .update(entityDispatchState)
404
416
  .set({
405
417
  activeConsumerId: null,
@@ -419,8 +431,10 @@ export class PostgresRegistry {
419
431
  eq(entityDispatchState.activeEpoch, input.epoch)
420
432
  )
421
433
  )
434
+ .returning({ entityUrl: entityDispatchState.entityUrl })
435
+ entityCleared = cleared.length > 0
422
436
  }
423
- return claim
437
+ return { claim, entityCleared }
424
438
  }
425
439
 
426
440
  async getActiveClaimsForRunner(
@@ -713,10 +727,13 @@ export class PostgresRegistry {
713
727
  }
714
728
 
715
729
  async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
716
- const whereClause =
717
- status === `stopped`
718
- ? this.entityWhere(entityUrl)
719
- : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
730
+ const whereClause = isTerminalEntityStatus(status)
731
+ ? this.entityWhere(entityUrl)
732
+ : and(
733
+ this.entityWhere(entityUrl),
734
+ ne(entities.status, `stopped`),
735
+ ne(entities.status, `killed`)
736
+ )
720
737
 
721
738
  await this.db
722
739
  .update(entities)
@@ -727,21 +744,41 @@ export class PostgresRegistry {
727
744
  async updateStatusWithTxid(
728
745
  entityUrl: string,
729
746
  status: EntityStatus
730
- ): Promise<number> {
747
+ ): Promise<number | null> {
731
748
  return await this.db.transaction(async (tx) => {
732
- const whereClause =
733
- status === `stopped`
734
- ? this.entityWhere(entityUrl)
735
- : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
736
-
737
- await tx
749
+ const rows = await tx
738
750
  .update(entities)
739
751
  .set({ status, updatedAt: Date.now() })
740
- .where(whereClause)
741
- const result = await tx.execute(
742
- sql`SELECT pg_current_xact_id()::xid::text AS txid`
743
- )
744
- return parseInt((result[0] as { txid: string }).txid)
752
+ .where(
753
+ and(
754
+ this.entityWhere(entityUrl),
755
+ ne(entities.status, `stopped`),
756
+ ne(entities.status, `killed`)
757
+ )
758
+ )
759
+ .returning({
760
+ txid: sql<string>`pg_current_xact_id()::xid::text`,
761
+ })
762
+ return rows[0] ? parseInt(rows[0].txid) : null
763
+ })
764
+ }
765
+
766
+ async touchEntityWithTxid(entityUrl: string): Promise<number | null> {
767
+ return await this.db.transaction(async (tx) => {
768
+ const rows = await tx
769
+ .update(entities)
770
+ .set({ updatedAt: Date.now() })
771
+ .where(
772
+ and(
773
+ eq(entities.url, entityUrl),
774
+ ne(entities.status, `stopped`),
775
+ ne(entities.status, `killed`)
776
+ )
777
+ )
778
+ .returning({
779
+ txid: sql<string>`pg_current_xact_id()::xid::text`,
780
+ })
781
+ return rows[0] ? parseInt(rows[0].txid) : null
745
782
  })
746
783
  }
747
784
 
@@ -139,6 +139,10 @@ export function resolveElectricAgentsEntrypointOptions(
139
139
  `ELECTRIC_URL`,
140
140
  ])
141
141
  const electricSecret = readEnv(env, [`ELECTRIC_AGENTS_ELECTRIC_SECRET`])
142
+ const webhookSigningKey = readEnv(env, [
143
+ `ELECTRIC_AGENTS_WEBHOOK_SIGNING_PRIVATE_KEY`,
144
+ `WEBHOOK_SIGNING_PRIVATE_KEY`,
145
+ ])
142
146
  const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BASE_URL`, `BASE_URL`])
143
147
  return {
144
148
  service: readEnv(env, [`ELECTRIC_AGENTS_SERVICE`, `SERVICE`]),
@@ -153,6 +157,7 @@ export function resolveElectricAgentsEntrypointOptions(
153
157
  ? validateUrl(`Electric URL`, electricUrl)
154
158
  : undefined,
155
159
  electricSecret,
160
+ webhookSigningKey,
156
161
  host: readEnv(env, [`ELECTRIC_AGENTS_HOST`, `HOST`]) ?? DEFAULT_HOST,
157
162
  port: readPort(env),
158
163
  workingDirectory:
package/src/index.ts CHANGED
@@ -15,6 +15,14 @@ export type {
15
15
  SubscriptionResponse,
16
16
  SubscriptionStreamInfo,
17
17
  } from './stream-client.js'
18
+ export {
19
+ assertEntitySignal,
20
+ assertEntityStatus,
21
+ expectedSignalStatus,
22
+ isTerminalEntityStatus,
23
+ rejectsNormalWrites,
24
+ toPublicEntity,
25
+ } from './electric-agents-types.js'
18
26
  export type {
19
27
  AuthenticateRequest,
20
28
  ConsumerClaim,
@@ -32,6 +40,18 @@ export type {
32
40
  RunnerLiveness,
33
41
  SourceStreamOffset,
34
42
  WakeNotificationRow,
43
+ ElectricAgentsEntity,
44
+ ElectricAgentsEntityRow,
45
+ ElectricAgentsEntityType,
46
+ EntityStatus,
47
+ EntitySignal,
48
+ PublicElectricAgentsEntity,
49
+ EntityListFilter,
50
+ RegisterEntityTypeRequest,
51
+ SendRequest,
52
+ SignalRequest,
53
+ SignalResponse,
54
+ TypedSpawnRequest,
35
55
  } from './electric-agents-types.js'
36
56
  export type { Principal, PrincipalKind } from './principal.js'
37
57
  export { globalRouter } from './routing/global-router.js'
@@ -46,6 +66,19 @@ export type {
46
66
  DurableStreamsRoutingAdapter,
47
67
  DurableStreamsRoutingInput,
48
68
  } from './routing/durable-streams-routing-adapter.js'
69
+ export {
70
+ createEd25519WebhookSigner,
71
+ getDefaultWebhookSigner,
72
+ webhookSigningMetadata,
73
+ } from './webhook-signing.js'
74
+ export type {
75
+ Ed25519WebhookSignerOptions,
76
+ WebhookJwks,
77
+ WebhookPublicJwk,
78
+ WebhookSigner,
79
+ WebhookSigningKeyInput,
80
+ WebhookSigningMetadata,
81
+ } from './webhook-signing.js'
49
82
  export type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
50
83
  export {
51
84
  DEFAULT_TENANT_ID,
@@ -1,4 +1,5 @@
1
1
  import type { Agent } from 'undici'
2
+ import type { WebhookSignatureVerifierConfig } from '@electric-ax/agents-runtime'
2
3
  import type { DrizzleDB } from '../db/index.js'
3
4
  import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
4
5
  import type { EntityManager } from '../entity-manager.js'
@@ -7,6 +8,7 @@ import type { StreamClient } from '../stream-client.js'
7
8
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
8
9
  import type { Principal } from '../principal.js'
9
10
  import type { DurableStreamsBearerProvider } from '../stream-client.js'
11
+ import type { WebhookSigner } from '../webhook-signing.js'
10
12
 
11
13
  /**
12
14
  * Per-request tenant context passed through every router and handler.
@@ -24,6 +26,10 @@ export interface TenantContext {
24
26
  durableStreamsBearer?: DurableStreamsBearerProvider
25
27
  durableStreamsRouting?: DurableStreamsRoutingAdapter
26
28
  durableStreamsDispatcher: Agent
29
+ durableStreamsWebhookSignature?:
30
+ | false
31
+ | Partial<WebhookSignatureVerifierConfig>
32
+ webhookSigner?: WebhookSigner
27
33
  electricUrl?: string
28
34
  electricSecret?: string
29
35
  ownAgentHandlerPaths?: ReadonlyArray<string>
@@ -217,6 +217,12 @@ export async function assertDispatchPolicyAllowed(
217
217
  }
218
218
  }
219
219
 
220
+ export function shouldLinkDispatchBeforeInitialMessage(
221
+ policy: DispatchPolicy | undefined
222
+ ): boolean {
223
+ return policy?.targets[0] !== undefined
224
+ }
225
+
220
226
  export async function linkEntityDispatchSubscription(
221
227
  ctx: TenantContext,
222
228
  entity: ElectricAgentsEntity
@@ -15,10 +15,15 @@ import {
15
15
  import { validateBody } from './schema.js'
16
16
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
17
17
  import { forwardFetchRequest } from '../utils/server-utils.js'
18
+ import {
19
+ getDefaultWebhookSigner,
20
+ webhookSigningMetadata,
21
+ } from '../webhook-signing.js'
18
22
  import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
19
23
  import type { IRequest, RouterType } from 'itty-router'
20
24
  import type { TenantContext } from './context.js'
21
25
  import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
26
+ import type { WebhookSigner } from '../webhook-signing.js'
22
27
 
23
28
  const subscriptionProxyBodySchema = Type.Object(
24
29
  {
@@ -81,6 +86,7 @@ for (const action of subscriptionControlActions) {
81
86
  subscriptionAction(action)
82
87
  )
83
88
  }
89
+ durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks)
84
90
  durableStreamsRouter.all(`/__ds`, controlPassThrough)
85
91
  durableStreamsRouter.all(`/__ds/*`, controlPassThrough)
86
92
  durableStreamsRouter.post(`*`, streamAppend)
@@ -94,13 +100,22 @@ function bodyFromBytes(body: Uint8Array): ArrayBuffer {
94
100
  }
95
101
 
96
102
  function responseFromUpstream(response: Response, body?: Uint8Array): Response {
97
- return new Response(body ? bodyFromBytes(body) : response.body, {
103
+ const responseBody = forbidsResponseBody(response.status)
104
+ ? null
105
+ : body !== undefined
106
+ ? bodyFromBytes(body)
107
+ : response.body
108
+ return new Response(responseBody, {
98
109
  status: response.status,
99
110
  statusText: response.statusText,
100
111
  headers: responseHeaders(response),
101
112
  })
102
113
  }
103
114
 
115
+ function forbidsResponseBody(status: number): boolean {
116
+ return status === 204 || status === 205 || status === 304
117
+ }
118
+
104
119
  async function forwardToDurableStreams(
105
120
  ctx: TenantContext,
106
121
  request: IRequest,
@@ -178,12 +193,12 @@ function rewriteSubscriptionBodyForBackend(
178
193
  }
179
194
  }
180
195
 
181
- function rewriteSubscriptionResponseForClient(
196
+ async function rewriteSubscriptionResponseForClient(
182
197
  bytes: Uint8Array,
183
198
  response: Response,
184
- service: string,
199
+ ctx: TenantContext,
185
200
  routingAdapter: DurableStreamsRoutingAdapter
186
- ): Uint8Array {
201
+ ): Promise<Uint8Array> {
187
202
  if (!response.headers.get(`content-type`)?.includes(`application/json`)) {
188
203
  return bytes
189
204
  }
@@ -192,14 +207,14 @@ function rewriteSubscriptionResponseForClient(
192
207
 
193
208
  if (typeof payload.pattern === `string`) {
194
209
  payload.pattern = routingAdapter.toRuntimeStreamPath(
195
- service,
210
+ ctx.service,
196
211
  payload.pattern
197
212
  )
198
213
  }
199
214
  if (Array.isArray(payload.streams)) {
200
215
  payload.streams = payload.streams.map((stream) => {
201
216
  if (typeof stream === `string`) {
202
- return routingAdapter.toRuntimeStreamPath(service, stream)
217
+ return routingAdapter.toRuntimeStreamPath(ctx.service, stream)
203
218
  }
204
219
  if (
205
220
  stream &&
@@ -209,7 +224,7 @@ function rewriteSubscriptionResponseForClient(
209
224
  return {
210
225
  ...(stream as Record<string, unknown>),
211
226
  path: routingAdapter.toRuntimeStreamPath(
212
- service,
227
+ ctx.service,
213
228
  (stream as Record<string, string>).path
214
229
  ),
215
230
  }
@@ -219,26 +234,43 @@ function rewriteSubscriptionResponseForClient(
219
234
  }
220
235
  if (typeof payload.wake_stream === `string`) {
221
236
  payload.wake_stream = routingAdapter.toRuntimeStreamPath(
222
- service,
237
+ ctx.service,
223
238
  payload.wake_stream
224
239
  )
225
240
  }
226
241
  if (typeof payload.stream === `string`) {
227
- payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream)
242
+ payload.stream = routingAdapter.toRuntimeStreamPath(
243
+ ctx.service,
244
+ payload.stream
245
+ )
228
246
  }
229
247
  if (Array.isArray(payload.acks)) {
230
248
  payload.acks = payload.acks.map((ack) => {
231
249
  if (!ack || typeof ack !== `object`) return ack
232
250
  const next = { ...(ack as Record<string, unknown>) }
233
251
  if (typeof next.stream === `string`) {
234
- next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream)
252
+ next.stream = routingAdapter.toRuntimeStreamPath(
253
+ ctx.service,
254
+ next.stream
255
+ )
235
256
  }
236
257
  if (typeof next.path === `string`) {
237
- next.path = routingAdapter.toRuntimeStreamPath(service, next.path)
258
+ next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path)
238
259
  }
239
260
  return next
240
261
  })
241
262
  }
263
+ if (
264
+ payload.webhook &&
265
+ typeof payload.webhook === `object` &&
266
+ !Array.isArray(payload.webhook)
267
+ ) {
268
+ const webhook = payload.webhook as Record<string, unknown>
269
+ webhook.signing = await webhookSigningMetadata(
270
+ resolveWebhookSigner(ctx),
271
+ ctx.publicUrl
272
+ )
273
+ }
242
274
 
243
275
  return new TextEncoder().encode(JSON.stringify(payload))
244
276
  }
@@ -269,6 +301,10 @@ function subscriptionRoutingAdapter(
269
301
  )
270
302
  }
271
303
 
304
+ function resolveWebhookSigner(ctx: TenantContext): WebhookSigner {
305
+ return ctx.webhookSigner ?? getDefaultWebhookSigner()
306
+ }
307
+
272
308
  async function rewriteSubscriptionRequestBody(
273
309
  request: IRequest,
274
310
  ctx: TenantContext,
@@ -334,10 +370,10 @@ async function forwardSubscriptionRequest(
334
370
  let responseBytes: Uint8Array = upstream.body
335
371
  ? new Uint8Array(await upstream.arrayBuffer())
336
372
  : new Uint8Array()
337
- responseBytes = rewriteSubscriptionResponseForClient(
373
+ responseBytes = await rewriteSubscriptionResponseForClient(
338
374
  responseBytes,
339
375
  upstream,
340
- ctx.service,
376
+ ctx,
341
377
  routingAdapter
342
378
  )
343
379
  return {
@@ -530,6 +566,19 @@ async function controlPassThrough(
530
566
  return responseFromUpstream(upstream)
531
567
  }
532
568
 
569
+ async function webhookJwks(
570
+ _request: IRequest,
571
+ ctx: TenantContext
572
+ ): Promise<Response> {
573
+ return new Response(JSON.stringify(await resolveWebhookSigner(ctx).jwks()), {
574
+ status: 200,
575
+ headers: {
576
+ 'content-type': `application/jwk-set+json`,
577
+ 'cache-control': `public, max-age=300`,
578
+ },
579
+ })
580
+ }
581
+
533
582
  async function streamAppend(
534
583
  request: IRequest,
535
584
  ctx: TenantContext
@@ -18,6 +18,7 @@ import {
18
18
  backfillEntityDispatchPolicy,
19
19
  linkEntityDispatchSubscription,
20
20
  resolveEffectiveDispatchPolicyForSpawn,
21
+ shouldLinkDispatchBeforeInitialMessage,
21
22
  unlinkEntityDispatchSubscription,
22
23
  } from './dispatch-policy.js'
23
24
  import { routeBody, withSchema } from './schema.js'
@@ -133,6 +134,22 @@ const setTagBodySchema = Type.Object({
133
134
  value: Type.String(),
134
135
  })
135
136
 
137
+ const entitySignalSchema = Type.Union([
138
+ Type.Literal(`SIGINT`),
139
+ Type.Literal(`SIGHUP`),
140
+ Type.Literal(`SIGTERM`),
141
+ Type.Literal(`SIGKILL`),
142
+ Type.Literal(`SIGSTOP`),
143
+ Type.Literal(`SIGCONT`),
144
+ Type.Literal(`SIGUSR`),
145
+ ])
146
+
147
+ const signalBodySchema = Type.Object({
148
+ signal: entitySignalSchema,
149
+ reason: Type.Optional(Type.String()),
150
+ payload: Type.Optional(Type.Unknown()),
151
+ })
152
+
136
153
  const scheduleBodySchema = Type.Union([
137
154
  Type.Object({
138
155
  scheduleType: Type.Literal(`cron`),
@@ -161,6 +178,7 @@ type SendBody = Static<typeof sendBodySchema>
161
178
  type InboxMessageBody = Static<typeof inboxMessageBodySchema>
162
179
  type ForkBody = Static<typeof forkBodySchema>
163
180
  type SetTagBody = Static<typeof setTagBodySchema>
181
+ type SignalBody = Static<typeof signalBodySchema>
164
182
  type ScheduleBody = Static<typeof scheduleBodySchema>
165
183
  type EntitiesRegisterBody = Static<typeof entitiesRegisterBodySchema>
166
184
 
@@ -187,6 +205,12 @@ entitiesRouter.put(
187
205
  entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity)
188
206
  entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity)
189
207
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity)
208
+ entitiesRouter.post(
209
+ `/:type/:instanceId/signal`,
210
+ withExistingEntity,
211
+ withSchema(signalBodySchema),
212
+ signalEntity
213
+ )
190
214
  entitiesRouter.post(
191
215
  `/:type/:instanceId/send`,
192
216
  withExistingEntity,
@@ -614,13 +638,21 @@ async function spawnEntity(
614
638
  wake: parsed.wake,
615
639
  created_by: principal.url,
616
640
  })
617
- await linkEntityDispatchSubscription(ctx, entity)
641
+ const linkBeforeInitialMessage =
642
+ parsed.initialMessage !== undefined &&
643
+ shouldLinkDispatchBeforeInitialMessage(dispatchPolicy)
644
+ if (linkBeforeInitialMessage) {
645
+ await linkEntityDispatchSubscription(ctx, entity)
646
+ }
618
647
  if (parsed.initialMessage !== undefined) {
619
648
  await ctx.entityManager.send(entity.url, {
620
649
  from: principal.url,
621
650
  payload: parsed.initialMessage,
622
651
  })
623
652
  }
653
+ if (!linkBeforeInitialMessage) {
654
+ await linkEntityDispatchSubscription(ctx, entity)
655
+ }
624
656
 
625
657
  return json(
626
658
  { ...toPublicEntity(entity), txid: entity.txid },
@@ -655,3 +687,27 @@ async function killEntity(
655
687
  ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main)
656
688
  return json(result)
657
689
  }
690
+
691
+ async function signalEntity(
692
+ request: AgentsRouteRequest,
693
+ ctx: TenantContext
694
+ ): Promise<Response> {
695
+ const principalMutationError = rejectPrincipalEntityMutation(
696
+ request,
697
+ `signaled`
698
+ )
699
+ if (principalMutationError) return principalMutationError
700
+
701
+ const parsed = routeBody<SignalBody>(request)
702
+ const { entityUrl, entity } = requireExistingEntityRoute(request)
703
+ const result = await ctx.entityManager.signal(entityUrl, {
704
+ signal: parsed.signal,
705
+ reason: parsed.reason,
706
+ payload: parsed.payload,
707
+ })
708
+ if (result.new_state === `stopped` || result.new_state === `killed`) {
709
+ await unlinkEntityDispatchSubscription(ctx, entity)
710
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main)
711
+ }
712
+ return json(result)
713
+ }