@schlessera/brain-ui-server 0.17.0 → 0.19.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 (112) hide show
  1. package/README.md +1 -0
  2. package/dist/activity/digest.d.ts +29 -0
  3. package/dist/activity/digest.d.ts.map +1 -0
  4. package/dist/activity/digest.js +61 -0
  5. package/dist/activity/digest.js.map +1 -0
  6. package/dist/activity/notify.d.ts +62 -0
  7. package/dist/activity/notify.d.ts.map +1 -0
  8. package/dist/activity/notify.js +171 -0
  9. package/dist/activity/notify.js.map +1 -0
  10. package/dist/activity/push-sender.d.ts +63 -0
  11. package/dist/activity/push-sender.d.ts.map +1 -0
  12. package/dist/activity/push-sender.js +131 -0
  13. package/dist/activity/push-sender.js.map +1 -0
  14. package/dist/activity/query.d.ts +16 -0
  15. package/dist/activity/query.d.ts.map +1 -0
  16. package/dist/activity/query.js +128 -0
  17. package/dist/activity/query.js.map +1 -0
  18. package/dist/activity/recorder.d.ts +48 -0
  19. package/dist/activity/recorder.d.ts.map +1 -0
  20. package/dist/activity/recorder.js +205 -0
  21. package/dist/activity/recorder.js.map +1 -0
  22. package/dist/activity/runtime.d.ts +31 -0
  23. package/dist/activity/runtime.d.ts.map +1 -0
  24. package/dist/activity/runtime.js +109 -0
  25. package/dist/activity/runtime.js.map +1 -0
  26. package/dist/activity/span-sink.d.ts +22 -0
  27. package/dist/activity/span-sink.d.ts.map +1 -0
  28. package/dist/activity/span-sink.js +138 -0
  29. package/dist/activity/span-sink.js.map +1 -0
  30. package/dist/activity/store.d.ts +207 -0
  31. package/dist/activity/store.d.ts.map +1 -0
  32. package/dist/activity/store.js +440 -0
  33. package/dist/activity/store.js.map +1 -0
  34. package/dist/activity/stream.d.ts +45 -0
  35. package/dist/activity/stream.d.ts.map +1 -0
  36. package/dist/activity/stream.js +312 -0
  37. package/dist/activity/stream.js.map +1 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/app.js +20 -1
  40. package/dist/app.js.map +1 -1
  41. package/dist/config/env.d.ts +5 -0
  42. package/dist/config/env.d.ts.map +1 -1
  43. package/dist/config/env.js +12 -0
  44. package/dist/config/env.js.map +1 -1
  45. package/dist/cron/scheduler.d.ts.map +1 -1
  46. package/dist/cron/scheduler.js +20 -5
  47. package/dist/cron/scheduler.js.map +1 -1
  48. package/dist/db/client.d.ts.map +1 -1
  49. package/dist/db/client.js +32 -6
  50. package/dist/db/client.js.map +1 -1
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +7 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/routes/activity.d.ts +20 -0
  56. package/dist/routes/activity.d.ts.map +1 -0
  57. package/dist/routes/activity.js +300 -0
  58. package/dist/routes/activity.js.map +1 -0
  59. package/dist/routes/push.d.ts +6 -0
  60. package/dist/routes/push.d.ts.map +1 -0
  61. package/dist/routes/push.js +73 -0
  62. package/dist/routes/push.js.map +1 -0
  63. package/dist/routes/sessions.d.ts.map +1 -1
  64. package/dist/routes/sessions.js +20 -4
  65. package/dist/routes/sessions.js.map +1 -1
  66. package/dist/ws/bridge.d.ts +2 -1
  67. package/dist/ws/bridge.d.ts.map +1 -1
  68. package/dist/ws/bridge.js +45 -3
  69. package/dist/ws/bridge.js.map +1 -1
  70. package/dist/ws/connection.d.ts.map +1 -1
  71. package/dist/ws/connection.js +41 -5
  72. package/dist/ws/connection.js.map +1 -1
  73. package/dist/ws/dispatch.d.ts.map +1 -1
  74. package/dist/ws/dispatch.js +11 -0
  75. package/dist/ws/dispatch.js.map +1 -1
  76. package/dist/ws/host.d.ts +16 -0
  77. package/dist/ws/host.d.ts.map +1 -1
  78. package/dist/ws/host.js +2 -0
  79. package/dist/ws/host.js.map +1 -1
  80. package/dist/ws/run-session.d.ts.map +1 -1
  81. package/dist/ws/run-session.js +25 -1
  82. package/dist/ws/run-session.js.map +1 -1
  83. package/dist/ws/turns.d.ts +22 -2
  84. package/dist/ws/turns.d.ts.map +1 -1
  85. package/dist/ws/turns.js +13 -0
  86. package/dist/ws/turns.js.map +1 -1
  87. package/migrations/007_activity.sql +114 -0
  88. package/migrations/008_push_subscriptions.sql +24 -0
  89. package/package.json +5 -3
  90. package/src/activity/digest.ts +92 -0
  91. package/src/activity/notify.ts +285 -0
  92. package/src/activity/push-sender.ts +215 -0
  93. package/src/activity/query.ts +160 -0
  94. package/src/activity/recorder.ts +261 -0
  95. package/src/activity/runtime.ts +141 -0
  96. package/src/activity/span-sink.ts +152 -0
  97. package/src/activity/store.ts +774 -0
  98. package/src/activity/stream.ts +366 -0
  99. package/src/app.ts +21 -1
  100. package/src/config/env.ts +19 -0
  101. package/src/cron/scheduler.ts +28 -8
  102. package/src/db/client.ts +31 -6
  103. package/src/index.ts +22 -0
  104. package/src/routes/activity.ts +363 -0
  105. package/src/routes/push.ts +90 -0
  106. package/src/routes/sessions.ts +24 -4
  107. package/src/ws/bridge.ts +47 -3
  108. package/src/ws/connection.ts +57 -5
  109. package/src/ws/dispatch.ts +13 -0
  110. package/src/ws/host.ts +18 -0
  111. package/src/ws/run-session.ts +32 -1
  112. package/src/ws/turns.ts +29 -1
