@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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSS server-only wrapper routes.
|
|
3
|
+
*
|
|
4
|
+
* The exported global router stays library-safe. The standalone OSS server adds
|
|
5
|
+
* its dashboard and optional mock-agent handler here before falling through to
|
|
6
|
+
* the global router.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AutoRouter, status } from 'itty-router'
|
|
10
|
+
import { agentUiRouter } from './agent-ui-router.js'
|
|
11
|
+
import { globalRouter } from './global-router.js'
|
|
12
|
+
import { applyCors, errorMapper, preflightCors } from './hooks.js'
|
|
13
|
+
import type { RuntimeHandler } from '@electric-ax/agents-runtime'
|
|
14
|
+
import type { AutoRouterType, IRequest } from 'itty-router'
|
|
15
|
+
import type { TenantContext } from './context.js'
|
|
16
|
+
|
|
17
|
+
export interface OssServerContext extends TenantContext {
|
|
18
|
+
mockAgent?: { runtime: RuntimeHandler }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type OssServerRoutes = AutoRouterType<
|
|
22
|
+
IRequest,
|
|
23
|
+
[OssServerContext],
|
|
24
|
+
Response
|
|
25
|
+
>
|
|
26
|
+
|
|
27
|
+
export const ossServerRouter: OssServerRoutes = AutoRouter<
|
|
28
|
+
IRequest,
|
|
29
|
+
[OssServerContext],
|
|
30
|
+
Response
|
|
31
|
+
>({
|
|
32
|
+
before: [preflightCors],
|
|
33
|
+
catch: errorMapper,
|
|
34
|
+
finally: [applyCors],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
ossServerRouter.get(`/`, redirectToAgentUi)
|
|
38
|
+
ossServerRouter.head(`/`, redirectToAgentUi)
|
|
39
|
+
ossServerRouter.all(`/__agent_ui/*`, agentUiRouter.fetch)
|
|
40
|
+
ossServerRouter.post(`/_electric/mock-agent-handler`, mockAgentHandler)
|
|
41
|
+
ossServerRouter.all(`*`, (request, ctx) => globalRouter.fetch(request, ctx))
|
|
42
|
+
|
|
43
|
+
function redirectToAgentUi(): Response {
|
|
44
|
+
return new Response(null, {
|
|
45
|
+
status: 302,
|
|
46
|
+
headers: { location: `/__agent_ui/` },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function mockAgentHandler(
|
|
51
|
+
request: IRequest,
|
|
52
|
+
ctx: OssServerContext
|
|
53
|
+
): Promise<Response> {
|
|
54
|
+
if (!ctx.mockAgent) return status(404)
|
|
55
|
+
return await ctx.mockAgent.runtime.handleWebhookRequest(request as Request)
|
|
56
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { appendPathToUrl } from '@electric-ax/agents-runtime'
|
|
2
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
3
|
+
import { Router, json, status } from 'itty-router'
|
|
4
|
+
import { consumerCallbacks } from '../db/schema.js'
|
|
5
|
+
import { apiError } from '../electric-agents-http.js'
|
|
6
|
+
import { ElectricAgentsError } from '../entity-manager.js'
|
|
7
|
+
import {
|
|
8
|
+
ErrCodeInvalidRequest,
|
|
9
|
+
ErrCodeNotFound,
|
|
10
|
+
ErrCodeNotRunning,
|
|
11
|
+
ErrCodeUnauthorized,
|
|
12
|
+
} from '../electric-agents-types.js'
|
|
13
|
+
import { routeBody, withSchema } from './schema.js'
|
|
14
|
+
import { subscriptionIdForDispatchTarget } from './dispatch-policy.js'
|
|
15
|
+
import { withLeadingSlash } from './tenant-stream-paths.js'
|
|
16
|
+
import type { JsonRouteRequest } from './schema.js'
|
|
17
|
+
import type { RouterType } from 'itty-router'
|
|
18
|
+
import type { TenantContext } from './context.js'
|
|
19
|
+
import {
|
|
20
|
+
DurableStreamsSubscriptionError,
|
|
21
|
+
type SubscriptionClaimResponse,
|
|
22
|
+
} from '../stream-client.js'
|
|
23
|
+
|
|
24
|
+
interface RunnersRouteRequest extends JsonRouteRequest {}
|
|
25
|
+
|
|
26
|
+
type RunnersRouteArgs = [TenantContext]
|
|
27
|
+
type RunnersRouteResult = Response | undefined
|
|
28
|
+
|
|
29
|
+
export type RunnersRoutes = RouterType<
|
|
30
|
+
RunnersRouteRequest,
|
|
31
|
+
RunnersRouteArgs,
|
|
32
|
+
RunnersRouteResult
|
|
33
|
+
>
|
|
34
|
+
|
|
35
|
+
const registerRunnerBodySchema = Type.Object({
|
|
36
|
+
id: Type.String(),
|
|
37
|
+
owner_user_id: Type.Optional(Type.String()),
|
|
38
|
+
label: Type.String(),
|
|
39
|
+
kind: Type.Optional(
|
|
40
|
+
Type.Union([
|
|
41
|
+
Type.Literal(`local`),
|
|
42
|
+
Type.Literal(`cloud-worker`),
|
|
43
|
+
Type.Literal(`sandbox`),
|
|
44
|
+
Type.Literal(`ci`),
|
|
45
|
+
Type.Literal(`server`),
|
|
46
|
+
])
|
|
47
|
+
),
|
|
48
|
+
admin_status: Type.Optional(
|
|
49
|
+
Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])
|
|
50
|
+
),
|
|
51
|
+
wake_stream: Type.Optional(Type.String()),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const heartbeatBodySchema = Type.Object({
|
|
55
|
+
lease_ms: Type.Optional(Type.Number()),
|
|
56
|
+
wake_stream_offset: Type.Optional(Type.String()),
|
|
57
|
+
wakeStreamOffset: Type.Optional(Type.String()),
|
|
58
|
+
liveness_lease_expires_at: Type.Optional(Type.String()),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const claimBodySchema = Type.Object(
|
|
62
|
+
{
|
|
63
|
+
subscription_id: Type.Optional(Type.String()),
|
|
64
|
+
stream: Type.Optional(Type.String()),
|
|
65
|
+
generation: Type.Optional(Type.Number()),
|
|
66
|
+
ts: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
67
|
+
},
|
|
68
|
+
{ additionalProperties: true }
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
type RegisterRunnerBody = Static<typeof registerRunnerBodySchema>
|
|
72
|
+
type HeartbeatBody = Static<typeof heartbeatBodySchema>
|
|
73
|
+
type ClaimBody = Static<typeof claimBodySchema>
|
|
74
|
+
|
|
75
|
+
export const runnersRouter: RunnersRoutes = Router<
|
|
76
|
+
RunnersRouteRequest,
|
|
77
|
+
RunnersRouteArgs,
|
|
78
|
+
RunnersRouteResult
|
|
79
|
+
>({
|
|
80
|
+
base: `/_electric/runners`,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
runnersRouter.post(`/`, withSchema(registerRunnerBodySchema), registerRunner)
|
|
84
|
+
runnersRouter.get(`/`, listRunners)
|
|
85
|
+
runnersRouter.get(`/:id`, getRunner)
|
|
86
|
+
runnersRouter.post(`/:id/heartbeat`, withSchema(heartbeatBodySchema), heartbeat)
|
|
87
|
+
runnersRouter.post(`/:id/enable`, setEnabled)
|
|
88
|
+
runnersRouter.post(`/:id/disable`, setDisabled)
|
|
89
|
+
runnersRouter.post(`/:id/claim`, withSchema(claimBodySchema), claimWake)
|
|
90
|
+
|
|
91
|
+
function routeParam(request: RunnersRouteRequest, name: string): string {
|
|
92
|
+
const value = request.params[name]
|
|
93
|
+
return decodeURIComponent(Array.isArray(value) ? value[0]! : value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function firstQueryValue(
|
|
97
|
+
value: string | Array<string> | undefined
|
|
98
|
+
): string | undefined {
|
|
99
|
+
return Array.isArray(value) ? value[0] : value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function registerRunner(
|
|
103
|
+
request: RunnersRouteRequest,
|
|
104
|
+
ctx: TenantContext
|
|
105
|
+
): Promise<Response> {
|
|
106
|
+
const parsed = routeBody<RegisterRunnerBody>(request)
|
|
107
|
+
const ownerUserId = parsed.owner_user_id ?? ctx.authenticatedUser?.userId
|
|
108
|
+
if (!ownerUserId) {
|
|
109
|
+
throw new ElectricAgentsError(
|
|
110
|
+
ErrCodeInvalidRequest,
|
|
111
|
+
`owner_user_id is required when no authenticated user is present`,
|
|
112
|
+
400
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (ctx.authenticatedUser && ownerUserId !== ctx.authenticatedUser.userId) {
|
|
116
|
+
throw new ElectricAgentsError(
|
|
117
|
+
ErrCodeUnauthorized,
|
|
118
|
+
`owner_user_id must match the authenticated user`,
|
|
119
|
+
403
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const runner = await ctx.entityManager.registry.createRunner({
|
|
124
|
+
id: parsed.id,
|
|
125
|
+
ownerUserId,
|
|
126
|
+
label: parsed.label,
|
|
127
|
+
kind: parsed.kind,
|
|
128
|
+
adminStatus: parsed.admin_status,
|
|
129
|
+
wakeStream: parsed.wake_stream,
|
|
130
|
+
})
|
|
131
|
+
await ctx.streamClient.ensure(runner.wake_stream, {
|
|
132
|
+
contentType: `application/json`,
|
|
133
|
+
})
|
|
134
|
+
return json(runner, { status: 201 })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function listRunners(
|
|
138
|
+
request: RunnersRouteRequest,
|
|
139
|
+
ctx: TenantContext
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
const requestedOwner = firstQueryValue(request.query.owner_user_id)
|
|
142
|
+
if (
|
|
143
|
+
ctx.authenticatedUser &&
|
|
144
|
+
requestedOwner &&
|
|
145
|
+
requestedOwner !== ctx.authenticatedUser.userId
|
|
146
|
+
) {
|
|
147
|
+
throw new ElectricAgentsError(
|
|
148
|
+
ErrCodeUnauthorized,
|
|
149
|
+
`owner_user_id must match the authenticated user`,
|
|
150
|
+
403
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const runners = await ctx.entityManager.registry.listRunners({
|
|
154
|
+
ownerUserId: ctx.authenticatedUser?.userId ?? requestedOwner,
|
|
155
|
+
})
|
|
156
|
+
return json(runners)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function getRunner(
|
|
160
|
+
request: RunnersRouteRequest,
|
|
161
|
+
ctx: TenantContext
|
|
162
|
+
): Promise<Response> {
|
|
163
|
+
const runner = await requireRunner(ctx, routeParam(request, `id`))
|
|
164
|
+
assertRunnerOwnerIfAuthenticated(ctx, runner.owner_user_id)
|
|
165
|
+
return json(runner)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function heartbeat(
|
|
169
|
+
request: RunnersRouteRequest,
|
|
170
|
+
ctx: TenantContext
|
|
171
|
+
): Promise<Response> {
|
|
172
|
+
const runnerId = routeParam(request, `id`)
|
|
173
|
+
const existing = await requireRunner(ctx, runnerId)
|
|
174
|
+
assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id)
|
|
175
|
+
const parsed = routeBody<HeartbeatBody>(request)
|
|
176
|
+
const runner = await ctx.entityManager.registry.heartbeatRunner({
|
|
177
|
+
runnerId,
|
|
178
|
+
leaseMs: parsed.lease_ms,
|
|
179
|
+
wakeStreamOffset: parsed.wake_stream_offset ?? parsed.wakeStreamOffset,
|
|
180
|
+
livenessLeaseExpiresAt: parsed.liveness_lease_expires_at
|
|
181
|
+
? new Date(parsed.liveness_lease_expires_at)
|
|
182
|
+
: undefined,
|
|
183
|
+
})
|
|
184
|
+
if (!runner) {
|
|
185
|
+
throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
|
|
186
|
+
}
|
|
187
|
+
return json(runner)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function setEnabled(
|
|
191
|
+
request: RunnersRouteRequest,
|
|
192
|
+
ctx: TenantContext
|
|
193
|
+
): Promise<Response> {
|
|
194
|
+
return await setRunnerStatus(request, ctx, `enabled`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function setDisabled(
|
|
198
|
+
request: RunnersRouteRequest,
|
|
199
|
+
ctx: TenantContext
|
|
200
|
+
): Promise<Response> {
|
|
201
|
+
return await setRunnerStatus(request, ctx, `disabled`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function setRunnerStatus(
|
|
205
|
+
request: RunnersRouteRequest,
|
|
206
|
+
ctx: TenantContext,
|
|
207
|
+
adminStatus: `enabled` | `disabled`
|
|
208
|
+
): Promise<Response> {
|
|
209
|
+
const runnerId = routeParam(request, `id`)
|
|
210
|
+
const existing = await requireRunner(ctx, runnerId)
|
|
211
|
+
assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id)
|
|
212
|
+
const runner = await ctx.entityManager.registry.setRunnerAdminStatus(
|
|
213
|
+
runnerId,
|
|
214
|
+
adminStatus
|
|
215
|
+
)
|
|
216
|
+
if (!runner) {
|
|
217
|
+
throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
|
|
218
|
+
}
|
|
219
|
+
return json(runner)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function claimWake(
|
|
223
|
+
request: RunnersRouteRequest,
|
|
224
|
+
ctx: TenantContext
|
|
225
|
+
): Promise<Response> {
|
|
226
|
+
const runnerId = routeParam(request, `id`)
|
|
227
|
+
if (!ctx.authenticatedUser) {
|
|
228
|
+
throw new ElectricAgentsError(
|
|
229
|
+
ErrCodeUnauthorized,
|
|
230
|
+
`Authentication is required to claim runner work`,
|
|
231
|
+
401
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
const runner = await requireRunner(ctx, runnerId)
|
|
235
|
+
if (runner.owner_user_id !== ctx.authenticatedUser.userId) {
|
|
236
|
+
throw new ElectricAgentsError(
|
|
237
|
+
ErrCodeUnauthorized,
|
|
238
|
+
`Runner claim requires the authenticated owner`,
|
|
239
|
+
403
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
if (runner.admin_status !== `enabled`) {
|
|
243
|
+
throw new ElectricAgentsError(ErrCodeNotRunning, `Runner is disabled`, 409)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const parsed = routeBody<ClaimBody>(request)
|
|
247
|
+
const expectedSubscriptionId = subscriptionIdForDispatchTarget({
|
|
248
|
+
type: `runner`,
|
|
249
|
+
runnerId,
|
|
250
|
+
})
|
|
251
|
+
const subscriptionId = parsed.subscription_id ?? expectedSubscriptionId
|
|
252
|
+
if (
|
|
253
|
+
subscriptionId !== expectedSubscriptionId &&
|
|
254
|
+
!subscriptionId.startsWith(`${expectedSubscriptionId}:`)
|
|
255
|
+
) {
|
|
256
|
+
throw new ElectricAgentsError(
|
|
257
|
+
ErrCodeInvalidRequest,
|
|
258
|
+
`Wake event subscription_id does not match runner`,
|
|
259
|
+
400
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const claim = await ctx.streamClient
|
|
264
|
+
.claimSubscription(subscriptionId, runnerId)
|
|
265
|
+
.catch((err) => {
|
|
266
|
+
if (isExpectedClaimConflict(err)) {
|
|
267
|
+
return err
|
|
268
|
+
}
|
|
269
|
+
throw err
|
|
270
|
+
})
|
|
271
|
+
if (claim instanceof DurableStreamsSubscriptionError) {
|
|
272
|
+
return apiError(
|
|
273
|
+
claim.status,
|
|
274
|
+
claim.code ?? `SUBSCRIPTION_CLAIM_FAILED`,
|
|
275
|
+
claim.errorMessage ?? claim.body
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
if (!claim) return status(204)
|
|
279
|
+
|
|
280
|
+
const notification = await notificationFromClaim(ctx, {
|
|
281
|
+
runnerId,
|
|
282
|
+
runnerWakeStream: runner.wake_stream,
|
|
283
|
+
subscriptionId,
|
|
284
|
+
claim,
|
|
285
|
+
})
|
|
286
|
+
return json(notification)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isExpectedClaimConflict(
|
|
290
|
+
err: unknown
|
|
291
|
+
): err is DurableStreamsSubscriptionError {
|
|
292
|
+
return (
|
|
293
|
+
err instanceof DurableStreamsSubscriptionError &&
|
|
294
|
+
err.status === 409 &&
|
|
295
|
+
(err.code === `NO_PENDING_WORK` || err.code === `ALREADY_CLAIMED`)
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function requireRunner(ctx: TenantContext, runnerId: string) {
|
|
300
|
+
const runner = await ctx.entityManager.registry.getRunner(runnerId)
|
|
301
|
+
if (!runner) {
|
|
302
|
+
throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
|
|
303
|
+
}
|
|
304
|
+
return runner
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertRunnerOwnerIfAuthenticated(
|
|
308
|
+
ctx: TenantContext,
|
|
309
|
+
ownerUserId: string
|
|
310
|
+
): void {
|
|
311
|
+
if (!ctx.authenticatedUser) return
|
|
312
|
+
if (ownerUserId === ctx.authenticatedUser.userId) return
|
|
313
|
+
throw new ElectricAgentsError(
|
|
314
|
+
ErrCodeUnauthorized,
|
|
315
|
+
`Runner access requires the authenticated owner`,
|
|
316
|
+
403
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function notificationFromClaim(
|
|
321
|
+
ctx: TenantContext,
|
|
322
|
+
input: {
|
|
323
|
+
runnerId: string
|
|
324
|
+
runnerWakeStream: string
|
|
325
|
+
subscriptionId: string
|
|
326
|
+
claim: SubscriptionClaimResponse
|
|
327
|
+
}
|
|
328
|
+
): Promise<Record<string, unknown>> {
|
|
329
|
+
const primary =
|
|
330
|
+
input.claim.streams.find((stream) => stream.has_pending === true) ??
|
|
331
|
+
input.claim.streams[0]
|
|
332
|
+
if (!primary?.path) {
|
|
333
|
+
throw new ElectricAgentsError(
|
|
334
|
+
ErrCodeInvalidRequest,
|
|
335
|
+
`Claim response did not include a stream`,
|
|
336
|
+
502
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const primaryStream = withLeadingSlash(primary.path)
|
|
341
|
+
const entity =
|
|
342
|
+
await ctx.entityManager.registry.getEntityByStream(primaryStream)
|
|
343
|
+
if (!entity) {
|
|
344
|
+
throw new ElectricAgentsError(
|
|
345
|
+
ErrCodeNotFound,
|
|
346
|
+
`Claim stream is not attached to an entity`,
|
|
347
|
+
404
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
if (entity.status === `stopped`) {
|
|
351
|
+
await ctx.streamClient.releaseSubscription(
|
|
352
|
+
input.subscriptionId,
|
|
353
|
+
input.claim.token,
|
|
354
|
+
{
|
|
355
|
+
wake_id: input.claim.wake_id,
|
|
356
|
+
generation: input.claim.generation,
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
return { done: true }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await ctx.pgDb
|
|
363
|
+
.insert(consumerCallbacks)
|
|
364
|
+
.values({
|
|
365
|
+
tenantId: ctx.service,
|
|
366
|
+
consumerId: input.claim.wake_id,
|
|
367
|
+
callbackUrl: `ds-subscription:${input.subscriptionId}`,
|
|
368
|
+
primaryStream,
|
|
369
|
+
})
|
|
370
|
+
.onConflictDoUpdate({
|
|
371
|
+
target: [consumerCallbacks.tenantId, consumerCallbacks.consumerId],
|
|
372
|
+
set: {
|
|
373
|
+
callbackUrl: `ds-subscription:${input.subscriptionId}`,
|
|
374
|
+
primaryStream,
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await ctx.entityManager.registry.materializeActiveClaim({
|
|
379
|
+
consumerId: input.claim.wake_id,
|
|
380
|
+
epoch: input.claim.generation,
|
|
381
|
+
wakeId: input.claim.wake_id,
|
|
382
|
+
entityUrl: entity.url,
|
|
383
|
+
streamPath: primaryStream,
|
|
384
|
+
runnerId: input.runnerId,
|
|
385
|
+
leaseExpiresAt: input.claim.lease_ttl_ms
|
|
386
|
+
? new Date(Date.now() + input.claim.lease_ttl_ms)
|
|
387
|
+
: undefined,
|
|
388
|
+
})
|
|
389
|
+
await ctx.entityManager.registry.updateStatus(entity.url, `running`)
|
|
390
|
+
|
|
391
|
+
const streams = input.claim.streams.map((stream) => ({
|
|
392
|
+
path: withLeadingSlash(stream.path),
|
|
393
|
+
offset: stream.tail_offset ?? ``,
|
|
394
|
+
}))
|
|
395
|
+
return {
|
|
396
|
+
consumerId: input.claim.wake_id,
|
|
397
|
+
epoch: input.claim.generation,
|
|
398
|
+
wakeId: input.claim.wake_id,
|
|
399
|
+
streamPath: primaryStream,
|
|
400
|
+
streams,
|
|
401
|
+
callback: appendPathToUrl(
|
|
402
|
+
ctx.publicUrl,
|
|
403
|
+
`/_electric/callback-forward/${encodeURIComponent(input.claim.wake_id)}`
|
|
404
|
+
),
|
|
405
|
+
claimToken: input.claim.token,
|
|
406
|
+
triggerEvent: `message_received`,
|
|
407
|
+
entity: {
|
|
408
|
+
type: entity.type,
|
|
409
|
+
status: entity.status,
|
|
410
|
+
url: entity.url,
|
|
411
|
+
streams: entity.streams,
|
|
412
|
+
tags: entity.tags,
|
|
413
|
+
spawnArgs: entity.spawn_args,
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON body schema middleware for itty-router handlers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { apiError } from '../electric-agents-http'
|
|
6
|
+
import { ErrCodeInvalidRequest } from '../electric-agents-types'
|
|
7
|
+
import { schemaValidator } from '../schema-validation.js'
|
|
8
|
+
import type { TSchema as TypeBoxSchema } from '@sinclair/typebox'
|
|
9
|
+
import type { IRequest, RequestHandler } from 'itty-router'
|
|
10
|
+
|
|
11
|
+
export interface JsonRouteRequest extends IRequest {
|
|
12
|
+
content?: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function routeBody<T>(request: JsonRouteRequest): T {
|
|
16
|
+
return request.content as T
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WithSchemaOptions {
|
|
20
|
+
lenient?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function withSchema<TSchema extends TypeBoxSchema>(
|
|
24
|
+
schema: TSchema,
|
|
25
|
+
options: WithSchemaOptions = {}
|
|
26
|
+
): RequestHandler<JsonRouteRequest, Array<unknown>> {
|
|
27
|
+
return async (request) => {
|
|
28
|
+
const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``
|
|
29
|
+
const isJson = contentType.includes(`application/json`)
|
|
30
|
+
if (options.lenient && !isJson) {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bodyStr = await request.text()
|
|
35
|
+
let parsed: unknown
|
|
36
|
+
|
|
37
|
+
if (bodyStr.trim()) {
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(bodyStr)
|
|
40
|
+
} catch {
|
|
41
|
+
return apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`)
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
parsed = {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const validate = schemaValidator(schema)
|
|
48
|
+
if (!validate(parsed)) {
|
|
49
|
+
return apiError(
|
|
50
|
+
400,
|
|
51
|
+
ErrCodeInvalidRequest,
|
|
52
|
+
`Request body does not match API schema`,
|
|
53
|
+
(validate.errors ?? []).map((err) => ({
|
|
54
|
+
path: err.instancePath || `/`,
|
|
55
|
+
message: err.message ?? `validation error`,
|
|
56
|
+
}))
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
request.content = parsed
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateBody<TSchema extends TypeBoxSchema>(
|
|
66
|
+
schema: TSchema,
|
|
67
|
+
body: Uint8Array
|
|
68
|
+
): { ok: true; value: unknown } | { ok: false; response: Response } {
|
|
69
|
+
const parsed = parseJsonBodyBytes(body)
|
|
70
|
+
if (!parsed.ok) return parsed
|
|
71
|
+
|
|
72
|
+
const validation = validateParsedBody(schema, parsed.value)
|
|
73
|
+
if (!validation.ok) return validation
|
|
74
|
+
return { ok: true, value: parsed.value }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateOptionalJsonBody<TSchema extends TypeBoxSchema>(
|
|
78
|
+
schema: TSchema,
|
|
79
|
+
body: Uint8Array,
|
|
80
|
+
contentType?: string | null
|
|
81
|
+
):
|
|
82
|
+
| { ok: true; value: unknown | undefined }
|
|
83
|
+
| { ok: false; response: Response } {
|
|
84
|
+
const bodyText = new TextDecoder().decode(body)
|
|
85
|
+
const trimmed = bodyText.trim()
|
|
86
|
+
if (!trimmed) return { ok: true, value: undefined }
|
|
87
|
+
|
|
88
|
+
let parsed: unknown
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(bodyText)
|
|
91
|
+
} catch {
|
|
92
|
+
if (contentType?.toLowerCase().includes(`application/json`)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
response: apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, value: undefined }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const validation = validateParsedBody(schema, parsed)
|
|
102
|
+
if (!validation.ok) return validation
|
|
103
|
+
return { ok: true, value: parsed }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJsonBodyBytes(
|
|
107
|
+
body: Uint8Array
|
|
108
|
+
): { ok: true; value: unknown } | { ok: false; response: Response } {
|
|
109
|
+
if (body.length === 0) return { ok: true, value: {} }
|
|
110
|
+
try {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
value: JSON.parse(new TextDecoder().decode(body)) as unknown,
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
response: apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validateParsedBody<TSchema extends TypeBoxSchema>(
|
|
124
|
+
schema: TSchema,
|
|
125
|
+
parsed: unknown
|
|
126
|
+
): { ok: true } | { ok: false; response: Response } {
|
|
127
|
+
const validate = schemaValidator(schema)
|
|
128
|
+
if (validate(parsed)) return { ok: true }
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
response: apiError(
|
|
132
|
+
400,
|
|
133
|
+
ErrCodeInvalidRequest,
|
|
134
|
+
`Request body does not match API schema`,
|
|
135
|
+
(validate.errors ?? []).map((err) => ({
|
|
136
|
+
path: err.instancePath || `/`,
|
|
137
|
+
message: err.message ?? `validation error`,
|
|
138
|
+
}))
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
}
|