@electric-ax/agents-server 0.4.3 → 0.4.4
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/dist/entrypoint.js +375 -115
- package/dist/index.cjs +1434 -1188
- package/dist/index.d.cts +270 -160
- package/dist/index.d.ts +270 -160
- package/dist/index.js +1434 -1188
- package/drizzle/0007_runner_diagnostics_and_principal.sql +22 -0
- package/drizzle/0008_runner_runtime_diagnostics.sql +50 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/db/schema.ts +33 -10
- package/src/electric-agents-types.ts +49 -3
- package/src/entity-registry.ts +136 -26
- package/src/host.ts +4 -5
- package/src/principal.ts +23 -11
- package/src/routing/context.ts +1 -1
- package/src/routing/dispatch-policy.ts +123 -20
- package/src/routing/durable-streams-routing-adapter.ts +6 -7
- package/src/routing/electric-proxy-router.ts +1 -0
- package/src/routing/entities-router.ts +4 -4
- package/src/routing/hooks.ts +8 -1
- package/src/routing/runners-router.ts +257 -20
- package/src/runtime.ts +4 -7
- package/src/server.ts +20 -15
- package/src/standalone-runtime.ts +4 -7
- package/src/stream-client.ts +16 -59
- package/src/utils/server-utils.ts +22 -4
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ErrCodeUnauthorized,
|
|
9
9
|
} from '../electric-agents-types.js'
|
|
10
10
|
import { runnerWakeStream } from '../entity-registry.js'
|
|
11
|
+
import { DurableStreamsSubscriptionError } from '../stream-client.js'
|
|
11
12
|
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
12
13
|
import { serverLog } from '../utils/log.js'
|
|
13
14
|
import type {
|
|
@@ -16,6 +17,9 @@ import type {
|
|
|
16
17
|
ElectricAgentsEntity,
|
|
17
18
|
} from '../electric-agents-types.js'
|
|
18
19
|
import type { TenantContext } from './context.js'
|
|
20
|
+
import type { SubscriptionCreateInput } from '../stream-client.js'
|
|
21
|
+
|
|
22
|
+
const linkedDispatchSubscriptions = new WeakMap<object, Set<string>>()
|
|
19
23
|
|
|
20
24
|
export function subscriptionIdForDispatchTarget(
|
|
21
25
|
target: DispatchTarget
|
|
@@ -31,7 +35,7 @@ function subscriptionIdForEntityDispatchTarget(
|
|
|
31
35
|
entityUrl: string
|
|
32
36
|
): string {
|
|
33
37
|
const base = subscriptionIdForDispatchTarget(target)
|
|
34
|
-
if (!target.subscription_id) return base
|
|
38
|
+
if (!target.subscription_id && target.type !== `runner`) return base
|
|
35
39
|
const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
|
|
36
40
|
return `${base}:${digest.slice(0, 16)}`
|
|
37
41
|
}
|
|
@@ -109,12 +113,92 @@ function sameDispatchDestination(
|
|
|
109
113
|
return false
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
function subscriptionHasStream(
|
|
117
|
+
ctx: TenantContext,
|
|
118
|
+
existing: { streams?: Array<string | { path?: string }> },
|
|
119
|
+
streamPath: string
|
|
120
|
+
): boolean {
|
|
121
|
+
const normalizedStream = streamPath.replace(/^\/+/, ``)
|
|
122
|
+
const backendStream = `${ctx.service}/${normalizedStream}`
|
|
123
|
+
return (
|
|
124
|
+
existing.streams?.some((stream) => {
|
|
125
|
+
const path = typeof stream === `string` ? stream : stream.path
|
|
126
|
+
if (!path) return false
|
|
127
|
+
const normalized = path.replace(/^\/+/, ``)
|
|
128
|
+
return normalized === normalizedStream || normalized === backendStream
|
|
129
|
+
}) ?? false
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dispatchLinkCacheKey(
|
|
134
|
+
ctx: TenantContext,
|
|
135
|
+
subscriptionId: string,
|
|
136
|
+
streamPath: string
|
|
137
|
+
): string {
|
|
138
|
+
return `${ctx.service}:${subscriptionId}:${streamPath}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getDispatchLinkCache(ctx: TenantContext): Set<string> {
|
|
142
|
+
let cache = linkedDispatchSubscriptions.get(ctx.streamClient)
|
|
143
|
+
if (!cache) {
|
|
144
|
+
cache = new Set()
|
|
145
|
+
linkedDispatchSubscriptions.set(ctx.streamClient, cache)
|
|
146
|
+
}
|
|
147
|
+
return cache
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isSubscriptionAlreadyExistsError(err: unknown): boolean {
|
|
151
|
+
if (!(err instanceof DurableStreamsSubscriptionError)) return false
|
|
152
|
+
if (err.status === 409) return true
|
|
153
|
+
return (
|
|
154
|
+
err.code === `SUBSCRIPTION_ALREADY_EXISTS` ||
|
|
155
|
+
err.code === `ALREADY_EXISTS` ||
|
|
156
|
+
/already exists/i.test(err.errorMessage ?? err.body ?? err.message)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ensureSubscriptionIncludesStream(
|
|
161
|
+
ctx: TenantContext,
|
|
162
|
+
subscriptionId: string,
|
|
163
|
+
streamPath: string,
|
|
164
|
+
input: SubscriptionCreateInput,
|
|
165
|
+
existing: { streams?: Array<string | { path?: string }> } | null
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (!existing) {
|
|
168
|
+
try {
|
|
169
|
+
await ctx.streamClient.putSubscription(subscriptionId, input)
|
|
170
|
+
return
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (!isSubscriptionAlreadyExistsError(err)) throw err
|
|
173
|
+
existing = await ctx.streamClient.getSubscription(subscriptionId)
|
|
174
|
+
if (!existing) {
|
|
175
|
+
serverLog.warn(
|
|
176
|
+
`[dispatch-policy] subscription create raced with existing subscription but it could not be read`,
|
|
177
|
+
{ subscriptionId, stream: streamPath }
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!subscriptionHasStream(ctx, existing, streamPath)) {
|
|
185
|
+
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
112
189
|
export async function assertDispatchPolicyAllowed(
|
|
113
190
|
ctx: TenantContext,
|
|
114
191
|
policy: DispatchPolicy | undefined
|
|
115
192
|
): Promise<void> {
|
|
116
193
|
const target = policy?.targets[0]
|
|
117
194
|
if (!target || target.type !== `runner`) return
|
|
195
|
+
if (!ctx.principal) {
|
|
196
|
+
throw new ElectricAgentsError(
|
|
197
|
+
ErrCodeUnauthorized,
|
|
198
|
+
`Runner dispatch requires an authenticated owner`,
|
|
199
|
+
401
|
|
200
|
+
)
|
|
201
|
+
}
|
|
118
202
|
|
|
119
203
|
const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
|
|
120
204
|
if (!runner) {
|
|
@@ -124,7 +208,7 @@ export async function assertDispatchPolicyAllowed(
|
|
|
124
208
|
404
|
|
125
209
|
)
|
|
126
210
|
}
|
|
127
|
-
if (
|
|
211
|
+
if (runner.owner_principal !== ctx.principal.url) {
|
|
128
212
|
throw new ElectricAgentsError(
|
|
129
213
|
ErrCodeUnauthorized,
|
|
130
214
|
`Runner dispatch requires the authenticated owner`,
|
|
@@ -143,7 +227,19 @@ export async function linkEntityDispatchSubscription(
|
|
|
143
227
|
)
|
|
144
228
|
const target = dispatchPolicy?.targets[0]
|
|
145
229
|
if (!target) return
|
|
146
|
-
|
|
230
|
+
const subscriptionId = subscriptionIdForEntityDispatchTarget(
|
|
231
|
+
target,
|
|
232
|
+
entity.url
|
|
233
|
+
)
|
|
234
|
+
const cacheKey = dispatchLinkCacheKey(
|
|
235
|
+
ctx,
|
|
236
|
+
subscriptionId,
|
|
237
|
+
entity.streams.main
|
|
238
|
+
)
|
|
239
|
+
const cache = getDispatchLinkCache(ctx)
|
|
240
|
+
if (cache.has(cacheKey)) return
|
|
241
|
+
await linkStreamToTargetSubscription(ctx, target, entity, subscriptionId)
|
|
242
|
+
cache.add(cacheKey)
|
|
147
243
|
}
|
|
148
244
|
|
|
149
245
|
export async function unlinkEntityDispatchSubscription(
|
|
@@ -160,6 +256,9 @@ export async function unlinkEntityDispatchSubscription(
|
|
|
160
256
|
target,
|
|
161
257
|
entity.url
|
|
162
258
|
)
|
|
259
|
+
getDispatchLinkCache(ctx).delete(
|
|
260
|
+
dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main)
|
|
261
|
+
)
|
|
163
262
|
await ctx.streamClient
|
|
164
263
|
.removeSubscriptionStream(subscriptionId, entity.streams.main)
|
|
165
264
|
.catch((err) => {
|
|
@@ -174,13 +273,13 @@ export async function unlinkEntityDispatchSubscription(
|
|
|
174
273
|
async function linkStreamToTargetSubscription(
|
|
175
274
|
ctx: TenantContext,
|
|
176
275
|
target: DispatchTarget,
|
|
177
|
-
entity: ElectricAgentsEntity
|
|
276
|
+
entity: ElectricAgentsEntity,
|
|
277
|
+
subscriptionId: string
|
|
178
278
|
): Promise<void> {
|
|
179
279
|
const streamPath = entity.streams.main
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
)
|
|
280
|
+
await ctx.streamClient.ensure(streamPath, {
|
|
281
|
+
contentType: `application/json`,
|
|
282
|
+
})
|
|
184
283
|
const existing = await ctx.streamClient.getSubscription(subscriptionId)
|
|
185
284
|
|
|
186
285
|
if (target.type === `runner`) {
|
|
@@ -196,16 +295,18 @@ async function linkStreamToTargetSubscription(
|
|
|
196
295
|
await ctx.streamClient.ensure(wakeStream, {
|
|
197
296
|
contentType: `application/json`,
|
|
198
297
|
})
|
|
199
|
-
|
|
200
|
-
|
|
298
|
+
await ensureSubscriptionIncludesStream(
|
|
299
|
+
ctx,
|
|
300
|
+
subscriptionId,
|
|
301
|
+
streamPath,
|
|
302
|
+
{
|
|
201
303
|
type: `pull-wake`,
|
|
202
304
|
streams: [streamPath],
|
|
203
305
|
wake_stream: wakeStream,
|
|
204
306
|
description: `Electric Agents runner ${target.runnerId}`,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
307
|
+
},
|
|
308
|
+
existing
|
|
309
|
+
)
|
|
209
310
|
return
|
|
210
311
|
}
|
|
211
312
|
|
|
@@ -221,16 +322,18 @@ async function linkStreamToTargetSubscription(
|
|
|
221
322
|
ctx.publicUrl,
|
|
222
323
|
`/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
|
|
223
324
|
)
|
|
224
|
-
|
|
225
|
-
|
|
325
|
+
await ensureSubscriptionIncludesStream(
|
|
326
|
+
ctx,
|
|
327
|
+
subscriptionId,
|
|
328
|
+
streamPath,
|
|
329
|
+
{
|
|
226
330
|
type: `webhook`,
|
|
227
331
|
streams: [streamPath],
|
|
228
332
|
webhook: { url: forwardUrl },
|
|
229
333
|
description: `Electric Agents webhook ${subscriptionId}`,
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
334
|
+
},
|
|
335
|
+
existing
|
|
336
|
+
)
|
|
234
337
|
await ctx.pgDb
|
|
235
338
|
.insert(subscriptionWebhooks)
|
|
236
339
|
.values({
|
|
@@ -12,12 +12,11 @@ export interface DurableStreamsRoutingAdapter {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function appendSearch(target: URL, source: URL): URL {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
target.searchParams.delete(`service`)
|
|
15
|
+
source.searchParams.forEach((value, key) => {
|
|
16
|
+
if (key !== `service`) {
|
|
17
|
+
target.searchParams.append(key, value)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
21
20
|
return target
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -32,7 +31,7 @@ function appendRequestPathToStreamRoot(input: DurableStreamsRoutingInput): URL {
|
|
|
32
31
|
target.pathname = path
|
|
33
32
|
? `${withoutTrailingSlash(target.pathname)}/${path}`
|
|
34
33
|
: withoutTrailingSlash(target.pathname)
|
|
35
|
-
return
|
|
34
|
+
return appendSearch(target, incomingUrl)
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
export const streamRootDurableStreamsRoutingAdapter: DurableStreamsRoutingAdapter =
|
|
@@ -530,10 +530,10 @@ async function sendEntity(
|
|
|
530
530
|
await ctx.entityManager.ensurePrincipal(principal)
|
|
531
531
|
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
532
532
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
await
|
|
536
|
-
|
|
533
|
+
const dispatchEntity = entity.dispatch_policy
|
|
534
|
+
? entity
|
|
535
|
+
: await backfillEntityDispatchPolicy(ctx, entity)
|
|
536
|
+
await linkEntityDispatchSubscription(ctx, dispatchEntity)
|
|
537
537
|
|
|
538
538
|
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
539
539
|
await ctx.entityManager.enqueueDelayedSend(
|
package/src/routing/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
|
|
2
2
|
import { apiError } from '../electric-agents-http.js'
|
|
3
3
|
import { ElectricAgentsError } from '../entity-manager.js'
|
|
4
|
+
import { ELECTRIC_PRINCIPAL_HEADER } from '../principal.js'
|
|
4
5
|
import { ATTR, extractTraceContext, tracer } from '../tracing.js'
|
|
5
6
|
import { serverLog } from '../utils/log.js'
|
|
6
7
|
import type { Span } from '@opentelemetry/api'
|
|
@@ -80,7 +81,13 @@ export function applyCors(
|
|
|
80
81
|
)
|
|
81
82
|
headers.set(
|
|
82
83
|
`access-control-allow-headers`,
|
|
83
|
-
|
|
84
|
+
[
|
|
85
|
+
`content-type`,
|
|
86
|
+
`authorization`,
|
|
87
|
+
`electric-claim-token`,
|
|
88
|
+
ELECTRIC_PRINCIPAL_HEADER,
|
|
89
|
+
`ngrok-skip-browser-warning`,
|
|
90
|
+
].join(`, `)
|
|
84
91
|
)
|
|
85
92
|
headers.set(`access-control-expose-headers`, `*`)
|
|
86
93
|
return new Response(response.body, {
|