@electric-ax/agents-server 0.4.3 → 0.4.5
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 +8 -8
- 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
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
ErrCodeNotFound,
|
|
10
10
|
ErrCodeNotRunning,
|
|
11
11
|
ErrCodeUnauthorized,
|
|
12
|
+
type RunnerHealthResponse,
|
|
12
13
|
} from '../electric-agents-types.js'
|
|
13
14
|
import { routeBody, withSchema } from './schema.js'
|
|
14
15
|
import { subscriptionIdForDispatchTarget } from './dispatch-policy.js'
|
|
15
16
|
import { withLeadingSlash } from './tenant-stream-paths.js'
|
|
16
|
-
import { principalFromCreatedBy } from '../principal.js'
|
|
17
|
+
import { parsePrincipalUrl, principalFromCreatedBy } from '../principal.js'
|
|
17
18
|
import type { JsonRouteRequest } from './schema.js'
|
|
18
19
|
import type { RouterType } from 'itty-router'
|
|
19
20
|
import type { TenantContext } from './context.js'
|
|
@@ -35,7 +36,7 @@ export type RunnersRoutes = RouterType<
|
|
|
35
36
|
|
|
36
37
|
const registerRunnerBodySchema = Type.Object({
|
|
37
38
|
id: Type.String(),
|
|
38
|
-
|
|
39
|
+
owner_principal: Type.Optional(Type.String()),
|
|
39
40
|
label: Type.String(),
|
|
40
41
|
kind: Type.Optional(
|
|
41
42
|
Type.Union([
|
|
@@ -57,6 +58,7 @@ const heartbeatBodySchema = Type.Object({
|
|
|
57
58
|
wake_stream_offset: Type.Optional(Type.String()),
|
|
58
59
|
wakeStreamOffset: Type.Optional(Type.String()),
|
|
59
60
|
liveness_lease_expires_at: Type.Optional(Type.String()),
|
|
61
|
+
diagnostics: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
60
62
|
})
|
|
61
63
|
|
|
62
64
|
const claimBodySchema = Type.Object(
|
|
@@ -72,6 +74,35 @@ const claimBodySchema = Type.Object(
|
|
|
72
74
|
type RegisterRunnerBody = Static<typeof registerRunnerBodySchema>
|
|
73
75
|
type HeartbeatBody = Static<typeof heartbeatBodySchema>
|
|
74
76
|
type ClaimBody = Static<typeof claimBodySchema>
|
|
77
|
+
type RunnerClientDiagnostics = NonNullable<RunnerHealthResponse[`client`]>
|
|
78
|
+
|
|
79
|
+
const runnerClientStatuses = new Set<RunnerClientDiagnostics[`status`]>([
|
|
80
|
+
`stopped`,
|
|
81
|
+
`starting`,
|
|
82
|
+
`connecting`,
|
|
83
|
+
`streaming`,
|
|
84
|
+
`reconnecting`,
|
|
85
|
+
`stopping`,
|
|
86
|
+
])
|
|
87
|
+
const runnerLastClaimResults = new Set<
|
|
88
|
+
NonNullable<RunnerClientDiagnostics[`last_claim_result`]>
|
|
89
|
+
>([`claimed`, `no_work`, `error`])
|
|
90
|
+
const runnerStringOrNullDiagnostics = [
|
|
91
|
+
`started_at`,
|
|
92
|
+
`stream_connected_since`,
|
|
93
|
+
`last_error`,
|
|
94
|
+
`last_error_at`,
|
|
95
|
+
`last_heartbeat_at`,
|
|
96
|
+
`last_claim_at`,
|
|
97
|
+
`last_dispatch_at`,
|
|
98
|
+
] as const
|
|
99
|
+
const runnerNumberDiagnostics = [
|
|
100
|
+
`reconnect_count`,
|
|
101
|
+
`events_received`,
|
|
102
|
+
`claims_succeeded`,
|
|
103
|
+
`claims_skipped`,
|
|
104
|
+
`claims_failed`,
|
|
105
|
+
] as const
|
|
75
106
|
|
|
76
107
|
export const runnersRouter: RunnersRoutes = Router<
|
|
77
108
|
RunnersRouteRequest,
|
|
@@ -83,6 +114,7 @@ export const runnersRouter: RunnersRoutes = Router<
|
|
|
83
114
|
|
|
84
115
|
runnersRouter.post(`/`, withSchema(registerRunnerBodySchema), registerRunner)
|
|
85
116
|
runnersRouter.get(`/`, listRunners)
|
|
117
|
+
runnersRouter.get(`/:id/health`, runnerHealth)
|
|
86
118
|
runnersRouter.get(`/:id`, getRunner)
|
|
87
119
|
runnersRouter.post(`/:id/heartbeat`, withSchema(heartbeatBodySchema), heartbeat)
|
|
88
120
|
runnersRouter.post(`/:id/enable`, setEnabled)
|
|
@@ -100,31 +132,104 @@ function firstQueryValue(
|
|
|
100
132
|
return Array.isArray(value) ? value[0] : value
|
|
101
133
|
}
|
|
102
134
|
|
|
135
|
+
function requireAuthenticatedPrincipal(
|
|
136
|
+
ctx: TenantContext
|
|
137
|
+
): NonNullable<TenantContext[`principal`]> {
|
|
138
|
+
if (ctx.principal) return ctx.principal
|
|
139
|
+
throw new ElectricAgentsError(
|
|
140
|
+
ErrCodeUnauthorized,
|
|
141
|
+
`Runner route requires an authenticated principal`,
|
|
142
|
+
401
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function canonicalOwnerPrincipal(input: string): string | null {
|
|
147
|
+
return parsePrincipalUrl(input)?.url ?? null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sanitizeRunnerDiagnostics(
|
|
151
|
+
diagnostics: Record<string, unknown> | null | undefined
|
|
152
|
+
): RunnerClientDiagnostics | undefined {
|
|
153
|
+
if (!diagnostics) return undefined
|
|
154
|
+
const sanitized: Record<string, unknown> = {}
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
typeof diagnostics.status === `string` &&
|
|
158
|
+
runnerClientStatuses.has(
|
|
159
|
+
diagnostics.status as RunnerClientDiagnostics[`status`]
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
sanitized.status = diagnostics.status
|
|
163
|
+
}
|
|
164
|
+
if (typeof diagnostics.stream_connected === `boolean`) {
|
|
165
|
+
sanitized.stream_connected = diagnostics.stream_connected
|
|
166
|
+
}
|
|
167
|
+
if (typeof diagnostics.last_heartbeat_ok === `boolean`) {
|
|
168
|
+
sanitized.last_heartbeat_ok = diagnostics.last_heartbeat_ok
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
diagnostics.last_claim_result === null ||
|
|
172
|
+
(typeof diagnostics.last_claim_result === `string` &&
|
|
173
|
+
runnerLastClaimResults.has(
|
|
174
|
+
diagnostics.last_claim_result as NonNullable<
|
|
175
|
+
RunnerClientDiagnostics[`last_claim_result`]
|
|
176
|
+
>
|
|
177
|
+
))
|
|
178
|
+
) {
|
|
179
|
+
sanitized.last_claim_result = diagnostics.last_claim_result
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const key of runnerStringOrNullDiagnostics) {
|
|
183
|
+
const value = diagnostics[key]
|
|
184
|
+
if (typeof value === `string` || value === null) {
|
|
185
|
+
sanitized[key] = value
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const key of runnerNumberDiagnostics) {
|
|
189
|
+
const value = diagnostics[key]
|
|
190
|
+
if (typeof value === `number` && Number.isFinite(value) && value >= 0) {
|
|
191
|
+
sanitized[key] = value
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return Object.keys(sanitized).length > 0
|
|
196
|
+
? (sanitized as RunnerClientDiagnostics)
|
|
197
|
+
: undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
103
200
|
async function registerRunner(
|
|
104
201
|
request: RunnersRouteRequest,
|
|
105
202
|
ctx: TenantContext
|
|
106
203
|
): Promise<Response> {
|
|
107
204
|
const parsed = routeBody<RegisterRunnerBody>(request)
|
|
108
|
-
const
|
|
109
|
-
|
|
205
|
+
const principal = requireAuthenticatedPrincipal(ctx)
|
|
206
|
+
const ownerPrincipal = parsed.owner_principal ?? principal.url
|
|
207
|
+
if (!ownerPrincipal) {
|
|
110
208
|
throw new ElectricAgentsError(
|
|
111
209
|
ErrCodeInvalidRequest,
|
|
112
|
-
`
|
|
210
|
+
`owner_principal is required when no authenticated principal is present`,
|
|
113
211
|
400
|
|
114
212
|
)
|
|
115
213
|
}
|
|
116
|
-
|
|
117
|
-
if (
|
|
214
|
+
const canonicalOwner = canonicalOwnerPrincipal(ownerPrincipal)
|
|
215
|
+
if (!canonicalOwner) {
|
|
216
|
+
throw new ElectricAgentsError(
|
|
217
|
+
ErrCodeInvalidRequest,
|
|
218
|
+
`owner_principal must be a valid principal URL (e.g. /principal/user%3Aalice), got: ${ownerPrincipal}`,
|
|
219
|
+
400
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
if (canonicalOwner !== principal.url) {
|
|
118
223
|
throw new ElectricAgentsError(
|
|
119
224
|
ErrCodeUnauthorized,
|
|
120
|
-
`
|
|
225
|
+
`owner_principal must match the authenticated principal`,
|
|
121
226
|
403
|
|
122
227
|
)
|
|
123
228
|
}
|
|
124
229
|
|
|
125
230
|
const runner = await ctx.entityManager.registry.createRunner({
|
|
126
231
|
id: parsed.id,
|
|
127
|
-
|
|
232
|
+
ownerPrincipal: canonicalOwner,
|
|
128
233
|
label: parsed.label,
|
|
129
234
|
kind: parsed.kind,
|
|
130
235
|
adminStatus: parsed.admin_status,
|
|
@@ -140,16 +245,27 @@ async function listRunners(
|
|
|
140
245
|
request: RunnersRouteRequest,
|
|
141
246
|
ctx: TenantContext
|
|
142
247
|
): Promise<Response> {
|
|
143
|
-
const
|
|
144
|
-
|
|
248
|
+
const principal = requireAuthenticatedPrincipal(ctx)
|
|
249
|
+
const requestedOwner = firstQueryValue(request.query.owner_principal)
|
|
250
|
+
const canonicalRequestedOwner = requestedOwner
|
|
251
|
+
? canonicalOwnerPrincipal(requestedOwner)
|
|
252
|
+
: undefined
|
|
253
|
+
if (requestedOwner && !canonicalRequestedOwner) {
|
|
254
|
+
throw new ElectricAgentsError(
|
|
255
|
+
ErrCodeInvalidRequest,
|
|
256
|
+
`owner_principal must be a valid principal URL (e.g. /principal/user%3Aalice), got: ${requestedOwner}`,
|
|
257
|
+
400
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
if (canonicalRequestedOwner && canonicalRequestedOwner !== principal.url) {
|
|
145
261
|
throw new ElectricAgentsError(
|
|
146
262
|
ErrCodeUnauthorized,
|
|
147
|
-
`
|
|
263
|
+
`owner_principal must match the authenticated principal`,
|
|
148
264
|
403
|
|
149
265
|
)
|
|
150
266
|
}
|
|
151
267
|
const runners = await ctx.entityManager.registry.listRunners({
|
|
152
|
-
|
|
268
|
+
ownerPrincipal: principal.url,
|
|
153
269
|
})
|
|
154
270
|
return json(runners)
|
|
155
271
|
}
|
|
@@ -159,7 +275,7 @@ async function getRunner(
|
|
|
159
275
|
ctx: TenantContext
|
|
160
276
|
): Promise<Response> {
|
|
161
277
|
const runner = await requireRunner(ctx, routeParam(request, `id`))
|
|
162
|
-
assertRunnerOwnerIfAuthenticated(ctx, runner.
|
|
278
|
+
assertRunnerOwnerIfAuthenticated(ctx, runner.owner_principal)
|
|
163
279
|
return json(runner)
|
|
164
280
|
}
|
|
165
281
|
|
|
@@ -168,16 +284,19 @@ async function heartbeat(
|
|
|
168
284
|
ctx: TenantContext
|
|
169
285
|
): Promise<Response> {
|
|
170
286
|
const runnerId = routeParam(request, `id`)
|
|
287
|
+
requireAuthenticatedPrincipal(ctx)
|
|
171
288
|
const existing = await requireRunner(ctx, runnerId)
|
|
172
|
-
assertRunnerOwnerIfAuthenticated(ctx, existing.
|
|
289
|
+
assertRunnerOwnerIfAuthenticated(ctx, existing.owner_principal)
|
|
173
290
|
const parsed = routeBody<HeartbeatBody>(request)
|
|
174
291
|
const runner = await ctx.entityManager.registry.heartbeatRunner({
|
|
175
292
|
runnerId,
|
|
293
|
+
ownerPrincipal: existing.owner_principal,
|
|
176
294
|
leaseMs: parsed.lease_ms,
|
|
177
295
|
wakeStreamOffset: parsed.wake_stream_offset ?? parsed.wakeStreamOffset,
|
|
178
296
|
livenessLeaseExpiresAt: parsed.liveness_lease_expires_at
|
|
179
297
|
? new Date(parsed.liveness_lease_expires_at)
|
|
180
298
|
: undefined,
|
|
299
|
+
diagnostics: sanitizeRunnerDiagnostics(parsed.diagnostics),
|
|
181
300
|
})
|
|
182
301
|
if (!runner) {
|
|
183
302
|
throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
|
|
@@ -205,8 +324,9 @@ async function setRunnerStatus(
|
|
|
205
324
|
adminStatus: `enabled` | `disabled`
|
|
206
325
|
): Promise<Response> {
|
|
207
326
|
const runnerId = routeParam(request, `id`)
|
|
327
|
+
requireAuthenticatedPrincipal(ctx)
|
|
208
328
|
const existing = await requireRunner(ctx, runnerId)
|
|
209
|
-
assertRunnerOwnerIfAuthenticated(ctx, existing.
|
|
329
|
+
assertRunnerOwnerIfAuthenticated(ctx, existing.owner_principal)
|
|
210
330
|
const runner = await ctx.entityManager.registry.setRunnerAdminStatus(
|
|
211
331
|
runnerId,
|
|
212
332
|
adminStatus
|
|
@@ -222,8 +342,9 @@ async function claimWake(
|
|
|
222
342
|
ctx: TenantContext
|
|
223
343
|
): Promise<Response> {
|
|
224
344
|
const runnerId = routeParam(request, `id`)
|
|
345
|
+
const principal = requireAuthenticatedPrincipal(ctx)
|
|
225
346
|
const runner = await requireRunner(ctx, runnerId)
|
|
226
|
-
if (
|
|
347
|
+
if (runner.owner_principal !== principal.url) {
|
|
227
348
|
throw new ElectricAgentsError(
|
|
228
349
|
ErrCodeUnauthorized,
|
|
229
350
|
`Runner claim requires the authenticated owner`,
|
|
@@ -297,10 +418,10 @@ async function requireRunner(ctx: TenantContext, runnerId: string) {
|
|
|
297
418
|
|
|
298
419
|
function assertRunnerOwnerIfAuthenticated(
|
|
299
420
|
ctx: TenantContext,
|
|
300
|
-
|
|
421
|
+
ownerPrincipal: string
|
|
301
422
|
): void {
|
|
302
|
-
|
|
303
|
-
if (
|
|
423
|
+
requireAuthenticatedPrincipal(ctx)
|
|
424
|
+
if (ownerPrincipal === ctx.principal.url) return
|
|
304
425
|
throw new ElectricAgentsError(
|
|
305
426
|
ErrCodeUnauthorized,
|
|
306
427
|
`Runner access requires the authenticated owner`,
|
|
@@ -308,6 +429,122 @@ function assertRunnerOwnerIfAuthenticated(
|
|
|
308
429
|
)
|
|
309
430
|
}
|
|
310
431
|
|
|
432
|
+
async function runnerHealth(
|
|
433
|
+
request: RunnersRouteRequest,
|
|
434
|
+
ctx: TenantContext
|
|
435
|
+
): Promise<Response> {
|
|
436
|
+
const runnerId = routeParam(request, `id`)
|
|
437
|
+
const runner = await requireRunner(ctx, runnerId)
|
|
438
|
+
assertRunnerOwnerIfAuthenticated(ctx, runner.owner_principal)
|
|
439
|
+
const runtimeDiagnostics =
|
|
440
|
+
await ctx.entityManager.registry.getRunnerDiagnostics(runnerId)
|
|
441
|
+
|
|
442
|
+
const now = Date.now()
|
|
443
|
+
const parsedLeaseExpiresAt = runtimeDiagnostics?.liveness_lease_expires_at
|
|
444
|
+
? new Date(runtimeDiagnostics.liveness_lease_expires_at).getTime()
|
|
445
|
+
: null
|
|
446
|
+
const leaseExpiresAt =
|
|
447
|
+
parsedLeaseExpiresAt !== null && Number.isFinite(parsedLeaseExpiresAt)
|
|
448
|
+
? parsedLeaseExpiresAt
|
|
449
|
+
: null
|
|
450
|
+
|
|
451
|
+
let livenessStatus: `online` | `offline` | `expired`
|
|
452
|
+
if (runner.admin_status === `disabled`) {
|
|
453
|
+
livenessStatus = `offline`
|
|
454
|
+
} else if (leaseExpiresAt !== null && leaseExpiresAt > now) {
|
|
455
|
+
livenessStatus = `online`
|
|
456
|
+
} else if (leaseExpiresAt !== null) {
|
|
457
|
+
livenessStatus = `expired`
|
|
458
|
+
} else {
|
|
459
|
+
livenessStatus = `offline`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const [activeClaims, dispatchStats] = await Promise.all([
|
|
463
|
+
ctx.entityManager.registry.getActiveClaimsForRunner(runnerId),
|
|
464
|
+
ctx.entityManager.registry.getDispatchStatsForRunner(runnerId),
|
|
465
|
+
])
|
|
466
|
+
|
|
467
|
+
const clientDiagnostics =
|
|
468
|
+
sanitizeRunnerDiagnostics(runtimeDiagnostics?.diagnostics) ?? null
|
|
469
|
+
const issues: Array<string> = []
|
|
470
|
+
let healthStatus: `healthy` | `degraded` | `unhealthy` = `healthy`
|
|
471
|
+
|
|
472
|
+
const escalate = (floor: `degraded` | `unhealthy`): void => {
|
|
473
|
+
if (floor === `unhealthy`) healthStatus = `unhealthy`
|
|
474
|
+
else if (healthStatus === `healthy`) healthStatus = `degraded`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (runner.admin_status === `disabled`) {
|
|
478
|
+
escalate(`unhealthy`)
|
|
479
|
+
issues.push(`Runner is disabled`)
|
|
480
|
+
}
|
|
481
|
+
if (livenessStatus === `expired`) {
|
|
482
|
+
escalate(`unhealthy`)
|
|
483
|
+
const ago = leaseExpiresAt ? Math.round((now - leaseExpiresAt) / 1000) : 0
|
|
484
|
+
issues.push(`Heartbeat lease expired ${ago}s ago`)
|
|
485
|
+
}
|
|
486
|
+
if (livenessStatus === `offline` && runner.admin_status === `enabled`) {
|
|
487
|
+
escalate(`degraded`)
|
|
488
|
+
issues.push(`Runner has never sent a heartbeat`)
|
|
489
|
+
}
|
|
490
|
+
if (clientDiagnostics) {
|
|
491
|
+
if (clientDiagnostics.stream_connected === false) {
|
|
492
|
+
escalate(`degraded`)
|
|
493
|
+
issues.push(`Client reports stream disconnected`)
|
|
494
|
+
}
|
|
495
|
+
if (clientDiagnostics.last_heartbeat_ok === false) {
|
|
496
|
+
escalate(`degraded`)
|
|
497
|
+
issues.push(`Client reports last heartbeat failed`)
|
|
498
|
+
}
|
|
499
|
+
if (
|
|
500
|
+
typeof clientDiagnostics.reconnect_count === `number` &&
|
|
501
|
+
clientDiagnostics.reconnect_count > 5
|
|
502
|
+
) {
|
|
503
|
+
escalate(`degraded`)
|
|
504
|
+
issues.push(
|
|
505
|
+
`Client has reconnected ${clientDiagnostics.reconnect_count} times`
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
} else if (runtimeDiagnostics?.last_seen_at) {
|
|
509
|
+
escalate(`degraded`)
|
|
510
|
+
issues.push(`No client diagnostics available`)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const body: RunnerHealthResponse = {
|
|
514
|
+
runner: {
|
|
515
|
+
id: runner.id,
|
|
516
|
+
admin_status: runner.admin_status,
|
|
517
|
+
liveness_status: livenessStatus,
|
|
518
|
+
lease_expires_at:
|
|
519
|
+
leaseExpiresAt !== null
|
|
520
|
+
? (runtimeDiagnostics?.liveness_lease_expires_at ?? null)
|
|
521
|
+
: null,
|
|
522
|
+
lease_remaining_ms:
|
|
523
|
+
leaseExpiresAt !== null ? Math.max(0, leaseExpiresAt - now) : null,
|
|
524
|
+
wake_stream: runner.wake_stream,
|
|
525
|
+
wake_stream_offset: runtimeDiagnostics?.wake_stream_offset ?? null,
|
|
526
|
+
last_seen_at: runtimeDiagnostics?.last_seen_at ?? null,
|
|
527
|
+
created_at: runner.created_at,
|
|
528
|
+
},
|
|
529
|
+
client: clientDiagnostics,
|
|
530
|
+
claims: {
|
|
531
|
+
active_count: activeClaims.length,
|
|
532
|
+
active: activeClaims.map((c) => ({
|
|
533
|
+
consumer_id: c.consumer_id,
|
|
534
|
+
epoch: c.epoch,
|
|
535
|
+
entity_url: c.entity_url,
|
|
536
|
+
stream_path: c.stream_path,
|
|
537
|
+
claimed_at: c.claimed_at,
|
|
538
|
+
last_heartbeat_at: c.last_heartbeat_at ?? null,
|
|
539
|
+
lease_expires_at: c.lease_expires_at ?? null,
|
|
540
|
+
})),
|
|
541
|
+
},
|
|
542
|
+
dispatch: dispatchStats,
|
|
543
|
+
health: { status: healthStatus, issues },
|
|
544
|
+
}
|
|
545
|
+
return json(body)
|
|
546
|
+
}
|
|
547
|
+
|
|
311
548
|
async function notificationFromClaim(
|
|
312
549
|
ctx: TenantContext,
|
|
313
550
|
input: {
|
package/src/runtime.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { SchemaValidator } from './electric-agents/schema-validator.js'
|
|
12
12
|
import { serverLog } from './utils/log.js'
|
|
13
13
|
import { isPermanentElectricAgentsError } from './scheduler.js'
|
|
14
|
-
import { StreamClient
|
|
14
|
+
import { StreamClient } from './stream-client.js'
|
|
15
15
|
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
16
16
|
import type { DrizzleDB } from './db/index.js'
|
|
17
17
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
@@ -63,12 +63,9 @@ export class ElectricAgentsTenantRuntime {
|
|
|
63
63
|
if (options.streamClient) {
|
|
64
64
|
this.streamClient = options.streamClient
|
|
65
65
|
} else if (options.durableStreamsUrl) {
|
|
66
|
-
this.streamClient = new StreamClient(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}),
|
|
70
|
-
{ bearer: options.durableStreamsBearer }
|
|
71
|
-
)
|
|
66
|
+
this.streamClient = new StreamClient(options.durableStreamsUrl, {
|
|
67
|
+
bearer: options.durableStreamsBearer,
|
|
68
|
+
})
|
|
72
69
|
} else {
|
|
73
70
|
throw new Error(`Either durableStreamsUrl or streamClient is required`)
|
|
74
71
|
}
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { createDb, runMigrations } from './db/index.js'
|
|
10
10
|
import { ossServerRouter } from './routing/oss-server-router.js'
|
|
11
11
|
import { startStandaloneAgentsRuntime } from './standalone-runtime.js'
|
|
12
|
-
import { StreamClient
|
|
12
|
+
import { StreamClient } from './stream-client.js'
|
|
13
13
|
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
14
14
|
import { getDevPrincipal, getPrincipalFromRequest } from './principal.js'
|
|
15
15
|
import { apiError } from './electric-agents-http.js'
|
|
@@ -120,6 +120,16 @@ function createMockAgentBootstrap(options: {
|
|
|
120
120
|
return { runtime, registry }
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function durableStreamTestServerBackendUrl(origin: string): string {
|
|
124
|
+
// DurableStreamTestServer.start() returns the HTTP origin, while the
|
|
125
|
+
// reference server's stream backend is mounted under /v1/stream.
|
|
126
|
+
// User-provided durableStreamsUrl values are already backend prefixes and
|
|
127
|
+
// are passed through unchanged.
|
|
128
|
+
const url = new URL(origin)
|
|
129
|
+
url.pathname = `${url.pathname.replace(/\/+$/, ``)}/v1/stream`
|
|
130
|
+
return url.toString().replace(/\/+$/, ``)
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
export class ElectricAgentsServer {
|
|
124
134
|
private server?: Server
|
|
125
135
|
private electricAgentsManager?: StartedStandaloneAgentsRuntime[`manager`]
|
|
@@ -143,12 +153,9 @@ export class ElectricAgentsServer {
|
|
|
143
153
|
}
|
|
144
154
|
this.options = options
|
|
145
155
|
this.streamClient = options.durableStreamsUrl
|
|
146
|
-
? new StreamClient(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}),
|
|
150
|
-
{ bearer: options.durableStreamsBearer }
|
|
151
|
-
)
|
|
156
|
+
? new StreamClient(options.durableStreamsUrl, {
|
|
157
|
+
bearer: options.durableStreamsBearer,
|
|
158
|
+
})
|
|
152
159
|
: null!
|
|
153
160
|
}
|
|
154
161
|
|
|
@@ -185,13 +192,11 @@ export class ElectricAgentsServer {
|
|
|
185
192
|
serverLog.info(
|
|
186
193
|
`[agent-server] durable streams server started at ${streamsUrl}`
|
|
187
194
|
)
|
|
188
|
-
this.options.durableStreamsUrl =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
{ bearer: this.options.durableStreamsBearer }
|
|
194
|
-
)
|
|
195
|
+
this.options.durableStreamsUrl =
|
|
196
|
+
durableStreamTestServerBackendUrl(streamsUrl)
|
|
197
|
+
this.streamClient = new StreamClient(this.options.durableStreamsUrl, {
|
|
198
|
+
bearer: this.options.durableStreamsBearer,
|
|
199
|
+
})
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
this.streamsAgent = new Agent({
|
|
@@ -404,7 +409,7 @@ export class ElectricAgentsServer {
|
|
|
404
409
|
principal,
|
|
405
410
|
publicUrl: this.publicUrl,
|
|
406
411
|
localUrl: this._url,
|
|
407
|
-
durableStreamsUrl: this.
|
|
412
|
+
durableStreamsUrl: this.options.durableStreamsUrl!,
|
|
408
413
|
durableStreamsBearer: this.options.durableStreamsBearer,
|
|
409
414
|
durableStreamsRouting: this.options.durableStreamsRouting,
|
|
410
415
|
durableStreamsDispatcher: this.streamsAgent,
|
|
@@ -4,7 +4,7 @@ import { EntityBridgeManager } from './entity-bridge-manager.js'
|
|
|
4
4
|
import { serverLog } from './utils/log.js'
|
|
5
5
|
import { ElectricAgentsTenantRuntime } from './runtime.js'
|
|
6
6
|
import { Scheduler } from './scheduler.js'
|
|
7
|
-
import { StreamClient
|
|
7
|
+
import { StreamClient } from './stream-client.js'
|
|
8
8
|
import { TagStreamOutboxDrainer } from './tag-stream-outbox-drainer.js'
|
|
9
9
|
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
10
10
|
import { WakeRegistry } from './wake-registry.js'
|
|
@@ -57,12 +57,9 @@ export async function startStandaloneAgentsRuntime(
|
|
|
57
57
|
const streamClient =
|
|
58
58
|
options.streamClient ??
|
|
59
59
|
(options.durableStreamsUrl
|
|
60
|
-
? new StreamClient(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}),
|
|
64
|
-
{ bearer: options.durableStreamsBearer }
|
|
65
|
-
)
|
|
60
|
+
? new StreamClient(options.durableStreamsUrl, {
|
|
61
|
+
bearer: options.durableStreamsBearer,
|
|
62
|
+
})
|
|
66
63
|
: undefined)
|
|
67
64
|
if (!streamClient) {
|
|
68
65
|
throw new Error(`Either durableStreamsUrl or streamClient is required`)
|
package/src/stream-client.ts
CHANGED
|
@@ -14,8 +14,6 @@ export interface StreamClientOptions {
|
|
|
14
14
|
bearer?: DurableStreamsBearerProvider
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
type DurableStreamsUrlScope = `service` | `stream-root`
|
|
18
|
-
|
|
19
17
|
export interface StreamAppendResult {
|
|
20
18
|
offset: string
|
|
21
19
|
}
|
|
@@ -34,15 +32,6 @@ export interface WaitForMessagesResult {
|
|
|
34
32
|
timedOut: boolean
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
export interface ConsumerStateResponse {
|
|
38
|
-
state: string
|
|
39
|
-
wake_id?: string | null
|
|
40
|
-
webhook?: {
|
|
41
|
-
wake_id?: string | null
|
|
42
|
-
subscription_id?: string
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
35
|
export interface SubscriptionStreamInfo {
|
|
47
36
|
path: string
|
|
48
37
|
tail_offset?: string
|
|
@@ -131,6 +120,16 @@ export async function applyDurableStreamsBearer(
|
|
|
131
120
|
}
|
|
132
121
|
}
|
|
133
122
|
|
|
123
|
+
function appendPathToBaseUrl(baseUrl: string, path: string): string {
|
|
124
|
+
const url = new URL(baseUrl)
|
|
125
|
+
const basePath = url.pathname.replace(/\/+$/, ``)
|
|
126
|
+
const childPath = path.replace(/^\/+/, ``)
|
|
127
|
+
url.pathname = childPath
|
|
128
|
+
? `${basePath === `/` ? `` : basePath}/${childPath}`
|
|
129
|
+
: basePath || `/`
|
|
130
|
+
return url.toString().replace(/\/+$/, ``)
|
|
131
|
+
}
|
|
132
|
+
|
|
134
133
|
function durableStreamsBearerHeaders(
|
|
135
134
|
bearer: DurableStreamsBearerProvider | undefined
|
|
136
135
|
): HeadersRecord | undefined {
|
|
@@ -141,33 +140,6 @@ function durableStreamsBearerHeaders(
|
|
|
141
140
|
}
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
export function durableStreamsServiceUrl(
|
|
145
|
-
baseUrl: string,
|
|
146
|
-
serviceId: string,
|
|
147
|
-
options: { scope?: DurableStreamsUrlScope } = {}
|
|
148
|
-
): string {
|
|
149
|
-
const url = new URL(baseUrl)
|
|
150
|
-
if (/\/v1\/streams\/[^/]+\/?$/.test(url.pathname)) {
|
|
151
|
-
return baseUrl.replace(/\/+$/, ``)
|
|
152
|
-
}
|
|
153
|
-
if (/\/v1\/stream\/[^/]+\/?$/.test(url.pathname)) {
|
|
154
|
-
return baseUrl.replace(/\/+$/, ``)
|
|
155
|
-
}
|
|
156
|
-
const scope = options.scope ?? `service`
|
|
157
|
-
const encodedServiceId = encodeURIComponent(serviceId)
|
|
158
|
-
const path = url.pathname.replace(/\/+$/, ``) || `/`
|
|
159
|
-
if (path.endsWith(`/v1/streams`)) {
|
|
160
|
-
url.pathname = `${path}/${encodedServiceId}`
|
|
161
|
-
} else if (path.endsWith(`/v1/stream`)) {
|
|
162
|
-
url.pathname = scope === `service` ? `${path}/${encodedServiceId}` : path
|
|
163
|
-
} else if (scope === `stream-root`) {
|
|
164
|
-
url.pathname = `${path === `/` ? `` : path}/v1/stream`
|
|
165
|
-
} else {
|
|
166
|
-
url.pathname = `${path === `/` ? `` : path}/v1/stream/${encodedServiceId}`
|
|
167
|
-
}
|
|
168
|
-
return url.toString().replace(/\/+$/, ``)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
143
|
function isNotFoundError(err: unknown): boolean {
|
|
172
144
|
return (
|
|
173
145
|
(err instanceof DurableStreamError && err.code === ErrCodeNotFound) ||
|
|
@@ -201,7 +173,7 @@ export class StreamClient {
|
|
|
201
173
|
) {}
|
|
202
174
|
|
|
203
175
|
private streamUrl(path: string): string {
|
|
204
|
-
return
|
|
176
|
+
return appendPathToBaseUrl(this.baseUrl, path)
|
|
205
177
|
}
|
|
206
178
|
|
|
207
179
|
private streamHeaders(): HeadersRecord | undefined {
|
|
@@ -228,9 +200,10 @@ export class StreamClient {
|
|
|
228
200
|
}
|
|
229
201
|
|
|
230
202
|
private subscriptionUrl(subscriptionId: string): string {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
203
|
+
return appendPathToBaseUrl(
|
|
204
|
+
this.baseUrl,
|
|
205
|
+
`/__ds/subscriptions/${encodeURIComponent(subscriptionId)}`
|
|
206
|
+
)
|
|
234
207
|
}
|
|
235
208
|
|
|
236
209
|
private subscriptionChildUrl(
|
|
@@ -270,7 +243,7 @@ export class StreamClient {
|
|
|
270
243
|
})
|
|
271
244
|
const headers: Record<string, string> = {
|
|
272
245
|
'content-type': `application/json`,
|
|
273
|
-
'Stream-Forked-From': sourcePath,
|
|
246
|
+
'Stream-Forked-From': new URL(this.streamUrl(sourcePath)).pathname,
|
|
274
247
|
}
|
|
275
248
|
injectTraceHeaders(headers)
|
|
276
249
|
|
|
@@ -815,20 +788,4 @@ export class StreamClient {
|
|
|
815
788
|
JSON.parse(text) as SubscriptionResponse
|
|
816
789
|
)
|
|
817
790
|
}
|
|
818
|
-
|
|
819
|
-
async getConsumerState(
|
|
820
|
-
consumerId: string
|
|
821
|
-
): Promise<ConsumerStateResponse | null> {
|
|
822
|
-
const res = await fetch(
|
|
823
|
-
`${this.baseUrl}/consumers/${encodeURIComponent(consumerId)}`,
|
|
824
|
-
{ method: `GET`, headers: await this.requestHeaders() }
|
|
825
|
-
)
|
|
826
|
-
if (res.status === 404) return null
|
|
827
|
-
if (!res.ok) {
|
|
828
|
-
throw new Error(
|
|
829
|
-
`Consumer query failed: ${res.status} ${await res.text()}`
|
|
830
|
-
)
|
|
831
|
-
}
|
|
832
|
-
return res.json() as Promise<ConsumerStateResponse>
|
|
833
|
-
}
|
|
834
791
|
}
|