@@ -177,6 +177,19 @@ export async function handleClientMessage(
177
177
  break;
178
178
  }
179
179
 
180
+ case "activity_subscribe": {
181
+ // View-scoped opt-in: without a subscription this connection never
182
+ // receives an activity frame. No turn correlation — subscriptions are
183
+ // connection state, not turn state.
184
+ host.activity?.stream.handleSubscribe(ws, msg);
185
+ break;
186
+ }
187
+
188
+ case "activity_unsubscribe": {
189
+ host.activity?.stream.handleUnsubscribe(ws, msg);
190
+ break;
191
+ }
192
+
180
193
  case "session_resume": {
181
194
  host.sendMessage(ws, {
182
195
  type: "session_info",
package/src/ws/host.ts CHANGED
@@ -5,6 +5,16 @@ import type { SessionCatalog } from "./session-catalog.js";
5
5
  import type { BackendRegistry } from "../agent/backend.js";
6
6
  import { createSilentObservability, type Observability } from "../observability/index.js";
7
7
  import { FrameRateLimiter } from "./rate-limit.js";
8
+ import type { ActivityStore } from "../activity/store.js";
9
+ import type { ActivityStream } from "../activity/stream.js";
10
+
11
+ /** The activity record and its live stream, when the host records activity. */
12
+ export interface ActivityRuntime {
13
+ store: ActivityStore;
14
+ stream: ActivityStream;
15
+ /** Read seam for the agent-facing query tool (bridge.queryActivity). */
16
+ query?: (query: import("@schlessera/brain-ui-sdk/server").ActivityQuery) => Record<string, unknown>;
17
+ }
8
18
 
9
19
  /** Host-side turn timeout. The backend no longer times out — the host owns it. */
10
20
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
@@ -65,6 +75,12 @@ export interface WsHostOptions {
65
75
  * fronting the socket with its own limiter wants.
66
76
  */
67
77
  wsRate?: { ratePerSecond: number; burst: number };
78
+ /**
79
+ * Activity recording (span store + live stream). Optional: a host without
80
+ * one records nothing and never sends activity frames — which is also what
81
+ * most existing tests want.
82
+ */
83
+ activity?: ActivityRuntime;
68
84
  }
69
85
 
70
86
  /** Identity of one turn, as it appears on a log record. */
@@ -102,6 +118,7 @@ export class WsHost {
102
118
  maxConcurrentSessions: () => number;
103
119
  readonly observability: Observability;
104
120
  readonly wsRate: { ratePerSecond: number; burst: number } | null;
121
+ readonly activity: ActivityRuntime | null;
105
122
  /** Scoped instruments, resolved once — `[ws]` is the existing log prefix. */
106
123
  readonly log: ReturnType<Observability["logger"]>;
107
124
  private readonly framesDropped: ReturnType<
@@ -130,6 +147,7 @@ export class WsHost {
130
147
  this.observability = options.observability ?? createSilentObservability();
131
148
  this.wsRate =
132
149
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
150
+ this.activity = options.activity ?? null;
133
151
  this.log = this.observability.logger("ws");
134
152
  const meter = this.observability.meter("ws");
135
153
  this.framesDropped = meter.createCounter("ws.frames.dropped", {
@@ -2,6 +2,7 @@ import type { ChatImageAttachment, ClientEnvironment } from "@schlessera/brain-u
2
2
  import type { WSContext } from "./clients.js";
3
3
  import { withSessionId, withTurnScope } from "./frames.js";
4
4
  import { makeBridge, emitTurnError } from "./bridge.js";
5
+ import { createTurnRecorder, type TurnRecorder } from "../activity/recorder.js";
5
6
  import { resolveTurnTarget } from "./routing.js";
6
7
  import type { QueuedFollowUp, RunningTurn } from "./turns.js";
7
8
  import { queuedBytes, queuedFollowUpBytes } from "./turns.js";
@@ -114,7 +115,19 @@ export async function runSession(
114
115
  }, host.turnTimeoutMs);
115
116
  turn.timeoutHandle = timeoutHandle;
116
117
 
117
- const bridge = makeBridge(host, turn, text, backend.id);
118
+ // One recorder per turn identity: a queued follow-up re-mints turnId
119
+ // and gets its own run in the activity record.
120
+ const recorder: TurnRecorder | undefined = host.activity
121
+ ? createTurnRecorder(
122
+ {
123
+ store: host.activity.store,
124
+ onWrite: () => host.activity!.stream.pump(),
125
+ log: host.log,
126
+ },
127
+ { turnId: turn.turnId, sessionId: turn.sessionId }
128
+ )
129
+ : undefined;
130
+ const bridge = makeBridge(host, turn, text, backend.id, recorder);
118
131
  const startedAt = Date.now();
119
132
  host.reportTurnStarted(turn);
120
133
  try {
@@ -124,6 +137,10 @@ export async function runSession(
124
137
  sessionId: resumeId,
125
138
  profileId,
126
139
  signal: abortController.signal,
140
+ // The budget this very timer enforces, so the backend can put the
141
+ // real number in front of the model instead of it finding the cap
142
+ // mid-flight.
143
+ turnBudgetMs: host.turnTimeoutMs,
127
144
  bridge,
128
145
  ...(client ? { client } : {}),
129
146
  });
@@ -135,8 +152,22 @@ export async function runSession(
135
152
  } else {
136
153
  host.reportTurnCompleted(turn, Date.now() - startedAt);
137
154
  }
155
+ // Terminal precedence: cancellation and timeout are host-owned facts;
156
+ // otherwise the recorder refines from the buffered result frame.
157
+ recorder?.finish(
158
+ turn.cancelled
159
+ ? "cancelled"
160
+ : abortController.signal.aborted
161
+ ? "timeout"
162
+ : turn.lastResult === "error"
163
+ ? "error"
164
+ : "success"
165
+ );
138
166
  } catch (err) {
139
167
  emitTurnError(host, turn, err);
168
+ recorder?.finish(
169
+ turn.cancelled ? "cancelled" : abortController.signal.aborted ? "timeout" : "error"
170
+ );
140
171
  } finally {
141
172
  clearTimeout(timeoutHandle);
142
173
  }
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 { ChatImageAttachment, ClientEnvironment } from "@schlessera/brain-ui-sdk/protocol";
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;
@@ -77,12 +82,21 @@ export interface RunningTurn {
77
82
  export interface PendingApproval {
78
83
  turn: RunningTurn;
79
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;
80
91
  resolve: (decision: PermissionDecision) => void;
81
92
  }
82
93
 
83
94
  export interface PendingAskUser {
84
95
  turn: RunningTurn;
85
96
  turnId: string;
97
+ /** Kept for re-delivery on reconnect, like PendingApproval.request. */
98
+ requestId: string;
99
+ questions: AskUserQuestion[];
86
100
  resolve: (result: AskUserResult) => void;
87
101
  reject: (err: Error) => void;
88
102
  }
@@ -168,6 +182,20 @@ export class TurnCoordinator {
168
182
  p.reject(new Error(reason));
169
183
  this.pendingAskUser.delete(id);
170
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 {
171
199
  for (const [id, p] of this.pendingLocation) {
172
200
  if (p.turn !== turn) continue;
173
201
  p.reject(new Error(reason));