@electric-ax/agents-server 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ UPDATE consumer_claims
2
+ SET status = 'expired', updated_at = NOW()
3
+ WHERE status = 'active' AND runner_id IS NOT NULL;
4
+ --> statement-breakpoint
5
+ UPDATE entity_dispatch_state
6
+ SET active_runner_id = NULL,
7
+ active_consumer_id = NULL,
8
+ active_epoch = NULL,
9
+ active_claimed_at = NULL,
10
+ active_lease_expires_at = NULL,
11
+ updated_at = NOW()
12
+ WHERE active_runner_id IS NOT NULL;
13
+ --> statement-breakpoint
14
+ DELETE FROM runners;
15
+ --> statement-breakpoint
16
+ ALTER TABLE runners RENAME COLUMN owner_user_id TO owner_principal;
17
+ --> statement-breakpoint
18
+ DROP INDEX IF EXISTS idx_runners_owner_user_id;
19
+ --> statement-breakpoint
20
+ CREATE INDEX idx_runners_owner_principal ON runners (tenant_id, owner_principal);
21
+ --> statement-breakpoint
22
+ ALTER TABLE runners ADD COLUMN diagnostics jsonb;
@@ -0,0 +1,50 @@
1
+ CREATE TABLE runner_runtime_diagnostics (
2
+ tenant_id text NOT NULL DEFAULT 'default',
3
+ runner_id text NOT NULL,
4
+ owner_principal text NOT NULL,
5
+ wake_stream_offset text,
6
+ last_seen_at timestamp with time zone NOT NULL,
7
+ liveness_lease_expires_at timestamp with time zone NOT NULL,
8
+ diagnostics jsonb,
9
+ updated_at timestamp with time zone NOT NULL DEFAULT NOW(),
10
+ PRIMARY KEY (tenant_id, runner_id)
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE INDEX idx_runner_runtime_diagnostics_owner
14
+ ON runner_runtime_diagnostics (tenant_id, owner_principal);
15
+ --> statement-breakpoint
16
+ CREATE INDEX idx_runner_runtime_diagnostics_liveness
17
+ ON runner_runtime_diagnostics (tenant_id, liveness_lease_expires_at);
18
+ --> statement-breakpoint
19
+ INSERT INTO runner_runtime_diagnostics (
20
+ tenant_id,
21
+ runner_id,
22
+ owner_principal,
23
+ wake_stream_offset,
24
+ last_seen_at,
25
+ liveness_lease_expires_at,
26
+ diagnostics,
27
+ updated_at
28
+ )
29
+ SELECT
30
+ tenant_id,
31
+ id,
32
+ owner_principal,
33
+ wake_stream_offset,
34
+ COALESCE(last_seen_at, updated_at),
35
+ COALESCE(liveness_lease_expires_at, updated_at),
36
+ diagnostics,
37
+ updated_at
38
+ FROM runners
39
+ WHERE last_seen_at IS NOT NULL
40
+ OR liveness_lease_expires_at IS NOT NULL
41
+ OR wake_stream_offset IS NOT NULL
42
+ OR diagnostics IS NOT NULL;
43
+ --> statement-breakpoint
44
+ ALTER TABLE runners DROP COLUMN diagnostics;
45
+ --> statement-breakpoint
46
+ ALTER TABLE runners DROP COLUMN wake_stream_offset;
47
+ --> statement-breakpoint
48
+ ALTER TABLE runners DROP COLUMN last_seen_at;
49
+ --> statement-breakpoint
50
+ ALTER TABLE runners DROP COLUMN liveness_lease_expires_at;
@@ -50,6 +50,20 @@
50
50
  "when": 1776268800000,
51
51
  "tag": "0006_principals",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1778899200000,
58
+ "tag": "0007_runner_diagnostics_and_principal",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1778976000000,
65
+ "tag": "0008_runner_runtime_diagnostics",
66
+ "breakpoints": true
53
67
  }
54
68
  ]
55
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -36,9 +36,9 @@
36
36
  "sideEffects": false,
