@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.
@@ -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
+ })
@@ -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(path: string, sourcePath: string): Promise<void> {
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
- const LOG_DIR = USE_FILE_LOGS
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
- if (LOG_DIR) {
19
- fs.mkdirSync(LOG_DIR, { recursive: true })
20
- }
13
+ function getLogger(): pino.Logger {
14
+ if (_logger) return _logger
21
15
 
22
- export const LOG_FILE_PATH = LOG_FILE
16
+ const streams: Array<pino.StreamEntry> = []
23
17
 
24
- const streams: Array<pino.StreamEntry> = []
25
- if (LOG_FILE) {
26
- streams.push({
27
- stream: pino.destination({
28
- dest: LOG_FILE,
29
- sync: IS_ELECTRON_MAIN,
30
- }),
31
- })
32
- }
33
- if (USE_PRETTY_LOGS) {
34
- streams.push({
35
- stream: pino.transport({
36
- target: `pino-pretty`,
37
- options: {
38
- colorize: true,
39
- ignore: `pid,hostname,name`,
40
- translateTime: `SYS:HH:MM:ss`,
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
- const logger =
47
- streams.length > 0
48
- ? pino(
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
- pino.multistream(streams)
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 a of args) {
65
- if (a instanceof Error) errors.push(a)
66
- else parts.push(typeof a === `string` ? a : JSON.stringify(a))
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
- logger.info(msg)
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) logger.warn({ err }, msg)
83
- else logger.warn(msg)
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) logger.error({ err }, msg)
89
- else logger.error(msg)
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
- logger.info(obj, msg)
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 ?? ``)}`,
@@ -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
- return {
939
- change: {
940
- collection: eventType,
941
- kind,
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
  }