@electric-ax/agents-server 0.3.0

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.
Files changed (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,143 @@
1
+ import Ajv from 'ajv'
2
+ import {
3
+ ErrCodeInvalidRequest,
4
+ ErrCodeSchemaValidationFailed,
5
+ } from '../electric-agents-types.js'
6
+
7
+ export class SchemaValidator {
8
+ private ajv: Ajv
9
+
10
+ constructor() {
11
+ this.ajv = new Ajv({ allErrors: true })
12
+ }
13
+
14
+ /**
15
+ * Validate data against a JSON Schema. Returns null if valid.
16
+ * Returns error details on failure.
17
+ */
18
+ validate(
19
+ schema: Record<string, unknown>,
20
+ data: unknown
21
+ ): {
22
+ code: string
23
+ message: string
24
+ details: Array<{ path: string; message: string }>
25
+ } | null {
26
+ const validate = this.ajv.compile(schema)
27
+ if (validate(data)) return null
28
+
29
+ return {
30
+ code: ErrCodeSchemaValidationFailed,
31
+ message: `Validation failed`,
32
+ details: (validate.errors ?? []).map((err) => ({
33
+ path: err.instancePath || `/`,
34
+ message: err.message ?? `validation error`,
35
+ })),
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check that a JSON Schema only uses allowed keywords.
41
+ * Returns null if valid, error details if disallowed keywords found.
42
+ */
43
+ validateSchemaSubset(schema: Record<string, unknown>): {
44
+ code: string
45
+ message: string
46
+ details: Array<{ path: string; message: string }>
47
+ } | null {
48
+ // Recursively walk the schema and check each key
49
+ const disallowed = this.findDisallowedKeywords(schema, ``)
50
+ if (disallowed.length === 0) return null
51
+
52
+ return {
53
+ code: ErrCodeInvalidRequest,
54
+ message: `Schema uses disallowed keywords`,
55
+ details: disallowed,
56
+ }
57
+ }
58
+
59
+ private findDisallowedKeywords(
60
+ obj: Record<string, unknown>,
61
+ path: string
62
+ ): Array<{ path: string; message: string }> {
63
+ const issues: Array<{ path: string; message: string }> = []
64
+
65
+ for (const [key, value] of Object.entries(obj)) {
66
+ if (!ALLOWED_SCHEMA_KEYWORDS.has(key)) {
67
+ issues.push({
68
+ path: path ? `${path}/${key}` : `/${key}`,
69
+ message: `Disallowed keyword: ${key}`,
70
+ })
71
+ }
72
+
73
+ if (Array.isArray(value)) {
74
+ if (key === `anyOf` || key === `oneOf` || key === `allOf`) {
75
+ for (const [index, item] of value.entries()) {
76
+ if (isPlainObject(item)) {
77
+ issues.push(
78
+ ...this.findDisallowedKeywords(item, `${path}/${key}/${index}`)
79
+ )
80
+ }
81
+ }
82
+ }
83
+ continue
84
+ }
85
+
86
+ if (!isPlainObject(value)) continue
87
+
88
+ if (key === `properties` || key === `$defs` || key === `definitions`) {
89
+ for (const [subKey, subValue] of Object.entries(value)) {
90
+ if (isPlainObject(subValue)) {
91
+ issues.push(
92
+ ...this.findDisallowedKeywords(
93
+ subValue,
94
+ `${path}/${key}/${subKey}`
95
+ )
96
+ )
97
+ }
98
+ }
99
+ } else if (key === `items`) {
100
+ issues.push(...this.findDisallowedKeywords(value, `${path}/items`))
101
+ }
102
+ }
103
+
104
+ return issues
105
+ }
106
+ }
107
+
108
+ const ALLOWED_SCHEMA_KEYWORDS = new Set([
109
+ `type`,
110
+ `properties`,
111
+ `required`,
112
+ `enum`,
113
+ `const`,
114
+ `minimum`,
115
+ `maximum`,
116
+ `exclusiveMinimum`,
117
+ `exclusiveMaximum`,
118
+ `minLength`,
119
+ `maxLength`,
120
+ `pattern`,
121
+ `items`,
122
+ `minItems`,
123
+ `maxItems`,
124
+ `$ref`,
125
+ `anyOf`,
126
+ `oneOf`,
127
+ `allOf`,
128
+ `not`,
129
+ `format`,
130
+ `title`,
131
+ `description`,
132
+ `default`,
133
+ `additionalProperties`,
134
+ `nullable`,
135
+ `$schema`,
136
+ `$id`,
137
+ `$defs`,
138
+ `definitions`,
139
+ ])
140
+
141
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
142
+ return value !== null && typeof value === `object` && !Array.isArray(value)
143
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared HTTP utilities for Electric Agents route handlers.
3
+ */
4
+
5
+ import { json } from 'itty-router'
6
+
7
+ export function apiError(
8
+ status: number,
9
+ code: string,
10
+ message: string,
11
+ details?: unknown
12
+ ): Response {
13
+ return json(
14
+ {
15
+ error: {
16
+ code,
17
+ message,
18
+ ...(details ? { details } : {}),
19
+ },
20
+ },
21
+ { status }
22
+ )
23
+ }
24
+
25
+ export async function readRequestBody(request: Request): Promise<Uint8Array> {
26
+ return new Uint8Array(await request.arrayBuffer())
27
+ }
28
+
29
+ export function responseHeaders(response: Response): Record<string, string> {
30
+ const headers: Record<string, string> = {}
31
+ response.headers.forEach((value, key) => {
32
+ if (
33
+ key === `content-encoding` ||
34
+ key === `content-length` ||
35
+ key === `transfer-encoding` ||
36
+ key === `connection` ||
37
+ key.startsWith(`access-control-`)
38
+ ) {
39
+ return
40
+ }
41
+ headers[key] = value
42
+ })
43
+ headers[`access-control-allow-origin`] = `*`
44
+ headers[`access-control-expose-headers`] = `*`
45
+ return headers
46
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Types for the Electric Agents entity runtime.
3
+ */
4
+
5
+ import type { WebhookNotification } from '@electric-ax/agents-runtime'
6
+
7
+ type WakeNotification = WebhookNotification
8
+
9
+ export interface AuthenticatedRequestUser {
10
+ userId: string
11
+ email?: string
12
+ name?: string
13
+ }
14
+
15
+ export type AuthenticateRequest = (
16
+ request: Request
17
+ ) => Promise<AuthenticatedRequestUser | null> | AuthenticatedRequestUser | null
18
+
19
+ export type EntityStatus = `spawning` | `running` | `idle` | `stopped`
20
+
21
+ const VALID_ENTITY_STATUSES = new Set<string>([
22
+ `spawning`,
23
+ `running`,
24
+ `idle`,
25
+ `stopped`,
26
+ ])
27
+
28
+ export function assertEntityStatus(s: string): EntityStatus {
29
+ if (!VALID_ENTITY_STATUSES.has(s)) {
30
+ throw new Error(`Invalid entity status: "${s}"`)
31
+ }
32
+ return s as EntityStatus
33
+ }
34
+
35
+ export type DispatchTarget =
36
+ | { type: `webhook`; url: string; subscription_id?: string }
37
+ | { type: `runner`; runnerId: string; subscription_id?: string }
38
+
39
+ export interface DispatchPolicy {
40
+ readonly targets: readonly [DispatchTarget, ...ReadonlyArray<DispatchTarget>]
41
+ }
42
+
43
+ export type RunnerKind = `local` | `cloud-worker` | `sandbox` | `ci` | `server`
44
+ export type RunnerAdminStatus = `enabled` | `disabled`
45
+ export type RunnerLiveness = `online` | `offline`
46
+
47
+ const VALID_RUNNER_KINDS = new Set<string>([
48
+ `local`,
49
+ `cloud-worker`,
50
+ `sandbox`,
51
+ `ci`,
52
+ `server`,
53
+ ])
54
+ const VALID_RUNNER_ADMIN_STATUSES = new Set<string>([`enabled`, `disabled`])
55
+
56
+ export function assertRunnerKind(s: string): RunnerKind {
57
+ if (!VALID_RUNNER_KINDS.has(s)) {
58
+ throw new Error(`Invalid runner kind: "${s}"`)
59
+ }
60
+ return s as RunnerKind
61
+ }
62
+
63
+ export function assertRunnerAdminStatus(s: string): RunnerAdminStatus {
64
+ if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) {
65
+ throw new Error(`Invalid runner admin status: "${s}"`)
66
+ }
67
+ return s as RunnerAdminStatus
68
+ }
69
+
70
+ export type WakeDeliveryStatus =
71
+ | `queued`
72
+ | `delivered`
73
+ | `failed`
74
+ | `superseded`
75
+ export type WakeClaimStatus = `unclaimed` | `claimed` | `completed` | `expired`
76
+ export type ConsumerClaimStatus = `active` | `released` | `expired` | `failed`
77
+
78
+ export interface SourceStreamOffset {
79
+ path: string
80
+ offset: string
81
+ }
82
+
83
+ export type PublicWakeNotification = Omit<
84
+ WakeNotification,
85
+ `callback` | `claimToken` | `entity`
86
+ > & {
87
+ entity?: NonNullable<WakeNotification[`entity`]>
88
+ }
89
+
90
+ export interface ElectricAgentsUser {
91
+ id: string
92
+ display_name?: string
93
+ email?: string
94
+ avatar_url?: string
95
+ auth_provider?: string
96
+ auth_subject?: string
97
+ profile: Record<string, unknown>
98
+ metadata: Record<string, unknown>
99
+ created_at: string
100
+ updated_at: string
101
+ }
102
+
103
+ export interface RunnerActiveClaim {
104
+ entityPath: string
105
+ consumerId: string
106
+ claimedAt: string
107
+ leaseExpiresAt?: string
108
+ }
109
+
110
+ export interface ElectricAgentsRunner {
111
+ id: string
112
+ owner_user_id: string
113
+ label: string
114
+ kind: RunnerKind
115
+ admin_status: RunnerAdminStatus
116
+ liveness?: RunnerLiveness
117
+ last_seen_at?: string
118
+ liveness_lease_expires_at?: string
119
+ active_claims?: Array<RunnerActiveClaim>
120
+ wake_stream: string
121
+ wake_stream_offset?: string
122
+ created_at: string
123
+ updated_at: string
124
+ }
125
+
126
+ export interface RegisterRunnerRequest {
127
+ id: string
128
+ owner_user_id: string
129
+ label: string
130
+ kind?: RunnerKind
131
+ admin_status?: RunnerAdminStatus
132
+ wake_stream?: string
133
+ }
134
+
135
+ export interface RunnerHeartbeatRequest {
136
+ lease_ms?: number
137
+ wake_stream_offset?: string
138
+ wakeStreamOffset?: string
139
+ liveness_lease_expires_at?: string
140
+ }
141
+
142
+ export interface EntityDispatchState {
143
+ entity_url: string
144
+ pending_source_streams: Array<SourceStreamOffset>
145
+ pending_reason?: string
146
+ pending_since?: string
147
+ outstanding_wake_id?: string
148
+ outstanding_wake_target?: DispatchTarget
149
+ outstanding_wake_created_at?: string
150
+ active_consumer_id?: string
151
+ active_runner_id?: string
152
+ active_epoch?: number
153
+ active_claimed_at?: string
154
+ active_lease_expires_at?: string
155
+ last_wake_id?: string
156
+ last_claimed_at?: string
157
+ last_released_at?: string
158
+ last_completed_at?: string
159
+ last_error?: string
160
+ updated_at: string
161
+ }
162
+
163
+ export interface WakeNotificationRow {
164
+ wake_id: string
165
+ entity_url: string
166
+ target_type: DispatchTarget[`type`]
167
+ target_runner_id?: string
168
+ target_webhook_url?: string
169
+ target_worker_pool_id?: string
170
+ runner_wake_stream?: string
171
+ runner_wake_stream_offset?: string
172
+ notification_public: PublicWakeNotification
173
+ delivery_status: WakeDeliveryStatus
174
+ claim_status: WakeClaimStatus
175
+ created_at: string
176
+ delivered_at?: string
177
+ claimed_at?: string
178
+ resolved_at?: string
179
+ }
180
+
181
+ export interface ConsumerClaim {
182
+ consumer_id: string
183
+ epoch: number
184
+ wake_id?: string
185
+ entity_url: string
186
+ stream_path: string
187
+ runner_id?: string
188
+ status: ConsumerClaimStatus
189
+ claimed_at: string
190
+ last_heartbeat_at?: string
191
+ lease_expires_at?: string
192
+ released_at?: string
193
+ acked_streams?: Array<SourceStreamOffset>
194
+ updated_at: string
195
+ }
196
+
197
+ export interface ElectricAgentsEntity {
198
+ url: string
199
+ type: string
200
+ status: EntityStatus
201
+ streams: {
202
+ main: string
203
+ error: string
204
+ }
205
+ subscription_id: string
206
+ dispatch_policy?: DispatchPolicy
207
+ write_token: string
208
+ tags: Record<string, string>
209
+ spawn_args?: Record<string, unknown>
210
+ parent?: string
211
+ type_revision?: number
212
+ inbox_schemas?: Record<string, Record<string, unknown>>
213
+ state_schemas?: Record<string, Record<string, unknown>>
214
+ created_at: number
215
+ updated_at: number
216
+ }
217
+
218
+ /** Public-facing entity — internal fields stripped. Standalone type so new internal fields don't silently leak. */
219
+ export interface PublicElectricAgentsEntity {
220
+ url: string
221
+ type: string
222
+ status: EntityStatus
223
+ streams: { main: string; error: string }
224
+ dispatch_policy?: DispatchPolicy
225
+ tags: Record<string, string>
226
+ spawn_args?: Record<string, unknown>
227
+ parent?: string
228
+ created_at: number
229
+ updated_at: number
230
+ }
231
+
232
+ /** Entity row as stored in Postgres / returned by Electric shapes (no derived `streams` field). */
233
+ export type ElectricAgentsEntityRow = Omit<
234
+ PublicElectricAgentsEntity,
235
+ `streams`
236
+ >
237
+
238
+ /** Strip internal fields (write_token, subscription_id) from an entity. */
239
+ export function toPublicEntity(
240
+ entity: ElectricAgentsEntity
241
+ ): PublicElectricAgentsEntity {
242
+ return {
243
+ url: entity.url,
244
+ type: entity.type,
245
+ status: entity.status,
246
+ streams: entity.streams,
247
+ dispatch_policy: entity.dispatch_policy,
248
+ tags: entity.tags,
249
+ spawn_args: entity.spawn_args,
250
+ parent: entity.parent,
251
+ created_at: entity.created_at,
252
+ updated_at: entity.updated_at,
253
+ }
254
+ }
255
+
256
+ export interface ElectricAgentsEntityType {
257
+ name: string
258
+ description: string
259
+ creation_schema?: Record<string, unknown>
260
+ inbox_schemas?: Record<string, Record<string, unknown>>
261
+ state_schemas?: Record<string, Record<string, unknown>>
262
+ serve_endpoint?: string
263
+ default_dispatch_policy?: DispatchPolicy
264
+ revision: number
265
+ created_at: string
266
+ updated_at: string
267
+ }
268
+
269
+ export interface RegisterEntityTypeRequest {
270
+ name: string
271
+ description: string
272
+ creation_schema?: Record<string, unknown>
273
+ inbox_schemas?: Record<string, Record<string, unknown>>
274
+ state_schemas?: Record<string, Record<string, unknown>>
275
+ serve_endpoint?: string
276
+ default_dispatch_policy?: DispatchPolicy
277
+ }
278
+
279
+ export interface TypedSpawnRequest {
280
+ instance_id: string
281
+ args?: Record<string, unknown>
282
+ tags?: Record<string, string>
283
+ parent?: string
284
+ dispatch_policy?: DispatchPolicy
285
+ initialMessage?: unknown
286
+ wake?: {
287
+ subscriberUrl: string
288
+ condition:
289
+ | `runFinished`
290
+ | {
291
+ on: `change`
292
+ collections?: Array<string>
293
+ ops?: Array<`insert` | `update` | `delete`>
294
+ }
295
+ debounceMs?: number
296
+ timeoutMs?: number
297
+ includeResponse?: boolean
298
+ }
299
+ }
300
+
301
+ export interface SendRequest {
302
+ from?: string
303
+ payload?: unknown
304
+ key?: string
305
+ type?: string
306
+ }
307
+
308
+ export interface SetTagRequest {
309
+ value: string
310
+ }
311
+
312
+ export interface EntityListFilter {
313
+ type?: string
314
+ status?: EntityStatus
315
+ }
316
+
317
+ export const ErrCodeDuplicateURL = `DUPLICATE_URL`
318
+ export const ErrCodeUnauthorized = `UNAUTHORIZED`
319
+ export const ErrCodeNoSubscription = `NO_SUBSCRIPTION`
320
+ export const ErrCodeNotFound = `NOT_FOUND`
321
+ export const ErrCodeNotRunning = `NOT_RUNNING`
322
+ export const ErrCodeInvalidRequest = `INVALID_REQUEST`
323
+ export const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`
324
+ export const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`
325
+ export const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`
326
+ export const ErrCodeUnknownEventType = `UNKNOWN_EVENT_TYPE`
327
+ export const ErrCodeSchemaKeyExists = `SCHEMA_KEY_EXISTS`
328
+ export const ErrCodeServeEndpointUnreachable = `SERVE_ENDPOINT_UNREACHABLE`
329
+ export const ErrCodeServeEndpointNameMismatch = `SERVE_ENDPOINT_NAME_MISMATCH`
330
+ export const ErrCodeForkInProgress = `FORK_IN_PROGRESS`
331
+ export const ErrCodeForkWaitTimeout = `FORK_WAIT_TIMEOUT`
332
+ export const ErrCodeEntityPersistFailed = `ENTITY_PERSIST_FAILED`
333
+ export const ErrCodeAgentUiNotFound = `AGENT_UI_NOT_FOUND`
334
+ export const ErrCodeSubscriptionNotFound = `SUBSCRIPTION_NOT_FOUND`
335
+ export const ErrCodeCallbackNotFound = `CALLBACK_NOT_FOUND`