@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.
@@ -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
- owner_user_id: Type.Optional(Type.String()),
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 ownerUserId = parsed.owner_user_id ?? ctx.principal?.key
109
- if (!ownerUserId) {
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
- `owner_user_id is required when no authenticated user is present`,
210
+ `owner_principal is required when no authenticated principal is present`,
113
211
  400
114
212
  )
115
213
  }
116
-
117
- if (ctx.principal && ownerUserId !== ctx.principal.key) {
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
- `owner_user_id must match the authenticated user`,
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
- ownerUserId,
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 requestedOwner = firstQueryValue(request.query.owner_user_id)
144
- if (ctx.principal && requestedOwner && requestedOwner !== ctx.principal.key) {
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
- `owner_user_id must match the authenticated user`,
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
- ownerUserId: ctx.principal?.key ?? requestedOwner,
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.owner_user_id)
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.owner_user_id)
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.owner_user_id)
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 (ctx.principal && runner.owner_user_id !== ctx.principal.key) {
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
- ownerUserId: string
421
+ ownerPrincipal: string
301
422
  ): void {
302
- if (!ctx.principal) return
303
- if (ownerUserId === ctx.principal.key) return
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, durableStreamsServiceUrl } from './stream-client.js'
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
- durableStreamsServiceUrl(options.durableStreamsUrl, this.serviceId, {
68
- scope: `stream-root`,
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, durableStreamsServiceUrl } from './stream-client.js'
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
- durableStreamsServiceUrl(options.durableStreamsUrl, this.tenantId, {
148
- scope: `stream-root`,
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 = streamsUrl
189
- this.streamClient = new StreamClient(
190
- durableStreamsServiceUrl(streamsUrl, this.tenantId, {
191
- scope: `stream-root`,
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.streamClient.baseUrl,
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, durableStreamsServiceUrl } from './stream-client.js'
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
- durableStreamsServiceUrl(options.durableStreamsUrl, serviceId, {
62
- scope: `stream-root`,
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`)
@@ -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 `${this.baseUrl}${path}`
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
- const url = new URL(this.baseUrl)
232
- url.pathname = `${url.pathname.replace(/\/+$/, ``)}/__ds/subscriptions/${encodeURIComponent(subscriptionId)}`
233
- return url.toString()
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
  }