@electric-ax/agents-server 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +394 -51
- package/dist/index.cjs +396 -51
- package/dist/index.d.cts +332 -164
- package/dist/index.d.ts +332 -164
- package/dist/index.js +396 -51
- package/drizzle/0010_sandbox_profiles.sql +5 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +2 -0
- package/src/electric-agents-types.ts +71 -0
- package/src/entity-manager.ts +337 -6
- package/src/entity-projector.ts +3 -0
- package/src/entity-registry.ts +73 -0
- package/src/routing/entities-router.ts +18 -0
- package/src/routing/runners-router.ts +10 -0
- package/src/routing/sandbox.ts +173 -0
- package/src/sandbox-choice-schema.ts +28 -0
- package/src/stream-client.ts +17 -1
- package/src/utils/log.ts +63 -52
- package/src/utils/server-utils.ts +2 -2
- package/src/wake-registry.ts +22 -11
package/src/entity-registry.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
EntityStatus,
|
|
27
27
|
RunnerAdminStatus,
|
|
28
28
|
RunnerKind,
|
|
29
|
+
SandboxProfileAdvertisement,
|
|
29
30
|
SourceStreamOffset,
|
|
30
31
|
ConsumerClaim,
|
|
31
32
|
DispatchPolicy,
|
|
@@ -80,6 +81,13 @@ export interface RegisterRunnerInput {
|
|
|
80
81
|
kind?: RunnerKind
|
|
81
82
|
adminStatus?: RunnerAdminStatus
|
|
82
83
|
wakeStream?: string
|
|
84
|
+
/**
|
|
85
|
+
* Full-set replacement: provide the complete list of profiles the
|
|
86
|
+
* runner currently exposes. Existing rows for the runner are
|
|
87
|
+
* removed and the supplied set is inserted in one transaction.
|
|
88
|
+
* Omit (or pass undefined) to leave the existing set untouched.
|
|
89
|
+
*/
|
|
90
|
+
sandboxProfiles?: ReadonlyArray<SandboxProfileAdvertisement>
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
export interface HeartbeatRunnerInput {
|
|
@@ -148,6 +156,17 @@ export class PostgresRegistry {
|
|
|
148
156
|
): Promise<ElectricAgentsRunner> {
|
|
149
157
|
const now = new Date()
|
|
150
158
|
const wakeStream = input.wakeStream ?? runnerWakeStream(input.id)
|
|
159
|
+
// Full-set replace: when the caller provides a profile list it
|
|
160
|
+
// overwrites whatever the runner previously advertised. Omitting
|
|
161
|
+
// the field on a re-registration preserves the existing value.
|
|
162
|
+
const sandboxProfilesValue = input.sandboxProfiles
|
|
163
|
+
? input.sandboxProfiles.map((p) => ({
|
|
164
|
+
name: p.name,
|
|
165
|
+
label: p.label,
|
|
166
|
+
...(p.description !== undefined && { description: p.description }),
|
|
167
|
+
...(p.remote !== undefined && { remote: p.remote }),
|
|
168
|
+
}))
|
|
169
|
+
: undefined
|
|
151
170
|
|
|
152
171
|
await this.db
|
|
153
172
|
.insert(runners)
|
|
@@ -159,6 +178,9 @@ export class PostgresRegistry {
|
|
|
159
178
|
kind: input.kind ?? `local`,
|
|
160
179
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
161
180
|
wakeStream,
|
|
181
|
+
...(sandboxProfilesValue !== undefined && {
|
|
182
|
+
sandboxProfiles: sandboxProfilesValue,
|
|
183
|
+
}),
|
|
162
184
|
updatedAt: now,
|
|
163
185
|
})
|
|
164
186
|
.onConflictDoUpdate({
|
|
@@ -169,6 +191,9 @@ export class PostgresRegistry {
|
|
|
169
191
|
kind: input.kind ?? `local`,
|
|
170
192
|
adminStatus: input.adminStatus ?? `enabled`,
|
|
171
193
|
wakeStream,
|
|
194
|
+
...(sandboxProfilesValue !== undefined && {
|
|
195
|
+
sandboxProfiles: sandboxProfilesValue,
|
|
196
|
+
}),
|
|
172
197
|
updatedAt: now,
|
|
173
198
|
},
|
|
174
199
|
})
|
|
@@ -180,6 +205,44 @@ export class PostgresRegistry {
|
|
|
180
205
|
return runner
|
|
181
206
|
}
|
|
182
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Every sandbox profile advertised by a runner in this tenant (one entry
|
|
210
|
+
* per runner that advertises it — names may repeat across runners). Used by
|
|
211
|
+
* spawn validation for unpinned dispatch to learn whether a chosen profile
|
|
212
|
+
* is remote (so a shared sandbox can skip the single-runner guard).
|
|
213
|
+
*/
|
|
214
|
+
async listSandboxProfiles(): Promise<Array<SandboxProfileAdvertisement>> {
|
|
215
|
+
const rows = await this.db
|
|
216
|
+
.select({ sandboxProfiles: runners.sandboxProfiles })
|
|
217
|
+
.from(runners)
|
|
218
|
+
.where(eq(runners.tenantId, this.tenantId))
|
|
219
|
+
const profiles: Array<SandboxProfileAdvertisement> = []
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
const list = row.sandboxProfiles as
|
|
222
|
+
| Array<{
|
|
223
|
+
name?: unknown
|
|
224
|
+
label?: unknown
|
|
225
|
+
description?: unknown
|
|
226
|
+
remote?: unknown
|
|
227
|
+
}>
|
|
228
|
+
| null
|
|
229
|
+
| undefined
|
|
230
|
+
if (!Array.isArray(list)) continue
|
|
231
|
+
for (const entry of list) {
|
|
232
|
+
if (!entry || typeof entry.name !== `string`) continue
|
|
233
|
+
profiles.push({
|
|
234
|
+
name: entry.name,
|
|
235
|
+
label: typeof entry.label === `string` ? entry.label : entry.name,
|
|
236
|
+
...(typeof entry.description === `string` && {
|
|
237
|
+
description: entry.description,
|
|
238
|
+
}),
|
|
239
|
+
...(typeof entry.remote === `boolean` && { remote: entry.remote }),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return profiles
|
|
244
|
+
}
|
|
245
|
+
|
|
183
246
|
async getRunner(id: string): Promise<ElectricAgentsRunner | null> {
|
|
184
247
|
const rows = await this.db
|
|
185
248
|
.select()
|
|
@@ -613,6 +676,7 @@ export class PostgresRegistry {
|
|
|
613
676
|
tags: normalizeTags(entity.tags),
|
|
614
677
|
tagsIndex: buildTagsIndex(entity.tags),
|
|
615
678
|
spawnArgs: entity.spawn_args ?? {},
|
|
679
|
+
sandbox: entity.sandbox ?? null,
|
|
616
680
|
parent: entity.parent ?? null,
|
|
617
681
|
createdBy: entity.created_by ?? null,
|
|
618
682
|
typeRevision: entity.type_revision ?? null,
|
|
@@ -1223,6 +1287,8 @@ export class PostgresRegistry {
|
|
|
1223
1287
|
write_token: row.writeToken,
|
|
1224
1288
|
tags: (row.tags as EntityTags | null | undefined) ?? {},
|
|
1225
1289
|
spawn_args: row.spawnArgs as Record<string, unknown> | undefined,
|
|
1290
|
+
sandbox:
|
|
1291
|
+
(row.sandbox as ElectricAgentsEntity[`sandbox`] | null) ?? undefined,
|
|
1226
1292
|
parent: row.parent ?? undefined,
|
|
1227
1293
|
created_by: row.createdBy ?? undefined,
|
|
1228
1294
|
type_revision: row.typeRevision ?? undefined,
|
|
@@ -1295,6 +1361,13 @@ export class PostgresRegistry {
|
|
|
1295
1361
|
kind: assertRunnerKind(row.kind),
|
|
1296
1362
|
admin_status: assertRunnerAdminStatus(row.adminStatus),
|
|
1297
1363
|
wake_stream: row.wakeStream,
|
|
1364
|
+
sandbox_profiles:
|
|
1365
|
+
(row.sandboxProfiles as Array<{
|
|
1366
|
+
name: string
|
|
1367
|
+
label: string
|
|
1368
|
+
description?: string
|
|
1369
|
+
remote?: boolean
|
|
1370
|
+
}> | null) ?? [],
|
|
1298
1371
|
created_at: row.createdAt.toISOString(),
|
|
1299
1372
|
updated_at: row.updatedAt.toISOString(),
|
|
1300
1373
|
}
|
|
@@ -11,6 +11,7 @@ import { Router, json, status } from 'itty-router'
|
|
|
11
11
|
import { apiError } from '../electric-agents-http.js'
|
|
12
12
|
import { parsePrincipalKey, principalUrl } from '../principal.js'
|
|
13
13
|
import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
|
|
14
|
+
import { sandboxChoiceSchema } from '../sandbox-choice-schema.js'
|
|
14
15
|
import {
|
|
15
16
|
ErrCodeNotFound,
|
|
16
17
|
ErrCodeUnknownEntityType,
|
|
@@ -82,6 +83,7 @@ const spawnBodySchema = Type.Object({
|
|
|
82
83
|
tags: Type.Optional(stringRecordSchema),
|
|
83
84
|
parent: Type.Optional(Type.String()),
|
|
84
85
|
dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
86
|
+
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
85
87
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
86
88
|
wake: Type.Optional(
|
|
87
89
|
Type.Object({
|
|
@@ -135,6 +137,15 @@ const inboxMessageBodySchema = Type.Object({
|
|
|
135
137
|
const forkBodySchema = Type.Object({
|
|
136
138
|
instance_id: Type.Optional(Type.String()),
|
|
137
139
|
waitTimeoutMs: Type.Optional(Type.Number()),
|
|
140
|
+
// Optional anchor pointing at an event on the source root's `main`
|
|
141
|
+
// stream. Wire shape is snake_case; the route handler translates to
|
|
142
|
+
// camelCase before forwarding to entity-manager.
|
|
143
|
+
fork_pointer: Type.Optional(
|
|
144
|
+
Type.Object({
|
|
145
|
+
offset: Type.Union([Type.String(), Type.Null()]),
|
|
146
|
+
sub_offset: Type.Number(),
|
|
147
|
+
})
|
|
148
|
+
),
|
|
138
149
|
})
|
|
139
150
|
|
|
140
151
|
const setTagBodySchema = Type.Object({
|
|
@@ -794,6 +805,12 @@ async function forkEntity(
|
|
|
794
805
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
795
806
|
rootInstanceId: parsed.instance_id,
|
|
796
807
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
808
|
+
...(parsed.fork_pointer && {
|
|
809
|
+
forkPointer: {
|
|
810
|
+
offset: parsed.fork_pointer.offset,
|
|
811
|
+
subOffset: parsed.fork_pointer.sub_offset,
|
|
812
|
+
},
|
|
813
|
+
}),
|
|
797
814
|
})
|
|
798
815
|
for (const forkedEntity of result.entities) {
|
|
799
816
|
await linkEntityDispatchSubscription(ctx, forkedEntity)
|
|
@@ -969,6 +986,7 @@ async function spawnEntity(
|
|
|
969
986
|
tags: parsed.tags,
|
|
970
987
|
parent: parsed.parent,
|
|
971
988
|
dispatch_policy: dispatchPolicy,
|
|
989
|
+
sandbox: parsed.sandbox,
|
|
972
990
|
initialMessage: undefined,
|
|
973
991
|
wake: parsed.wake,
|
|
974
992
|
created_by: principal.url,
|
|
@@ -34,6 +34,13 @@ export type RunnersRoutes = RouterType<
|
|
|
34
34
|
RunnersRouteResult
|
|
35
35
|
>
|
|
36
36
|
|
|
37
|
+
const sandboxProfileBodySchema = Type.Object({
|
|
38
|
+
name: Type.String(),
|
|
39
|
+
label: Type.String(),
|
|
40
|
+
description: Type.Optional(Type.String()),
|
|
41
|
+
remote: Type.Optional(Type.Boolean()),
|
|
42
|
+
})
|
|
43
|
+
|
|
37
44
|
const registerRunnerBodySchema = Type.Object({
|
|
38
45
|
id: Type.String(),
|
|
39
46
|
owner_principal: Type.Optional(Type.String()),
|
|
@@ -51,6 +58,7 @@ const registerRunnerBodySchema = Type.Object({
|
|
|
51
58
|
Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])
|
|
52
59
|
),
|
|
53
60
|
wake_stream: Type.Optional(Type.String()),
|
|
61
|
+
sandbox_profiles: Type.Optional(Type.Array(sandboxProfileBodySchema)),
|
|
54
62
|
})
|
|
55
63
|
|
|
56
64
|
const heartbeatBodySchema = Type.Object({
|
|
@@ -234,6 +242,7 @@ async function registerRunner(
|
|
|
234
242
|
kind: parsed.kind,
|
|
235
243
|
adminStatus: parsed.admin_status,
|
|
236
244
|
wakeStream: parsed.wake_stream,
|
|
245
|
+
sandboxProfiles: parsed.sandbox_profiles,
|
|
237
246
|
})
|
|
238
247
|
await ctx.streamClient.ensure(runner.wake_stream, {
|
|
239
248
|
contentType: `application/json`,
|
|
@@ -639,6 +648,7 @@ async function notificationFromClaim(
|
|
|
639
648
|
streams: entity.streams,
|
|
640
649
|
tags: entity.tags,
|
|
641
650
|
spawnArgs: entity.spawn_args,
|
|
651
|
+
sandbox: entity.sandbox,
|
|
642
652
|
createdBy: entity.created_by,
|
|
643
653
|
},
|
|
644
654
|
principal: principalFromCreatedBy(entity.created_by),
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ElectricAgentsError } from '../entity-manager.js'
|
|
2
|
+
import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
|
|
3
|
+
import type {
|
|
4
|
+
DispatchPolicy,
|
|
5
|
+
ElectricAgentsEntity,
|
|
6
|
+
EntitySandboxSelection,
|
|
7
|
+
SandboxChoice,
|
|
8
|
+
} from '../electric-agents-types.js'
|
|
9
|
+
import type { PostgresRegistry } from '../entity-registry.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve and validate a spawn's sandbox CHOICE into the {@link
|
|
13
|
+
* EntitySandboxSelection} persisted on the entity. Sibling of
|
|
14
|
+
* `dispatch-policy.ts`'s `resolveEffectiveDispatchPolicyForSpawn`: kept off the
|
|
15
|
+
* EntityManager so the spawn path reads as composed resolution steps.
|
|
16
|
+
*
|
|
17
|
+
* Profiles are a per-runner concern: each runner advertises what it supports.
|
|
18
|
+
* When the spawn pins a runner via dispatch_policy, the chosen profile must be
|
|
19
|
+
* in that runner's advertised set; otherwise we'd persist an unserviceable
|
|
20
|
+
* choice that fails late at first wake. For unpinned dispatch (webhook /
|
|
21
|
+
* parent-inherited) we can't pick a target ahead of time, so we fall back to a
|
|
22
|
+
* tenant-wide "some runner offers this" check — better than nothing.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveSandboxForSpawn(
|
|
25
|
+
registry: PostgresRegistry,
|
|
26
|
+
dispatchPolicy: DispatchPolicy | undefined,
|
|
27
|
+
requested: SandboxChoice | undefined,
|
|
28
|
+
parentEntity: ElectricAgentsEntity | null
|
|
29
|
+
): Promise<EntitySandboxSelection | undefined> {
|
|
30
|
+
if (!requested) return undefined
|
|
31
|
+
|
|
32
|
+
const choice = applyInheritedSandbox(requested, parentEntity)
|
|
33
|
+
// `inherit` against a parent with no shareable (keyed) sandbox yields none.
|
|
34
|
+
if (!choice) return undefined
|
|
35
|
+
|
|
36
|
+
const chosenName = choice.profile
|
|
37
|
+
if (!chosenName) {
|
|
38
|
+
throw new ElectricAgentsError(
|
|
39
|
+
ErrCodeInvalidRequest,
|
|
40
|
+
`sandbox requires a "profile" (or "inherit": true with a parent that has a shared sandbox).`,
|
|
41
|
+
400
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const chosenIsRemote = await resolveChosenProfileRemote(
|
|
46
|
+
registry,
|
|
47
|
+
chosenName,
|
|
48
|
+
dispatchPolicy
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assertSharedSandboxColocated(choice.key, chosenIsRemote, dispatchPolicy)
|
|
52
|
+
|
|
53
|
+
// Persist the selection. Only an explicit/inherited `key` is stored (it's
|
|
54
|
+
// cross-entity, so the guard above applies); a `scope` is kept so the wake
|
|
55
|
+
// can derive the key, but no `key` is stored for it — leaving the
|
|
56
|
+
// co-location guard correctly keyed on genuine cross-entity sharing.
|
|
57
|
+
const selection: EntitySandboxSelection = { profile: chosenName }
|
|
58
|
+
if (choice.key !== undefined) selection.key = choice.key
|
|
59
|
+
else if (choice.scope !== undefined) selection.scope = choice.scope
|
|
60
|
+
if (choice.persistent !== undefined) selection.persistent = choice.persistent
|
|
61
|
+
// Store ownership only when this entity is an attacher; owner is the
|
|
62
|
+
// default, so it's left implicit (the wake resolver defaults to owner).
|
|
63
|
+
if (choice.owner === false) selection.owner = false
|
|
64
|
+
return selection
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `inherit` against the parent's *stored* sandbox. `inherit` reuses the
|
|
69
|
+
* parent's keyed sandbox as a non-owner (attach-only). It's graceful: if the
|
|
70
|
+
* parent has no shareable (keyed) sandbox the child simply gets none (returns
|
|
71
|
+
* `undefined`), so `spawn_worker` can always request inheritance without
|
|
72
|
+
* breaking unkeyed parents. (A running parent wake resolves inherit to its live
|
|
73
|
+
* explicit key in the runtime instead — this server-side path covers direct API
|
|
74
|
+
* callers, where only the parent's *stored* explicit key is available.)
|
|
75
|
+
*
|
|
76
|
+
* For a non-inherit choice the request passes through unchanged.
|
|
77
|
+
*
|
|
78
|
+
* NOTE: `inherit: true` takes the parent's identity AND durability wholesale —
|
|
79
|
+
* any sibling field on the request (e.g. a caller-supplied `persistent: false`)
|
|
80
|
+
* is intentionally ignored, because a child attaches to the parent's existing
|
|
81
|
+
* sandbox and cannot change how that sandbox is torn down. `sandboxChoiceSchema`
|
|
82
|
+
* permits the `{ inherit: true, persistent: ... }` combination, so the
|
|
83
|
+
* precedence is resolved here rather than rejected at the schema level.
|
|
84
|
+
*/
|
|
85
|
+
function applyInheritedSandbox(
|
|
86
|
+
requested: SandboxChoice,
|
|
87
|
+
parentEntity: ElectricAgentsEntity | null
|
|
88
|
+
): SandboxChoice | undefined {
|
|
89
|
+
if (!requested.inherit) return requested
|
|
90
|
+
const parentKey = parentEntity?.sandbox?.key
|
|
91
|
+
if (!parentKey) return undefined
|
|
92
|
+
return {
|
|
93
|
+
profile: parentEntity!.sandbox!.profile,
|
|
94
|
+
key: parentKey,
|
|
95
|
+
// Adopt the parent's durability; an explicit key has no scope. The child
|
|
96
|
+
// attaches to (never owns) the parent's sandbox.
|
|
97
|
+
persistent: parentEntity!.sandbox!.persistent,
|
|
98
|
+
owner: false,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate the chosen profile is advertised by the relevant runner(s) and
|
|
104
|
+
* determine whether it is a remote (off-host) sandbox, reachable from any
|
|
105
|
+
* runner. Defaults to host-local (co-location required) unless every relevant
|
|
106
|
+
* advertisement marks it remote. Throws if the profile is unserviceable.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveChosenProfileRemote(
|
|
109
|
+
registry: PostgresRegistry,
|
|
110
|
+
chosenName: string,
|
|
111
|
+
dispatchPolicy: DispatchPolicy | undefined
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
const runnerIds: Array<string> = []
|
|
114
|
+
for (const target of dispatchPolicy?.targets ?? []) {
|
|
115
|
+
if (target.type === `runner`) runnerIds.push(target.runnerId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (runnerIds.length > 0) {
|
|
119
|
+
let allRemote = true
|
|
120
|
+
for (const runnerId of runnerIds) {
|
|
121
|
+
const runner = await registry.getRunner(runnerId)
|
|
122
|
+
const advertised = runner?.sandbox_profiles ?? []
|
|
123
|
+
const match = advertised.find((p) => p.name === chosenName)
|
|
124
|
+
if (!match) {
|
|
125
|
+
throw new ElectricAgentsError(
|
|
126
|
+
ErrCodeInvalidRequest,
|
|
127
|
+
`sandbox profile "${chosenName}" is not advertised by runner "${runnerId}" (advertised: ${advertised.map((p) => p.name).join(`, `) || `(none)`}).`,
|
|
128
|
+
400
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (match.remote !== true) allRemote = false
|
|
132
|
+
}
|
|
133
|
+
return allRemote
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const available = await registry.listSandboxProfiles()
|
|
137
|
+
const matches = available.filter((p) => p.name === chosenName)
|
|
138
|
+
if (matches.length === 0) {
|
|
139
|
+
throw new ElectricAgentsError(
|
|
140
|
+
ErrCodeInvalidRequest,
|
|
141
|
+
`sandbox profile "${chosenName}" is not offered by any registered runner (available: ${[...new Set(available.map((p) => p.name))].join(`, `) || `(none)`}).`,
|
|
142
|
+
400
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
// Only skip the co-location guard when every advertiser of this name is
|
|
146
|
+
// remote — a same-named host-local profile on another runner could
|
|
147
|
+
// otherwise land a collaborator on the wrong host.
|
|
148
|
+
return matches.every((p) => p.remote === true)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Co-location: a shared *local* sandbox lives on one host, so every
|
|
153
|
+
* collaborator must be pinned to the same single runner. Subagents inherit the
|
|
154
|
+
* parent's dispatch policy, so this holds once the root is pinned. A shared
|
|
155
|
+
* *remote* sandbox is reachable from any runner, so the guard does not apply.
|
|
156
|
+
*/
|
|
157
|
+
function assertSharedSandboxColocated(
|
|
158
|
+
key: string | undefined,
|
|
159
|
+
chosenIsRemote: boolean,
|
|
160
|
+
dispatchPolicy: DispatchPolicy | undefined
|
|
161
|
+
): void {
|
|
162
|
+
if (key === undefined || chosenIsRemote) return
|
|
163
|
+
const targets = dispatchPolicy?.targets ?? []
|
|
164
|
+
const pinnedToSingleRunner =
|
|
165
|
+
targets.length === 1 && targets[0]?.type === `runner`
|
|
166
|
+
if (!pinnedToSingleRunner) {
|
|
167
|
+
throw new ElectricAgentsError(
|
|
168
|
+
ErrCodeInvalidRequest,
|
|
169
|
+
`a shared sandbox (sandbox.key / sandbox.inherit) requires the entity to be pinned to a single runner via dispatch_policy, so all collaborators share one host.`,
|
|
170
|
+
400
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire schema for a spawn-time sandbox CHOICE (the request input), as opposed to
|
|
5
|
+
* the resolved {@link import('./electric-agents-types.js').EntitySandboxSelection}
|
|
6
|
+
* persisted on the entity. The matching `SandboxChoice` type is hand-maintained
|
|
7
|
+
* in `electric-agents-types.ts` — mirrors how `dispatchPolicySchema` pairs with
|
|
8
|
+
* the `DispatchPolicy` type in `dispatch-policy-schema.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Validation happens once, at the router boundary (this schema is embedded in
|
|
11
|
+
* the spawn body schema); the spawn resolver consumes already-validated input,
|
|
12
|
+
* so there is intentionally no separate `parse` helper here.
|
|
13
|
+
*/
|
|
14
|
+
export const sandboxChoiceSchema = Type.Object({
|
|
15
|
+
profile: Type.Optional(Type.String()),
|
|
16
|
+
// Explicit cross-entity identity — entities with the same key collaborate on
|
|
17
|
+
// one workspace. `inherit` reuses the parent entity's resolved sandbox.
|
|
18
|
+
key: Type.Optional(Type.String()),
|
|
19
|
+
// Identity scope when no explicit `key`: per-entity (default) or per-wake.
|
|
20
|
+
scope: Type.Optional(
|
|
21
|
+
Type.Union([Type.Literal(`entity`), Type.Literal(`wake`)])
|
|
22
|
+
),
|
|
23
|
+
// Idle-teardown durability; defaults by scope when unset.
|
|
24
|
+
persistent: Type.Optional(Type.Boolean()),
|
|
25
|
+
// Whether this entity owns the sandbox (default) or only attaches to one.
|
|
26
|
+
owner: Type.Optional(Type.Boolean()),
|
|
27
|
+
inherit: Type.Optional(Type.Boolean()),
|
|
28
|
+
})
|
package/src/stream-client.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FetchError,
|
|
5
5
|
IdempotentProducer,
|
|
6
6
|
} from '@durable-streams/client'
|
|
7
|
+
import type { EventPointer } from '@electric-ax/agents-runtime'
|
|
7
8
|
import { ErrCodeNotFound } from './electric-agents-types.js'
|
|
8
9
|
import { ATTR, injectTraceHeaders, withSpan } from './tracing.js'
|
|
9
10
|
import type { HeadersRecord, MaybePromise } from '@durable-streams/client'
|
|
@@ -242,7 +243,11 @@ export class StreamClient {
|
|
|
242
243
|
})
|
|
243
244
|
}
|
|
244
245
|
|
|
245
|
-
async fork(
|
|
246
|
+
async fork(
|
|
247
|
+
path: string,
|
|
248
|
+
sourcePath: string,
|
|
249
|
+
opts?: { forkPointer?: EventPointer }
|
|
250
|
+
): Promise<void> {
|
|
246
251
|
return await withSpan(`stream.fork`, async (span) => {
|
|
247
252
|
span.setAttributes({
|
|
248
253
|
[ATTR.STREAM_PATH]: path,
|
|
@@ -252,6 +257,17 @@ export class StreamClient {
|
|
|
252
257
|
'content-type': `application/json`,
|
|
253
258
|
'Stream-Forked-From': new URL(this.streamUrl(sourcePath)).pathname,
|
|
254
259
|
}
|
|
260
|
+
if (opts?.forkPointer) {
|
|
261
|
+
// The durable-streams server returns 400 if Stream-Fork-Sub-Offset
|
|
262
|
+
// > 0 without an accompanying Stream-Fork-Offset. When our
|
|
263
|
+
// pointer's offset is `null` (anchor at stream start), send the
|
|
264
|
+
// explicit zero-offset string to satisfy that constraint.
|
|
265
|
+
const ZERO_OFFSET = `0000000000000000_0000000000000000`
|
|
266
|
+
headers[`Stream-Fork-Offset`] = opts.forkPointer.offset ?? ZERO_OFFSET
|
|
267
|
+
if (opts.forkPointer.subOffset > 0) {
|
|
268
|
+
headers[`Stream-Fork-Sub-Offset`] = String(opts.forkPointer.subOffset)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
255
271
|
injectTraceHeaders(headers)
|
|
256
272
|
|
|
257
273
|
const response = await fetch(this.streamUrl(path), {
|
package/src/utils/log.ts
CHANGED
|
@@ -8,62 +8,73 @@ const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`
|
|
|
8
8
|
const USE_PRETTY_LOGS =
|
|
9
9
|
LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
? (process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`))
|
|
13
|
-
: undefined
|
|
14
|
-
const LOG_FILE = LOG_DIR
|
|
15
|
-
? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`)
|
|
16
|
-
: undefined
|
|
11
|
+
let _logger: pino.Logger | undefined
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
13
|
+
function getLogger(): pino.Logger {
|
|
14
|
+
if (_logger) return _logger
|
|
21
15
|
|
|
22
|
-
|
|
16
|
+
const streams: Array<pino.StreamEntry> = []
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
44
|
-
}
|
|
18
|
+
try {
|
|
19
|
+
if (USE_FILE_LOGS) {
|
|
20
|
+
const logDir =
|
|
21
|
+
process.env.ELECTRIC_AGENTS_LOG_DIR ??
|
|
22
|
+
path.resolve(process.cwd(), `logs`)
|
|
23
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
24
|
+
const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`)
|
|
25
|
+
streams.push({
|
|
26
|
+
stream: pino.destination({
|
|
27
|
+
dest: logFile,
|
|
28
|
+
sync: IS_ELECTRON_MAIN,
|
|
29
|
+
}),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`
|
|
35
|
+
)
|
|
36
|
+
}
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{
|
|
38
|
+
try {
|
|
39
|
+
if (USE_PRETTY_LOGS) {
|
|
40
|
+
streams.push({
|
|
41
|
+
stream: pino.transport({
|
|
42
|
+
target: `pino-pretty`,
|
|
43
|
+
options: {
|
|
44
|
+
colorize: true,
|
|
45
|
+
ignore: `pid,hostname,name`,
|
|
46
|
+
translateTime: `SYS:HH:MM:ss`,
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// pino-pretty unavailable — continue without pretty logging
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_logger =
|
|
56
|
+
streams.length > 0
|
|
57
|
+
? pino(
|
|
58
|
+
{
|
|
59
|
+
base: undefined,
|
|
60
|
+
level: LOG_LEVEL,
|
|
61
|
+
},
|
|
62
|
+
pino.multistream(streams)
|
|
63
|
+
)
|
|
64
|
+
: pino({
|
|
50
65
|
base: undefined,
|
|
66
|
+
enabled: false,
|
|
51
67
|
level: LOG_LEVEL,
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: pino({
|
|
56
|
-
base: undefined,
|
|
57
|
-
enabled: false,
|
|
58
|
-
level: LOG_LEVEL,
|
|
59
|
-
})
|
|
68
|
+
})
|
|
69
|
+
return _logger
|
|
70
|
+
}
|
|
60
71
|
|
|
61
72
|
function formatArgs(args: Array<unknown>): { err?: Error; msg: string } {
|
|
62
73
|
const errors: Array<Error> = []
|
|
63
74
|
const parts: Array<string> = []
|
|
64
|
-
for (const
|
|
65
|
-
if (
|
|
66
|
-
else parts.push(typeof
|
|
75
|
+
for (const value of args) {
|
|
76
|
+
if (value instanceof Error) errors.push(value)
|
|
77
|
+
else parts.push(typeof value === `string` ? value : JSON.stringify(value))
|
|
67
78
|
}
|
|
68
79
|
return {
|
|
69
80
|
err: errors[0],
|
|
@@ -74,22 +85,22 @@ function formatArgs(args: Array<unknown>): { err?: Error; msg: string } {
|
|
|
74
85
|
export const serverLog = {
|
|
75
86
|
info(...args: Array<unknown>): void {
|
|
76
87
|
const { msg } = formatArgs(args)
|
|
77
|
-
|
|
88
|
+
getLogger().info(msg)
|
|
78
89
|
},
|
|
79
90
|
|
|
80
91
|
warn(...args: Array<unknown>): void {
|
|
81
92
|
const { err, msg } = formatArgs(args)
|
|
82
|
-
if (err)
|
|
83
|
-
else
|
|
93
|
+
if (err) getLogger().warn({ err }, msg)
|
|
94
|
+
else getLogger().warn(msg)
|
|
84
95
|
},
|
|
85
96
|
|
|
86
97
|
error(...args: Array<unknown>): void {
|
|
87
98
|
const { err, msg } = formatArgs(args)
|
|
88
|
-
if (err)
|
|
89
|
-
else
|
|
99
|
+
if (err) getLogger().error({ err }, msg)
|
|
100
|
+
else getLogger().error(msg)
|
|
90
101
|
},
|
|
91
102
|
|
|
92
103
|
event(obj: Record<string, unknown>, msg: string): void {
|
|
93
|
-
|
|
104
|
+
getLogger().info(obj, msg)
|
|
94
105
|
},
|
|
95
106
|
}
|
|
@@ -119,7 +119,7 @@ export function buildElectricProxyTarget(options: {
|
|
|
119
119
|
if (table === `entities`) {
|
|
120
120
|
target.searchParams.set(
|
|
121
121
|
`columns`,
|
|
122
|
-
`"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
|
|
122
|
+
`"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","sandbox","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
|
|
123
123
|
)
|
|
124
124
|
applyTenantShapeWhere(target, options.tenantId)
|
|
125
125
|
} else if (table === `entity_types`) {
|
|
@@ -131,7 +131,7 @@ export function buildElectricProxyTarget(options: {
|
|
|
131
131
|
} else if (table === `runners`) {
|
|
132
132
|
target.searchParams.set(
|
|
133
133
|
`columns`,
|
|
134
|
-
`"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","created_at","updated_at"`
|
|
134
|
+
`"tenant_id","id","owner_principal","label","kind","admin_status","wake_stream","sandbox_profiles","created_at","updated_at"`
|
|
135
135
|
)
|
|
136
136
|
applyTenantShapeWhere(target, options.tenantId, [
|
|
137
137
|
`owner_principal = ${sqlStringLiteral(options.principalUrl ?? ``)}`,
|
package/src/wake-registry.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface WakeEvalResult {
|
|
|
41
41
|
collection: string
|
|
42
42
|
kind: `insert` | `update` | `delete`
|
|
43
43
|
key: string
|
|
44
|
+
from?: string
|
|
45
|
+
payload?: unknown
|
|
46
|
+
timestamp?: string
|
|
47
|
+
message_type?: string
|
|
44
48
|
}>
|
|
45
49
|
}
|
|
46
50
|
runFinishedStatus?: `completed` | `failed`
|
|
@@ -885,11 +889,7 @@ export class WakeRegistry {
|
|
|
885
889
|
reg: WakeRegistration,
|
|
886
890
|
event: Record<string, unknown>
|
|
887
891
|
): {
|
|
888
|
-
change:
|
|
889
|
-
collection: string
|
|
890
|
-
kind: `insert` | `update` | `delete`
|
|
891
|
-
key: string
|
|
892
|
-
}
|
|
892
|
+
change: WakeEvalResult[`wakeMessage`][`changes`][number]
|
|
893
893
|
runFinishedStatus?: `completed` | `failed`
|
|
894
894
|
} | null {
|
|
895
895
|
if (reg.condition === `runFinished`) {
|
|
@@ -935,12 +935,23 @@ export class WakeRegistry {
|
|
|
935
935
|
return null
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
key: (event.key as string) || ``,
|
|
943
|
-
},
|
|
938
|
+
const change: WakeEvalResult[`wakeMessage`][`changes`][number] = {
|
|
939
|
+
collection: eventType,
|
|
940
|
+
kind,
|
|
941
|
+
key: (event.key as string) || ``,
|
|
944
942
|
}
|
|
943
|
+
|
|
944
|
+
if (eventType === `inbox`) {
|
|
945
|
+
const value = event.value as Record<string, unknown> | undefined
|
|
946
|
+
if (typeof value?.from === `string`) change.from = value.from
|
|
947
|
+
if (`payload` in (value ?? {})) change.payload = value?.payload
|
|
948
|
+
if (typeof value?.timestamp === `string`)
|
|
949
|
+
change.timestamp = value.timestamp
|
|
950
|
+
if (typeof value?.message_type === `string`) {
|
|
951
|
+
change.message_type = value.message_type
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return { change }
|
|
945
956
|
}
|
|
946
957
|
}
|