@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.
- package/LICENSE +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- package/src/wake-registry.ts +946 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes for Electric Agents entity management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
6
|
+
import { Router, json, status } from 'itty-router'
|
|
7
|
+
import { apiError } from '../electric-agents-http.js'
|
|
8
|
+
import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
|
|
9
|
+
import {
|
|
10
|
+
ErrCodeNotFound,
|
|
11
|
+
ErrCodeUnknownEntityType,
|
|
12
|
+
toPublicEntity,
|
|
13
|
+
} from '../electric-agents-types.js'
|
|
14
|
+
import {
|
|
15
|
+
assertDispatchPolicyAllowed,
|
|
16
|
+
backfillEntityDispatchPolicy,
|
|
17
|
+
linkEntityDispatchSubscription,
|
|
18
|
+
resolveEffectiveDispatchPolicyForSpawn,
|
|
19
|
+
unlinkEntityDispatchSubscription,
|
|
20
|
+
} from './dispatch-policy.js'
|
|
21
|
+
import { routeBody, withSchema } from './schema.js'
|
|
22
|
+
import type { ElectricAgentsEntity } from '../electric-agents-types.js'
|
|
23
|
+
import type { JsonRouteRequest } from './schema.js'
|
|
24
|
+
import type { RouterType } from 'itty-router'
|
|
25
|
+
import type { TenantContext } from './context.js'
|
|
26
|
+
|
|
27
|
+
interface AgentsRouteRequest extends JsonRouteRequest {
|
|
28
|
+
entityRoute?: ExistingEntityRoute
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ExistingEntityRoute = { entityUrl: string; entity: ElectricAgentsEntity }
|
|
32
|
+
type AgentsRouteArgs = [TenantContext]
|
|
33
|
+
type AgentsRouteResult = Response | undefined
|
|
34
|
+
|
|
35
|
+
export type EntitiesRoutes = RouterType<
|
|
36
|
+
AgentsRouteRequest,
|
|
37
|
+
AgentsRouteArgs,
|
|
38
|
+
AgentsRouteResult
|
|
39
|
+
>
|
|
40
|
+
|
|
41
|
+
const stringRecordSchema = Type.Record(Type.String(), Type.String())
|
|
42
|
+
|
|
43
|
+
function writeTokenFromRequest(request: AgentsRouteRequest): string {
|
|
44
|
+
const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim()
|
|
45
|
+
if (electricClaimToken) return electricClaimToken
|
|
46
|
+
return (
|
|
47
|
+
request.headers
|
|
48
|
+
.get(`authorization`)
|
|
49
|
+
?.replace(/^Bearer\s+/i, ``)
|
|
50
|
+
.trim() ?? ``
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const wakeConditionSchema = Type.Union([
|
|
55
|
+
Type.Literal(`runFinished`),
|
|
56
|
+
Type.Object({
|
|
57
|
+
on: Type.Literal(`change`),
|
|
58
|
+
collections: Type.Optional(Type.Array(Type.String())),
|
|
59
|
+
ops: Type.Optional(
|
|
60
|
+
Type.Array(
|
|
61
|
+
Type.Union([
|
|
62
|
+
Type.Literal(`insert`),
|
|
63
|
+
Type.Literal(`update`),
|
|
64
|
+
Type.Literal(`delete`),
|
|
65
|
+
])
|
|
66
|
+
)
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
const spawnBodySchema = Type.Object({
|
|
72
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
73
|
+
tags: Type.Optional(stringRecordSchema),
|
|
74
|
+
parent: Type.Optional(Type.String()),
|
|
75
|
+
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
76
|
+
initialMessage: Type.Optional(Type.Unknown()),
|
|
77
|
+
wake: Type.Optional(
|
|
78
|
+
Type.Object({
|
|
79
|
+
subscriberUrl: Type.String(),
|
|
80
|
+
condition: wakeConditionSchema,
|
|
81
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
82
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
83
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
84
|
+
})
|
|
85
|
+
),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const sendBodySchema = Type.Object({
|
|
89
|
+
from: Type.Optional(Type.String()),
|
|
90
|
+
payload: Type.Optional(Type.Unknown()),
|
|
91
|
+
key: Type.Optional(Type.String()),
|
|
92
|
+
type: Type.Optional(Type.String()),
|
|
93
|
+
afterMs: Type.Optional(Type.Number()),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const forkBodySchema = Type.Object({
|
|
97
|
+
instance_id: Type.Optional(Type.String()),
|
|
98
|
+
waitTimeoutMs: Type.Optional(Type.Number()),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const setTagBodySchema = Type.Object({
|
|
102
|
+
value: Type.String(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const scheduleBodySchema = Type.Union([
|
|
106
|
+
Type.Object({
|
|
107
|
+
scheduleType: Type.Literal(`cron`),
|
|
108
|
+
expression: Type.String(),
|
|
109
|
+
timezone: Type.Optional(Type.String()),
|
|
110
|
+
payload: Type.Unknown(),
|
|
111
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
112
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
113
|
+
}),
|
|
114
|
+
Type.Object({
|
|
115
|
+
scheduleType: Type.Literal(`future_send`),
|
|
116
|
+
payload: Type.Unknown(),
|
|
117
|
+
targetUrl: Type.Optional(Type.String()),
|
|
118
|
+
fireAt: Type.String(),
|
|
119
|
+
from: Type.Optional(Type.String()),
|
|
120
|
+
messageType: Type.Optional(Type.String()),
|
|
121
|
+
}),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
const entitiesRegisterBodySchema = Type.Object({
|
|
125
|
+
tags: Type.Optional(stringRecordSchema),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
type SpawnBody = Static<typeof spawnBodySchema>
|
|
129
|
+
type SendBody = Static<typeof sendBodySchema>
|
|
130
|
+
type ForkBody = Static<typeof forkBodySchema>
|
|
131
|
+
type SetTagBody = Static<typeof setTagBodySchema>
|
|
132
|
+
type ScheduleBody = Static<typeof scheduleBodySchema>
|
|
133
|
+
type EntitiesRegisterBody = Static<typeof entitiesRegisterBodySchema>
|
|
134
|
+
|
|
135
|
+
export const entitiesRouter: EntitiesRoutes = Router<
|
|
136
|
+
AgentsRouteRequest,
|
|
137
|
+
AgentsRouteArgs,
|
|
138
|
+
AgentsRouteResult
|
|
139
|
+
>({
|
|
140
|
+
base: `/_electric/entities`,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
entitiesRouter.get(`/`, listEntities)
|
|
144
|
+
entitiesRouter.post(
|
|
145
|
+
`/register`,
|
|
146
|
+
withSchema(entitiesRegisterBodySchema),
|
|
147
|
+
registerEntitiesSource
|
|
148
|
+
)
|
|
149
|
+
entitiesRouter.put(
|
|
150
|
+
`/:type/:instanceId`,
|
|
151
|
+
withSpawnableEntityType,
|
|
152
|
+
withSchema(spawnBodySchema),
|
|
153
|
+
spawnEntity
|
|
154
|
+
)
|
|
155
|
+
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity)
|
|
156
|
+
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity)
|
|
157
|
+
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity)
|
|
158
|
+
entitiesRouter.post(
|
|
159
|
+
`/:type/:instanceId/send`,
|
|
160
|
+
withExistingEntity,
|
|
161
|
+
withSchema(sendBodySchema),
|
|
162
|
+
sendEntity
|
|
163
|
+
)
|
|
164
|
+
entitiesRouter.post(
|
|
165
|
+
`/:type/:instanceId/fork`,
|
|
166
|
+
withExistingEntity,
|
|
167
|
+
withSchema(forkBodySchema),
|
|
168
|
+
forkEntity
|
|
169
|
+
)
|
|
170
|
+
entitiesRouter.post(
|
|
171
|
+
`/:type/:instanceId/tags/:tagKey`,
|
|
172
|
+
withExistingEntity,
|
|
173
|
+
withSchema(setTagBodySchema),
|
|
174
|
+
setTag
|
|
175
|
+
)
|
|
176
|
+
entitiesRouter.delete(
|
|
177
|
+
`/:type/:instanceId/tags/:tagKey`,
|
|
178
|
+
withExistingEntity,
|
|
179
|
+
removeTag
|
|
180
|
+
)
|
|
181
|
+
entitiesRouter.put(
|
|
182
|
+
`/:type/:instanceId/schedules/:scheduleId`,
|
|
183
|
+
withExistingEntity,
|
|
184
|
+
withSchema(scheduleBodySchema),
|
|
185
|
+
upsertSchedule
|
|
186
|
+
)
|
|
187
|
+
entitiesRouter.delete(
|
|
188
|
+
`/:type/:instanceId/schedules/:scheduleId`,
|
|
189
|
+
withExistingEntity,
|
|
190
|
+
deleteSchedule
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
function entityUrlFromSegments(
|
|
194
|
+
type: string,
|
|
195
|
+
instanceId: string
|
|
196
|
+
): string | null {
|
|
197
|
+
if (!type || !instanceId) return null
|
|
198
|
+
if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
return `/${type}/${instanceId}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function firstQueryValue(
|
|
205
|
+
value: string | Array<string> | undefined
|
|
206
|
+
): string | undefined {
|
|
207
|
+
return Array.isArray(value) ? value[0] : value
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function requireExistingEntityRoute(
|
|
211
|
+
request: AgentsRouteRequest
|
|
212
|
+
): ExistingEntityRoute {
|
|
213
|
+
if (!request.entityRoute) {
|
|
214
|
+
throw new Error(`existing entity middleware did not run`)
|
|
215
|
+
}
|
|
216
|
+
return request.entityRoute
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function withExistingEntity(
|
|
220
|
+
request: AgentsRouteRequest,
|
|
221
|
+
ctx: TenantContext
|
|
222
|
+
): Promise<AgentsRouteResult> {
|
|
223
|
+
const entityUrl = entityUrlFromSegments(
|
|
224
|
+
request.params.type,
|
|
225
|
+
request.params.instanceId
|
|
226
|
+
)
|
|
227
|
+
if (!entityUrl) return undefined
|
|
228
|
+
|
|
229
|
+
const entity = await ctx.entityManager.registry.getEntity(entityUrl)
|
|
230
|
+
if (!entity) {
|
|
231
|
+
const entityType = await ctx.entityManager.registry.getEntityType(
|
|
232
|
+
request.params.type
|
|
233
|
+
)
|
|
234
|
+
if (entityType) {
|
|
235
|
+
return apiError(404, ErrCodeNotFound, `Entity not found at ${entityUrl}`)
|
|
236
|
+
}
|
|
237
|
+
return apiError(
|
|
238
|
+
404,
|
|
239
|
+
ErrCodeUnknownEntityType,
|
|
240
|
+
`Entity type "${request.params.type}" not found`
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
request.entityRoute = { entityUrl, entity }
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function withSpawnableEntityType(
|
|
249
|
+
request: AgentsRouteRequest,
|
|
250
|
+
ctx: TenantContext
|
|
251
|
+
): Promise<AgentsRouteResult> {
|
|
252
|
+
if (!entityUrlFromSegments(request.params.type, request.params.instanceId)) {
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const entityType = await ctx.entityManager.registry.getEntityType(
|
|
257
|
+
request.params.type
|
|
258
|
+
)
|
|
259
|
+
if (!entityType) {
|
|
260
|
+
return apiError(
|
|
261
|
+
404,
|
|
262
|
+
ErrCodeUnknownEntityType,
|
|
263
|
+
`Entity type "${request.params.type}" not found`
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function listEntities(
|
|
271
|
+
{ query }: AgentsRouteRequest,
|
|
272
|
+
ctx: TenantContext
|
|
273
|
+
): Promise<Response> {
|
|
274
|
+
const { entities } = await ctx.entityManager.registry.listEntities({
|
|
275
|
+
type: firstQueryValue(query.type),
|
|
276
|
+
status: firstQueryValue(query.status),
|
|
277
|
+
parent: firstQueryValue(query.parent),
|
|
278
|
+
})
|
|
279
|
+
return json(entities.map((entity) => toPublicEntity(entity)))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function registerEntitiesSource(
|
|
283
|
+
request: AgentsRouteRequest,
|
|
284
|
+
ctx: TenantContext
|
|
285
|
+
): Promise<Response> {
|
|
286
|
+
const parsed = routeBody<EntitiesRegisterBody>(request)
|
|
287
|
+
const result = await ctx.entityManager.registerEntitiesSource(
|
|
288
|
+
parsed.tags ?? {}
|
|
289
|
+
)
|
|
290
|
+
return json(result)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function upsertSchedule(
|
|
294
|
+
request: AgentsRouteRequest,
|
|
295
|
+
ctx: TenantContext
|
|
296
|
+
): Promise<Response> {
|
|
297
|
+
const parsed = routeBody<ScheduleBody>(request)
|
|
298
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
299
|
+
const scheduleId = decodeURIComponent(request.params.scheduleId)
|
|
300
|
+
|
|
301
|
+
if (parsed.scheduleType === `cron`) {
|
|
302
|
+
const result = await ctx.entityManager.upsertCronSchedule(entityUrl, {
|
|
303
|
+
id: scheduleId,
|
|
304
|
+
expression: parsed.expression,
|
|
305
|
+
timezone: parsed.timezone,
|
|
306
|
+
payload: parsed.payload,
|
|
307
|
+
debounceMs: parsed.debounceMs,
|
|
308
|
+
timeoutMs: parsed.timeoutMs,
|
|
309
|
+
})
|
|
310
|
+
return json(result)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (parsed.scheduleType === `future_send`) {
|
|
314
|
+
const result = await ctx.entityManager.upsertFutureSendSchedule(entityUrl, {
|
|
315
|
+
id: scheduleId,
|
|
316
|
+
payload: parsed.payload,
|
|
317
|
+
targetUrl: parsed.targetUrl,
|
|
318
|
+
fireAt: parsed.fireAt,
|
|
319
|
+
from: parsed.from,
|
|
320
|
+
messageType: parsed.messageType,
|
|
321
|
+
})
|
|
322
|
+
return json(result)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error(`schedule schema accepted an unknown scheduleType`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function deleteSchedule(
|
|
329
|
+
request: AgentsRouteRequest,
|
|
330
|
+
ctx: TenantContext
|
|
331
|
+
): Promise<Response> {
|
|
332
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
333
|
+
const result = await ctx.entityManager.deleteSchedule(entityUrl, {
|
|
334
|
+
id: decodeURIComponent(request.params.scheduleId),
|
|
335
|
+
})
|
|
336
|
+
return json(result)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function setTag(
|
|
340
|
+
request: AgentsRouteRequest,
|
|
341
|
+
ctx: TenantContext
|
|
342
|
+
): Promise<Response> {
|
|
343
|
+
const parsed = routeBody<SetTagBody>(request)
|
|
344
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
345
|
+
const token = writeTokenFromRequest(request)
|
|
346
|
+
const updated = await ctx.entityManager.setTag(
|
|
347
|
+
entityUrl,
|
|
348
|
+
decodeURIComponent(request.params.tagKey),
|
|
349
|
+
{ value: parsed.value },
|
|
350
|
+
token
|
|
351
|
+
)
|
|
352
|
+
return json(toPublicEntity(updated))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function removeTag(
|
|
356
|
+
request: AgentsRouteRequest,
|
|
357
|
+
ctx: TenantContext
|
|
358
|
+
): Promise<Response> {
|
|
359
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
360
|
+
const token = writeTokenFromRequest(request)
|
|
361
|
+
const updated = await ctx.entityManager.removeTag(
|
|
362
|
+
entityUrl,
|
|
363
|
+
decodeURIComponent(request.params.tagKey),
|
|
364
|
+
token
|
|
365
|
+
)
|
|
366
|
+
return json(toPublicEntity(updated))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function forkEntity(
|
|
370
|
+
request: AgentsRouteRequest,
|
|
371
|
+
ctx: TenantContext
|
|
372
|
+
): Promise<Response> {
|
|
373
|
+
const parsed = routeBody<ForkBody>(request)
|
|
374
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
375
|
+
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
|
|
376
|
+
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
377
|
+
rootInstanceId: parsed.instance_id,
|
|
378
|
+
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
379
|
+
})
|
|
380
|
+
for (const forkedEntity of result.entities) {
|
|
381
|
+
await linkEntityDispatchSubscription(ctx, forkedEntity)
|
|
382
|
+
}
|
|
383
|
+
return json(
|
|
384
|
+
{
|
|
385
|
+
root: toPublicEntity(result.root),
|
|
386
|
+
entities: result.entities.map((entity) => toPublicEntity(entity)),
|
|
387
|
+
},
|
|
388
|
+
{ status: 201 }
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function sendEntity(
|
|
393
|
+
request: AgentsRouteRequest,
|
|
394
|
+
ctx: TenantContext
|
|
395
|
+
): Promise<Response> {
|
|
396
|
+
const parsed = routeBody<SendBody>(request)
|
|
397
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
398
|
+
|
|
399
|
+
if (!entity.dispatch_policy) {
|
|
400
|
+
const updatedEntity = await backfillEntityDispatchPolicy(ctx, entity)
|
|
401
|
+
await linkEntityDispatchSubscription(ctx, updatedEntity)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (parsed.afterMs && parsed.afterMs > 0) {
|
|
405
|
+
await ctx.entityManager.enqueueDelayedSend(
|
|
406
|
+
entityUrl,
|
|
407
|
+
{
|
|
408
|
+
from: parsed.from,
|
|
409
|
+
payload: parsed.payload,
|
|
410
|
+
key: parsed.key,
|
|
411
|
+
type: parsed.type,
|
|
412
|
+
},
|
|
413
|
+
new Date(Date.now() + parsed.afterMs)
|
|
414
|
+
)
|
|
415
|
+
} else {
|
|
416
|
+
await ctx.entityManager.send(entityUrl, {
|
|
417
|
+
from: parsed.from,
|
|
418
|
+
payload: parsed.payload,
|
|
419
|
+
key: parsed.key,
|
|
420
|
+
type: parsed.type,
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return status(204)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function spawnEntity(
|
|
428
|
+
request: AgentsRouteRequest,
|
|
429
|
+
ctx: TenantContext
|
|
430
|
+
): Promise<Response> {
|
|
431
|
+
const parsed = routeBody<SpawnBody>(request)
|
|
432
|
+
const dispatchPolicy = await resolveEffectiveDispatchPolicyForSpawn(
|
|
433
|
+
ctx,
|
|
434
|
+
request.params.type,
|
|
435
|
+
{
|
|
436
|
+
dispatchPolicy: parsed.dispatch_policy,
|
|
437
|
+
parent: parsed.parent,
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
await assertDispatchPolicyAllowed(ctx, dispatchPolicy)
|
|
441
|
+
const entity = await ctx.entityManager.spawn(request.params.type, {
|
|
442
|
+
instance_id: request.params.instanceId,
|
|
443
|
+
args: parsed.args,
|
|
444
|
+
tags: parsed.tags,
|
|
445
|
+
parent: parsed.parent,
|
|
446
|
+
dispatch_policy: dispatchPolicy,
|
|
447
|
+
initialMessage: undefined,
|
|
448
|
+
wake: parsed.wake,
|
|
449
|
+
})
|
|
450
|
+
await linkEntityDispatchSubscription(ctx, entity)
|
|
451
|
+
if (parsed.initialMessage !== undefined) {
|
|
452
|
+
await ctx.entityManager.send(entity.url, {
|
|
453
|
+
from: parsed.parent ?? `spawn`,
|
|
454
|
+
payload: parsed.initialMessage,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return json(
|
|
459
|
+
{ ...toPublicEntity(entity), txid: entity.txid },
|
|
460
|
+
{
|
|
461
|
+
status: 201,
|
|
462
|
+
headers: { 'x-write-token': entity.write_token },
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getEntity(request: AgentsRouteRequest): Response {
|
|
468
|
+
return json(toPublicEntity(requireExistingEntityRoute(request).entity))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function headEntity(): Response {
|
|
472
|
+
return status(200)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function killEntity(
|
|
476
|
+
request: AgentsRouteRequest,
|
|
477
|
+
ctx: TenantContext
|
|
478
|
+
): Promise<Response> {
|
|
479
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
480
|
+
await unlinkEntityDispatchSubscription(ctx, entity)
|
|
481
|
+
const result = await ctx.entityManager.kill(entityUrl)
|
|
482
|
+
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main)
|
|
483
|
+
return json(result)
|
|
484
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes for Electric Agents entity type management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
6
|
+
import { Router, json, status } from 'itty-router'
|
|
7
|
+
import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
|
|
8
|
+
import { ElectricAgentsError } from '../entity-manager.js'
|
|
9
|
+
import {
|
|
10
|
+
ErrCodeNotFound,
|
|
11
|
+
ErrCodeServeEndpointNameMismatch,
|
|
12
|
+
ErrCodeServeEndpointUnreachable,
|
|
13
|
+
} from '../electric-agents-types.js'
|
|
14
|
+
import { apiError } from '../electric-agents-http.js'
|
|
15
|
+
import { routeBody, withSchema } from './schema.js'
|
|
16
|
+
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
17
|
+
import type {
|
|
18
|
+
ElectricAgentsEntityType,
|
|
19
|
+
RegisterEntityTypeRequest,
|
|
20
|
+
} from '../electric-agents-types.js'
|
|
21
|
+
import type { JsonRouteRequest } from './schema.js'
|
|
22
|
+
import type { RouterType } from 'itty-router'
|
|
23
|
+
import type { TenantContext } from './context.js'
|
|
24
|
+
|
|
25
|
+
export interface ElectricAgentsEntityTypeRouteRequest
|
|
26
|
+
extends JsonRouteRequest {}
|
|
27
|
+
|
|
28
|
+
type EntityTypeRouteArgs = [TenantContext]
|
|
29
|
+
type EntityTypeRouteResult = Response | undefined
|
|
30
|
+
|
|
31
|
+
export type ElectricAgentsEntityTypeRoutes = RouterType<
|
|
32
|
+
ElectricAgentsEntityTypeRouteRequest,
|
|
33
|
+
EntityTypeRouteArgs,
|
|
34
|
+
EntityTypeRouteResult
|
|
35
|
+
>
|
|
36
|
+
|
|
37
|
+
type PublicEntityTypeResponse = ElectricAgentsEntityType & {
|
|
38
|
+
input_schemas?: Record<string, Record<string, unknown>>
|
|
39
|
+
output_schemas?: Record<string, Record<string, unknown>>
|
|
40
|
+
revision: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
|
|
44
|
+
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
|
|
45
|
+
|
|
46
|
+
const registerEntityTypeBodySchema = Type.Object({
|
|
47
|
+
name: Type.Optional(Type.String()),
|
|
48
|
+
description: Type.Optional(Type.String()),
|
|
49
|
+
creation_schema: Type.Optional(jsonObjectSchema),
|
|
50
|
+
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
51
|
+
state_schemas: Type.Optional(schemaMapSchema),
|
|
52
|
+
input_schemas: Type.Optional(schemaMapSchema),
|
|
53
|
+
output_schemas: Type.Optional(schemaMapSchema),
|
|
54
|
+
serve_endpoint: Type.Optional(Type.String()),
|
|
55
|
+
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const amendEntityTypeSchemasBodySchema = Type.Object({
|
|
59
|
+
input_schemas: Type.Optional(schemaMapSchema),
|
|
60
|
+
output_schemas: Type.Optional(schemaMapSchema),
|
|
61
|
+
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
62
|
+
state_schemas: Type.Optional(schemaMapSchema),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
type RegisterEntityTypeBody = Static<typeof registerEntityTypeBodySchema>
|
|
66
|
+
type AmendEntityTypeSchemasBody = Static<
|
|
67
|
+
typeof amendEntityTypeSchemasBodySchema
|
|
68
|
+
>
|
|
69
|
+
|
|
70
|
+
export const entityTypesRouter: ElectricAgentsEntityTypeRoutes = Router<
|
|
71
|
+
ElectricAgentsEntityTypeRouteRequest,
|
|
72
|
+
EntityTypeRouteArgs,
|
|
73
|
+
EntityTypeRouteResult
|
|
74
|
+
>({
|
|
75
|
+
base: `/_electric/entity-types`,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
entityTypesRouter.get(`/`, listEntityTypes)
|
|
79
|
+
entityTypesRouter.post(
|
|
80
|
+
`/`,
|
|
81
|
+
withSchema(registerEntityTypeBodySchema),
|
|
82
|
+
registerEntityType
|
|
83
|
+
)
|
|
84
|
+
entityTypesRouter.patch(
|
|
85
|
+
`/:name/schemas`,
|
|
86
|
+
withSchema(amendEntityTypeSchemasBodySchema),
|
|
87
|
+
amendSchemas
|
|
88
|
+
)
|
|
89
|
+
entityTypesRouter.get(`/:name`, getEntityType)
|
|
90
|
+
entityTypesRouter.delete(`/:name`, deleteEntityType)
|
|
91
|
+
|
|
92
|
+
async function registerEntityType(
|
|
93
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
94
|
+
ctx: TenantContext
|
|
95
|
+
): Promise<EntityTypeRouteResult> {
|
|
96
|
+
const parsed = routeBody<RegisterEntityTypeBody>(request)
|
|
97
|
+
const normalized = normalizeEntityTypeRequest(parsed)
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
normalized.serve_endpoint &&
|
|
101
|
+
!normalized.description &&
|
|
102
|
+
!normalized.creation_schema
|
|
103
|
+
) {
|
|
104
|
+
return await discoverServeEndpoint(ctx, normalized)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const entityType = await ctx.entityManager.registerEntityType(normalized)
|
|
108
|
+
return json(toPublicEntityType(entityType), { status: 201 })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function listEntityTypes(
|
|
112
|
+
_request: ElectricAgentsEntityTypeRouteRequest,
|
|
113
|
+
ctx: TenantContext
|
|
114
|
+
): Promise<EntityTypeRouteResult> {
|
|
115
|
+
const entityTypes = await ctx.entityManager.registry.listEntityTypes()
|
|
116
|
+
return json(entityTypes.map((entityType) => toPublicEntityType(entityType)))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function discoverServeEndpoint(
|
|
120
|
+
ctx: TenantContext,
|
|
121
|
+
parsed: RegisterEntityTypeRequest
|
|
122
|
+
): Promise<Response> {
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(parsed.serve_endpoint!, { method: `PUT` })
|
|
125
|
+
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
return apiError(
|
|
128
|
+
502,
|
|
129
|
+
ErrCodeServeEndpointUnreachable,
|
|
130
|
+
`Serve endpoint returned status ${response.status}`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const manifest = (await response.json()) as RegisterEntityTypeRequest
|
|
135
|
+
if (manifest.name !== parsed.name) {
|
|
136
|
+
return apiError(
|
|
137
|
+
400,
|
|
138
|
+
ErrCodeServeEndpointNameMismatch,
|
|
139
|
+
`Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
manifest.serve_endpoint = parsed.serve_endpoint
|
|
144
|
+
|
|
145
|
+
const entityType = await ctx.entityManager.registerEntityType(
|
|
146
|
+
normalizeEntityTypeRequest(manifest)
|
|
147
|
+
)
|
|
148
|
+
return json(toPublicEntityType(entityType), { status: 201 })
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof ElectricAgentsError) {
|
|
151
|
+
throw err
|
|
152
|
+
}
|
|
153
|
+
return apiError(
|
|
154
|
+
502,
|
|
155
|
+
ErrCodeServeEndpointUnreachable,
|
|
156
|
+
`Failed to reach serve endpoint: ${
|
|
157
|
+
err instanceof Error ? err.message : String(err)
|
|
158
|
+
}`
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function getEntityType(
|
|
164
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
165
|
+
ctx: TenantContext
|
|
166
|
+
): Promise<EntityTypeRouteResult> {
|
|
167
|
+
const entityType = await ctx.entityManager.registry.getEntityType(
|
|
168
|
+
request.params.name
|
|
169
|
+
)
|
|
170
|
+
if (!entityType) {
|
|
171
|
+
return apiError(404, ErrCodeNotFound, `Entity type not found`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return json(toPublicEntityType(entityType))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function amendSchemas(
|
|
178
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
179
|
+
ctx: TenantContext
|
|
180
|
+
): Promise<EntityTypeRouteResult> {
|
|
181
|
+
const parsed = routeBody<AmendEntityTypeSchemasBody>(request)
|
|
182
|
+
|
|
183
|
+
const updated = await ctx.entityManager.amendSchemas(request.params.name, {
|
|
184
|
+
inbox_schemas: parsed.inbox_schemas ?? parsed.input_schemas,
|
|
185
|
+
state_schemas: parsed.state_schemas ?? parsed.output_schemas,
|
|
186
|
+
})
|
|
187
|
+
return json(toPublicEntityType(updated))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function deleteEntityType(
|
|
191
|
+
request: ElectricAgentsEntityTypeRouteRequest,
|
|
192
|
+
ctx: TenantContext
|
|
193
|
+
): Promise<EntityTypeRouteResult> {
|
|
194
|
+
await ctx.entityManager.deleteEntityType(request.params.name)
|
|
195
|
+
return status(204)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeEntityTypeRequest(
|
|
199
|
+
parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
|
|
200
|
+
): RegisterEntityTypeRequest {
|
|
201
|
+
const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint)
|
|
202
|
+
const compatibilityFields = parsed as RegisterEntityTypeBody
|
|
203
|
+
return {
|
|
204
|
+
name: parsed.name ?? ``,
|
|
205
|
+
description: parsed.description ?? ``,
|
|
206
|
+
creation_schema: parsed.creation_schema,
|
|
207
|
+
inbox_schemas: parsed.inbox_schemas ?? compatibilityFields.input_schemas,
|
|
208
|
+
state_schemas: parsed.state_schemas ?? compatibilityFields.output_schemas,
|
|
209
|
+
serve_endpoint: serveEndpoint,
|
|
210
|
+
default_dispatch_policy:
|
|
211
|
+
parsed.default_dispatch_policy ??
|
|
212
|
+
(serveEndpoint
|
|
213
|
+
? ({
|
|
214
|
+
targets: [{ type: `webhook`, url: serveEndpoint }],
|
|
215
|
+
} as RegisterEntityTypeRequest[`default_dispatch_policy`])
|
|
216
|
+
: undefined),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function toPublicEntityType(
|
|
221
|
+
entityType: ElectricAgentsEntityType
|
|
222
|
+
): PublicEntityTypeResponse {
|
|
223
|
+
return {
|
|
224
|
+
...entityType,
|
|
225
|
+
input_schemas: entityType.inbox_schemas,
|
|
226
|
+
output_schemas: entityType.state_schemas,
|
|
227
|
+
revision: entityType.revision,
|
|
228
|
+
}
|
|
229
|
+
}
|