@schlessera/brain-ui-server 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +3 -3
  20. package/dist/activity/sql.d.ts.map +1 -1
  21. package/dist/activity/sql.js +7 -5
  22. package/dist/activity/sql.js.map +1 -1
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +15 -3
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +7 -5
  140. package/src/activity/store.ts +32 -1
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
@@ -9,7 +9,7 @@ import { withSessionId, withTurnScope } from "./frames.js";
9
9
  import { makeBridge, emitTurnError } from "./bridge.js";
10
10
  import { createTurnRecorder, type TurnRecorder } from "../activity/recorder.js";
11
11
  import { resolveTurnTarget } from "./routing.js";
12
- import type { QueuedFollowUp, RunningTurn } from "./turns.js";
12
+ import type { AuthorizationContext, QueuedFollowUp, RunningTurn } from "./turns.js";
13
13
  import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
14
14
  import {
15
15
  MAX_SESSION_QUEUE,
@@ -24,19 +24,71 @@ import {
24
24
  * order. The host owns per-turn cancellation (AbortController + timeout). Never
25
25
  * throws — runtime failures are emitted as error frames.
26
26
  */
27
- export async function runSession(
27
+ /**
28
+ * Write out cancellations that were accepted before a recorder existed.
29
+ *
30
+ * A cancel can land while routing or billing is still in flight, when there is
31
+ * nothing to record it on yet, so the actor is buffered on the turn. Both exits
32
+ * from that window have to drain it — the ordinary one, which then has a
33
+ * recorder, and the revoked one, which must build a throwaway recorder rather
34
+ * than drop a decision someone actually made.
35
+ */
36
+ function recordPendingCancellations(
28
37
  host: WsHost,
29
- initial: {
30
- text: string;
31
- sessionId?: string;
32
- attachments: ChatImageAttachment[];
33
- providerId?: string;
34
- client?: ClientEnvironment;
35
- /** Client correlation id for a new conversation; echoed on session_info. */
36
- draftId?: string;
38
+ turn: RunningTurn,
39
+ billing: Awaited<ReturnType<typeof resolveRunBilling>> | undefined
40
+ ): void {
41
+ if (turn.pendingCancellationPrincipalIds.length === 0) return;
42
+ const recorder =
43
+ turn.recorder ??
44
+ (host.activity
45
+ ? createTurnRecorder(
46
+ {
47
+ store: host.activity.store,
48
+ onWrite: () => host.activity!.stream.pump(),
49
+ log: host.log,
50
+ },
51
+ {
52
+ turnId: turn.turnId,
53
+ sessionId: turn.sessionId,
54
+ principalId: turn.principalId,
55
+ ...billing,
56
+ }
57
+ )
58
+ : undefined);
59
+ for (const principalId of turn.pendingCancellationPrincipalIds) {
60
+ recorder?.recordCancellation(principalId);
61
+ }
62
+ turn.pendingCancellationPrincipalIds.length = 0;
63
+ }
64
+
65
+ type RunSessionInput = {
66
+ authorization: AuthorizationContext;
67
+ text: string;
68
+ sessionId?: string;
69
+ attachments: ChatImageAttachment[];
70
+ providerId?: string;
71
+ client?: ClientEnvironment;
72
+ /** Client correlation id for a new conversation; echoed on session_info. */
73
+ draftId?: string;
74
+ };
75
+
76
+ export async function runSession(host: WsHost, initial: RunSessionInput): Promise<void> {
77
+ const releaseAuthorization = initial.authorization.retain();
78
+ try {
79
+ await runRetainedSession(host, initial);
80
+ } finally {
81
+ releaseAuthorization();
37
82
  }
83
+ }
84
+
85
+ async function runRetainedSession(
86
+ host: WsHost,
87
+ initial: RunSessionInput
38
88
  ): Promise<void> {
39
89
  const { coordinator } = host;
90
+ const starting = { queue: [] as QueuedFollowUp[], cancelled: false };
91
+ if (initial.sessionId) coordinator.startingBySession.set(initial.sessionId, starting);
40
92
  coordinator.startingSessions += 1;
41
93
  let target: Awaited<ReturnType<typeof resolveTurnTarget>>;
42
94
  try {
@@ -55,8 +107,14 @@ export async function runSession(
55
107
  return;
56
108
  } finally {
57
109
  coordinator.startingSessions = Math.max(0, coordinator.startingSessions - 1);
110
+ if (initial.sessionId && coordinator.startingBySession.get(initial.sessionId) === starting) {
111
+ coordinator.startingBySession.delete(initial.sessionId);
112
+ }
58
113
  }
59
114
 
115
+ if (starting.cancelled) return;
116
+ if (!initial.authorization.valid) return;
117
+
60
118
  const { backend, profileId: initialProfileId } = target;
61
119
  if (target.droppedPin) {
62
120
  host.log.emit({
@@ -72,6 +130,8 @@ export async function runSession(
72
130
  });
73
131
  }
74
132
  const turn: RunningTurn = {
133
+ principalId: initial.authorization.principalId,
134
+ authorization: initial.authorization,
75
135
  sessionId: initial.sessionId ?? null,
76
136
  turnId: crypto.randomUUID(),
77
137
  // Only meaningful when the client had no session id to send.
@@ -80,7 +140,8 @@ export async function runSession(
80
140
  backend,
81
141
  abortController: new AbortController(),
82
142
  timeoutHandle: setTimeout(() => {}, 0),
83
- queue: [],
143
+ queue: starting.queue,
144
+ pendingCancellationPrincipalIds: [],
84
145
  cancelled: false,
85
146
  lastResult: null,
86
147
  };
@@ -91,13 +152,30 @@ export async function runSession(
91
152
  let profileId = initialProfileId;
92
153
  let resumeId = initial.sessionId;
93
154
  let next: QueuedFollowUp | null = {
155
+ principalId: initial.authorization.principalId,
156
+ authorization: initial.authorization,
94
157
  text: initial.text,
95
158
  attachments: initial.attachments,
96
159
  ...(initial.client ? { client: initial.client } : {}),
160
+ releaseAuthorization: () => {},
97
161
  };
162
+ let releaseActiveAuthorization: (() => void) | undefined;
98
163
 
99
164
  try {
100
165
  while (next && !turn.cancelled) {
166
+ releaseActiveAuthorization = next.releaseAuthorization;
167
+ turn.principalId = next.principalId;
168
+ turn.authorization = next.authorization;
169
+ if (!turn.authorization.valid) {
170
+ releaseActiveAuthorization();
171
+ releaseActiveAuthorization = undefined;
172
+ next = turn.queue.shift() ?? null;
173
+ if (next) {
174
+ turn.turnId = crypto.randomUUID();
175
+ turn.lastResult = null;
176
+ }
177
+ continue;
178
+ }
101
179
  const { text, attachments, client } = next;
102
180
  next = null;
103
181
 
@@ -129,6 +207,32 @@ export async function runSession(
129
207
  const billing = host.activity
130
208
  ? await resolveRunBilling(host.registry, backend.id, profileId)
131
209
  : undefined;
210
+ // Revocation can land while routing or billing is in flight. This is the
211
+ // final await boundary before startTurn, so an invalid principal never
212
+ // reaches the backend while an already-running turn remains untouched.
213
+ // Expiry is authoritative, not advisory: the sweep that turns an expired
214
+ // principal into a revocation runs on a timer, and a follow-up dequeued
215
+ // inside that window would otherwise start — reviewer measured one
216
+ // starting 12ms past expiry — and then survive, because a started turn is
217
+ // deliberately never aborted. Enforce it at the same boundary that
218
+ // enforces revocation, so the timer only has to close the socket.
219
+ host.expireAuthorizationContexts();
220
+ if (!turn.authorization.valid) {
221
+ clearTimeout(timeoutHandle);
222
+ releaseActiveAuthorization();
223
+ releaseActiveAuthorization = undefined;
224
+ // A cancellation accepted while billing was in flight is already a
225
+ // decision someone made; losing it because the turn's own principal
226
+ // was revoked a moment later would erase the actor, not the turn. Drain
227
+ // the buffer before leaving.
228
+ recordPendingCancellations(host, turn, billing);
229
+ if (turn.queue.length > 0) {
230
+ next = turn.queue.shift()!;
231
+ turn.turnId = crypto.randomUUID();
232
+ turn.lastResult = null;
233
+ }
234
+ continue;
235
+ }
132
236
  const recorder: TurnRecorder | undefined = host.activity
133
237
  ? createTurnRecorder(
134
238
  {
@@ -136,9 +240,19 @@ export async function runSession(
136
240
  onWrite: () => host.activity!.stream.pump(),
137
241
  log: host.log,
138
242
  },
139
- { turnId: turn.turnId, sessionId: turn.sessionId, ...billing }
243
+ {
244
+ turnId: turn.turnId,
245
+ sessionId: turn.sessionId,
246
+ principalId: turn.principalId,
247
+ ...billing,
248
+ }
140
249
  )
141
250
  : undefined;
251
+ turn.recorder = recorder;
252
+ for (const principalId of turn.pendingCancellationPrincipalIds) {
253
+ recorder?.recordCancellation(principalId);
254
+ }
255
+ turn.pendingCancellationPrincipalIds.length = 0;
142
256
  const bridge = makeBridge(host, turn, text, backend.id, recorder);
143
257
  const startedAt = Date.now();
144
258
  host.reportTurnStarted(turn);
@@ -182,6 +296,9 @@ export async function runSession(
182
296
  );
183
297
  } finally {
184
298
  clearTimeout(timeoutHandle);
299
+ turn.recorder = undefined;
300
+ releaseActiveAuthorization();
301
+ releaseActiveAuthorization = undefined;
185
302
  }
186
303
 
187
304
  // Subsequent (queued) turns resume the now-known session and keep its
@@ -200,7 +317,12 @@ export async function runSession(
200
317
  }
201
318
  }
202
319
  } finally {
203
- if (turn.sessionId) coordinator.bySession.delete(turn.sessionId);
320
+ releaseActiveAuthorization?.();
321
+ next?.releaseAuthorization();
322
+ for (const entry of turn.queue.splice(0)) entry.releaseAuthorization();
323
+ if (turn.sessionId && coordinator.bySession.get(turn.sessionId) === turn) {
324
+ coordinator.bySession.delete(turn.sessionId);
325
+ }
204
326
  coordinator.running.delete(turn);
205
327
  coordinator.drainPendingForTurn(turn, "Session ended");
206
328
  }
@@ -241,11 +363,70 @@ function formatMb(bytes: number): string {
241
363
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
242
364
  }
243
365
 
366
+ /** Apply the same queue budgets during routing and during a running turn. */
367
+ function queueFollowUp(host: WsHost, ws: WSContext, sessionId: string, slot: { queue: QueuedFollowUp[] }, entry: QueuedFollowUp): void {
368
+ const parked = queuedBytes(slot);
369
+ const incoming = queuedFollowUpBytes(entry);
370
+ // A single message can never exceed the budget on its own: the frame cap
371
+ // is 12 MB and a message's attachments are capped well below that, so an
372
+ // empty queue always has room and this cannot wedge.
373
+ const overBudget = parked + incoming > QUEUE_MAX_BYTES;
374
+ const overDepth = slot.queue.length >= MAX_SESSION_QUEUE;
375
+
376
+ if (overBudget || overDepth) {
377
+ host.sendMessage(
378
+ ws,
379
+ // Session-scoped only: the rejected message would have become a
380
+ // FUTURE turn in this slot, not the one currently running.
381
+ withSessionId(
382
+ {
383
+ type: "error",
384
+ code: "SESSION_QUEUE_FULL",
385
+ message: overBudget
386
+ ? `This session's queue is full (${formatMb(parked)} of ${formatMb(QUEUE_MAX_BYTES)}; this message needs ${formatMb(incoming)}). Wait for it to catch up.`
387
+ : `This session's queue is full (${MAX_SESSION_QUEUE} messages). Wait for it to catch up.`,
388
+ },
389
+ sessionId
390
+ )
391
+ );
392
+ // The entry never joins a queue, so nothing else will ever release its
393
+ // lease: a refused follow-up would otherwise hold its principal
394
+ // discoverable forever and the registry would never empty.
395
+ entry.releaseAuthorization();
396
+ return;
397
+ }
398
+
399
+ // Queue it as the session's next turn; report queued immediately.
400
+ slot.queue.push(entry);
401
+ const total = parked + incoming;
402
+ // Accepted, but heavy enough that the sender should know before they hit
403
+ // the wall — every queued byte is held in this process until its turn runs.
404
+ const detail =
405
+ total >= QUEUE_WARN_BYTES
406
+ ? `Queue is holding ${formatMb(total)} across ${slot.queue.length} messages (limit ${formatMb(QUEUE_MAX_BYTES)}).`
407
+ : undefined;
408
+ if (detail) {
409
+ host.log.emit({
410
+ severityText: "WARN",
411
+ body: "session follow-up queue is heavy",
412
+ attributes: {
413
+ "session.id": sessionId,
414
+ size: formatMb(total),
415
+ queued: slot.queue.length,
416
+ },
417
+ });
418
+ }
419
+ host.sendToClients(
420
+ withSessionId({ type: "status", status: "queued", ...(detail ? { detail } : {}) }, sessionId)
421
+ );
422
+ }
423
+
244
424
  /** Dispatch a chat_message: follow-up to a running session, or a new session. */
245
425
  export async function handleChatMessage(
246
426
  host: WsHost,
247
427
  ws: WSContext,
248
428
  msg: {
429
+ authorization: AuthorizationContext;
249
430
  text: string;
250
431
  sessionId?: string;
251
432
  attachments: ChatImageAttachment[];
@@ -254,76 +435,67 @@ export async function handleChatMessage(
254
435
  draftId?: string;
255
436
  }
256
437
  ): Promise<void> {
257
- const { text, attachments, sessionId, providerId: requestedProviderId, client, draftId } = msg;
438
+ const {
439
+ authorization,
440
+ text,
441
+ attachments,
442
+ sessionId,
443
+ providerId: requestedProviderId,
444
+ client,
445
+ draftId,
446
+ } = msg;
258
447
  const { coordinator } = host;
259
448
  const runningTurn = sessionId ? coordinator.bySession.get(sessionId) : undefined;
260
449
 
450
+ const starting = sessionId ? coordinator.startingBySession.get(sessionId) : undefined;
451
+ if (starting && sessionId) {
452
+ if (starting.cancelled) {
453
+ host.sendMessage(ws, { type: "error", code: "SESSION_BUSY", sessionId, message: "This session is cancelling. Wait before sending again." });
454
+ } else {
455
+ queueFollowUp(host, ws, sessionId, starting, {
456
+ principalId: authorization.principalId,
457
+ authorization,
458
+ text,
459
+ attachments,
460
+ ...(client ? { client } : {}),
461
+ releaseAuthorization: authorization.retain(),
462
+ });
463
+ }
464
+ return;
465
+ }
466
+
261
467
  if (runningTurn && sessionId) {
262
468
  const backend = runningTurn.backend;
263
- // Message to a session whose turn is running = a follow-up.
264
- if (backend.capabilities.followUp && backend.followUp) {
469
+ // Drain older messages queued during routing before allowing native
470
+ // injection to overtake them.
471
+ if (backend.capabilities.followUp && backend.followUp && runningTurn.queue.length === 0) {
265
472
  // Inject into the running turn; frames flow through its bridge. The
266
473
  // device snapshot is deliberately not forwarded: a follow-up joins a
267
474
  // turn whose system prompt was already built and cannot be revised.
268
- backend.followUp({ sessionId, prompt: text, attachments }).catch((err) => {
269
- const message = err instanceof Error ? err.message : String(err);
270
- host.reportTurnFailed("FOLLOWUP_FAILED", runningTurn, message);
271
- host.sendToClients(
272
- withTurnScope({ type: "error", code: "FOLLOWUP_FAILED", message }, runningTurn)
273
- );
274
- });
475
+ runningTurn.recorder?.recordFollowUp(authorization.principalId);
476
+ void (async () => {
477
+ const releaseFollowUp = authorization.retain();
478
+ try {
479
+ await backend.followUp!({ sessionId, prompt: text, attachments });
480
+ } catch (err) {
481
+ const message = err instanceof Error ? err.message : String(err);
482
+ host.reportTurnFailed("FOLLOWUP_FAILED", runningTurn, message);
483
+ host.sendToClients(
484
+ withTurnScope({ type: "error", code: "FOLLOWUP_FAILED", message }, runningTurn)
485
+ );
486
+ } finally {
487
+ releaseFollowUp();
488
+ }
489
+ })();
275
490
  } else {
276
- const entry: QueuedFollowUp = { text, attachments, ...(client ? { client } : {}) };
277
- const parked = queuedBytes(runningTurn);
278
- const incoming = queuedFollowUpBytes(entry);
279
- // A single message can never exceed the budget on its own: the frame cap
280
- // is 12 MB and a message's attachments are capped well below that, so an
281
- // empty queue always has room and this cannot wedge.
282
- const overBudget = parked + incoming > QUEUE_MAX_BYTES;
283
- const overDepth = runningTurn.queue.length >= MAX_SESSION_QUEUE;
284
-
285
- if (overBudget || overDepth) {
286
- host.sendMessage(
287
- ws,
288
- // Session-scoped only: the rejected message would have become a
289
- // FUTURE turn in this slot, not the one currently running.
290
- withSessionId(
291
- {
292
- type: "error",
293
- code: "SESSION_QUEUE_FULL",
294
- message: overBudget
295
- ? `This session's queue is full (${formatMb(parked)} of ${formatMb(QUEUE_MAX_BYTES)}; this message needs ${formatMb(incoming)}). Wait for it to catch up.`
296
- : `This session's queue is full (${MAX_SESSION_QUEUE} messages). Wait for it to catch up.`,
297
- },
298
- sessionId
299
- )
300
- );
301
- return;
302
- }
303
-
304
- // Queue it as the session's next turn; report queued immediately.
305
- runningTurn.queue.push(entry);
306
- const total = parked + incoming;
307
- // Accepted, but heavy enough that the sender should know before they hit
308
- // the wall — every queued byte is held in this process until its turn runs.
309
- const detail =
310
- total >= QUEUE_WARN_BYTES
311
- ? `Queue is holding ${formatMb(total)} across ${runningTurn.queue.length} messages (limit ${formatMb(QUEUE_MAX_BYTES)}).`
312
- : undefined;
313
- if (detail) {
314
- host.log.emit({
315
- severityText: "WARN",
316
- body: "session follow-up queue is heavy",
317
- attributes: {
318
- "session.id": sessionId,
319
- size: formatMb(total),
320
- queued: runningTurn.queue.length,
321
- },
322
- });
323
- }
324
- host.sendToClients(
325
- withSessionId({ type: "status", status: "queued", ...(detail ? { detail } : {}) }, sessionId)
326
- );
491
+ queueFollowUp(host, ws, sessionId, runningTurn, {
492
+ principalId: authorization.principalId,
493
+ authorization,
494
+ text,
495
+ attachments,
496
+ ...(client ? { client } : {}),
497
+ releaseAuthorization: authorization.retain(),
498
+ });
327
499
  }
328
500
  return;
329
501
  }
@@ -344,6 +516,7 @@ export async function handleChatMessage(
344
516
  host.sendToClients(withSessionId({ type: "status", status: "thinking" }, sessionId));
345
517
  }
346
518
  void runSession(host, {
519
+ authorization,
347
520
  text,
348
521
  sessionId,
349
522
  attachments,
package/src/ws/shrink.ts CHANGED
@@ -32,10 +32,9 @@ interface ShrinkPass {
32
32
  }
33
33
 
34
34
  /**
35
- * Progressive shrink passes. Each tightens both the per-string cap and the
36
- * per-array head limit; the loop stops at the first pass whose output fits
37
- * {@link MAX_WS_MESSAGE_BYTES}. The final pass clamps every string to 64 B and
38
- * every array to one element, so even pathological mixes converge.
35
+ * Progressive shrink passes. Each tightens display strings and array tails;
36
+ * the loop stops when the serialized UTF-8 payload fits. Identity strings
37
+ * remain intact, and typed arrays never receive an untyped elision marker.
39
38
  */
40
39
  const SHRINK_PASSES: readonly ShrinkPass[] = [
41
40
  { stringCap: 64 * 1024, arrayLimit: 256 },
@@ -49,29 +48,64 @@ const SHRINK_PASSES: readonly ShrinkPass[] = [
49
48
 
50
49
  const STRING_ELISION_RESERVE = 80;
51
50
 
51
+ /** JSON's UTF-8 wire size, including escaped control characters. */
52
+ export function jsonBytes(value: unknown): number {
53
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
54
+ }
55
+
56
+ // These strings identify protocol objects rather than display text.
57
+ const IDENTITY_FIELDS = new Set([
58
+ "id", "type", "kind", "role", "name", "sessionId", "turnId", "draftId",
59
+ "requestId", "toolUseId", "parentToolUseId", "providerId", "backendId",
60
+ ]);
61
+
52
62
  /**
53
63
  * Recursively walk `value`, head-truncating any string longer than
54
64
  * `stringCap` and head-clipping any array longer than `arrayLimit`. Returns a
55
65
  * freshly built deep clone; short strings/numbers/booleans pass through.
56
66
  */
57
- function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unknown {
67
+ function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number, opaque = false): unknown {
58
68
  if (typeof value === "string") {
59
69
  if (value.length <= stringCap) return value;
60
- const headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
70
+ let headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
71
+ // Never split a UTF-16 surrogate pair at the truncation boundary.
72
+ const last = value.charCodeAt(headLen - 1);
73
+ if (last >= 0xd800 && last <= 0xdbff) headLen--;
61
74
  return `${value.slice(0, headLen)}\n…[${value.length - headLen} chars elided]`;
62
75
  }
63
76
  if (Array.isArray(value)) {
64
77
  const keep = Math.min(value.length, arrayLimit);
65
- const elided = value.length - keep;
66
- const out: unknown[] = new Array(elided > 0 ? keep + 1 : keep);
67
- for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit);
68
- if (elided > 0) out[keep] = `…[${elided} items elided]`;
78
+ const out: unknown[] = new Array(keep);
79
+ for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit, opaque);
69
80
  return out;
70
81
  }
71
82
  if (value && typeof value === "object") {
72
83
  const src = value as Record<string, unknown>;
73
84
  const out: Record<string, unknown> = {};
74
- for (const k in src) out[k] = shrinkWalk(src[k], stringCap, arrayLimit);
85
+ // Arbitrary tool input can exceed the cap through key count alone. Clip
86
+ // its records recursively, while preserving fields on protocol objects.
87
+ const keys = Object.keys(src);
88
+ for (const k of opaque ? keys.slice(0, arrayLimit) : keys) {
89
+ out[k] = !opaque && IDENTITY_FIELDS.has(k) && typeof src[k] === "string"
90
+ ? src[k]
91
+ : shrinkWalk(src[k], stringCap, arrayLimit, opaque || k === "input");
92
+ }
93
+ // History parts refer to toolCalls by array index. Head clipping preserves
94
+ // retained indices, but references to removed tools must go with them.
95
+ if (!opaque && (src.role === "assistant" || src.role === "user") && Array.isArray(out.toolCalls)) {
96
+ const toolCount = out.toolCalls.length;
97
+ if (Array.isArray(out.parts)) {
98
+ out.parts = out.parts.filter((part) =>
99
+ !part || part.kind !== "tool" ||
100
+ (Number.isInteger(part.toolIndex) && part.toolIndex >= 0 && part.toolIndex < toolCount)
101
+ );
102
+ }
103
+ if (Array.isArray(src.toolCalls) && src.toolCalls.length > toolCount && typeof out.content === "string") {
104
+ const notice = `\n…[${src.toolCalls.length - toolCount} tool calls elided]`;
105
+ out.content += notice;
106
+ if (Array.isArray(out.parts)) out.parts.push({ kind: "text", text: notice });
107
+ }
108
+ }
75
109
  return out;
76
110
  }
77
111
  return value;
@@ -84,12 +118,12 @@ function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unkn
84
118
  * only string leaves and array tails change — discriminator fields, ids, and
85
119
  * other small metadata pass through untouched.
86
120
  */
87
- export function shrinkForReplication<T>(value: T): T {
88
- if (JSON.stringify(value).length <= MAX_WS_MESSAGE_BYTES) return value;
121
+ export function shrinkForReplication<T>(value: T, maxBytes = MAX_WS_MESSAGE_BYTES): T {
122
+ if (jsonBytes(value) <= maxBytes) return value;
89
123
  let shrunk: unknown = value;
90
124
  for (const pass of SHRINK_PASSES) {
91
125
  shrunk = shrinkWalk(value, pass.stringCap, pass.arrayLimit);
92
- if (JSON.stringify(shrunk).length <= MAX_WS_MESSAGE_BYTES) return shrunk as T;
126
+ if (jsonBytes(shrunk) <= maxBytes) return shrunk as T;
93
127
  }
94
128
  return shrunk as T;
95
129
  }