37
37
  "dependencies": {
38
38
  "@anthropic-ai/sdk": "^0.78.0",
39
- "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@350",
40
- "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@350",
41
- "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@350",
39
+ "@durable-streams/client": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
+ "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@5d5c217",
41
+ "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
42
  "@electric-sql/client": "^1.5.18",
43
43
  "@mariozechner/pi-agent-core": "^0.70.2",
44
44
  "@opentelemetry/api": "^1.9.1",
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.2.1"
57
+ "@electric-ax/agents-runtime": "0.3.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.2",
69
- "@electric-ax/agents-server-conformance-tests": "0.1.5",
70
- "@electric-ax/agents-server-ui": "0.4.3"
68
+ "@electric-ax/agents-server-ui": "0.4.5",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.6",
70
+ "@electric-ax/agents": "0.4.4"
71
71
  },
72
72
  "files": [
73
73
  "dist",
package/src/db/schema.ts CHANGED
@@ -106,16 +106,11 @@ export const runners = pgTable(
106
106
  {
107
107
  tenantId: text(`tenant_id`).notNull().default(`default`),
108
108
  id: text(`id`).notNull(),
109
- ownerUserId: text(`owner_user_id`).notNull(),
109
+ ownerPrincipal: text(`owner_principal`).notNull(),
110
110
  label: text(`label`).notNull(),
111
111
  kind: text(`kind`).notNull().default(`local`),
112
112
  adminStatus: text(`admin_status`).notNull().default(`enabled`),
113
113
  wakeStream: text(`wake_stream`).notNull(),
114
- wakeStreamOffset: text(`wake_stream_offset`),
115
- lastSeenAt: timestamp(`last_seen_at`, { withTimezone: true }),
116
- livenessLeaseExpiresAt: timestamp(`liveness_lease_expires_at`, {
117
- withTimezone: true,
118
- }),
119
114
  createdAt: timestamp(`created_at`, { withTimezone: true })
120
115
  .notNull()
121
116
  .defaultNow(),
@@ -126,12 +121,11 @@ export const runners = pgTable(
126
121
  (table) => [
127
122
  primaryKey({ columns: [table.tenantId, table.id] }),
128
123
  unique(`uq_runners_wake_stream`).on(table.tenantId, table.wakeStream),
129
- index(`idx_runners_owner_user_id`).on(table.tenantId, table.ownerUserId),
130
- index(`idx_runners_admin_status`).on(table.tenantId, table.adminStatus),
131
- index(`idx_runners_liveness_lease_expires_at`).on(
124
+ index(`idx_runners_owner_principal`).on(
132
125
  table.tenantId,
133
- table.livenessLeaseExpiresAt
126
+ table.ownerPrincipal
134
127
  ),
128
+ index(`idx_runners_admin_status`).on(table.tenantId, table.adminStatus),
135
129
  check(
136
130
  `chk_runners_kind`,
137
131
  sql`${table.kind} IN ('local', 'cloud-worker', 'sandbox', 'ci', 'server')`
@@ -143,6 +137,35 @@ export const runners = pgTable(
143
137
  ]
144
138
  )
145
139
 
140
+ export const runnerRuntimeDiagnostics = pgTable(
141
+ `runner_runtime_diagnostics`,
142
+ {
143
+ tenantId: text(`tenant_id`).notNull().default(`default`),
144
+ runnerId: text(`runner_id`).notNull(),
145
+ ownerPrincipal: text(`owner_principal`).notNull(),
146
+ wakeStreamOffset: text(`wake_stream_offset`),
147
+ lastSeenAt: timestamp(`last_seen_at`, { withTimezone: true }).notNull(),
148
+ livenessLeaseExpiresAt: timestamp(`liveness_lease_expires_at`, {
149
+ withTimezone: true,
150
+ }).notNull(),
151
+ diagnostics: jsonb(`diagnostics`),
152
+ updatedAt: timestamp(`updated_at`, { withTimezone: true })
153
+ .notNull()
154
+ .defaultNow(),
155
+ },
156
+ (table) => [
157
+ primaryKey({ columns: [table.tenantId, table.runnerId] }),
158
+ index(`idx_runner_runtime_diagnostics_owner`).on(
159
+ table.tenantId,
160
+ table.ownerPrincipal
161
+ ),
162
+ index(`idx_runner_runtime_diagnostics_liveness`).on(
163
+ table.tenantId,
164
+ table.livenessLeaseExpiresAt
165
+ ),
166
+ ]
167
+ )
168
+
146
169
  export const entityDispatchState = pgTable(
147
170
  `entity_dispatch_state`,
148
171
  {
@@ -2,7 +2,10 @@
2
2
  * Types for the Electric Agents entity runtime.
3
3
  */
4
4
 
5
- import type { WebhookNotification } from '@electric-ax/agents-runtime'
5
+ import type {
6
+ PullWakeRunnerHealth,
7
+ WebhookNotification,
8
+ } from '@electric-ax/agents-runtime'
6
9
  import type { Principal } from './principal.js'
7
10
 
8
11
  type WakeNotification = WebhookNotification
@@ -105,7 +108,7 @@ export interface RunnerActiveClaim {
105
108
 
106
109
  export interface ElectricAgentsRunner {
107
110
  id: string
108
- owner_user_id: string
111
+ owner_principal: string
109
112
  label: string
110
113
  kind: RunnerKind
111
114
  admin_status: RunnerAdminStatus
@@ -115,13 +118,14 @@ export interface ElectricAgentsRunner {
115
118
  active_claims?: Array<RunnerActiveClaim>
116
119
  wake_stream: string
117
120
  wake_stream_offset?: string
121
+ diagnostics?: Record<string, unknown>
118
122
  created_at: string
119
123
  updated_at: string
120
124
  }
121
125
 
122
126
  export interface RegisterRunnerRequest {
123
127
  id: string
124
- owner_user_id: string
128
+ owner_principal: string
125
129
  label: string
126
130
  kind?: RunnerKind
127
131
  admin_status?: RunnerAdminStatus
@@ -133,6 +137,48 @@ export interface RunnerHeartbeatRequest {
133
137
  wake_stream_offset?: string
134
138
  wakeStreamOffset?: string
135
139
  liveness_lease_expires_at?: string
140
+ diagnostics?: Record<string, unknown>
141
+ }
142
+
143
+ export type RunnerHealthStatus = `healthy` | `degraded` | `unhealthy`
144
+ export type RunnerClientDiagnostics = Partial<
145
+ Omit<PullWakeRunnerHealth, `running` | `offset`>
146
+ >
147
+
148
+ export interface RunnerHealthResponse {
149
+ runner: {
150
+ id: string
151
+ admin_status: RunnerAdminStatus
152
+ liveness_status: RunnerLiveness | `expired`
153
+ lease_expires_at: string | null
154
+ lease_remaining_ms: number | null
155
+ wake_stream: string
156
+ wake_stream_offset: string | null
157
+ last_seen_at: string | null
158
+ created_at: string
159
+ }
160
+ client: RunnerClientDiagnostics | null
161
+ claims: {
162
+ active_count: number
163
+ active: Array<{
164
+ consumer_id: string
165
+ epoch: number
166
+ entity_url: string
167
+ stream_path: string
168
+ claimed_at: string
169
+ last_heartbeat_at: string | null
170
+ lease_expires_at: string | null
171
+ }>
172
+ }
173
+ dispatch: {
174
+ entities_with_active_claim: number
175
+ entities_with_outstanding_wake: number
176
+ entities_with_pending_work: number
177
+ }
178
+ health: {
179
+ status: RunnerHealthStatus
180
+ issues: Array<string>
181
+ }
136
182
  }
137
183
 
138
184
  export interface EntityDispatchState {
@@ -7,6 +7,7 @@ import {
7
7
  entityDispatchState,
8
8
  entityManifestSources,
9
9
  entityTypes,
10
+ runnerRuntimeDiagnostics,
10
11
  runners,
11
12
  tagStreamOutbox,
12
13
  } from './db/schema.js'
@@ -73,7 +74,7 @@ export interface TagStreamOutboxRow {
73
74
 
74
75
  export interface RegisterRunnerInput {
75
76
  id: string
76
- ownerUserId: string
77
+ ownerPrincipal: string
77
78
  label: string
78
79
  kind?: RunnerKind
79
80
  adminStatus?: RunnerAdminStatus
@@ -82,10 +83,22 @@ export interface RegisterRunnerInput {
82
83
 
83
84
  export interface HeartbeatRunnerInput {
84
85
  runnerId: string
86
+ ownerPrincipal: string
85
87
  heartbeatAt?: Date
86
88
  livenessLeaseExpiresAt?: Date
87
89
  leaseMs?: number
88
90
  wakeStreamOffset?: string
91
+ diagnostics?: Record<string, unknown>
92
+ }
93
+
94
+ export interface RunnerRuntimeDiagnostics {
95
+ runner_id: string
96
+ owner_principal: string
97
+ wake_stream_offset?: string
98
+ last_seen_at: string
99
+ liveness_lease_expires_at: string
100
+ diagnostics?: Record<string, unknown>
101
+ updated_at: string
89
102
  }
90
103
 
91
104
  export interface MaterializeActiveClaimInput {
@@ -140,7 +153,7 @@ export class PostgresRegistry {
140
153
  .values({
141
154
  tenantId: this.tenantId,
142
155
  id: input.id,
143
- ownerUserId: input.ownerUserId,
156
+ ownerPrincipal: input.ownerPrincipal,
144
157
  label: input.label,
145
158
  kind: input.kind ?? `local`,
146
159
  adminStatus: input.adminStatus ?? `enabled`,
@@ -150,7 +163,7 @@ export class PostgresRegistry {
150
163
  .onConflictDoUpdate({
151
164
  target: [runners.tenantId, runners.id],
152
165
  set: {
153
- ownerUserId: input.ownerUserId,
166
+ ownerPrincipal: input.ownerPrincipal,
154
167
  label: input.label,
155
168
  kind: input.kind ?? `local`,
156
169
  adminStatus: input.adminStatus ?? `enabled`,
@@ -176,11 +189,11 @@ export class PostgresRegistry {
176
189
  }
177
190
 
178
191
  async listRunners(filter?: {
179
- ownerUserId?: string
192
+ ownerPrincipal?: string
180
193
  }): Promise<Array<ElectricAgentsRunner>> {
181
194
  const conditions = [eq(runners.tenantId, this.tenantId)]
182
- if (filter?.ownerUserId) {
183
- conditions.push(eq(runners.ownerUserId, filter.ownerUserId))
195
+ if (filter?.ownerPrincipal) {
196
+ conditions.push(eq(runners.ownerPrincipal, filter.ownerPrincipal))
184
197
  }
185
198
  const rows = await this.db
186
199
  .select()
@@ -198,22 +211,66 @@ export class PostgresRegistry {
198
211
  input.livenessLeaseExpiresAt ??
199
212
  new Date(now.getTime() + (input.leaseMs ?? DEFAULT_RUNNER_LEASE_MS))
200
213
 
201
- const rows = await this.db
202
- .update(runners)
203
- .set({
214
+ await this.db
215
+ .insert(runnerRuntimeDiagnostics)
216
+ .values({
217
+ tenantId: this.tenantId,
218
+ runnerId: input.runnerId,
219
+ ownerPrincipal: input.ownerPrincipal,
204
220
  lastSeenAt: now,
205
221
  livenessLeaseExpiresAt: leaseExpiresAt,
206
- ...(input.wakeStreamOffset !== undefined
207
- ? { wakeStreamOffset: input.wakeStreamOffset }
208
- : {}),
222
+ wakeStreamOffset: input.wakeStreamOffset,
223
+ diagnostics: input.diagnostics,
209
224
  updatedAt: now,
210
225
  })
226
+ .onConflictDoUpdate({
227
+ target: [
228
+ runnerRuntimeDiagnostics.tenantId,
229
+ runnerRuntimeDiagnostics.runnerId,
230
+ ],
231
+ set: {
232
+ lastSeenAt: now,
233
+ ownerPrincipal: input.ownerPrincipal,
234
+ livenessLeaseExpiresAt: leaseExpiresAt,
235
+ ...(input.wakeStreamOffset !== undefined
236
+ ? { wakeStreamOffset: input.wakeStreamOffset }
237
+ : {}),
238
+ ...(input.diagnostics !== undefined
239
+ ? { diagnostics: input.diagnostics }
240
+ : {}),
241
+ updatedAt: now,
242
+ },
243
+ })
244
+
245
+ const runner = await this.getRunner(input.runnerId)
246
+ if (!runner) return null
247
+ return {
248
+ ...runner,
249
+ last_seen_at: now.toISOString(),
250
+ liveness_lease_expires_at: leaseExpiresAt.toISOString(),
251
+ ...(input.wakeStreamOffset !== undefined
252
+ ? { wake_stream_offset: input.wakeStreamOffset }
253
+ : {}),
254
+ ...(input.diagnostics !== undefined
255
+ ? { diagnostics: input.diagnostics }
256
+ : {}),
257
+ }
258
+ }
259
+
260
+ async getRunnerDiagnostics(
261
+ runnerId: string
262
+ ): Promise<RunnerRuntimeDiagnostics | null> {
263
+ const rows = await this.db
264
+ .select()
265
+ .from(runnerRuntimeDiagnostics)
211
266
  .where(
212
- and(eq(runners.tenantId, this.tenantId), eq(runners.id, input.runnerId))
267
+ and(
268
+ eq(runnerRuntimeDiagnostics.tenantId, this.tenantId),
269
+ eq(runnerRuntimeDiagnostics.runnerId, runnerId)
270
+ )
213
271
  )
214
- .returning()
215
-
216
- return rows[0] ? this.rowToRunner(rows[0]) : null
272
+ .limit(1)
273
+ return rows[0] ? this.rowToRunnerRuntimeDiagnostics(rows[0]) : null
217
274
  }
218
275
 
219
276
  async setRunnerAdminStatus(
@@ -366,6 +423,54 @@ export class PostgresRegistry {
366
423
  return claim
367
424
  }
368
425
 
426
+ async getActiveClaimsForRunner(
427
+ runnerId: string
428
+ ): Promise<Array<ConsumerClaim>> {
429
+ const rows = await this.db
430
+ .select()
431
+ .from(consumerClaims)
432
+ .where(
433
+ and(
434
+ eq(consumerClaims.tenantId, this.tenantId),
435
+ eq(consumerClaims.runnerId, runnerId),
436
+ eq(consumerClaims.status, `active`)
437
+ )
438
+ )
439
+ return rows.map((row) => this.rowToConsumerClaim(row))
440
+ }
441
+
442
+ async getDispatchStatsForRunner(runnerId: string): Promise<{
443
+ entities_with_active_claim: number
444
+ entities_with_outstanding_wake: number
445
+ entities_with_pending_work: number
446
+ }> {
447
+ const rows = await this.db
448
+ .select()
449
+ .from(entityDispatchState)
450
+ .where(
451
+ and(
452
+ eq(entityDispatchState.tenantId, this.tenantId),
453
+ eq(entityDispatchState.activeRunnerId, runnerId)
454
+ )
455
+ )
456
+
457
+ let activeClaim = 0
458
+ let outstandingWake = 0
459
+ let pendingWork = 0
460
+ for (const row of rows) {
461
+ if (row.activeConsumerId) activeClaim++
462
+ if (row.outstandingWakeId && !row.activeConsumerId) outstandingWake++
463
+ const pending = row.pendingSourceStreams as Array<unknown> | null
464
+ if (pending && pending.length > 0) pendingWork++
465
+ }
466
+
467
+ return {
468
+ entities_with_active_claim: activeClaim,
469
+ entities_with_outstanding_wake: outstandingWake,
470
+ entities_with_pending_work: pendingWork,
471
+ }
472
+ }
473
+
369
474
  private entityTypeWhere(name: string) {
370
475
  return and(
371
476
  eq(entityTypes.tenantId, this.tenantId),
@@ -1146,27 +1251,32 @@ export class PostgresRegistry {
1146
1251
  }
1147
1252
 
1148
1253
  private rowToRunner(row: typeof runners.$inferSelect): ElectricAgentsRunner {
1149
- const now = Date.now()
1150
- const livenessExpiry = row.livenessLeaseExpiresAt?.getTime()
1151
1254
  return {
1152
1255
  id: row.id,
1153
- owner_user_id: row.ownerUserId,
1256
+ owner_principal: row.ownerPrincipal,
1154
1257
  label: row.label,
1155
1258
  kind: assertRunnerKind(row.kind),
1156
1259
  admin_status: assertRunnerAdminStatus(row.adminStatus),
1157
- liveness:
1158
- livenessExpiry !== undefined && livenessExpiry > now
1159
- ? `online`
1160
- : `offline`,
1161
- last_seen_at: row.lastSeenAt?.toISOString(),
1162
- liveness_lease_expires_at: row.livenessLeaseExpiresAt?.toISOString(),
1163
1260
  wake_stream: row.wakeStream,
1164
- wake_stream_offset: row.wakeStreamOffset ?? undefined,
1165
1261
  created_at: row.createdAt.toISOString(),
1166
1262
  updated_at: row.updatedAt.toISOString(),
1167
1263
  }
1168
1264
  }
1169
1265
 
1266
+ private rowToRunnerRuntimeDiagnostics(
1267
+ row: typeof runnerRuntimeDiagnostics.$inferSelect
1268
+ ): RunnerRuntimeDiagnostics {
1269
+ return {
1270
+ runner_id: row.runnerId,
1271
+ owner_principal: row.ownerPrincipal,
1272
+ wake_stream_offset: row.wakeStreamOffset ?? undefined,
1273
+ last_seen_at: row.lastSeenAt.toISOString(),
1274
+ liveness_lease_expires_at: row.livenessLeaseExpiresAt.toISOString(),
1275
+ diagnostics: (row.diagnostics as Record<string, unknown>) ?? undefined,
1276
+ updated_at: row.updatedAt.toISOString(),
1277
+ }
1278
+ }
1279
+
1170
1280
  private rowToConsumerClaim(
1171
1281
  row: typeof consumerClaims.$inferSelect
1172
1282
  ): ConsumerClaim {
package/src/host.ts CHANGED
@@ -3,7 +3,7 @@ import { PostgresRegistry } from './entity-registry.js'
3
3
  import { EntityProjector } from './entity-projector.js'
4
4
  import { ElectricAgentsTenantRuntime } from './runtime.js'
5
5
  import { PostgresSchedulerClient, Scheduler } from './scheduler.js'
6
- import { StreamClient, durableStreamsServiceUrl } from './stream-client.js'
6
+ import { StreamClient } from './stream-client.js'
7
7
  import { TagStreamOutboxDrainer } from './tag-stream-outbox-drainer.js'
8
8
  import { DEFAULT_TENANT_ID, UnregisteredTenantError } from './tenant.js'
9
9
  import { WakeRegistry } from './wake-registry.js'
@@ -313,10 +313,9 @@ export class AgentsHost {
313
313
  private createStreamClient(config: AgentsHostTenantConfig): StreamClient {
314
314
  if (config.streamClient) return config.streamClient
315
315
  if (config.durableStreamsUrl) {
316
- return new StreamClient(
317
- durableStreamsServiceUrl(config.durableStreamsUrl, config.serviceId),
318
- { bearer: config.durableStreamsBearer }
319
- )
316
+ return new StreamClient(config.durableStreamsUrl, {
317
+ bearer: config.durableStreamsBearer,
318
+ })
320
319
  }
321
320
  throw new Error(
322
321
  `AgentsHost tenant "${config.serviceId}" must provide a streamClient or durableStreamsUrl`
package/src/principal.ts CHANGED
@@ -20,7 +20,7 @@ const PRINCIPAL_KINDS = new Set<PrincipalKind>([
20
20
 
21
21
  export function parsePrincipalKey(input: string): Principal {
22
22
  const colon = input.indexOf(`:`)
23
- if (colon <= 0) throw new Error(`Invalid principal key`)
23
+ if (colon <= 0) throw new Error(`Invalid principal identifier`)
24
24
  const kind = input.slice(0, colon) as PrincipalKind
25
25
  const id = input.slice(colon + 1)
26
26
  if (!PRINCIPAL_KINDS.has(kind)) throw new Error(`Invalid principal kind`)
@@ -33,24 +33,38 @@ export function principalUrl(key: string): string {
33
33
  return parsePrincipalKey(key).url
34
34
  }
35
35
 
36
- export function principalKeyFromUrl(url: string): string | null {
36
+ export function parsePrincipalUrl(url: string): Principal | null {
37
37
  if (!url.startsWith(`/principal/`)) return null
38
38
  const segment = url.slice(`/principal/`.length)
39
39
  if (!segment || segment.includes(`/`)) return null
40
40
  try {
41
- const key = decodeURIComponent(segment)
42
41
  // Principal URLs produced by parsePrincipalKey/principalUrl are canonical
43
42
  // encoded single path segments, but accept legacy unencoded single-segment
44
43
  // URLs here so callers can canonicalize them via parsePrincipalKey(key).url.
45
- return parsePrincipalKey(key).key
44
+ return parsePrincipalKey(decodeURIComponent(segment))
46
45
  } catch {
47
46
  return null
48
47
  }
49
48
  }
50
49
 
50
+ export function parsePrincipalInput(input: string): Principal | null {
51
+ const urlPrincipal = parsePrincipalUrl(input)
52
+ if (urlPrincipal) return urlPrincipal
53
+ try {
54
+ return parsePrincipalKey(input)
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export function isPrincipalUrl(url: string): boolean {
61
+ return parsePrincipalUrl(url) !== null
62
+ }
63
+
51
64
  export function getPrincipalFromRequest(request: Request): Principal | null {
52
65
  const value = request.headers.get(ELECTRIC_PRINCIPAL_HEADER)
53
- return value ? parsePrincipalKey(value) : null
66
+ if (!value) return null
67
+ return parsePrincipalInput(value)
54
68
  }
55
69
 
56
70
  export function getDevPrincipal(): Principal {
@@ -66,9 +80,8 @@ const BUILT_IN_SYSTEM_PRINCIPAL_IDS = new Set([
66
80
  export function isBuiltInSystemPrincipalUrl(url: string | undefined): boolean {
67
81
  if (!url?.startsWith(`/principal/`)) return false
68
82
  try {
69
- const key = principalKeyFromUrl(url)
70
- if (!key) return false
71
- const principal = parsePrincipalKey(key)
83
+ const principal = parsePrincipalUrl(url)
84
+ if (!principal) return false
72
85
  return (
73
86
  principal.kind === `system` &&
74
87
  BUILT_IN_SYSTEM_PRINCIPAL_IDS.has(principal.id)
@@ -84,9 +97,8 @@ export function principalFromCreatedBy(
84
97
  | { url: string; key?: string | null; kind?: string; id?: string }
85
98
  | undefined {
86
99
  if (!createdBy) return undefined
87
- const key = principalKeyFromUrl(createdBy)
88
- if (!key) return { url: createdBy, key: null }
89
- const principal = parsePrincipalKey(key)
100
+ const principal = parsePrincipalUrl(createdBy)
101
+ if (!principal) return { url: createdBy, key: null }
90
102
  return {
91
103
  url: principal.url,
92
104
  key: principal.key,
@@ -19,7 +19,7 @@ export interface TenantContext {
19
19
  principal: Principal
20
20
  publicUrl: string
21
21
  localUrl?: string
22
- /** Resolved Durable Streams root URL for this tenant. */
22
+ /** Durable Streams backend URL prefix. Stream and control paths are appended as-is. */
23
23
  durableStreamsUrl: string
24
24
  durableStreamsBearer?: DurableStreamsBearerProvider
25
25
  durableStreamsRouting?: DurableStreamsRoutingAdapter