@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,183 @@
1
+ import {
2
+ getCronStreamPathFromSpec,
3
+ getSharedStateStreamPath,
4
+ resolveCronScheduleSpec,
5
+ } from '@electric-ax/agents-runtime'
6
+ import type { WakeRegistration } from './wake-registry.js'
7
+
8
+ export function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === `object` && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ export function extractManifestSourceUrl(
13
+ manifest: Record<string, unknown> | undefined
14
+ ): string | undefined {
15
+ if (!manifest) return undefined
16
+
17
+ if (manifest.kind === `child` || manifest.kind === `observe`) {
18
+ return typeof manifest.entity_url === `string`
19
+ ? manifest.entity_url
20
+ : undefined
21
+ }
22
+
23
+ if (manifest.kind === `source`) {
24
+ const config = isRecord(manifest.config) ? manifest.config : undefined
25
+
26
+ if (manifest.sourceType === `entity`) {
27
+ return typeof config?.entityUrl === `string`
28
+ ? config.entityUrl
29
+ : typeof manifest.sourceRef === `string`
30
+ ? manifest.sourceRef
31
+ : undefined
32
+ }
33
+
34
+ if (manifest.sourceType === `cron` && config) {
35
+ const expression = config.expression
36
+ if (typeof expression === `string`) {
37
+ return getCronStreamPathFromSpec(
38
+ resolveCronScheduleSpec(
39
+ expression,
40
+ typeof config.timezone === `string` ? config.timezone : undefined,
41
+ { fallback: `utc` }
42
+ )
43
+ )
44
+ }
45
+ }
46
+
47
+ if (manifest.sourceType === `entities`) {
48
+ return typeof manifest.sourceRef === `string`
49
+ ? `/_entities/${manifest.sourceRef}`
50
+ : undefined
51
+ }
52
+
53
+ if (manifest.sourceType === `db`) {
54
+ return typeof manifest.sourceRef === `string`
55
+ ? getSharedStateStreamPath(manifest.sourceRef)
56
+ : undefined
57
+ }
58
+
59
+ return undefined
60
+ }
61
+
62
+ if (manifest.kind === `shared-state`) {
63
+ return typeof manifest.id === `string`
64
+ ? getSharedStateStreamPath(manifest.id)
65
+ : undefined
66
+ }
67
+
68
+ if (
69
+ manifest.kind === `schedule` &&
70
+ manifest.scheduleType === `cron` &&
71
+ typeof manifest.expression === `string`
72
+ ) {
73
+ return getCronStreamPathFromSpec(
74
+ resolveCronScheduleSpec(
75
+ manifest.expression,
76
+ typeof manifest.timezone === `string` ? manifest.timezone : undefined,
77
+ { fallback: `utc` }
78
+ )
79
+ )
80
+ }
81
+
82
+ return undefined
83
+ }
84
+
85
+ export function extractManifestCronSpec(
86
+ manifest: Record<string, unknown> | undefined
87
+ ): { expression: string; timezone: string } | undefined {
88
+ if (!manifest) return undefined
89
+
90
+ if (manifest.kind === `source` && manifest.sourceType === `cron`) {
91
+ const config = isRecord(manifest.config) ? manifest.config : undefined
92
+ if (typeof config?.expression === `string`) {
93
+ return resolveCronScheduleSpec(
94
+ config.expression,
95
+ typeof config.timezone === `string` ? config.timezone : undefined,
96
+ { fallback: `utc` }
97
+ )
98
+ }
99
+ }
100
+
101
+ if (
102
+ manifest.kind === `schedule` &&
103
+ manifest.scheduleType === `cron` &&
104
+ typeof manifest.expression === `string`
105
+ ) {
106
+ return resolveCronScheduleSpec(
107
+ manifest.expression,
108
+ typeof manifest.timezone === `string` ? manifest.timezone : undefined,
109
+ { fallback: `utc` }
110
+ )
111
+ }
112
+
113
+ return undefined
114
+ }
115
+
116
+ export function buildManifestWakeRegistration(
117
+ subscriberUrl: string,
118
+ manifest: Record<string, unknown> | undefined,
119
+ manifestKey?: string
120
+ ): WakeRegistration | null {
121
+ if (!manifest) return null
122
+
123
+ const sourceUrl = extractManifestSourceUrl(manifest)
124
+ if (!sourceUrl) return null
125
+
126
+ const wake =
127
+ manifest.kind === `schedule` && manifest.scheduleType === `cron`
128
+ ? (manifest.wake ?? { on: `change` })
129
+ : manifest.wake
130
+
131
+ if (wake === `runFinished`) {
132
+ return {
133
+ subscriberUrl,
134
+ sourceUrl,
135
+ condition: `runFinished`,
136
+ oneShot: false,
137
+ manifestKey,
138
+ }
139
+ }
140
+
141
+ if (!isRecord(wake)) return null
142
+
143
+ if (wake.on === `runFinished`) {
144
+ return {
145
+ subscriberUrl,
146
+ sourceUrl,
147
+ condition: `runFinished`,
148
+ oneShot: false,
149
+ includeResponse:
150
+ typeof wake.includeResponse === `boolean`
151
+ ? wake.includeResponse
152
+ : undefined,
153
+ manifestKey,
154
+ }
155
+ }
156
+
157
+ if (wake.on !== `change`) return null
158
+
159
+ const collections = Array.isArray(wake.collections)
160
+ ? wake.collections.filter((c): c is string => typeof c === `string`)
161
+ : undefined
162
+ const ops = Array.isArray(wake.ops)
163
+ ? wake.ops.filter(
164
+ (op): op is `insert` | `update` | `delete` =>
165
+ op === `insert` || op === `update` || op === `delete`
166
+ )
167
+ : undefined
168
+
169
+ return {
170
+ subscriberUrl,
171
+ sourceUrl,
172
+ condition: {
173
+ on: `change`,
174
+ ...(collections ? { collections } : {}),
175
+ ...(ops ? { ops } : {}),
176
+ },
177
+ debounceMs:
178
+ typeof wake.debounceMs === `number` ? wake.debounceMs : undefined,
179
+ timeoutMs: typeof wake.timeoutMs === `number` ? wake.timeoutMs : undefined,
180
+ oneShot: false,
181
+ manifestKey,
182
+ }
183
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Serves the bundled agent UI from packages/agents-server-ui/dist.
3
+ */
4
+
5
+ import { existsSync } from 'node:fs'
6
+ import { readFile } from 'node:fs/promises'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { Router, status } from 'itty-router'
10
+ import { apiError } from '../electric-agents-http.js'
11
+ import { ErrCodeAgentUiNotFound } from '../electric-agents-types.js'
12
+ import {
13
+ cacheControlForAgentUiFile,
14
+ contentTypeForStaticFile,
15
+ pickAgentUiFile,
16
+ resolveAgentUiPath,
17
+ } from '../utils/server-utils.js'
18
+ import type { IRequest, RouterType } from 'itty-router'
19
+ import type { TenantContext } from './context.js'
20
+
21
+ function resolveAgentUiDistDir(fromUrl = import.meta.url): string {
22
+ const moduleDir = path.dirname(fileURLToPath(fromUrl))
23
+ const candidates = [
24
+ path.resolve(moduleDir, `../../../agents-server-ui/dist`),
25
+ path.resolve(moduleDir, `../../agents-server-ui/dist`),
26
+ path.resolve(process.cwd(), `packages/agents-server-ui/dist`),
27
+ ]
28
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!
29
+ }
30
+
31
+ const AGENT_UI_DIST_DIR = resolveAgentUiDistDir()
32
+
33
+ export type AgentUiRoutes = RouterType<
34
+ IRequest,
35
+ [TenantContext],
36
+ Response | undefined
37
+ >
38
+
39
+ export const agentUiRouter: AgentUiRoutes = Router<
40
+ IRequest,
41
+ [TenantContext],
42
+ Response | undefined
43
+ >({
44
+ base: `/__agent_ui`,
45
+ })
46
+
47
+ agentUiRouter.get(`/*`, serveAgentUi)
48
+ agentUiRouter.head(`/*`, serveAgentUi)
49
+ agentUiRouter.all(`*`, () => status(404))
50
+
51
+ async function serveAgentUi(request: IRequest): Promise<Response> {
52
+ const requestPath = new URL(request.url).pathname
53
+ const relativePath = decodeURIComponent(
54
+ requestPath.slice(`/__agent_ui/`.length)
55
+ )
56
+ const requestedFile = relativePath.length === 0 ? `index.html` : relativePath
57
+ const filePath = resolveAgentUiPath(AGENT_UI_DIST_DIR, requestedFile)
58
+ const fallbackToIndex =
59
+ path.extname(requestedFile) === `` || requestedFile.endsWith(`/`)
60
+ const resolvedFile = await pickAgentUiFile(
61
+ AGENT_UI_DIST_DIR,
62
+ filePath,
63
+ fallbackToIndex
64
+ )
65
+
66
+ if (!resolvedFile) {
67
+ return apiError(
68
+ 404,
69
+ ErrCodeAgentUiNotFound,
70
+ `Agent UI build artifacts are missing`
71
+ )
72
+ }
73
+
74
+ const body = request.method === `HEAD` ? null : await readFile(resolvedFile)
75
+ return new Response(body, {
76
+ headers: {
77
+ 'content-type': contentTypeForStaticFile(resolvedFile),
78
+ 'cache-control': cacheControlForAgentUiFile(resolvedFile),
79
+ },
80
+ })
81
+ }
@@ -0,0 +1,35 @@
1
+ import type { Agent } from 'undici'
2
+ import type { DrizzleDB } from '../db/index.js'
3
+ import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
4
+ import type { EntityManager } from '../entity-manager.js'
5
+ import type { ElectricAgentsTenantRuntime } from '../runtime.js'
6
+ import type { StreamClient } from '../stream-client.js'
7
+ import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
8
+ import type { AuthenticatedRequestUser } from '../electric-agents-types.js'
9
+ import type { DurableStreamsBearerProvider } from '../stream-client.js'
10
+
11
+ /**
12
+ * Per-request tenant context passed through every router and handler.
13
+ *
14
+ * The OSS server builds this from its single runtime. Library-mode callers can
15
+ * build one per request and call `globalRouter.fetch(request, ctx)` directly.
16
+ */
17
+ export interface TenantContext {
18
+ service: string
19
+ authenticatedUser?: AuthenticatedRequestUser
20
+ publicUrl: string
21
+ localUrl?: string
22
+ durableStreamsUrl: string
23
+ durableStreamsBearer?: DurableStreamsBearerProvider
24
+ durableStreamsRouting?: DurableStreamsRoutingAdapter
25
+ durableStreamsDispatcher: Agent
26
+ electricUrl?: string
27
+ electricSecret?: string
28
+ ownAgentHandlerPaths?: ReadonlyArray<string>
29
+ pgDb: DrizzleDB
30
+ entityManager: EntityManager
31
+ streamClient: StreamClient
32
+ runtime: ElectricAgentsTenantRuntime
33
+ entityBridgeManager: EntityBridgeCoordinator
34
+ isShuttingDown: () => boolean
35
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * HTTP routes under /_electric/cron.
3
+ */
4
+
5
+ import { Type, type Static } from '@sinclair/typebox'
6
+ import { Router, json } from 'itty-router'
7
+ import { routeBody, withSchema } from './schema.js'
8
+ import type { JsonRouteRequest } from './schema.js'
9
+ import type { RouterType } from 'itty-router'
10
+ import type { TenantContext } from './context.js'
11
+
12
+ const cronRegisterBodySchema = Type.Object({
13
+ expression: Type.String(),
14
+ timezone: Type.Optional(Type.String()),
15
+ })
16
+
17
+ type CronRegisterBody = Static<typeof cronRegisterBodySchema>
18
+
19
+ export type CronRoutes = RouterType<
20
+ JsonRouteRequest,
21
+ [TenantContext],
22
+ Response | undefined
23
+ >
24
+
25
+ export const cronRouter: CronRoutes = Router<
26
+ JsonRouteRequest,
27
+ [TenantContext],
28
+ Response | undefined
29
+ >({
30
+ base: `/_electric/cron`,
31
+ })
32
+
33
+ cronRouter.post(`/register`, withSchema(cronRegisterBodySchema), registerCron)
34
+
35
+ async function registerCron(
36
+ request: JsonRouteRequest,
37
+ ctx: TenantContext
38
+ ): Promise<Response> {
39
+ const parsed = routeBody<CronRegisterBody>(request)
40
+ const streamPath = await ctx.entityManager.getOrCreateCronStream(
41
+ parsed.expression,
42
+ parsed.timezone
43
+ )
44
+ return json({ streamUrl: streamPath })
45
+ }
@@ -0,0 +1,248 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { appendPathToUrl } from '@electric-ax/agents-runtime'
3
+ import { subscriptionWebhooks } from '../db/schema.js'
4
+ import { ElectricAgentsError } from '../entity-manager.js'
5
+ import {
6
+ ErrCodeInvalidRequest,
7
+ ErrCodeNotFound,
8
+ ErrCodeUnauthorized,
9
+ } from '../electric-agents-types.js'
10
+ import { runnerWakeStream } from '../entity-registry.js'
11
+ import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
12
+ import type {
13
+ DispatchPolicy,
14
+ DispatchTarget,
15
+ ElectricAgentsEntity,
16
+ } from '../electric-agents-types.js'
17
+ import type { TenantContext } from './context.js'
18
+
19
+ export function subscriptionIdForDispatchTarget(
20
+ target: DispatchTarget
21
+ ): string {
22
+ if (target.subscription_id) return target.subscription_id
23
+ if (target.type === `runner`) return `runner:${target.runnerId}`
24
+ const digest = createHash(`sha256`).update(target.url).digest(`hex`)
25
+ return `webhook:${digest.slice(0, 16)}`
26
+ }
27
+
28
+ function subscriptionIdForEntityDispatchTarget(
29
+ target: DispatchTarget,
30
+ entityUrl: string
31
+ ): string {
32
+ const base = subscriptionIdForDispatchTarget(target)
33
+ if (!target.subscription_id) return base
34
+ const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
35
+ return `${base}:${digest.slice(0, 16)}`
36
+ }
37
+
38
+ export async function resolveEffectiveDispatchPolicyForSpawn(
39
+ ctx: TenantContext,
40
+ typeName: string,
41
+ opts: { dispatchPolicy?: DispatchPolicy; parent?: string }
42
+ ): Promise<DispatchPolicy | undefined> {
43
+ if (opts.dispatchPolicy) return opts.dispatchPolicy
44
+ const entityType = await ctx.entityManager.registry.getEntityType(typeName)
45
+ if (opts.parent) {
46
+ const parent = await ctx.entityManager.registry.getEntity(opts.parent)
47
+ if (parent?.dispatch_policy) {
48
+ return applyTypeDefaultSubscriptionScope(
49
+ parent.dispatch_policy,
50
+ entityType?.default_dispatch_policy
51
+ )
52
+ }
53
+ }
54
+ return entityType?.default_dispatch_policy
55
+ }
56
+
57
+ export async function resolveEffectiveDispatchPolicyForEntity(
58
+ ctx: TenantContext,
59
+ entity: ElectricAgentsEntity
60
+ ): Promise<DispatchPolicy | undefined> {
61
+ if (entity.dispatch_policy) return entity.dispatch_policy
62
+ const entityType = await ctx.entityManager.registry.getEntityType(entity.type)
63
+ return entityType?.default_dispatch_policy
64
+ }
65
+
66
+ export async function backfillEntityDispatchPolicy(
67
+ ctx: TenantContext,
68
+ entity: ElectricAgentsEntity
69
+ ): Promise<ElectricAgentsEntity> {
70
+ if (entity.dispatch_policy) return entity
71
+ const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
72
+ ctx,
73
+ entity
74
+ )
75
+ if (!dispatchPolicy) return entity
76
+ return (
77
+ (await ctx.entityManager.registry.updateEntityDispatchPolicy(
78
+ entity.url,
79
+ dispatchPolicy
80
+ )) ?? { ...entity, dispatch_policy: dispatchPolicy }
81
+ )
82
+ }
83
+
84
+ function applyTypeDefaultSubscriptionScope(
85
+ policy: DispatchPolicy,
86
+ typeDefault: DispatchPolicy | undefined
87
+ ): DispatchPolicy {
88
+ const target = policy.targets[0]
89
+ const defaultTarget = typeDefault?.targets[0]
90
+ if (!target || !defaultTarget?.subscription_id) return policy
91
+ if (!sameDispatchDestination(target, defaultTarget)) return policy
92
+ if (target.subscription_id === defaultTarget.subscription_id) return policy
93
+
94
+ return {
95
+ targets: [{ ...target, subscription_id: defaultTarget.subscription_id }],
96
+ }
97
+ }
98
+
99
+ function sameDispatchDestination(
100
+ a: DispatchTarget,
101
+ b: DispatchTarget
102
+ ): boolean {
103
+ if (a.type !== b.type) return false
104
+ if (a.type === `runner` && b.type === `runner`) {
105
+ return a.runnerId === b.runnerId
106
+ }
107
+ if (a.type === `webhook` && b.type === `webhook`) return a.url === b.url
108
+ return false
109
+ }
110
+
111
+ export async function assertDispatchPolicyAllowed(
112
+ ctx: TenantContext,
113
+ policy: DispatchPolicy | undefined
114
+ ): Promise<void> {
115
+ const target = policy?.targets[0]
116
+ if (!target || target.type !== `runner`) return
117
+
118
+ const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
119
+ if (!runner) {
120
+ throw new ElectricAgentsError(
121
+ ErrCodeNotFound,
122
+ `Runner "${target.runnerId}" not found`,
123
+ 404
124
+ )
125
+ }
126
+ if (!ctx.authenticatedUser) {
127
+ throw new ElectricAgentsError(
128
+ ErrCodeUnauthorized,
129
+ `Authentication is required for runner-targeted dispatch`,
130
+ 401
131
+ )
132
+ }
133
+ if (runner.owner_user_id !== ctx.authenticatedUser.userId) {
134
+ throw new ElectricAgentsError(
135
+ ErrCodeUnauthorized,
136
+ `Runner dispatch requires the authenticated owner`,
137
+ 403
138
+ )
139
+ }
140
+ }
141
+
142
+ export async function linkEntityDispatchSubscription(
143
+ ctx: TenantContext,
144
+ entity: ElectricAgentsEntity
145
+ ): Promise<void> {
146
+ const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
147
+ ctx,
148
+ entity
149
+ )
150
+ const target = dispatchPolicy?.targets[0]
151
+ if (!target) return
152
+ await linkStreamToTargetSubscription(ctx, target, entity)
153
+ }
154
+
155
+ export async function unlinkEntityDispatchSubscription(
156
+ ctx: TenantContext,
157
+ entity: ElectricAgentsEntity
158
+ ): Promise<void> {
159
+ const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
160
+ ctx,
161
+ entity
162
+ )
163
+ const target = dispatchPolicy?.targets[0]
164
+ if (!target) return
165
+ const subscriptionId = subscriptionIdForEntityDispatchTarget(
166
+ target,
167
+ entity.url
168
+ )
169
+ await ctx.streamClient
170
+ .removeSubscriptionStream(subscriptionId, entity.streams.main)
171
+ .catch(() => {})
172
+ }
173
+
174
+ async function linkStreamToTargetSubscription(
175
+ ctx: TenantContext,
176
+ target: DispatchTarget,
177
+ entity: ElectricAgentsEntity
178
+ ): Promise<void> {
179
+ const streamPath = entity.streams.main
180
+ const subscriptionId = subscriptionIdForEntityDispatchTarget(
181
+ target,
182
+ entity.url
183
+ )
184
+ const existing = await ctx.streamClient.getSubscription(subscriptionId)
185
+
186
+ if (target.type === `runner`) {
187
+ const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
188
+ if (!runner) {
189
+ throw new ElectricAgentsError(
190
+ ErrCodeNotFound,
191
+ `Runner "${target.runnerId}" not found`,
192
+ 404
193
+ )
194
+ }
195
+ const wakeStream = runner.wake_stream || runnerWakeStream(target.runnerId)
196
+ await ctx.streamClient.ensure(wakeStream, {
197
+ contentType: `application/json`,
198
+ })
199
+ if (!existing) {
200
+ await ctx.streamClient.putSubscription(subscriptionId, {
201
+ type: `pull-wake`,
202
+ streams: [streamPath],
203
+ wake_stream: wakeStream,
204
+ description: `Electric Agents runner ${target.runnerId}`,
205
+ })
206
+ return
207
+ }
208
+ await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
209
+ return
210
+ }
211
+
212
+ const webhookUrl = rewriteLoopbackWebhookUrl(target.url)
213
+ if (!webhookUrl) {
214
+ throw new ElectricAgentsError(
215
+ ErrCodeInvalidRequest,
216
+ `Webhook dispatch target must include a valid URL`,
217
+ 400
218
+ )
219
+ }
220
+ const forwardUrl = appendPathToUrl(
221
+ ctx.publicUrl,
222
+ `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
223
+ )
224
+ if (!existing) {
225
+ await ctx.streamClient.putSubscription(subscriptionId, {
226
+ type: `webhook`,
227
+ streams: [streamPath],
228
+ webhook: { url: forwardUrl },
229
+ description: `Electric Agents webhook ${subscriptionId}`,
230
+ })
231
+ } else {
232
+ await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
233
+ }
234
+ await ctx.pgDb
235
+ .insert(subscriptionWebhooks)
236
+ .values({
237
+ tenantId: ctx.service,
238
+ subscriptionId,
239
+ webhookUrl,
240
+ })
241
+ .onConflictDoUpdate({
242
+ target: [
243
+ subscriptionWebhooks.tenantId,
244
+ subscriptionWebhooks.subscriptionId,
245
+ ],
246
+ set: { webhookUrl },
247
+ })
248
+ }