@schlessera/brain-ui-server 0.16.0 → 0.18.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 +2 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +86 -26
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +51 -17
- package/dist/app.js.map +1 -1
- package/dist/config/env-core.d.ts +73 -0
- package/dist/config/env-core.d.ts.map +1 -0
- package/dist/config/env-core.js +62 -0
- package/dist/config/env-core.js.map +1 -0
- package/dist/config/env.d.ts +24 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +55 -11
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +17 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +33 -6
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/settings.d.ts +1 -1
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +2 -2
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +5 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +46 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/health.d.ts +4 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +22 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +2 -2
- package/dist/routes/share.js.map +1 -1
- package/dist/voice/keyterm-builder.js +2 -2
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +36 -4
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +10 -3
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +16 -4
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +43 -6
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +3 -1
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +36 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +93 -4
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +31 -5
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -1
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +20 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/dist/ws/turns.d.ts +30 -2
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +13 -0
- package/dist/ws/turns.js.map +1 -1
- package/package.json +7 -6
- package/src/agent/backend.ts +123 -29
- package/src/app.ts +59 -17
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +80 -17
- package/src/cron/scheduler.ts +48 -14
- package/src/db/settings.ts +2 -2
- package/src/index.ts +6 -0
- package/src/middleware/auth.ts +50 -1
- package/src/routes/health.ts +22 -6
- package/src/routes/share.ts +11 -7
- package/src/voice/keyterm-builder.ts +2 -2
- package/src/ws/bridge.ts +36 -7
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +59 -6
- package/src/ws/dispatch.ts +3 -1
- package/src/ws/host.ts +115 -6
- package/src/ws/run-session.ts +36 -8
- package/src/ws/session-catalog.ts +21 -3
- package/src/ws/turns.ts +37 -1
package/src/ws/connection.ts
CHANGED
|
@@ -2,6 +2,45 @@ import { upgradeWebSocket, websocket } from "hono/bun";
|
|
|
2
2
|
import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
3
|
import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
|
|
4
4
|
import { withTurnScope } from "./frames.js";
|
|
5
|
+
import type { WSContext as WSContextType } from "./clients.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Re-send every pending approval and ask-user card to a client that just
|
|
9
|
+
* connected. These survive disconnects (see drainClientBoundForTurn) precisely
|
|
10
|
+
* so this re-delivery can happen: a phone that dropped its socket at screen
|
|
11
|
+
* lock reconnects and finds the card waiting instead of a dead turn. The
|
|
12
|
+
* frames carry their original turn scope, so answering them resolves the
|
|
13
|
+
* correct turn's promise through the normal dispatch path.
|
|
14
|
+
*/
|
|
15
|
+
function resendPendingInteractive(host: WsHost, ws: WSContextType): void {
|
|
16
|
+
const { coordinator } = host;
|
|
17
|
+
for (const p of coordinator.pendingApprovals.values()) {
|
|
18
|
+
host.sendMessage(
|
|
19
|
+
ws,
|
|
20
|
+
withTurnScope(
|
|
21
|
+
{
|
|
22
|
+
type: "tool_approval_request",
|
|
23
|
+
toolUseId: p.request.toolUseId,
|
|
24
|
+
toolName: p.request.toolName,
|
|
25
|
+
input: p.request.input,
|
|
26
|
+
description: p.request.description,
|
|
27
|
+
},
|
|
28
|
+
p.turn,
|
|
29
|
+
p.turnId
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
for (const p of coordinator.pendingAskUser.values()) {
|
|
34
|
+
host.sendMessage(
|
|
35
|
+
ws,
|
|
36
|
+
withTurnScope(
|
|
37
|
+
{ type: "ask_user_request", requestId: p.requestId, questions: p.questions },
|
|
38
|
+
p.turn,
|
|
39
|
+
p.turnId
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
5
44
|
import { sendSessionHistory } from "./history.js";
|
|
6
45
|
import { handleClientMessage, type ConnectionState } from "./dispatch.js";
|
|
7
46
|
import type { WsHost } from "./host.js";
|
|
@@ -88,6 +127,7 @@ export function createWsHandlers(host: WsHost) {
|
|
|
88
127
|
turn
|
|
89
128
|
)
|
|
90
129
|
);
|
|
130
|
+
resendPendingInteractive(host, ws);
|
|
91
131
|
}
|
|
92
132
|
return;
|
|
93
133
|
}
|
|
@@ -98,6 +138,7 @@ export function createWsHandlers(host: WsHost) {
|
|
|
98
138
|
status: "idle",
|
|
99
139
|
detail: `Connected to ${host.appName}`,
|
|
100
140
|
});
|
|
141
|
+
resendPendingInteractive(host, ws);
|
|
101
142
|
},
|
|
102
143
|
|
|
103
144
|
onMessage(evt: MessageEvent, ws: WSContext) {
|
|
@@ -141,7 +182,7 @@ export function createWsHandlers(host: WsHost) {
|
|
|
141
182
|
// handleClientMessage is async — a rejection must not escape as an
|
|
142
183
|
// unhandled rejection with no frame sent.
|
|
143
184
|
void Promise.resolve()
|
|
144
|
-
.then(() => handleClientMessage(host, ws, parsed.message))
|
|
185
|
+
.then(() => handleClientMessage(host, ws, parsed.message, connection))
|
|
145
186
|
.catch((err) => {
|
|
146
187
|
// This used to swallow the cause entirely: the client got a generic
|
|
147
188
|
// frame and the server kept no record of what threw.
|
|
@@ -161,15 +202,27 @@ export function createWsHandlers(host: WsHost) {
|
|
|
161
202
|
});
|
|
162
203
|
},
|
|
163
204
|
|
|
164
|
-
onClose(
|
|
205
|
+
onClose(evt: CloseEvent, ws: WSContext) {
|
|
165
206
|
host.log.emit({ severityText: "INFO", body: "client disconnected" });
|
|
207
|
+
// No onError here on purpose: hono's Bun adapter never dispatches it
|
|
208
|
+
// (only open/message/close reach these handlers), so a transport failure
|
|
209
|
+
// is only visible as an abnormal close code. 1000/1001 are the two
|
|
210
|
+
// clean endings (normal closure, going away); anything else — before it
|
|
211
|
+
// was recorded — looked exactly like a clean disconnect.
|
|
212
|
+
const code = (evt as { code?: unknown }).code;
|
|
213
|
+
if (typeof code === "number" && code !== 1000 && code !== 1001) {
|
|
214
|
+
host.reportAbnormalClose(code);
|
|
215
|
+
}
|
|
166
216
|
host.clients.remove(ws);
|
|
167
|
-
// Turns keep running in the background.
|
|
168
|
-
//
|
|
169
|
-
//
|
|
217
|
+
// Turns keep running in the background. Once the LAST client leaves,
|
|
218
|
+
// reject only the requests that need a live client RIGHT NOW (location,
|
|
219
|
+
// mask). Approvals and ask-user cards survive the disconnect and are
|
|
220
|
+
// re-delivered on reconnect — a phone drops its socket at every screen
|
|
221
|
+
// lock, and denying pending approvals on that signal killed real work.
|
|
222
|
+
// The turn timeout remains their upper bound.
|
|
170
223
|
if (host.clients.hasClients()) return;
|
|
171
224
|
for (const turn of host.coordinator.running) {
|
|
172
|
-
host.coordinator.
|
|
225
|
+
host.coordinator.drainClientBoundForTurn(turn, "Client disconnected");
|
|
173
226
|
}
|
|
174
227
|
},
|
|
175
228
|
};
|
package/src/ws/dispatch.ts
CHANGED
|
@@ -202,10 +202,12 @@ export async function handleClientMessage(
|
|
|
202
202
|
...(runningTurn ? { turnId: runningTurn.turnId } : {}),
|
|
203
203
|
});
|
|
204
204
|
} catch (err) {
|
|
205
|
+
const message = err instanceof Error ? err.message : "Failed to load session";
|
|
206
|
+
host.reportTurnFailed("SESSION_LOAD_ERROR", { sessionId: msg.sessionId }, message);
|
|
205
207
|
host.sendMessage(ws, {
|
|
206
208
|
type: "error",
|
|
207
209
|
code: "SESSION_LOAD_ERROR",
|
|
208
|
-
message
|
|
210
|
+
message,
|
|
209
211
|
sessionId: msg.sessionId,
|
|
210
212
|
});
|
|
211
213
|
}
|
package/src/ws/host.ts
CHANGED
|
@@ -67,6 +67,25 @@ export interface WsHostOptions {
|
|
|
67
67
|
wsRate?: { ratePerSecond: number; burst: number };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Identity of one turn, as it appears on a log record. */
|
|
71
|
+
export interface TurnLogContext {
|
|
72
|
+
sessionId?: string | null;
|
|
73
|
+
turnId?: string | null;
|
|
74
|
+
providerId?: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Log attributes for one turn. Null fields are omitted rather than stringified
|
|
79
|
+
* — a new session has no sessionId until `session_info` names it.
|
|
80
|
+
*/
|
|
81
|
+
export function turnLogAttributes(turn: TurnLogContext): Record<string, string> {
|
|
82
|
+
return {
|
|
83
|
+
...(turn.sessionId ? { "session.id": turn.sessionId } : {}),
|
|
84
|
+
...(turn.turnId ? { "turn.id": turn.turnId } : {}),
|
|
85
|
+
...(turn.providerId ? { profile: turn.providerId } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
/**
|
|
71
90
|
* Everything one WebSocket coordinator instance owns: turn state, the session
|
|
72
91
|
* catalog, the backend registry, the attached client sockets, branding copy,
|
|
@@ -88,6 +107,18 @@ export class WsHost {
|
|
|
88
107
|
private readonly framesDropped: ReturnType<
|
|
89
108
|
ReturnType<Observability["meter"]>["createCounter"]
|
|
90
109
|
>;
|
|
110
|
+
private readonly turnsStarted: ReturnType<
|
|
111
|
+
ReturnType<Observability["meter"]>["createCounter"]
|
|
112
|
+
>;
|
|
113
|
+
private readonly turnsCompleted: ReturnType<
|
|
114
|
+
ReturnType<Observability["meter"]>["createCounter"]
|
|
115
|
+
>;
|
|
116
|
+
private readonly turnsFailed: ReturnType<
|
|
117
|
+
ReturnType<Observability["meter"]>["createCounter"]
|
|
118
|
+
>;
|
|
119
|
+
private readonly wsErrors: ReturnType<
|
|
120
|
+
ReturnType<Observability["meter"]>["createCounter"]
|
|
121
|
+
>;
|
|
91
122
|
|
|
92
123
|
constructor(options: WsHostOptions) {
|
|
93
124
|
this.registry = options.registry;
|
|
@@ -100,11 +131,22 @@ export class WsHost {
|
|
|
100
131
|
this.wsRate =
|
|
101
132
|
options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
|
|
102
133
|
this.log = this.observability.logger("ws");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
134
|
+
const meter = this.observability.meter("ws");
|
|
135
|
+
this.framesDropped = meter.createCounter("ws.frames.dropped", {
|
|
136
|
+
description: "Inbound frames refused before reaching a handler",
|
|
137
|
+
});
|
|
138
|
+
this.turnsStarted = meter.createCounter("turns.started", {
|
|
139
|
+
description: "Turns handed to a backend",
|
|
140
|
+
});
|
|
141
|
+
this.turnsCompleted = meter.createCounter("turns.completed", {
|
|
142
|
+
description: "Turns whose backend call resolved without throwing",
|
|
143
|
+
});
|
|
144
|
+
this.turnsFailed = meter.createCounter("turns.failed", {
|
|
145
|
+
description: "Turn failures surfaced to the client, by error code",
|
|
146
|
+
});
|
|
147
|
+
this.wsErrors = meter.createCounter("ws.errors", {
|
|
148
|
+
description: "Transport errors reported by the socket layer",
|
|
149
|
+
});
|
|
108
150
|
this.coordinator.log = this.log;
|
|
109
151
|
}
|
|
110
152
|
|
|
@@ -130,9 +172,76 @@ export class WsHost {
|
|
|
130
172
|
});
|
|
131
173
|
}
|
|
132
174
|
|
|
175
|
+
/** A turn began executing: counted, and logged with its correlation ids. */
|
|
176
|
+
reportTurnStarted(turn: TurnLogContext): void {
|
|
177
|
+
this.turnsStarted.add(1);
|
|
178
|
+
this.log.emit({
|
|
179
|
+
severityText: "INFO",
|
|
180
|
+
body: "turn started",
|
|
181
|
+
attributes: turnLogAttributes(turn),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A turn's backend call resolved: counted, and logged with its duration. */
|
|
186
|
+
reportTurnCompleted(turn: TurnLogContext, durationMs: number): void {
|
|
187
|
+
this.turnsCompleted.add(1);
|
|
188
|
+
this.log.emit({
|
|
189
|
+
severityText: "INFO",
|
|
190
|
+
body: "turn completed",
|
|
191
|
+
attributes: { ...turnLogAttributes(turn), "duration.ms": durationMs },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Record a turn failure the client is being told about. Before this existed
|
|
197
|
+
* every such failure was an error FRAME only — visible on one phone screen,
|
|
198
|
+
* absent from the server's own record.
|
|
199
|
+
*
|
|
200
|
+
* `code` is the bounded error-frame code (it feeds a counter attribute);
|
|
201
|
+
* `error` is the thrown message and rides only on the log record — never a
|
|
202
|
+
* caller-supplied frame body, per the reportDroppedFrame model.
|
|
203
|
+
*/
|
|
204
|
+
reportTurnFailed(code: string, turn: TurnLogContext, error?: string): void {
|
|
205
|
+
this.turnsFailed.add(1, { code });
|
|
206
|
+
this.log.emit({
|
|
207
|
+
// A busy session is the client racing itself; everything else is a
|
|
208
|
+
// failure the operator should see.
|
|
209
|
+
severityText: code === "SESSION_BUSY" ? "WARN" : "ERROR",
|
|
210
|
+
body: "turn failed",
|
|
211
|
+
attributes: {
|
|
212
|
+
code,
|
|
213
|
+
...turnLogAttributes(turn),
|
|
214
|
+
...(error ? { error } : {}),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Record a socket that closed abnormally. This is the transport-error signal
|
|
221
|
+
* available on Bun: hono's Bun adapter dispatches only open/message/close
|
|
222
|
+
* (never WSEvents.onError, and Bun's ServerWebSocket has no error callback),
|
|
223
|
+
* so a transport failure surfaces as a close with an abnormal code.
|
|
224
|
+
*/
|
|
225
|
+
reportAbnormalClose(code: number): void {
|
|
226
|
+
this.wsErrors.add(1, { "close.code": code });
|
|
227
|
+
this.log.emit({
|
|
228
|
+
severityText: "WARN",
|
|
229
|
+
body: "websocket closed abnormally",
|
|
230
|
+
attributes: { "close.code": code },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
133
234
|
/** Fan a frame out to every attached client (size-bounded per frame). */
|
|
134
235
|
sendToClients(msg: ServerMessage): void {
|
|
135
|
-
this.clients.broadcast(msg)
|
|
236
|
+
this.clients.broadcast(msg, () => {
|
|
237
|
+
// Outbound counterpart of reportDroppedFrame: the peer never saw this
|
|
238
|
+
// frame. Counted only — a dead socket would otherwise WARN per frame
|
|
239
|
+
// until its onClose prunes it.
|
|
240
|
+
this.framesDropped.add(1, {
|
|
241
|
+
reason: "broadcast_send_failed",
|
|
242
|
+
direction: "outbound",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
136
245
|
}
|
|
137
246
|
|
|
138
247
|
/** Send a frame to one specific socket (size-bounded). */
|
package/src/ws/run-session.ts
CHANGED
|
@@ -5,7 +5,13 @@ import { makeBridge, emitTurnError } from "./bridge.js";
|
|
|
5
5
|
import { resolveTurnTarget } from "./routing.js";
|
|
6
6
|
import type { QueuedFollowUp, RunningTurn } from "./turns.js";
|
|
7
7
|
import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
MAX_SESSION_QUEUE,
|
|
10
|
+
QUEUE_MAX_BYTES,
|
|
11
|
+
QUEUE_WARN_BYTES,
|
|
12
|
+
turnLogAttributes,
|
|
13
|
+
type WsHost,
|
|
14
|
+
} from "./host.js";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Run one session slot: the initial turn, then any queued follow-up turns in
|
|
@@ -30,10 +36,14 @@ export async function runSession(
|
|
|
30
36
|
try {
|
|
31
37
|
target = await resolveTurnTarget(host.registry, host.catalog, initial.sessionId, initial.providerId);
|
|
32
38
|
} catch (err) {
|
|
39
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
40
|
+
// No turn exists yet — routing failed before one was minted — so the
|
|
41
|
+
// report carries only the session identity the client asked for.
|
|
42
|
+
host.reportTurnFailed("BACKEND_ERROR", { sessionId: initial.sessionId }, message);
|
|
33
43
|
host.sendToClients({
|
|
34
44
|
type: "error",
|
|
35
45
|
code: "BACKEND_ERROR",
|
|
36
|
-
message
|
|
46
|
+
message,
|
|
37
47
|
...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
|
|
38
48
|
});
|
|
39
49
|
return;
|
|
@@ -66,6 +76,7 @@ export async function runSession(
|
|
|
66
76
|
timeoutHandle: setTimeout(() => {}, 0),
|
|
67
77
|
queue: [],
|
|
68
78
|
cancelled: false,
|
|
79
|
+
lastResult: null,
|
|
69
80
|
};
|
|
70
81
|
clearTimeout(turn.timeoutHandle);
|
|
71
82
|
coordinator.running.add(turn);
|
|
@@ -90,7 +101,9 @@ export async function runSession(
|
|
|
90
101
|
host.log.emit({
|
|
91
102
|
severityText: "WARN",
|
|
92
103
|
body: "turn timed out",
|
|
93
|
-
|
|
104
|
+
// Read from the live turn: session_info may have named the session
|
|
105
|
+
// after this timer was armed.
|
|
106
|
+
attributes: { ...turnLogAttributes(turn), "timeout.ms": host.turnTimeoutMs },
|
|
94
107
|
});
|
|
95
108
|
abortController.abort();
|
|
96
109
|
// Reject any pending interactive request for this turn too. A bridge
|
|
@@ -102,6 +115,8 @@ export async function runSession(
|
|
|
102
115
|
turn.timeoutHandle = timeoutHandle;
|
|
103
116
|
|
|
104
117
|
const bridge = makeBridge(host, turn, text, backend.id);
|
|
118
|
+
const startedAt = Date.now();
|
|
119
|
+
host.reportTurnStarted(turn);
|
|
105
120
|
try {
|
|
106
121
|
await backend.startTurn({
|
|
107
122
|
prompt: text,
|
|
@@ -109,9 +124,21 @@ export async function runSession(
|
|
|
109
124
|
sessionId: resumeId,
|
|
110
125
|
profileId,
|
|
111
126
|
signal: abortController.signal,
|
|
127
|
+
// The budget this very timer enforces, so the backend can put the
|
|
128
|
+
// real number in front of the model instead of it finding the cap
|
|
129
|
+
// mid-flight.
|
|
130
|
+
turnBudgetMs: host.turnTimeoutMs,
|
|
112
131
|
bridge,
|
|
113
132
|
...(client ? { client } : {}),
|
|
114
133
|
});
|
|
134
|
+
// A resolved startTurn is not a successful turn: backends resolve for
|
|
135
|
+
// runtime failures and report them on the terminal result frame, which
|
|
136
|
+
// the bridge recorded on the turn.
|
|
137
|
+
if (turn.lastResult === "error") {
|
|
138
|
+
host.reportTurnFailed("BACKEND_RESULT_ERROR", turn);
|
|
139
|
+
} else {
|
|
140
|
+
host.reportTurnCompleted(turn, Date.now() - startedAt);
|
|
141
|
+
}
|
|
115
142
|
} catch (err) {
|
|
116
143
|
emitTurnError(host, turn, err);
|
|
117
144
|
} finally {
|
|
@@ -127,8 +154,10 @@ export async function runSession(
|
|
|
127
154
|
|
|
128
155
|
if (!turn.cancelled && turn.queue.length > 0) {
|
|
129
156
|
next = turn.queue.shift()!;
|
|
130
|
-
// A queued follow-up is its own turn — give it a fresh identity
|
|
157
|
+
// A queued follow-up is its own turn — give it a fresh identity, and
|
|
158
|
+
// its own terminal disposition.
|
|
131
159
|
turn.turnId = crypto.randomUUID();
|
|
160
|
+
turn.lastResult = null;
|
|
132
161
|
}
|
|
133
162
|
}
|
|
134
163
|
} finally {
|
|
@@ -168,11 +197,10 @@ export async function handleChatMessage(
|
|
|
168
197
|
// device snapshot is deliberately not forwarded: a follow-up joins a
|
|
169
198
|
// turn whose system prompt was already built and cannot be revised.
|
|
170
199
|
backend.followUp({ sessionId, prompt: text, attachments }).catch((err) => {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
host.reportTurnFailed("FOLLOWUP_FAILED", runningTurn, message);
|
|
171
202
|
host.sendToClients(
|
|
172
|
-
withTurnScope(
|
|
173
|
-
{ type: "error", code: "FOLLOWUP_FAILED", message: err instanceof Error ? err.message : String(err) },
|
|
174
|
-
runningTurn
|
|
175
|
-
)
|
|
203
|
+
withTurnScope({ type: "error", code: "FOLLOWUP_FAILED", message }, runningTurn)
|
|
176
204
|
);
|
|
177
205
|
});
|
|
178
206
|
} else {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
3
|
import type { ServerResultMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -42,7 +43,20 @@ const UPSERT_SESSION_SQL = `INSERT INTO sessions (id, title, created_at, last_ac
|
|
|
42
43
|
backend_id = COALESCE(excluded.backend_id, backend_id)`;
|
|
43
44
|
|
|
44
45
|
/** SQLite-backed catalog over the app's own database (injected by createApp). */
|
|
45
|
-
export function createSessionCatalog(db: () => Database): SessionCatalog {
|
|
46
|
+
export function createSessionCatalog(db: () => Database, log?: Logger): SessionCatalog {
|
|
47
|
+
// Persistence stays non-throwing — losing one accounting row must not kill
|
|
48
|
+
// the turn — but never silent: a full disk or locked database would
|
|
49
|
+
// otherwise drop every session record with nothing anywhere.
|
|
50
|
+
const reportWriteFailure = (sessionId: string, err: unknown): void => {
|
|
51
|
+
log?.emit({
|
|
52
|
+
severityText: "WARN",
|
|
53
|
+
body: "session persistence failed",
|
|
54
|
+
attributes: {
|
|
55
|
+
"session.id": sessionId,
|
|
56
|
+
error: err instanceof Error ? err.message : String(err),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
};
|
|
46
60
|
return {
|
|
47
61
|
getStoredProviderId(sessionId) {
|
|
48
62
|
const row = db()
|
|
@@ -72,7 +86,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
|
|
|
72
86
|
providerId,
|
|
73
87
|
backendId
|
|
74
88
|
);
|
|
75
|
-
} catch {
|
|
89
|
+
} catch (err) {
|
|
90
|
+
reportWriteFailure(sessionId, err);
|
|
91
|
+
}
|
|
76
92
|
},
|
|
77
93
|
|
|
78
94
|
persistSession(msg, promptText, providerId, backendId) {
|
|
@@ -90,7 +106,9 @@ export function createSessionCatalog(db: () => Database): SessionCatalog {
|
|
|
90
106
|
providerId,
|
|
91
107
|
backendId
|
|
92
108
|
);
|
|
93
|
-
} catch {
|
|
109
|
+
} catch (err) {
|
|
110
|
+
reportWriteFailure(msg.sessionId, err);
|
|
111
|
+
}
|
|
94
112
|
},
|
|
95
113
|
};
|
|
96
114
|
}
|
package/src/ws/turns.ts
CHANGED
|
@@ -2,10 +2,15 @@ import type { Logger } from "@opentelemetry/api-logs";
|
|
|
2
2
|
import type {
|
|
3
3
|
AgentBackend,
|
|
4
4
|
PermissionDecision,
|
|
5
|
+
PermissionRequest,
|
|
5
6
|
AskUserResult,
|
|
6
7
|
LocationFix,
|
|
7
8
|
} from "@schlessera/brain-ui-sdk/server";
|
|
8
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AskUserQuestion,
|
|
11
|
+
ChatImageAttachment,
|
|
12
|
+
ClientEnvironment,
|
|
13
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
9
14
|
|
|
10
15
|
export interface QueuedFollowUp {
|
|
11
16
|
text: string;
|
|
@@ -55,6 +60,14 @@ export interface RunningTurn {
|
|
|
55
60
|
timeoutHandle: ReturnType<typeof setTimeout>;
|
|
56
61
|
queue: QueuedFollowUp[];
|
|
57
62
|
cancelled: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Terminal disposition of the CURRENT turn's `result` frame, when one has
|
|
65
|
+
* streamed. Backends RESOLVE startTurn for runtime failures (the failure
|
|
66
|
+
* rides the result frame as outcome "error"), so completion accounting must
|
|
67
|
+
* read this — a resolved startTurn alone does not mean the turn succeeded.
|
|
68
|
+
* Reset when a queued follow-up becomes the next turn.
|
|
69
|
+
*/
|
|
70
|
+
lastResult: "success" | "error" | "cancelled" | null;
|
|
58
71
|
/**
|
|
59
72
|
* Correlation id the client minted for this NEW conversation, echoed back on
|
|
60
73
|
* `session_info` so the client can tell its own turn's identity from a
|
|
@@ -69,12 +82,21 @@ export interface RunningTurn {
|
|
|
69
82
|
export interface PendingApproval {
|
|
70
83
|
turn: RunningTurn;
|
|
71
84
|
turnId: string;
|
|
85
|
+
/**
|
|
86
|
+
* The original request, kept so the card can be RE-DELIVERED to a client
|
|
87
|
+
* that connects later. On a phone the socket drops every time the screen
|
|
88
|
+
* locks; an approval must survive that and reappear, not silently die.
|
|
89
|
+
*/
|
|
90
|
+
request: PermissionRequest;
|
|
72
91
|
resolve: (decision: PermissionDecision) => void;
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
export interface PendingAskUser {
|
|
76
95
|
turn: RunningTurn;
|
|
77
96
|
turnId: string;
|
|
97
|
+
/** Kept for re-delivery on reconnect, like PendingApproval.request. */
|
|
98
|
+
requestId: string;
|
|
99
|
+
questions: AskUserQuestion[];
|
|
78
100
|
resolve: (result: AskUserResult) => void;
|
|
79
101
|
reject: (err: Error) => void;
|
|
80
102
|
}
|
|
@@ -160,6 +182,20 @@ export class TurnCoordinator {
|
|
|
160
182
|
p.reject(new Error(reason));
|
|
161
183
|
this.pendingAskUser.delete(id);
|
|
162
184
|
}
|
|
185
|
+
this.drainClientBoundForTurn(turn, reason);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reject only the requests that NEED a live client at this instant —
|
|
190
|
+
* location and mask, whose sibling handlers already fail fast with no
|
|
191
|
+
* client attached. Approvals and ask-user cards deliberately survive a
|
|
192
|
+
* disconnect: on a phone the socket drops at every screen lock, and before
|
|
193
|
+
* this split a pending approval was silently denied the moment the screen
|
|
194
|
+
* went dark ("Client disconnected") while one raised DURING the dark parked
|
|
195
|
+
* until the turn timeout. Both now hold, bounded by the turn timeout, and
|
|
196
|
+
* re-deliver on reconnect.
|
|
197
|
+
*/
|
|
198
|
+
drainClientBoundForTurn(turn: RunningTurn, reason: string): void {
|
|
163
199
|
for (const [id, p] of this.pendingLocation) {
|
|
164
200
|
if (p.turn !== turn) continue;
|
|
165
201
|
p.reject(new Error(reason));
|