@electric-ax/agents-server 0.4.2 → 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.
@@ -43,6 +43,7 @@ __export(schema_exports, {
43
43
  entityDispatchState: () => entityDispatchState,
44
44
  entityManifestSources: () => entityManifestSources,
45
45
  entityTypes: () => entityTypes,
46
+ runnerRuntimeDiagnostics: () => runnerRuntimeDiagnostics,
46
47
  runners: () => runners,
47
48
  scheduledTasks: () => scheduledTasks,
48
49
  subscriptionWebhooks: () => subscriptionWebhooks,
@@ -111,25 +112,35 @@ const users = pgTable(`users`, {
111
112
  const runners = pgTable(`runners`, {
112
113
  tenantId: text(`tenant_id`).notNull().default(`default`),
113
114
  id: text(`id`).notNull(),
114
- ownerUserId: text(`owner_user_id`).notNull(),
115
+ ownerPrincipal: text(`owner_principal`).notNull(),
115
116
  label: text(`label`).notNull(),
116
117
  kind: text(`kind`).notNull().default(`local`),
117
118
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
118
119
  wakeStream: text(`wake_stream`).notNull(),
119
- wakeStreamOffset: text(`wake_stream_offset`),
120
- lastSeenAt: timestamp(`last_seen_at`, { withTimezone: true }),
121
- livenessLeaseExpiresAt: timestamp(`liveness_lease_expires_at`, { withTimezone: true }),
122
120
  createdAt: timestamp(`created_at`, { withTimezone: true }).notNull().defaultNow(),
123
121
  updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
124
122
  }, (table) => [
125
123
  primaryKey({ columns: [table.tenantId, table.id] }),
126
124
  unique(`uq_runners_wake_stream`).on(table.tenantId, table.wakeStream),
127
- index(`idx_runners_owner_user_id`).on(table.tenantId, table.ownerUserId),
125
+ index(`idx_runners_owner_principal`).on(table.tenantId, table.ownerPrincipal),
128
126
  index(`idx_runners_admin_status`).on(table.tenantId, table.adminStatus),
129
- index(`idx_runners_liveness_lease_expires_at`).on(table.tenantId, table.livenessLeaseExpiresAt),
130
127
  check(`chk_runners_kind`, sql`${table.kind} IN ('local', 'cloud-worker', 'sandbox', 'ci', 'server')`),
131
128
  check(`chk_runners_admin_status`, sql`${table.adminStatus} IN ('enabled', 'disabled')`)
132
129
  ]);
130
+ const runnerRuntimeDiagnostics = pgTable(`runner_runtime_diagnostics`, {
131
+ tenantId: text(`tenant_id`).notNull().default(`default`),
132
+ runnerId: text(`runner_id`).notNull(),
133
+ ownerPrincipal: text(`owner_principal`).notNull(),
134
+ wakeStreamOffset: text(`wake_stream_offset`),
135
+ lastSeenAt: timestamp(`last_seen_at`, { withTimezone: true }).notNull(),
136
+ livenessLeaseExpiresAt: timestamp(`liveness_lease_expires_at`, { withTimezone: true }).notNull(),
137
+ diagnostics: jsonb(`diagnostics`),
138
+ updatedAt: timestamp(`updated_at`, { withTimezone: true }).notNull().defaultNow()
139
+ }, (table) => [
140
+ primaryKey({ columns: [table.tenantId, table.runnerId] }),
141
+ index(`idx_runner_runtime_diagnostics_owner`).on(table.tenantId, table.ownerPrincipal),
142
+ index(`idx_runner_runtime_diagnostics_liveness`).on(table.tenantId, table.livenessLeaseExpiresAt)
143
+ ]);
133
144
  const entityDispatchState = pgTable(`entity_dispatch_state`, {
134
145
  tenantId: text(`tenant_id`).notNull().default(`default`),
135
146
  entityUrl: text(`entity_url`).notNull(),
@@ -331,76 +342,6 @@ async function runMigrations(postgresUrl) {
331
342
  await migrationClient.end();
332
343
  }
333
344
 
334
- //#endregion
335
- //#region src/routing/tenant-stream-paths.ts
336
- function withoutLeadingSlash(path$1) {
337
- return path$1.replace(/^\/+/, ``);
338
- }
339
- function withLeadingSlash(path$1) {
340
- return path$1.startsWith(`/`) ? path$1 : `/${path$1}`;
341
- }
342
- function prefixTenantStreamPath(path$1, tenantId) {
343
- const normalized = withoutLeadingSlash(path$1);
344
- if (!normalized || normalized === tenantId) return tenantId;
345
- if (normalized.startsWith(`${tenantId}/`)) return normalized;
346
- return `${tenantId}/${normalized}`;
347
- }
348
- function stripTenantStreamPrefix(path$1, tenantId) {
349
- const normalized = withoutLeadingSlash(path$1);
350
- if (normalized === tenantId) return ``;
351
- if (normalized.startsWith(`${tenantId}/`)) return normalized.slice(tenantId.length + 1);
352
- return normalized;
353
- }
354
-
355
- //#endregion
356
- //#region src/routing/durable-streams-routing-adapter.ts
357
- function appendSearch(target, source) {
358
- target.search = source.search;
359
- return target;
360
- }
361
- function removeServiceQuery(target) {
362
- target.searchParams.delete(`service`);
363
- return target;
364
- }
365
- function logicalStreamPathFromRequest(requestUrl, serviceId) {
366
- const incomingUrl = new URL(requestUrl, `http://localhost`);
367
- const segments = incomingUrl.pathname.split(`/`).filter(Boolean);
368
- if (segments[0] === `v1` && segments[1] === `stream`) return {
369
- incomingUrl,
370
- streamPath: segments.length > 2 ? `/${segments.slice(3).join(`/`)}` : `/`
371
- };
372
- return {
373
- incomingUrl,
374
- streamPath: incomingUrl.pathname || `/${serviceId}`
375
- };
376
- }
377
- function backendStreamUrl(input, backendStreamPath) {
378
- const path$1 = backendStreamPath.replace(/^\/+/, ``);
379
- const target = new URL(`/v1/stream/${path$1}`, input.durableStreamsUrl);
380
- return target;
381
- }
382
- function streamMetaUrlWithoutService(input) {
383
- const incomingUrl = new URL(input.requestUrl, `http://localhost`);
384
- return removeServiceQuery(appendSearch(new URL(incomingUrl.pathname, input.durableStreamsUrl), incomingUrl));
385
- }
386
- const pathPrefixedSingleTenantDurableStreamsRoutingAdapter = {
387
- streamUrl(input) {
388
- const { incomingUrl, streamPath } = logicalStreamPathFromRequest(input.requestUrl, input.serviceId);
389
- const target = backendStreamUrl(input, prefixTenantStreamPath(streamPath, input.serviceId));
390
- return removeServiceQuery(appendSearch(target, incomingUrl));
391
- },
392
- streamMetaUrl: streamMetaUrlWithoutService,
393
- toBackendStreamPath(serviceId, streamPath) {
394
- return prefixTenantStreamPath(streamPath, serviceId);
395
- },
396
- toRuntimeStreamPath(serviceId, streamPath) {
397
- return stripTenantStreamPrefix(streamPath, serviceId);
398
- }
399
- };
400
- function resolveDurableStreamsRoutingAdapter(adapter) {
401
- return adapter ?? pathPrefixedSingleTenantDurableStreamsRoutingAdapter;
402
- }
403
-
404
345
  //#endregion
405
346
  //#region src/electric-agents-http.ts
406
347
  function apiError(status$1, code, message, details) {
@@ -501,6 +442,38 @@ function electricUrlWithPath(electricUrl, path$1) {
501
442
  return target;
502
443
  }
503
444
 
445
+ //#endregion
446
+ //#region src/routing/durable-streams-routing-adapter.ts
447
+ function appendSearch(target, source) {
448
+ source.searchParams.forEach((value, key) => {
449
+ if (key !== `service`) target.searchParams.append(key, value);
450
+ });
451
+ return target;
452
+ }
453
+ function withoutTrailingSlash(pathname) {
454
+ return pathname.replace(/\/+$/, ``) || `/`;
455
+ }
456
+ function appendRequestPathToStreamRoot(input) {
457
+ const incomingUrl = new URL(input.requestUrl, `http://localhost`);
458
+ const path$1 = incomingUrl.pathname.replace(/^\/+/, ``);
459
+ const target = new URL(input.durableStreamsUrl);
460
+ target.pathname = path$1 ? `${withoutTrailingSlash(target.pathname)}/${path$1}` : withoutTrailingSlash(target.pathname);
461
+ return appendSearch(target, incomingUrl);
462
+ }
463
+ const streamRootDurableStreamsRoutingAdapter = {
464
+ streamUrl: appendRequestPathToStreamRoot,
465
+ controlUrl: appendRequestPathToStreamRoot,
466
+ toBackendStreamPath(_serviceId, streamPath) {
467
+ return streamPath.replace(/^\/+/, ``);
468
+ },
469
+ toRuntimeStreamPath(_serviceId, streamPath) {
470
+ return streamPath.replace(/^\/+/, ``);
471
+ }
472
+ };
473
+ function resolveDurableStreamsRoutingAdapter(adapter, _durableStreamsUrl) {
474
+ return adapter ?? streamRootDurableStreamsRoutingAdapter;
475
+ }
476
+
504
477
  //#endregion
505
478
  //#region src/tracing.ts
506
479
  const tracer = trace.getTracer(`agent-server`);
@@ -575,16 +548,17 @@ async function applyDurableStreamsBearer(headers, bearer, opts = {}) {
575
548
  const value = await resolveDurableStreamsBearer(bearer);
576
549
  if (value) headers.set(`authorization`, value);
577
550
  }
551
+ function appendPathToBaseUrl(baseUrl, path$1) {
552
+ const url = new URL(baseUrl);
553
+ const basePath = url.pathname.replace(/\/+$/, ``);
554
+ const childPath = path$1.replace(/^\/+/, ``);
555
+ url.pathname = childPath ? `${basePath === `/` ? `` : basePath}/${childPath}` : basePath || `/`;
556
+ return url.toString().replace(/\/+$/, ``);
557
+ }
578
558
  function durableStreamsBearerHeaders(bearer) {
579
559
  if (!bearer) return void 0;
580
560
  return { authorization: async () => await resolveDurableStreamsBearer(bearer) ?? `` };
581
561
  }
582
- function durableStreamsServiceUrl(baseUrl, serviceId) {
583
- const url = new URL(baseUrl);
584
- if (/^\/v1\/stream\/[^/]+\/?$/.test(url.pathname)) return baseUrl.replace(/\/+$/, ``);
585
- const base = baseUrl.replace(/\/+$/, ``);
586
- return `${base}/v1/stream/${encodeURIComponent(serviceId)}`;
587
- }
588
562
  function isNotFoundError(err) {
589
563
  return err instanceof DurableStreamError && err.code === ErrCodeNotFound || err instanceof FetchError && err.status === 404;
590
564
  }
@@ -606,7 +580,7 @@ var StreamClient = class {
606
580
  this.options = options;
607
581
  }
608
582
  streamUrl(path$1) {
609
- return `${this.baseUrl}${path$1}`;
583
+ return appendPathToBaseUrl(this.baseUrl, path$1);
610
584
  }
611
585
  streamHeaders() {
612
586
  return durableStreamsBearerHeaders(this.options.bearer);
@@ -616,35 +590,14 @@ var StreamClient = class {
616
590
  await applyDurableStreamsBearer(headers, this.options.bearer, { overwrite: opts.overwriteBearer });
617
591
  return headers;
618
592
  }
619
- subscriptionServiceId() {
620
- const url = new URL(this.baseUrl);
621
- const match = /^(.*)\/v1\/stream\/([^/]+)\/?$/.exec(url.pathname);
622
- return match ? decodeURIComponent(match[2]) : null;
623
- }
624
593
  backendSubscriptionPath(path$1) {
625
- const normalized = normalizeSubscriptionPath(path$1);
626
- const serviceId = this.subscriptionServiceId();
627
- if (!serviceId) return normalized;
628
- if (normalized === serviceId || normalized.startsWith(`${serviceId}/`)) return normalized;
629
- return `${serviceId}/${normalized}`;
594
+ return normalizeSubscriptionPath(path$1);
630
595
  }
631
596
  runtimeSubscriptionPath(path$1) {
632
- const normalized = normalizeSubscriptionPath(path$1);
633
- const serviceId = this.subscriptionServiceId();
634
- if (!serviceId) return normalized;
635
- return normalized.startsWith(`${serviceId}/`) ? normalized.slice(serviceId.length + 1) : normalized;
597
+ return normalizeSubscriptionPath(path$1);
636
598
  }
637
599
  subscriptionUrl(subscriptionId) {
638
- const url = new URL(this.baseUrl);
639
- const match = /^(.*)\/v1\/stream\/([^/]+)\/?$/.exec(url.pathname);
640
- if (match) {
641
- const [, prefix = ``, serviceId] = match;
642
- url.pathname = `${prefix}/v1/stream-meta/subscriptions/${encodeURIComponent(subscriptionId)}`;
643
- url.searchParams.set(`service`, decodeURIComponent(serviceId));
644
- return url.toString();
645
- }
646
- url.pathname = `${url.pathname.replace(/\/+$/, ``)}/v1/stream-meta/subscriptions/${encodeURIComponent(subscriptionId)}`;
647
- return url.toString();
600
+ return appendPathToBaseUrl(this.baseUrl, `/__ds/subscriptions/${encodeURIComponent(subscriptionId)}`);
648
601
  }
649
602
  subscriptionChildUrl(subscriptionId, ...segments) {
650
603
  const url = new URL(this.subscriptionUrl(subscriptionId));
@@ -673,7 +626,7 @@ var StreamClient = class {
673
626
  });
674
627
  const headers = {
675
628
  "content-type": `application/json`,
676
- "Stream-Forked-From": sourcePath
629
+ "Stream-Forked-From": new URL(this.streamUrl(sourcePath)).pathname
677
630
  };
678
631
  injectTraceHeaders(headers);
679
632
  const response = await fetch(this.streamUrl(path$1), {
@@ -1026,15 +979,6 @@ var StreamClient = class {
1026
979
  if (!text$1.trim()) return {};
1027
980
  return this.subscriptionResponseBody(JSON.parse(text$1));
1028
981
  }
1029
- async getConsumerState(consumerId) {
1030
- const res = await fetch(`${this.baseUrl}/consumers/${encodeURIComponent(consumerId)}`, {
1031
- method: `GET`,
1032
- headers: await this.requestHeaders()
1033
- });
1034
- if (res.status === 404) return null;
1035
- if (!res.ok) throw new Error(`Consumer query failed: ${res.status} ${await res.text()}`);
1036
- return res.json();
1037
- }
1038
982
  };
1039
983
 
1040
984
  //#endregion
@@ -1097,8 +1041,11 @@ function buildElectricProxyTarget(options) {
1097
1041
  target.searchParams.set(`columns`, `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`);
1098
1042
  applyTenantShapeWhere(target, options.tenantId);
1099
1043
  } else if (table === `runners`) {
1100
- target.searchParams.set(`columns`, `"tenant_id","id","owner_user_id","label","kind","admin_status","wake_stream","wake_stream_offset","last_seen_at","liveness_lease_expires_at","created_at","updated_at"`);
1101
- applyTenantShapeWhere(target, options.tenantId);
1044
+ target.searchParams.set(`columns`, `"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`);
1045
+ applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral$2(options.principalUrl ?? ``)}`]);
1046
+ } else if (table === `runner_runtime_diagnostics`) {
1047
+ target.searchParams.set(`columns`, `"tenant_id","runner_id","owner_principal","wake_stream_offset","last_seen_at","liveness_lease_expires_at","diagnostics","updated_at"`);
1048
+ applyTenantShapeWhere(target, options.tenantId, [`owner_principal = ${sqlStringLiteral$2(options.principalUrl ?? ``)}`]);
1102
1049
  } else if (table === `entity_dispatch_state`) {
1103
1050
  target.searchParams.set(`columns`, `"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`);
1104
1051
  applyTenantShapeWhere(target, options.tenantId);
@@ -1112,13 +1059,13 @@ function buildElectricProxyTarget(options) {
1112
1059
  return target;
1113
1060
  }
1114
1061
  async function forwardFetchRequest(options) {
1115
- const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting);
1062
+ const routingAdapter = resolveDurableStreamsRoutingAdapter(options.durableStreamsRouting, options.durableStreamsUrl);
1116
1063
  const routingInput = {
1117
1064
  durableStreamsUrl: options.durableStreamsUrl,
1118
1065
  serviceId: options.serviceId,
1119
1066
  requestUrl: options.request.url
1120
1067
  };
1121
- const upstreamUrl = options.route === `stream-meta` ? routingAdapter.streamMetaUrl(routingInput) : routingAdapter.streamUrl(routingInput);
1068
+ const upstreamUrl = options.route === `control` ? routingAdapter.controlUrl(routingInput) : routingAdapter.streamUrl(routingInput);
1122
1069
  const headers = new Headers(options.request.headers);
1123
1070
  if (options.durableStreamsBearerMode !== `none`) await applyDurableStreamsBearer(headers, options.durableStreamsBearer, { overwrite: options.durableStreamsBearerMode !== `if-missing` });
1124
1071
  const init = {
@@ -1144,8 +1091,8 @@ function decodeJsonObject(body) {
1144
1091
  } catch {}
1145
1092
  return null;
1146
1093
  }
1147
- function applyTenantShapeWhere(target, tenantId) {
1148
- const tenantWhere = `tenant_id = ${sqlStringLiteral$2(tenantId)}`;
1094
+ function applyTenantShapeWhere(target, tenantId, extraConditions = []) {
1095
+ const tenantWhere = [`tenant_id = ${sqlStringLiteral$2(tenantId)}`, ...extraConditions].join(` AND `);
1149
1096
  const existingWhere = target.searchParams.get(`where`);
1150
1097
  target.searchParams.set(`where`, existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere);
1151
1098
  }
@@ -1434,8 +1381,21 @@ function isLoopbackHostname(hostname) {
1434
1381
  //#endregion
1435
1382
  //#region src/routing/durable-streams-router.ts
1436
1383
  const subscriptionProxyBodySchema = Type.Object({ webhook: Type.Optional(Type.Object({ url: Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
1384
+ const subscriptionControlActions = [
1385
+ `callback`,
1386
+ `claim`,
1387
+ `ack`,
1388
+ `release`
1389
+ ];
1437
1390
  const durableStreamsRouter = Router();
1438
- durableStreamsRouter.all(`/v1/stream-meta/subscriptions/*`, subscriptionProxy);
1391
+ durableStreamsRouter.put(`/__ds/subscriptions/:subscriptionId`, putSubscriptionBase);
1392
+ durableStreamsRouter.get(`/__ds/subscriptions/:subscriptionId`, getSubscriptionBase);
1393
+ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscriptionBase);
1394
+ durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
1395
+ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
1396
+ for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
1397
+ durableStreamsRouter.all(`/__ds`, controlPassThrough);
1398
+ durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
1439
1399
  durableStreamsRouter.post(`*`, streamAppend);
1440
1400
  durableStreamsRouter.all(`*`, proxyPassThrough);
1441
1401
  function bodyFromBytes$1(body) {
@@ -1448,7 +1408,7 @@ function responseFromUpstream$1(response, body) {
1448
1408
  headers: responseHeaders(response)
1449
1409
  });
1450
1410
  }
1451
- async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride) {
1411
+ async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
1452
1412
  const headers = new Headers(request.headers);
1453
1413
  headers.delete(`host`);
1454
1414
  let requestBody = body;
@@ -1462,24 +1422,13 @@ async function forwardToDurableStreams(ctx, request, body, route = `stream`, url
1462
1422
  body: requestBody,
1463
1423
  durableStreamsUrl: ctx.durableStreamsUrl,
1464
1424
  durableStreamsBearer: ctx.durableStreamsBearer,
1465
- durableStreamsBearerMode: usesSubscriptionScopedBearer(urlOverride ?? request.url) ? `if-missing` : `overwrite`,
1425
+ durableStreamsBearerMode,
1466
1426
  durableStreamsRouting: ctx.durableStreamsRouting,
1467
1427
  serviceId: ctx.service,
1468
1428
  dispatcher: ctx.durableStreamsDispatcher,
1469
1429
  route
1470
1430
  });
1471
1431
  }
1472
- function subscriptionIdFromPath(pathname) {
1473
- const match = /^\/v1\/stream-meta\/subscriptions\/([^/]+)(?:\/.*)?$/.exec(pathname);
1474
- return match ? decodeURIComponent(match[1]) : null;
1475
- }
1476
- function isSubscriptionBasePath(pathname) {
1477
- return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/?$/.test(pathname);
1478
- }
1479
- function usesSubscriptionScopedBearer(requestUrl) {
1480
- const pathname = new URL(requestUrl, `http://localhost`).pathname;
1481
- return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/(?:ack|release|callback)\/?$/.test(pathname);
1482
- }
1483
1432
  function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
1484
1433
  if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toBackendStreamPath(service, payload.pattern);
1485
1434
  if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => typeof stream === `string` ? routingAdapter.toBackendStreamPath(service, stream) : stream);
@@ -1524,44 +1473,50 @@ function decodeJson(bytes) {
1524
1473
  return null;
1525
1474
  }
1526
1475
  }
1527
- function rewriteSubscriptionStreamPathInUrl(requestUrl, service, routingAdapter) {
1528
- const match = /^(\/v1\/stream-meta\/subscriptions\/[^/]+\/streams\/)(.+)$/.exec(requestUrl.pathname);
1529
- if (!match) return requestUrl.toString();
1530
- const [, prefix, encodedPath] = match;
1531
- const streamPath = decodeURIComponent(encodedPath);
1532
- requestUrl.pathname = `${prefix}${encodeURIComponent(routingAdapter.toBackendStreamPath(service, streamPath))}`;
1533
- return requestUrl.toString();
1476
+ function routeParam$2(request, name) {
1477
+ const value = request.params[name];
1478
+ const raw = Array.isArray(value) ? value[0] : value;
1479
+ return decodeURIComponent(raw ?? ``);
1480
+ }
1481
+ function subscriptionRoutingAdapter(ctx) {
1482
+ return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
1534
1483
  }
1535
- async function subscriptionProxy(request, ctx) {
1536
- const url = new URL(request.url);
1537
- const subscriptionId = subscriptionIdFromPath(url.pathname);
1538
- if (!subscriptionId) return void 0;
1539
- const routingAdapter = resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting);
1540
- let requestBody;
1484
+ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
1485
+ const body = await readRequestBody(request);
1486
+ if (body.length === 0) return {
1487
+ ok: true,
1488
+ body,
1489
+ targetWebhookUrl: null
1490
+ };
1491
+ const validation = validateBody(subscriptionProxyBodySchema, body);
1492
+ if (!validation.ok) return {
1493
+ ok: false,
1494
+ response: validation.response
1495
+ };
1496
+ const payload = validation.value;
1541
1497
  let targetWebhookUrl = null;
1542
- let requestUrl = request.url;
1543
- if ([`PUT`, `POST`].includes(request.method.toUpperCase())) {
1544
- requestBody = await readRequestBody(request);
1545
- if (requestBody.length > 0) {
1546
- const validation = validateBody(subscriptionProxyBodySchema, requestBody);
1547
- if (!validation.ok) return validation.response;
1548
- const payload = validation.value;
1549
- if (payload.webhook?.url !== void 0) {
1550
- targetWebhookUrl = rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null;
1551
- payload.webhook.url = appendPathToUrl(ctx.publicUrl, `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`);
1552
- }
1553
- rewriteSubscriptionBodyForBackend(payload, ctx.service, routingAdapter);
1554
- requestBody = new TextEncoder().encode(JSON.stringify(payload));
1555
- }
1498
+ if (payload.webhook?.url !== void 0) {
1499
+ targetWebhookUrl = rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null;
1500
+ payload.webhook.url = appendPathToUrl(ctx.publicUrl, `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`);
1556
1501
  }
1557
- if (request.method.toUpperCase() === `DELETE` && /\/streams\/.+$/.test(url.pathname)) requestUrl = rewriteSubscriptionStreamPathInUrl(url, ctx.service, routingAdapter);
1558
- const upstream = await forwardToDurableStreams(ctx, request, requestBody, `stream-meta`, requestUrl);
1502
+ rewriteSubscriptionBodyForBackend(payload, ctx.service, routingAdapter);
1503
+ return {
1504
+ ok: true,
1505
+ body: new TextEncoder().encode(JSON.stringify(payload)),
1506
+ targetWebhookUrl
1507
+ };
1508
+ }
1509
+ async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
1510
+ const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
1559
1511
  let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
1560
1512
  responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx.service, routingAdapter);
1561
- const response = responseFromUpstream$1(upstream, responseBytes);
1562
- if (!upstream.ok) return response;
1563
- if (request.method.toUpperCase() === `DELETE` && isSubscriptionBasePath(url.pathname)) await ctx.pgDb.delete(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId)));
1564
- else if (targetWebhookUrl) await ctx.pgDb.insert(subscriptionWebhooks).values({
1513
+ return {
1514
+ upstream,
1515
+ response: responseFromUpstream$1(upstream, responseBytes)
1516
+ };
1517
+ }
1518
+ async function upsertSubscriptionWebhook(ctx, subscriptionId, targetWebhookUrl) {
1519
+ await ctx.pgDb.insert(subscriptionWebhooks).values({
1565
1520
  tenantId: ctx.service,
1566
1521
  subscriptionId,
1567
1522
  webhookUrl: targetWebhookUrl
@@ -1569,8 +1524,64 @@ async function subscriptionProxy(request, ctx) {
1569
1524
  target: [subscriptionWebhooks.tenantId, subscriptionWebhooks.subscriptionId],
1570
1525
  set: { webhookUrl: targetWebhookUrl }
1571
1526
  });
1527
+ }
1528
+ async function deleteSubscriptionWebhook(ctx, subscriptionId) {
1529
+ await ctx.pgDb.delete(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId)));
1530
+ }
1531
+ function rewriteSubscriptionStreamPathInUrl(requestUrl, service, routingAdapter, streamPath) {
1532
+ const prefix = requestUrl.pathname.slice(0, requestUrl.pathname.indexOf(`/streams/`) + `/streams/`.length);
1533
+ requestUrl.pathname = `${prefix}${encodeURIComponent(routingAdapter.toBackendStreamPath(service, streamPath))}`;
1534
+ return requestUrl.toString();
1535
+ }
1536
+ async function putSubscriptionBase(request, ctx) {
1537
+ const subscriptionId = routeParam$2(request, `subscriptionId`);
1538
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1539
+ const rewrite = await rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter);
1540
+ if (!rewrite.ok) return rewrite.response;
1541
+ const { upstream, response } = await forwardSubscriptionRequest(request, ctx, routingAdapter, { body: rewrite.body });
1542
+ if (upstream.ok && rewrite.targetWebhookUrl) await upsertSubscriptionWebhook(ctx, subscriptionId, rewrite.targetWebhookUrl);
1572
1543
  return response;
1573
1544
  }
1545
+ async function getSubscriptionBase(request, ctx) {
1546
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1547
+ return (await forwardSubscriptionRequest(request, ctx, routingAdapter)).response;
1548
+ }
1549
+ async function deleteSubscriptionBase(request, ctx) {
1550
+ const subscriptionId = routeParam$2(request, `subscriptionId`);
1551
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1552
+ const { upstream, response } = await forwardSubscriptionRequest(request, ctx, routingAdapter);
1553
+ if (upstream.ok) await deleteSubscriptionWebhook(ctx, subscriptionId);
1554
+ return response;
1555
+ }
1556
+ async function postSubscriptionStreams(request, ctx) {
1557
+ const subscriptionId = routeParam$2(request, `subscriptionId`);
1558
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1559
+ const rewrite = await rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter);
1560
+ if (!rewrite.ok) return rewrite.response;
1561
+ return (await forwardSubscriptionRequest(request, ctx, routingAdapter, { body: rewrite.body })).response;
1562
+ }
1563
+ async function deleteSubscriptionStream(request, ctx) {
1564
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1565
+ const requestUrl = rewriteSubscriptionStreamPathInUrl(new URL(request.url), ctx.service, routingAdapter, routeParam$2(request, `streamPath`));
1566
+ return (await forwardSubscriptionRequest(request, ctx, routingAdapter, { requestUrl })).response;
1567
+ }
1568
+ function subscriptionAction(action) {
1569
+ return async (request, ctx) => {
1570
+ const subscriptionId = routeParam$2(request, `subscriptionId`);
1571
+ const routingAdapter = subscriptionRoutingAdapter(ctx);
1572
+ const rewrite = await rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter);
1573
+ if (!rewrite.ok) return rewrite.response;
1574
+ const bearerMode = action === `ack` || action === `release` || action === `callback` ? `if-missing` : `overwrite`;
1575
+ return (await forwardSubscriptionRequest(request, ctx, routingAdapter, {
1576
+ body: rewrite.body,
1577
+ bearerMode
1578
+ })).response;
1579
+ };
1580
+ }
1581
+ async function controlPassThrough(request, ctx) {
1582
+ const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
1583
+ return responseFromUpstream$1(upstream);
1584
+ }
1574
1585
  async function streamAppend(request, ctx) {
1575
1586
  return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
1576
1587
  request: {
@@ -1591,10 +1602,9 @@ async function proxyPassThrough(request, ctx) {
1591
1602
  const upstream = await forwardToDurableStreams(ctx, request);
1592
1603
  const streamPath = new URL(request.url).pathname;
1593
1604
  const method = request.method.toUpperCase();
1594
- const isControlPath = streamPath.startsWith(`/v1/stream-meta/`);
1595
- const endTrackedRead = method === `GET` && !isControlPath ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
1605
+ const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
1596
1606
  try {
1597
- if (method === `HEAD` && !isControlPath) await ctx.entityBridgeManager.touchByStreamPath(streamPath);
1607
+ if (method === `HEAD`) await ctx.entityBridgeManager.touchByStreamPath(streamPath);
1598
1608
  return responseFromUpstream$1(upstream);
1599
1609
  } finally {
1600
1610
  await endTrackedRead?.();
@@ -1625,7 +1635,8 @@ async function proxyElectric(request, ctx) {
1625
1635
  incomingUrl: new URL(request.url),
1626
1636
  electricUrl: ctx.electricUrl,
1627
1637
  electricSecret: ctx.electricSecret,
1628
- tenantId: ctx.service
1638
+ tenantId: ctx.service,
1639
+ principalUrl: ctx.principal.url
1629
1640
  });
1630
1641
  const headers = new Headers(request.headers);
1631
1642
  headers.delete(`host`);
@@ -1656,7 +1667,7 @@ const PRINCIPAL_KINDS = new Set([
1656
1667
  ]);
1657
1668
  function parsePrincipalKey(input) {
1658
1669
  const colon = input.indexOf(`:`);
1659
- if (colon <= 0) throw new Error(`Invalid principal key`);
1670
+ if (colon <= 0) throw new Error(`Invalid principal identifier`);
1660
1671
  const kind = input.slice(0, colon);
1661
1672
  const id = input.slice(colon + 1);
1662
1673
  if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`);
@@ -1672,20 +1683,29 @@ function parsePrincipalKey(input) {
1672
1683
  function principalUrl(key) {
1673
1684
  return parsePrincipalKey(key).url;
1674
1685
  }
1675
- function principalKeyFromUrl(url) {
1686
+ function parsePrincipalUrl(url) {
1676
1687
  if (!url.startsWith(`/principal/`)) return null;
1677
1688
  const segment = url.slice(`/principal/`.length);
1678
1689
  if (!segment || segment.includes(`/`)) return null;
1679
1690
  try {
1680
- const key = decodeURIComponent(segment);
1681
- return parsePrincipalKey(key).key;
1691
+ return parsePrincipalKey(decodeURIComponent(segment));
1692
+ } catch {
1693
+ return null;
1694
+ }
1695
+ }
1696
+ function parsePrincipalInput(input) {
1697
+ const urlPrincipal = parsePrincipalUrl(input);
1698
+ if (urlPrincipal) return urlPrincipal;
1699
+ try {
1700
+ return parsePrincipalKey(input);
1682
1701
  } catch {
1683
1702
  return null;
1684
1703
  }
1685
1704
  }
1686
1705
  function getPrincipalFromRequest(request) {
1687
1706
  const value = request.headers.get(ELECTRIC_PRINCIPAL_HEADER);
1688
- return value ? parsePrincipalKey(value) : null;
1707
+ if (!value) return null;
1708
+ return parsePrincipalInput(value);
1689
1709
  }
1690
1710
  function getDevPrincipal() {
1691
1711
  return parsePrincipalKey(`system:dev-local`);
@@ -1698,9 +1718,8 @@ const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
1698
1718
  function isBuiltInSystemPrincipalUrl(url) {
1699
1719
  if (!url?.startsWith(`/principal/`)) return false;
1700
1720
  try {
1701
- const key = principalKeyFromUrl(url);
1702
- if (!key) return false;
1703
- const principal = parsePrincipalKey(key);
1721
+ const principal = parsePrincipalUrl(url);
1722
+ if (!principal) return false;
1704
1723
  return principal.kind === `system` && BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id);
1705
1724
  } catch {
1706
1725
  return false;
@@ -1708,12 +1727,11 @@ function isBuiltInSystemPrincipalUrl(url) {
1708
1727
  }
1709
1728
  function principalFromCreatedBy(createdBy) {
1710
1729
  if (!createdBy) return void 0;
1711
- const key = principalKeyFromUrl(createdBy);
1712
- if (!key) return {
1730
+ const principal = parsePrincipalUrl(createdBy);
1731
+ if (!principal) return {
1713
1732
  url: createdBy,
1714
1733
  key: null
1715
1734
  };
1716
- const principal = parsePrincipalKey(key);
1717
1735
  return {
1718
1736
  url: principal.url,
1719
1737
  key: principal.key,
@@ -1812,7 +1830,7 @@ var PostgresRegistry = class {
1812
1830
  await this.db.insert(runners).values({
1813
1831
  tenantId: this.tenantId,
1814
1832
  id: input.id,
1815
- ownerUserId: input.ownerUserId,
1833
+ ownerPrincipal: input.ownerPrincipal,
1816
1834
  label: input.label,
1817
1835
  kind: input.kind ?? `local`,
1818
1836
  adminStatus: input.adminStatus ?? `enabled`,
@@ -1821,7 +1839,7 @@ var PostgresRegistry = class {
1821
1839
  }).onConflictDoUpdate({
1822
1840
  target: [runners.tenantId, runners.id],
1823
1841
  set: {
1824
- ownerUserId: input.ownerUserId,
1842
+ ownerPrincipal: input.ownerPrincipal,
1825
1843
  label: input.label,
1826
1844
  kind: input.kind ?? `local`,
1827
1845
  adminStatus: input.adminStatus ?? `enabled`,
@@ -1839,20 +1857,46 @@ var PostgresRegistry = class {
1839
1857
  }
1840
1858
  async listRunners(filter) {
1841
1859
  const conditions = [eq(runners.tenantId, this.tenantId)];
1842
- if (filter?.ownerUserId) conditions.push(eq(runners.ownerUserId, filter.ownerUserId));
1860
+ if (filter?.ownerPrincipal) conditions.push(eq(runners.ownerPrincipal, filter.ownerPrincipal));
1843
1861
  const rows = await this.db.select().from(runners).where(and(...conditions)).orderBy(desc(runners.createdAt));
1844
1862
  return rows.map((row) => this.rowToRunner(row));
1845
1863
  }
1846
1864
  async heartbeatRunner(input) {
1847
1865
  const now = input.heartbeatAt ?? new Date();
1848
1866
  const leaseExpiresAt = input.livenessLeaseExpiresAt ?? new Date(now.getTime() + (input.leaseMs ?? DEFAULT_RUNNER_LEASE_MS));
1849
- const rows = await this.db.update(runners).set({
1867
+ await this.db.insert(runnerRuntimeDiagnostics).values({
1868
+ tenantId: this.tenantId,
1869
+ runnerId: input.runnerId,
1870
+ ownerPrincipal: input.ownerPrincipal,
1850
1871
  lastSeenAt: now,
1851
1872
  livenessLeaseExpiresAt: leaseExpiresAt,
1852
- ...input.wakeStreamOffset !== void 0 ? { wakeStreamOffset: input.wakeStreamOffset } : {},
1873
+ wakeStreamOffset: input.wakeStreamOffset,
1874
+ diagnostics: input.diagnostics,
1853
1875
  updatedAt: now
1854
- }).where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, input.runnerId))).returning();
1855
- return rows[0] ? this.rowToRunner(rows[0]) : null;
1876
+ }).onConflictDoUpdate({
1877
+ target: [runnerRuntimeDiagnostics.tenantId, runnerRuntimeDiagnostics.runnerId],
1878
+ set: {
1879
+ lastSeenAt: now,
1880
+ ownerPrincipal: input.ownerPrincipal,
1881
+ livenessLeaseExpiresAt: leaseExpiresAt,
1882
+ ...input.wakeStreamOffset !== void 0 ? { wakeStreamOffset: input.wakeStreamOffset } : {},
1883
+ ...input.diagnostics !== void 0 ? { diagnostics: input.diagnostics } : {},
1884
+ updatedAt: now
1885
+ }
1886
+ });
1887
+ const runner = await this.getRunner(input.runnerId);
1888
+ if (!runner) return null;
1889
+ return {
1890
+ ...runner,
1891
+ last_seen_at: now.toISOString(),
1892
+ liveness_lease_expires_at: leaseExpiresAt.toISOString(),
1893
+ ...input.wakeStreamOffset !== void 0 ? { wake_stream_offset: input.wakeStreamOffset } : {},
1894
+ ...input.diagnostics !== void 0 ? { diagnostics: input.diagnostics } : {}
1895
+ };
1896
+ }
1897
+ async getRunnerDiagnostics(runnerId) {
1898
+ const rows = await this.db.select().from(runnerRuntimeDiagnostics).where(and(eq(runnerRuntimeDiagnostics.tenantId, this.tenantId), eq(runnerRuntimeDiagnostics.runnerId, runnerId))).limit(1);
1899
+ return rows[0] ? this.rowToRunnerRuntimeDiagnostics(rows[0]) : null;
1856
1900
  }
1857
1901
  async setRunnerAdminStatus(runnerId, adminStatus) {
1858
1902
  const rows = await this.db.update(runners).set({
@@ -1947,6 +1991,27 @@ var PostgresRegistry = class {
1947
1991
  }).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch)));
1948
1992
  return claim;
1949
1993
  }
1994
+ async getActiveClaimsForRunner(runnerId) {
1995
+ const rows = await this.db.select().from(consumerClaims).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.runnerId, runnerId), eq(consumerClaims.status, `active`)));
1996
+ return rows.map((row) => this.rowToConsumerClaim(row));
1997
+ }
1998
+ async getDispatchStatsForRunner(runnerId) {
1999
+ const rows = await this.db.select().from(entityDispatchState).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.activeRunnerId, runnerId)));
2000
+ let activeClaim = 0;
2001
+ let outstandingWake = 0;
2002
+ let pendingWork = 0;
2003
+ for (const row of rows) {
2004
+ if (row.activeConsumerId) activeClaim++;
2005
+ if (row.outstandingWakeId && !row.activeConsumerId) outstandingWake++;
2006
+ const pending = row.pendingSourceStreams;
2007
+ if (pending && pending.length > 0) pendingWork++;
2008
+ }
2009
+ return {
2010
+ entities_with_active_claim: activeClaim,
2011
+ entities_with_outstanding_wake: outstandingWake,
2012
+ entities_with_pending_work: pendingWork
2013
+ };
2014
+ }
1950
2015
  entityTypeWhere(name) {
1951
2016
  return and(eq(entityTypes.tenantId, this.tenantId), eq(entityTypes.name, name));
1952
2017
  }
@@ -2412,23 +2477,28 @@ var PostgresRegistry = class {
2412
2477
  };
2413
2478
  }
2414
2479
  rowToRunner(row) {
2415
- const now = Date.now();
2416
- const livenessExpiry = row.livenessLeaseExpiresAt?.getTime();
2417
2480
  return {
2418
2481
  id: row.id,
2419
- owner_user_id: row.ownerUserId,
2482
+ owner_principal: row.ownerPrincipal,
2420
2483
  label: row.label,
2421
2484
  kind: assertRunnerKind(row.kind),
2422
2485
  admin_status: assertRunnerAdminStatus(row.adminStatus),
2423
- liveness: livenessExpiry !== void 0 && livenessExpiry > now ? `online` : `offline`,
2424
- last_seen_at: row.lastSeenAt?.toISOString(),
2425
- liveness_lease_expires_at: row.livenessLeaseExpiresAt?.toISOString(),
2426
2486
  wake_stream: row.wakeStream,
2427
- wake_stream_offset: row.wakeStreamOffset ?? void 0,
2428
2487
  created_at: row.createdAt.toISOString(),
2429
2488
  updated_at: row.updatedAt.toISOString()
2430
2489
  };
2431
2490
  }
2491
+ rowToRunnerRuntimeDiagnostics(row) {
2492
+ return {
2493
+ runner_id: row.runnerId,
2494
+ owner_principal: row.ownerPrincipal,
2495
+ wake_stream_offset: row.wakeStreamOffset ?? void 0,
2496
+ last_seen_at: row.lastSeenAt.toISOString(),
2497
+ liveness_lease_expires_at: row.livenessLeaseExpiresAt.toISOString(),
2498
+ diagnostics: row.diagnostics ?? void 0,
2499
+ updated_at: row.updatedAt.toISOString()
2500
+ };
2501
+ }
2432
2502
  rowToConsumerClaim(row) {
2433
2503
  return {
2434
2504
  consumer_id: row.consumerId,
@@ -3967,6 +4037,7 @@ var ElectricAgentsError = class extends Error {
3967
4037
 
3968
4038
  //#endregion
3969
4039
  //#region src/routing/dispatch-policy.ts
4040
+ const linkedDispatchSubscriptions = new WeakMap();
3970
4041
  function subscriptionIdForDispatchTarget(target) {
3971
4042
  if (target.subscription_id) return target.subscription_id;
3972
4043
  if (target.type === `runner`) return `runner:${target.runnerId}`;
@@ -3975,7 +4046,7 @@ function subscriptionIdForDispatchTarget(target) {
3975
4046
  }
3976
4047
  function subscriptionIdForEntityDispatchTarget(target, entityUrl) {
3977
4048
  const base = subscriptionIdForDispatchTarget(target);
3978
- if (!target.subscription_id) return base;
4049
+ if (!target.subscription_id && target.type !== `runner`) return base;
3979
4050
  const digest = createHash(`sha256`).update(entityUrl).digest(`hex`);
3980
4051
  return `${base}:${digest.slice(0, 16)}`;
3981
4052
  }
@@ -4019,24 +4090,74 @@ function sameDispatchDestination(a, b) {
4019
4090
  if (a.type === `webhook` && b.type === `webhook`) return a.url === b.url;
4020
4091
  return false;
4021
4092
  }
4093
+ function subscriptionHasStream(ctx, existing, streamPath) {
4094
+ const normalizedStream = streamPath.replace(/^\/+/, ``);
4095
+ const backendStream = `${ctx.service}/${normalizedStream}`;
4096
+ return existing.streams?.some((stream) => {
4097
+ const path$1 = typeof stream === `string` ? stream : stream.path;
4098
+ if (!path$1) return false;
4099
+ const normalized = path$1.replace(/^\/+/, ``);
4100
+ return normalized === normalizedStream || normalized === backendStream;
4101
+ }) ?? false;
4102
+ }
4103
+ function dispatchLinkCacheKey(ctx, subscriptionId, streamPath) {
4104
+ return `${ctx.service}:${subscriptionId}:${streamPath}`;
4105
+ }
4106
+ function getDispatchLinkCache(ctx) {
4107
+ let cache = linkedDispatchSubscriptions.get(ctx.streamClient);
4108
+ if (!cache) {
4109
+ cache = new Set();
4110
+ linkedDispatchSubscriptions.set(ctx.streamClient, cache);
4111
+ }
4112
+ return cache;
4113
+ }
4114
+ function isSubscriptionAlreadyExistsError(err) {
4115
+ if (!(err instanceof DurableStreamsSubscriptionError)) return false;
4116
+ if (err.status === 409) return true;
4117
+ return err.code === `SUBSCRIPTION_ALREADY_EXISTS` || err.code === `ALREADY_EXISTS` || /already exists/i.test(err.errorMessage ?? err.body ?? err.message);
4118
+ }
4119
+ async function ensureSubscriptionIncludesStream(ctx, subscriptionId, streamPath, input, existing) {
4120
+ if (!existing) try {
4121
+ await ctx.streamClient.putSubscription(subscriptionId, input);
4122
+ return;
4123
+ } catch (err) {
4124
+ if (!isSubscriptionAlreadyExistsError(err)) throw err;
4125
+ existing = await ctx.streamClient.getSubscription(subscriptionId);
4126
+ if (!existing) {
4127
+ serverLog.warn(`[dispatch-policy] subscription create raced with existing subscription but it could not be read`, {
4128
+ subscriptionId,
4129
+ stream: streamPath
4130
+ });
4131
+ return;
4132
+ }
4133
+ }
4134
+ if (!subscriptionHasStream(ctx, existing, streamPath)) await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath]);
4135
+ }
4022
4136
  async function assertDispatchPolicyAllowed(ctx, policy) {
4023
4137
  const target = policy?.targets[0];
4024
4138
  if (!target || target.type !== `runner`) return;
4139
+ if (!ctx.principal) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires an authenticated owner`, 401);
4025
4140
  const runner = await ctx.entityManager.registry.getRunner(target.runnerId);
4026
4141
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
4027
- if (ctx.principal && runner.owner_user_id !== ctx.principal.key) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
4142
+ if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
4028
4143
  }
4029
4144
  async function linkEntityDispatchSubscription(ctx, entity) {
4030
4145
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
4031
4146
  const target = dispatchPolicy?.targets[0];
4032
4147
  if (!target) return;
4033
- await linkStreamToTargetSubscription(ctx, target, entity);
4148
+ const subscriptionId = subscriptionIdForEntityDispatchTarget(target, entity.url);
4149
+ const cacheKey = dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main);
4150
+ const cache = getDispatchLinkCache(ctx);
4151
+ if (cache.has(cacheKey)) return;
4152
+ await linkStreamToTargetSubscription(ctx, target, entity, subscriptionId);
4153
+ cache.add(cacheKey);
4034
4154
  }
4035
4155
  async function unlinkEntityDispatchSubscription(ctx, entity) {
4036
4156
  const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
4037
4157
  const target = dispatchPolicy?.targets[0];
4038
4158
  if (!target) return;
4039
4159
  const subscriptionId = subscriptionIdForEntityDispatchTarget(target, entity.url);
4160
+ getDispatchLinkCache(ctx).delete(dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main));
4040
4161
  await ctx.streamClient.removeSubscriptionStream(subscriptionId, entity.streams.main).catch((err) => {
4041
4162
  serverLog.warn(`[dispatch-policy] failed to remove stream from subscription`, {
4042
4163
  subscriptionId,
@@ -4044,37 +4165,32 @@ async function unlinkEntityDispatchSubscription(ctx, entity) {
4044
4165
  }, err);
4045
4166
  });
4046
4167
  }
4047
- async function linkStreamToTargetSubscription(ctx, target, entity) {
4168
+ async function linkStreamToTargetSubscription(ctx, target, entity, subscriptionId) {
4048
4169
  const streamPath = entity.streams.main;
4049
- const subscriptionId = subscriptionIdForEntityDispatchTarget(target, entity.url);
4170
+ await ctx.streamClient.ensure(streamPath, { contentType: `application/json` });
4050
4171
  const existing = await ctx.streamClient.getSubscription(subscriptionId);
4051
4172
  if (target.type === `runner`) {
4052
4173
  const runner = await ctx.entityManager.registry.getRunner(target.runnerId);
4053
4174
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
4054
4175
  const wakeStream = runner.wake_stream || runnerWakeStream(target.runnerId);
4055
4176
  await ctx.streamClient.ensure(wakeStream, { contentType: `application/json` });
4056
- if (!existing) {
4057
- await ctx.streamClient.putSubscription(subscriptionId, {
4058
- type: `pull-wake`,
4059
- streams: [streamPath],
4060
- wake_stream: wakeStream,
4061
- description: `Electric Agents runner ${target.runnerId}`
4062
- });
4063
- return;
4064
- }
4065
- await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath]);
4177
+ await ensureSubscriptionIncludesStream(ctx, subscriptionId, streamPath, {
4178
+ type: `pull-wake`,
4179
+ streams: [streamPath],
4180
+ wake_stream: wakeStream,
4181
+ description: `Electric Agents runner ${target.runnerId}`
4182
+ }, existing);
4066
4183
  return;
4067
4184
  }
4068
4185
  const webhookUrl = rewriteLoopbackWebhookUrl(target.url);
4069
4186
  if (!webhookUrl) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Webhook dispatch target must include a valid URL`, 400);
4070
4187
  const forwardUrl = appendPathToUrl(ctx.publicUrl, `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`);
4071
- if (!existing) await ctx.streamClient.putSubscription(subscriptionId, {
4188
+ await ensureSubscriptionIncludesStream(ctx, subscriptionId, streamPath, {
4072
4189
  type: `webhook`,
4073
4190
  streams: [streamPath],
4074
4191
  webhook: { url: forwardUrl },
4075
4192
  description: `Electric Agents webhook ${subscriptionId}`
4076
- });
4077
- else await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath]);
4193
+ }, existing);
4078
4194
  await ctx.pgDb.insert(subscriptionWebhooks).values({
4079
4195
  tenantId: ctx.service,
4080
4196
  subscriptionId,
@@ -4326,10 +4442,8 @@ async function sendEntity(request, ctx) {
4326
4442
  if (parsed.from !== void 0 && parsed.from !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from must match Electric-Principal`);
4327
4443
  await ctx.entityManager.ensurePrincipal(principal);
4328
4444
  const { entityUrl, entity } = requireExistingEntityRoute(request);
4329
- if (!entity.dispatch_policy) {
4330
- const updatedEntity = await backfillEntityDispatchPolicy(ctx, entity);
4331
- await linkEntityDispatchSubscription(ctx, updatedEntity);
4332
- }
4445
+ const dispatchEntity = entity.dispatch_policy ? entity : await backfillEntityDispatchPolicy(ctx, entity);
4446
+ await linkEntityDispatchSubscription(ctx, dispatchEntity);
4333
4447
  if (parsed.afterMs && parsed.afterMs > 0) await ctx.entityManager.enqueueDelayedSend(entityUrl, {
4334
4448
  from: principal.url,
4335
4449
  payload: parsed.payload,
@@ -4378,11 +4492,11 @@ async function spawnEntity(request, ctx) {
4378
4492
  wake: parsed.wake,
4379
4493
  created_by: principal.url
4380
4494
  });
4495
+ await linkEntityDispatchSubscription(ctx, entity);
4381
4496
  if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
4382
4497
  from: principal.url,
4383
4498
  payload: parsed.initialMessage
4384
4499
  });
4385
- await linkEntityDispatchSubscription(ctx, entity);
4386
4500
  return json({
4387
4501
  ...toPublicEntity(entity),
4388
4502
  txid: entity.txid
@@ -4546,7 +4660,13 @@ function applyCors(response) {
4546
4660
  const headers = new Headers(response.headers);
4547
4661
  headers.set(`access-control-allow-origin`, `*`);
4548
4662
  headers.set(`access-control-allow-methods`, `GET, POST, PUT, PATCH, DELETE, OPTIONS`);
4549
- headers.set(`access-control-allow-headers`, `content-type, authorization, electric-claim-token, ngrok-skip-browser-warning`);
4663
+ headers.set(`access-control-allow-headers`, [
4664
+ `content-type`,
4665
+ `authorization`,
4666
+ `electric-claim-token`,
4667
+ ELECTRIC_PRINCIPAL_HEADER,
4668
+ `ngrok-skip-browser-warning`
4669
+ ].join(`, `));
4550
4670
  headers.set(`access-control-expose-headers`, `*`);
4551
4671
  return new Response(response.body, {
4552
4672
  status: response.status,
@@ -4581,11 +4701,17 @@ function getRequestSpan(req) {
4581
4701
  return carrier(req)[SPAN_KEY];
4582
4702
  }
4583
4703
 
4704
+ //#endregion
4705
+ //#region src/routing/tenant-stream-paths.ts
4706
+ function withLeadingSlash(path$1) {
4707
+ return path$1.startsWith(`/`) ? path$1 : `/${path$1}`;
4708
+ }
4709
+
4584
4710
  //#endregion
4585
4711
  //#region src/routing/runners-router.ts
4586
4712
  const registerRunnerBodySchema = Type.Object({
4587
4713
  id: Type.String(),
4588
- owner_user_id: Type.Optional(Type.String()),
4714
+ owner_principal: Type.Optional(Type.String()),
4589
4715
  label: Type.String(),
4590
4716
  kind: Type.Optional(Type.Union([
4591
4717
  Type.Literal(`local`),
@@ -4601,7 +4727,8 @@ const heartbeatBodySchema = Type.Object({
4601
4727
  lease_ms: Type.Optional(Type.Number()),
4602
4728
  wake_stream_offset: Type.Optional(Type.String()),
4603
4729
  wakeStreamOffset: Type.Optional(Type.String()),
4604
- liveness_lease_expires_at: Type.Optional(Type.String())
4730
+ liveness_lease_expires_at: Type.Optional(Type.String()),
4731
+ diagnostics: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
4605
4732
  });
4606
4733
  const claimBodySchema = Type.Object({
4607
4734
  subscription_id: Type.Optional(Type.String()),
@@ -4609,9 +4736,39 @@ const claimBodySchema = Type.Object({
4609
4736
  generation: Type.Optional(Type.Number()),
4610
4737
  ts: Type.Optional(Type.Union([Type.String(), Type.Number()]))
4611
4738
  }, { additionalProperties: true });
4739
+ const runnerClientStatuses = new Set([
4740
+ `stopped`,
4741
+ `starting`,
4742
+ `connecting`,
4743
+ `streaming`,
4744
+ `reconnecting`,
4745
+ `stopping`
4746
+ ]);
4747
+ const runnerLastClaimResults = new Set([
4748
+ `claimed`,
4749
+ `no_work`,
4750
+ `error`
4751
+ ]);
4752
+ const runnerStringOrNullDiagnostics = [
4753
+ `started_at`,
4754
+ `stream_connected_since`,
4755
+ `last_error`,
4756
+ `last_error_at`,
4757
+ `last_heartbeat_at`,
4758
+ `last_claim_at`,
4759
+ `last_dispatch_at`
4760
+ ];
4761
+ const runnerNumberDiagnostics = [
4762
+ `reconnect_count`,
4763
+ `events_received`,
4764
+ `claims_succeeded`,
4765
+ `claims_skipped`,
4766
+ `claims_failed`
4767
+ ];
4612
4768
  const runnersRouter = Router({ base: `/_electric/runners` });
4613
4769
  runnersRouter.post(`/`, withSchema(registerRunnerBodySchema), registerRunner);
4614
4770
  runnersRouter.get(`/`, listRunners);
4771
+ runnersRouter.get(`/:id/health`, runnerHealth);
4615
4772
  runnersRouter.get(`/:id`, getRunner);
4616
4773
  runnersRouter.post(`/:id/heartbeat`, withSchema(heartbeatBodySchema), heartbeat);
4617
4774
  runnersRouter.post(`/:id/enable`, setEnabled);
@@ -4624,14 +4781,41 @@ function routeParam$1(request, name) {
4624
4781
  function firstQueryValue(value) {
4625
4782
  return Array.isArray(value) ? value[0] : value;
4626
4783
  }
4784
+ function requireAuthenticatedPrincipal(ctx) {
4785
+ if (ctx.principal) return ctx.principal;
4786
+ throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner route requires an authenticated principal`, 401);
4787
+ }
4788
+ function canonicalOwnerPrincipal(input) {
4789
+ return parsePrincipalUrl(input)?.url ?? null;
4790
+ }
4791
+ function sanitizeRunnerDiagnostics(diagnostics) {
4792
+ if (!diagnostics) return void 0;
4793
+ const sanitized = {};
4794
+ if (typeof diagnostics.status === `string` && runnerClientStatuses.has(diagnostics.status)) sanitized.status = diagnostics.status;
4795
+ if (typeof diagnostics.stream_connected === `boolean`) sanitized.stream_connected = diagnostics.stream_connected;
4796
+ if (typeof diagnostics.last_heartbeat_ok === `boolean`) sanitized.last_heartbeat_ok = diagnostics.last_heartbeat_ok;
4797
+ if (diagnostics.last_claim_result === null || typeof diagnostics.last_claim_result === `string` && runnerLastClaimResults.has(diagnostics.last_claim_result)) sanitized.last_claim_result = diagnostics.last_claim_result;
4798
+ for (const key of runnerStringOrNullDiagnostics) {
4799
+ const value = diagnostics[key];
4800
+ if (typeof value === `string` || value === null) sanitized[key] = value;
4801
+ }
4802
+ for (const key of runnerNumberDiagnostics) {
4803
+ const value = diagnostics[key];
4804
+ if (typeof value === `number` && Number.isFinite(value) && value >= 0) sanitized[key] = value;
4805
+ }
4806
+ return Object.keys(sanitized).length > 0 ? sanitized : void 0;
4807
+ }
4627
4808
  async function registerRunner(request, ctx) {
4628
4809
  const parsed = routeBody(request);
4629
- const ownerUserId = parsed.owner_user_id ?? ctx.principal?.key;
4630
- if (!ownerUserId) throw new ElectricAgentsError(ErrCodeInvalidRequest, `owner_user_id is required when no authenticated user is present`, 400);
4631
- if (ctx.principal && ownerUserId !== ctx.principal.key) throw new ElectricAgentsError(ErrCodeUnauthorized, `owner_user_id must match the authenticated user`, 403);
4810
+ const principal = requireAuthenticatedPrincipal(ctx);
4811
+ const ownerPrincipal = parsed.owner_principal ?? principal.url;
4812
+ if (!ownerPrincipal) throw new ElectricAgentsError(ErrCodeInvalidRequest, `owner_principal is required when no authenticated principal is present`, 400);
4813
+ const canonicalOwner = canonicalOwnerPrincipal(ownerPrincipal);
4814
+ if (!canonicalOwner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `owner_principal must be a valid principal URL (e.g. /principal/user%3Aalice), got: ${ownerPrincipal}`, 400);
4815
+ if (canonicalOwner !== principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `owner_principal must match the authenticated principal`, 403);
4632
4816
  const runner = await ctx.entityManager.registry.createRunner({
4633
4817
  id: parsed.id,
4634
- ownerUserId,
4818
+ ownerPrincipal: canonicalOwner,
4635
4819
  label: parsed.label,
4636
4820
  kind: parsed.kind,
4637
4821
  adminStatus: parsed.admin_status,
@@ -4641,26 +4825,32 @@ async function registerRunner(request, ctx) {
4641
4825
  return json(runner, { status: 201 });
4642
4826
  }
4643
4827
  async function listRunners(request, ctx) {
4644
- const requestedOwner = firstQueryValue(request.query.owner_user_id);
4645
- if (ctx.principal && requestedOwner && requestedOwner !== ctx.principal.key) throw new ElectricAgentsError(ErrCodeUnauthorized, `owner_user_id must match the authenticated user`, 403);
4646
- const runners$1 = await ctx.entityManager.registry.listRunners({ ownerUserId: ctx.principal?.key ?? requestedOwner });
4828
+ const principal = requireAuthenticatedPrincipal(ctx);
4829
+ const requestedOwner = firstQueryValue(request.query.owner_principal);
4830
+ const canonicalRequestedOwner = requestedOwner ? canonicalOwnerPrincipal(requestedOwner) : void 0;
4831
+ if (requestedOwner && !canonicalRequestedOwner) throw new ElectricAgentsError(ErrCodeInvalidRequest, `owner_principal must be a valid principal URL (e.g. /principal/user%3Aalice), got: ${requestedOwner}`, 400);
4832
+ if (canonicalRequestedOwner && canonicalRequestedOwner !== principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `owner_principal must match the authenticated principal`, 403);
4833
+ const runners$1 = await ctx.entityManager.registry.listRunners({ ownerPrincipal: principal.url });
4647
4834
  return json(runners$1);
4648
4835
  }
4649
4836
  async function getRunner(request, ctx) {
4650
4837
  const runner = await requireRunner(ctx, routeParam$1(request, `id`));
4651
- assertRunnerOwnerIfAuthenticated(ctx, runner.owner_user_id);
4838
+ assertRunnerOwnerIfAuthenticated(ctx, runner.owner_principal);
4652
4839
  return json(runner);
4653
4840
  }
4654
4841
  async function heartbeat(request, ctx) {
4655
4842
  const runnerId = routeParam$1(request, `id`);
4843
+ requireAuthenticatedPrincipal(ctx);
4656
4844
  const existing = await requireRunner(ctx, runnerId);
4657
- assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id);
4845
+ assertRunnerOwnerIfAuthenticated(ctx, existing.owner_principal);
4658
4846
  const parsed = routeBody(request);
4659
4847
  const runner = await ctx.entityManager.registry.heartbeatRunner({
4660
4848
  runnerId,
4849
+ ownerPrincipal: existing.owner_principal,
4661
4850
  leaseMs: parsed.lease_ms,
4662
4851
  wakeStreamOffset: parsed.wake_stream_offset ?? parsed.wakeStreamOffset,
4663
- livenessLeaseExpiresAt: parsed.liveness_lease_expires_at ? new Date(parsed.liveness_lease_expires_at) : void 0
4852
+ livenessLeaseExpiresAt: parsed.liveness_lease_expires_at ? new Date(parsed.liveness_lease_expires_at) : void 0,
4853
+ diagnostics: sanitizeRunnerDiagnostics(parsed.diagnostics)
4664
4854
  });
4665
4855
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404);
4666
4856
  return json(runner);
@@ -4673,16 +4863,18 @@ async function setDisabled(request, ctx) {
4673
4863
  }
4674
4864
  async function setRunnerStatus(request, ctx, adminStatus) {
4675
4865
  const runnerId = routeParam$1(request, `id`);
4866
+ requireAuthenticatedPrincipal(ctx);
4676
4867
  const existing = await requireRunner(ctx, runnerId);
4677
- assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id);
4868
+ assertRunnerOwnerIfAuthenticated(ctx, existing.owner_principal);
4678
4869
  const runner = await ctx.entityManager.registry.setRunnerAdminStatus(runnerId, adminStatus);
4679
4870
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404);
4680
4871
  return json(runner);
4681
4872
  }
4682
4873
  async function claimWake(request, ctx) {
4683
4874
  const runnerId = routeParam$1(request, `id`);
4875
+ const principal = requireAuthenticatedPrincipal(ctx);
4684
4876
  const runner = await requireRunner(ctx, runnerId);
4685
- if (ctx.principal && runner.owner_user_id !== ctx.principal.key) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner claim requires the authenticated owner`, 403);
4877
+ if (runner.owner_principal !== principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner claim requires the authenticated owner`, 403);
4686
4878
  if (runner.admin_status !== `enabled`) throw new ElectricAgentsError(ErrCodeNotRunning, `Runner is disabled`, 409);
4687
4879
  const parsed = routeBody(request);
4688
4880
  const expectedSubscriptionId = subscriptionIdForDispatchTarget({
@@ -4713,11 +4905,95 @@ async function requireRunner(ctx, runnerId) {
4713
4905
  if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404);
4714
4906
  return runner;
4715
4907
  }
4716
- function assertRunnerOwnerIfAuthenticated(ctx, ownerUserId) {
4717
- if (!ctx.principal) return;
4718
- if (ownerUserId === ctx.principal.key) return;
4908
+ function assertRunnerOwnerIfAuthenticated(ctx, ownerPrincipal) {
4909
+ requireAuthenticatedPrincipal(ctx);
4910
+ if (ownerPrincipal === ctx.principal.url) return;
4719
4911
  throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner access requires the authenticated owner`, 403);
4720
4912
  }
4913
+ async function runnerHealth(request, ctx) {
4914
+ const runnerId = routeParam$1(request, `id`);
4915
+ const runner = await requireRunner(ctx, runnerId);
4916
+ assertRunnerOwnerIfAuthenticated(ctx, runner.owner_principal);
4917
+ const runtimeDiagnostics = await ctx.entityManager.registry.getRunnerDiagnostics(runnerId);
4918
+ const now = Date.now();
4919
+ const parsedLeaseExpiresAt = runtimeDiagnostics?.liveness_lease_expires_at ? new Date(runtimeDiagnostics.liveness_lease_expires_at).getTime() : null;
4920
+ const leaseExpiresAt = parsedLeaseExpiresAt !== null && Number.isFinite(parsedLeaseExpiresAt) ? parsedLeaseExpiresAt : null;
4921
+ let livenessStatus;
4922
+ if (runner.admin_status === `disabled`) livenessStatus = `offline`;
4923
+ else if (leaseExpiresAt !== null && leaseExpiresAt > now) livenessStatus = `online`;
4924
+ else if (leaseExpiresAt !== null) livenessStatus = `expired`;
4925
+ else livenessStatus = `offline`;
4926
+ const [activeClaims, dispatchStats] = await Promise.all([ctx.entityManager.registry.getActiveClaimsForRunner(runnerId), ctx.entityManager.registry.getDispatchStatsForRunner(runnerId)]);
4927
+ const clientDiagnostics = sanitizeRunnerDiagnostics(runtimeDiagnostics?.diagnostics) ?? null;
4928
+ const issues = [];
4929
+ let healthStatus = `healthy`;
4930
+ const escalate = (floor) => {
4931
+ if (floor === `unhealthy`) healthStatus = `unhealthy`;
4932
+ else if (healthStatus === `healthy`) healthStatus = `degraded`;
4933
+ };
4934
+ if (runner.admin_status === `disabled`) {
4935
+ escalate(`unhealthy`);
4936
+ issues.push(`Runner is disabled`);
4937
+ }
4938
+ if (livenessStatus === `expired`) {
4939
+ escalate(`unhealthy`);
4940
+ const ago = leaseExpiresAt ? Math.round((now - leaseExpiresAt) / 1e3) : 0;
4941
+ issues.push(`Heartbeat lease expired ${ago}s ago`);
4942
+ }
4943
+ if (livenessStatus === `offline` && runner.admin_status === `enabled`) {
4944
+ escalate(`degraded`);
4945
+ issues.push(`Runner has never sent a heartbeat`);
4946
+ }
4947
+ if (clientDiagnostics) {
4948
+ if (clientDiagnostics.stream_connected === false) {
4949
+ escalate(`degraded`);
4950
+ issues.push(`Client reports stream disconnected`);
4951
+ }
4952
+ if (clientDiagnostics.last_heartbeat_ok === false) {
4953
+ escalate(`degraded`);
4954
+ issues.push(`Client reports last heartbeat failed`);
4955
+ }
4956
+ if (typeof clientDiagnostics.reconnect_count === `number` && clientDiagnostics.reconnect_count > 5) {
4957
+ escalate(`degraded`);
4958
+ issues.push(`Client has reconnected ${clientDiagnostics.reconnect_count} times`);
4959
+ }
4960
+ } else if (runtimeDiagnostics?.last_seen_at) {
4961
+ escalate(`degraded`);
4962
+ issues.push(`No client diagnostics available`);
4963
+ }
4964
+ const body = {
4965
+ runner: {
4966
+ id: runner.id,
4967
+ admin_status: runner.admin_status,
4968
+ liveness_status: livenessStatus,
4969
+ lease_expires_at: leaseExpiresAt !== null ? runtimeDiagnostics?.liveness_lease_expires_at ?? null : null,
4970
+ lease_remaining_ms: leaseExpiresAt !== null ? Math.max(0, leaseExpiresAt - now) : null,
4971
+ wake_stream: runner.wake_stream,
4972
+ wake_stream_offset: runtimeDiagnostics?.wake_stream_offset ?? null,
4973
+ last_seen_at: runtimeDiagnostics?.last_seen_at ?? null,
4974
+ created_at: runner.created_at
4975
+ },
4976
+ client: clientDiagnostics,
4977
+ claims: {
4978
+ active_count: activeClaims.length,
4979
+ active: activeClaims.map((c) => ({
4980
+ consumer_id: c.consumer_id,
4981
+ epoch: c.epoch,
4982
+ entity_url: c.entity_url,
4983
+ stream_path: c.stream_path,
4984
+ claimed_at: c.claimed_at,
4985
+ last_heartbeat_at: c.last_heartbeat_at ?? null,
4986
+ lease_expires_at: c.lease_expires_at ?? null
4987
+ }))
4988
+ },
4989
+ dispatch: dispatchStats,
4990
+ health: {
4991
+ status: healthStatus,
4992
+ issues
4993
+ }
4994
+ };
4995
+ return json(body);
4996
+ }
4721
4997
  async function notificationFromClaim(ctx, input) {
4722
4998
  const primary = input.claim.streams.find((stream) => stream.has_pending === true) ?? input.claim.streams[0];
4723
4999
  if (!primary?.path) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Claim response did not include a stream`, 502);
@@ -4904,7 +5180,7 @@ async function webhookForward(request, ctx) {
4904
5180
  let runningEntityUrl = null;
4905
5181
  const parsedBody = parsedBodyResult.value;
4906
5182
  const newWebhook = newWebhookPayload(parsedBody);
4907
- const routingAdapter = resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting);
5183
+ const routingAdapter = resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
4908
5184
  if (parsedBody) {
4909
5185
  const rawPrimaryStream = newWebhook?.primaryStream ?? parsedBody.primary_stream ?? parsedBody.primaryStream ?? parsedBody.streamPath ?? null;
4910
5186
  const primaryStream = typeof rawPrimaryStream === `string` ? toRuntimeStreamPath(rawPrimaryStream, ctx.service, routingAdapter) : null;
@@ -5033,7 +5309,7 @@ async function callbackForward(request, ctx) {
5033
5309
  }
5034
5310
  return json(responseBody);
5035
5311
  }
5036
- const upstreamBody = encodeCallbackForwardBody(ctx.service, consumerId, requestBody, resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting));
5312
+ const upstreamBody = encodeCallbackForwardBody(ctx.service, consumerId, requestBody, resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl));
5037
5313
  let upstream;
5038
5314
  try {
5039
5315
  const subscriptionId = durableStreamsSubscriptionCallback(target.callbackUrl);
@@ -6202,7 +6478,7 @@ var ElectricAgentsTenantRuntime = class {
6202
6478
  this.service = this.serviceId;
6203
6479
  this.db = options.db;
6204
6480
  if (options.streamClient) this.streamClient = options.streamClient;
6205
- else if (options.durableStreamsUrl) this.streamClient = new StreamClient(durableStreamsServiceUrl(options.durableStreamsUrl, this.serviceId), { bearer: options.durableStreamsBearer });
6481
+ else if (options.durableStreamsUrl) this.streamClient = new StreamClient(options.durableStreamsUrl, { bearer: options.durableStreamsBearer });
6206
6482
  else throw new Error(`Either durableStreamsUrl or streamClient is required`);
6207
6483
  this.registry = options.registry ?? new PostgresRegistry(this.db, this.serviceId);
6208
6484
  this.wakeRegistry = options.wakeRegistry;
@@ -7120,7 +7396,7 @@ var WakeRegistry = class {
7120
7396
  //#region src/standalone-runtime.ts
7121
7397
  async function startStandaloneAgentsRuntime(options) {
7122
7398
  const serviceId = options.service ?? options.tenantId ?? DEFAULT_TENANT_ID;
7123
- const streamClient = options.streamClient ?? (options.durableStreamsUrl ? new StreamClient(durableStreamsServiceUrl(options.durableStreamsUrl, serviceId), { bearer: options.durableStreamsBearer }) : void 0);
7399
+ const streamClient = options.streamClient ?? (options.durableStreamsUrl ? new StreamClient(options.durableStreamsUrl, { bearer: options.durableStreamsBearer }) : void 0);
7124
7400
  if (!streamClient) throw new Error(`Either durableStreamsUrl or streamClient is required`);
7125
7401
  const registry = new PostgresRegistry(options.db, serviceId);
7126
7402
  const wakeRegistry = options.wakeRegistry ?? new WakeRegistry(options.db, serviceId);
@@ -7268,6 +7544,11 @@ function createMockAgentBootstrap(options) {
7268
7544
  registry
7269
7545
  };
7270
7546
  }
7547
+ function durableStreamTestServerBackendUrl(origin) {
7548
+ const url = new URL(origin);
7549
+ url.pathname = `${url.pathname.replace(/\/+$/, ``)}/v1/stream`;
7550
+ return url.toString().replace(/\/+$/, ``);
7551
+ }
7271
7552
  var ElectricAgentsServer = class {
7272
7553
  server;
7273
7554
  electricAgentsManager;
@@ -7284,7 +7565,7 @@ var ElectricAgentsServer = class {
7284
7565
  constructor(options) {
7285
7566
  if (!options.durableStreamsUrl && !options.durableStreamsServer) throw new Error(`Either durableStreamsUrl or durableStreamsServer is required`);
7286
7567
  this.options = options;
7287
- this.streamClient = options.durableStreamsUrl ? new StreamClient(durableStreamsServiceUrl(options.durableStreamsUrl, this.tenantId), { bearer: options.durableStreamsBearer }) : null;
7568
+ this.streamClient = options.durableStreamsUrl ? new StreamClient(options.durableStreamsUrl, { bearer: options.durableStreamsBearer }) : null;
7288
7569
  }
7289
7570
  get url() {
7290
7571
  if (!this._url) throw new Error(`Server not started`);
@@ -7304,8 +7585,8 @@ var ElectricAgentsServer = class {
7304
7585
  serverLog.info(`[agent-server] starting durable streams server...`);
7305
7586
  const streamsUrl = await this.options.durableStreamsServer.start();
7306
7587
  serverLog.info(`[agent-server] durable streams server started at ${streamsUrl}`);
7307
- this.options.durableStreamsUrl = streamsUrl;
7308
- this.streamClient = new StreamClient(durableStreamsServiceUrl(streamsUrl, this.tenantId), { bearer: this.options.durableStreamsBearer });
7588
+ this.options.durableStreamsUrl = durableStreamTestServerBackendUrl(streamsUrl);
7589
+ this.streamClient = new StreamClient(this.options.durableStreamsUrl, { bearer: this.options.durableStreamsBearer });
7309
7590
  }
7310
7591
  this.streamsAgent = new Agent({
7311
7592
  keepAliveTimeout: 6e4,
@@ -7437,7 +7718,7 @@ var ElectricAgentsServer = class {
7437
7718
  localUrl: this._url,
7438
7719
  durableStreamsUrl: this.options.durableStreamsUrl,
7439
7720
  durableStreamsBearer: this.options.durableStreamsBearer,
7440
- durableStreamsRouting: this.options.durableStreamsRouting ?? pathPrefixedSingleTenantDurableStreamsRoutingAdapter,
7721
+ durableStreamsRouting: this.options.durableStreamsRouting,
7441
7722
  durableStreamsDispatcher: this.streamsAgent,
7442
7723
  electricUrl: this.options.electricUrl,
7443
7724
  electricSecret: this.options.electricSecret,