@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.
- package/dist/entrypoint.js +529 -248
- package/dist/index.cjs +1603 -1332
- package/dist/index.d.cts +274 -162
- package/dist/index.d.ts +274 -162
- package/dist/index.js +1601 -1332
- package/drizzle/0007_runner_diagnostics_and_principal.sql +22 -0
- package/drizzle/0008_runner_runtime_diagnostics.sql +50 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +33 -10
- package/src/electric-agents-types.ts +49 -3
- package/src/entity-registry.ts +136 -26
- package/src/host.ts +4 -5
- package/src/index.ts +5 -1
- package/src/principal.ts +23 -11
- package/src/routing/context.ts +1 -0
- package/src/routing/dispatch-policy.ts +123 -20
- package/src/routing/durable-streams-router.ts +286 -116
- package/src/routing/durable-streams-routing-adapter.ts +31 -64
- package/src/routing/electric-proxy-router.ts +1 -0
- package/src/routing/entities-router.ts +5 -5
- package/src/routing/hooks.ts +8 -1
- package/src/routing/internal-router.ts +6 -2
- package/src/routing/runners-router.ts +257 -19
- package/src/runtime.ts +4 -5
- package/src/server.ts +21 -15
- package/src/standalone-runtime.ts +4 -5
- package/src/stream-client.ts +18 -69
- package/src/utils/server-utils.ts +27 -8
package/dist/entrypoint.js
CHANGED
|
@@ -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
|
-
|
|
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(`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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","
|
|
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 === `
|
|
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.
|
|
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
|
|
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
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
return
|
|
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
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
1558
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
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
|
|
1702
|
-
if (!
|
|
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
|
|
1712
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
1873
|
+
wakeStreamOffset: input.wakeStreamOffset,
|
|
1874
|
+
diagnostics: input.diagnostics,
|
|
1853
1875
|
updatedAt: now
|
|
1854
|
-
}).
|
|
1855
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4330
|
-
|
|
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`,
|
|
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
|
-
|
|
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
|
|
4630
|
-
|
|
4631
|
-
if (
|
|
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
|
-
|
|
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
|
|
4645
|
-
|
|
4646
|
-
const
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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,
|
|
4717
|
-
|
|
4718
|
-
if (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
7721
|
+
durableStreamsRouting: this.options.durableStreamsRouting,
|
|
7441
7722
|
durableStreamsDispatcher: this.streamsAgent,
|
|
7442
7723
|
electricUrl: this.options.electricUrl,
|
|
7443
7724
|
electricSecret: this.options.electricSecret,
|