@schlessera/brain-ui-server 0.34.0 → 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.
- package/README.md +28 -0
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +18 -8
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/push-sender.d.ts +7 -3
- package/dist/activity/push-sender.d.ts.map +1 -1
- package/dist/activity/push-sender.js +45 -11
- package/dist/activity/push-sender.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +8 -0
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +11 -1
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +52 -2
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +2 -5
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/sql.d.ts +34 -0
- package/dist/activity/sql.d.ts.map +1 -0
- package/dist/activity/sql.js +114 -0
- package/dist/activity/sql.js.map +1 -0
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +48 -113
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +3 -1
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +32 -17
- package/dist/activity/stream.js.map +1 -1
- package/dist/app-env.d.ts +8 -0
- package/dist/app-env.d.ts.map +1 -0
- package/dist/app-env.js +2 -0
- package/dist/app-env.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +28 -3
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +4 -2
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +60 -20
- package/dist/brain/client.js.map +1 -1
- package/dist/brain/types.d.ts +9 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/cron/run-job.d.ts.map +1 -1
- package/dist/cron/run-job.js +60 -10
- package/dist/cron/run-job.js.map +1 -1
- package/dist/db/principals.d.ts +72 -0
- package/dist/db/principals.d.ts.map +1 -0
- package/dist/db/principals.js +294 -0
- package/dist/db/principals.js.map +1 -0
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -1
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +37 -9
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +148 -74
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +9 -7
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +25 -7
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/principals.d.ts +18 -0
- package/dist/middleware/principals.d.ts.map +1 -0
- package/dist/middleware/principals.js +138 -0
- package/dist/middleware/principals.js.map +1 -0
- package/dist/middleware/require-owner.d.ts +5 -0
- package/dist/middleware/require-owner.d.ts.map +1 -0
- package/dist/middleware/require-owner.js +10 -0
- package/dist/middleware/require-owner.js.map +1 -0
- package/dist/observability/loggers.js +1 -1
- package/dist/observability/loggers.js.map +1 -1
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +3 -0
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +102 -63
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/push.d.ts +61 -2
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +8 -3
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +30 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +11 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +9 -2
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +44 -6
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +7 -4
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +83 -12
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +12 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +46 -6
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/history.d.ts.map +1 -1
- package/dist/ws/history.js +7 -5
- package/dist/ws/history.js.map +1 -1
- package/dist/ws/host.d.ts +22 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +39 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts +7 -7
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +208 -52
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/shrink.d.ts +3 -1
- package/dist/ws/shrink.d.ts.map +1 -1
- package/dist/ws/shrink.js +46 -16
- package/dist/ws/shrink.js.map +1 -1
- package/dist/ws/turns.d.ts +58 -3
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +101 -2
- package/dist/ws/turns.js.map +1 -1
- package/migrations/011_principals.sql +13 -0
- package/migrations/012_activity_principals.sql +12 -0
- package/migrations/013_push_subscription_principals.sql +7 -0
- package/migrations/014_invalidate_legacy_sessions.sql +14 -0
- package/package.json +3 -3
- package/src/activity/notify.ts +16 -7
- package/src/activity/push-sender.ts +79 -16
- package/src/activity/query.ts +8 -0
- package/src/activity/recorder.ts +68 -3
- package/src/activity/runtime.ts +2 -5
- package/src/activity/sql.ts +122 -0
- package/src/activity/store.ts +73 -155
- package/src/activity/stream.ts +37 -17
- package/src/app-env.ts +8 -0
- package/src/app.ts +33 -4
- package/src/brain/client.ts +61 -20
- package/src/brain/types.ts +10 -0
- package/src/cron/run-job.ts +41 -17
- package/src/db/principals.ts +452 -0
- package/src/files/walker.ts +5 -1
- package/src/index.ts +6 -1
- package/src/middleware/auth.ts +241 -81
- package/src/middleware/passkeys.ts +35 -14
- package/src/middleware/principals.ts +189 -0
- package/src/middleware/require-owner.ts +13 -0
- package/src/observability/loggers.ts +1 -1
- package/src/routes/activity.ts +3 -0
- package/src/routes/brain.ts +107 -70
- package/src/routes/files.ts +10 -5
- package/src/routes/push.ts +11 -5
- package/src/routes/sessions.ts +45 -20
- package/src/share/staging.ts +11 -4
- package/src/ws/bridge.ts +14 -2
- package/src/ws/clients.ts +52 -7
- package/src/ws/connection.ts +98 -20
- package/src/ws/dispatch.ts +66 -6
- package/src/ws/history.ts +7 -5
- package/src/ws/host.ts +59 -1
- package/src/ws/run-session.ts +247 -74
- package/src/ws/shrink.ts +48 -14
- package/src/ws/turns.ts +136 -5
package/src/ws/run-session.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
126
|
+
if (jsonBytes(shrunk) <= maxBytes) return shrunk as T;
|
|
93
127
|
}
|
|
94
128
|
return shrunk as T;
|
|
95
129
|
}
|