@data-club/ai-hub-server 0.0.1

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 (248) hide show
  1. package/dist/auth/argon2.d.ts +72 -0
  2. package/dist/auth/argon2.d.ts.map +1 -0
  3. package/dist/auth/argon2.js +145 -0
  4. package/dist/auth/argon2.js.map +1 -0
  5. package/dist/auth/encryption.d.ts +52 -0
  6. package/dist/auth/encryption.d.ts.map +1 -0
  7. package/dist/auth/encryption.js +98 -0
  8. package/dist/auth/encryption.js.map +1 -0
  9. package/dist/auth/jwt.d.ts +133 -0
  10. package/dist/auth/jwt.d.ts.map +1 -0
  11. package/dist/auth/jwt.js +147 -0
  12. package/dist/auth/jwt.js.map +1 -0
  13. package/dist/auth/middleware.d.ts +101 -0
  14. package/dist/auth/middleware.d.ts.map +1 -0
  15. package/dist/auth/middleware.js +161 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/passwordPolicy.d.ts +72 -0
  18. package/dist/auth/passwordPolicy.d.ts.map +1 -0
  19. package/dist/auth/passwordPolicy.js +88 -0
  20. package/dist/auth/passwordPolicy.js.map +1 -0
  21. package/dist/auth/roleRules.d.ts +200 -0
  22. package/dist/auth/roleRules.d.ts.map +1 -0
  23. package/dist/auth/roleRules.js +365 -0
  24. package/dist/auth/roleRules.js.map +1 -0
  25. package/dist/auth/userView.d.ts +55 -0
  26. package/dist/auth/userView.d.ts.map +1 -0
  27. package/dist/auth/userView.js +77 -0
  28. package/dist/auth/userView.js.map +1 -0
  29. package/dist/bootstrap.d.ts +104 -0
  30. package/dist/bootstrap.d.ts.map +1 -0
  31. package/dist/bootstrap.js +160 -0
  32. package/dist/bootstrap.js.map +1 -0
  33. package/dist/db/connection.d.ts +39 -0
  34. package/dist/db/connection.d.ts.map +1 -0
  35. package/dist/db/connection.js +62 -0
  36. package/dist/db/connection.js.map +1 -0
  37. package/dist/db/migrate.d.ts +31 -0
  38. package/dist/db/migrate.d.ts.map +1 -0
  39. package/dist/db/migrate.js +197 -0
  40. package/dist/db/migrate.js.map +1 -0
  41. package/dist/db/schema.d.ts +1902 -0
  42. package/dist/db/schema.d.ts.map +1 -0
  43. package/dist/db/schema.js +662 -0
  44. package/dist/db/schema.js.map +1 -0
  45. package/dist/db/seed.d.ts +92 -0
  46. package/dist/db/seed.d.ts.map +1 -0
  47. package/dist/db/seed.js +160 -0
  48. package/dist/db/seed.js.map +1 -0
  49. package/dist/handlers/fetch.d.ts +89 -0
  50. package/dist/handlers/fetch.d.ts.map +1 -0
  51. package/dist/handlers/fetch.js +173 -0
  52. package/dist/handlers/fetch.js.map +1 -0
  53. package/dist/handlers/nextjs.d.ts +40 -0
  54. package/dist/handlers/nextjs.d.ts.map +1 -0
  55. package/dist/handlers/nextjs.js +42 -0
  56. package/dist/handlers/nextjs.js.map +1 -0
  57. package/dist/handlers/responses.d.ts +46 -0
  58. package/dist/handlers/responses.d.ts.map +1 -0
  59. package/dist/handlers/responses.js +74 -0
  60. package/dist/handlers/responses.js.map +1 -0
  61. package/dist/handlers/routeTable.d.ts +21 -0
  62. package/dist/handlers/routeTable.d.ts.map +1 -0
  63. package/dist/handlers/routeTable.js +133 -0
  64. package/dist/handlers/routeTable.js.map +1 -0
  65. package/dist/handlers/router.d.ts +37 -0
  66. package/dist/handlers/router.d.ts.map +1 -0
  67. package/dist/handlers/router.js +82 -0
  68. package/dist/handlers/router.js.map +1 -0
  69. package/dist/handlers/sse.d.ts +38 -0
  70. package/dist/handlers/sse.d.ts.map +1 -0
  71. package/dist/handlers/sse.js +52 -0
  72. package/dist/handlers/sse.js.map +1 -0
  73. package/dist/handlers/types.d.ts +96 -0
  74. package/dist/handlers/types.d.ts.map +1 -0
  75. package/dist/handlers/types.js +14 -0
  76. package/dist/handlers/types.js.map +1 -0
  77. package/dist/index.d.ts +25 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +24 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/letta/client.d.ts +355 -0
  82. package/dist/letta/client.d.ts.map +1 -0
  83. package/dist/letta/client.js +529 -0
  84. package/dist/letta/client.js.map +1 -0
  85. package/dist/letta/eventTranslator.d.ts +166 -0
  86. package/dist/letta/eventTranslator.d.ts.map +1 -0
  87. package/dist/letta/eventTranslator.js +532 -0
  88. package/dist/letta/eventTranslator.js.map +1 -0
  89. package/dist/letta/index.d.ts +13 -0
  90. package/dist/letta/index.d.ts.map +1 -0
  91. package/dist/letta/index.js +12 -0
  92. package/dist/letta/index.js.map +1 -0
  93. package/dist/letta/modelSettings.d.ts +176 -0
  94. package/dist/letta/modelSettings.d.ts.map +1 -0
  95. package/dist/letta/modelSettings.js +314 -0
  96. package/dist/letta/modelSettings.js.map +1 -0
  97. package/dist/letta/promptWrapping.d.ts +141 -0
  98. package/dist/letta/promptWrapping.d.ts.map +1 -0
  99. package/dist/letta/promptWrapping.js +213 -0
  100. package/dist/letta/promptWrapping.js.map +1 -0
  101. package/dist/letta/sandbox.d.ts +100 -0
  102. package/dist/letta/sandbox.d.ts.map +1 -0
  103. package/dist/letta/sandbox.js +151 -0
  104. package/dist/letta/sandbox.js.map +1 -0
  105. package/dist/letta/settingsFileSeed.d.ts +146 -0
  106. package/dist/letta/settingsFileSeed.d.ts.map +1 -0
  107. package/dist/letta/settingsFileSeed.js +177 -0
  108. package/dist/letta/settingsFileSeed.js.map +1 -0
  109. package/dist/letta/subprocessThrottle.d.ts +151 -0
  110. package/dist/letta/subprocessThrottle.d.ts.map +1 -0
  111. package/dist/letta/subprocessThrottle.js +195 -0
  112. package/dist/letta/subprocessThrottle.js.map +1 -0
  113. package/dist/letta/tpmRecovery.d.ts +202 -0
  114. package/dist/letta/tpmRecovery.d.ts.map +1 -0
  115. package/dist/letta/tpmRecovery.js +314 -0
  116. package/dist/letta/tpmRecovery.js.map +1 -0
  117. package/dist/letta/types.d.ts +54 -0
  118. package/dist/letta/types.d.ts.map +1 -0
  119. package/dist/letta/types.js +16 -0
  120. package/dist/letta/types.js.map +1 -0
  121. package/dist/observability/logger.d.ts +48 -0
  122. package/dist/observability/logger.d.ts.map +1 -0
  123. package/dist/observability/logger.js +81 -0
  124. package/dist/observability/logger.js.map +1 -0
  125. package/dist/observability/metrics.d.ts +53 -0
  126. package/dist/observability/metrics.d.ts.map +1 -0
  127. package/dist/observability/metrics.js +92 -0
  128. package/dist/observability/metrics.js.map +1 -0
  129. package/dist/routes/admin/agents.d.ts +63 -0
  130. package/dist/routes/admin/agents.d.ts.map +1 -0
  131. package/dist/routes/admin/agents.js +461 -0
  132. package/dist/routes/admin/agents.js.map +1 -0
  133. package/dist/routes/admin/rotatingMessages.d.ts +51 -0
  134. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
  135. package/dist/routes/admin/rotatingMessages.js +292 -0
  136. package/dist/routes/admin/rotatingMessages.js.map +1 -0
  137. package/dist/routes/admin/sessions.d.ts +61 -0
  138. package/dist/routes/admin/sessions.d.ts.map +1 -0
  139. package/dist/routes/admin/sessions.js +232 -0
  140. package/dist/routes/admin/sessions.js.map +1 -0
  141. package/dist/routes/admin/settings.d.ts +110 -0
  142. package/dist/routes/admin/settings.d.ts.map +1 -0
  143. package/dist/routes/admin/settings.js +401 -0
  144. package/dist/routes/admin/settings.js.map +1 -0
  145. package/dist/routes/admin/users.d.ts +115 -0
  146. package/dist/routes/admin/users.d.ts.map +1 -0
  147. package/dist/routes/admin/users.js +374 -0
  148. package/dist/routes/admin/users.js.map +1 -0
  149. package/dist/routes/agents.d.ts +30 -0
  150. package/dist/routes/agents.d.ts.map +1 -0
  151. package/dist/routes/agents.js +55 -0
  152. package/dist/routes/agents.js.map +1 -0
  153. package/dist/routes/auth.d.ts +121 -0
  154. package/dist/routes/auth.d.ts.map +1 -0
  155. package/dist/routes/auth.js +422 -0
  156. package/dist/routes/auth.js.map +1 -0
  157. package/dist/routes/chat.d.ts +97 -0
  158. package/dist/routes/chat.d.ts.map +1 -0
  159. package/dist/routes/chat.js +484 -0
  160. package/dist/routes/chat.js.map +1 -0
  161. package/dist/routes/featureToggles.d.ts +34 -0
  162. package/dist/routes/featureToggles.d.ts.map +1 -0
  163. package/dist/routes/featureToggles.js +47 -0
  164. package/dist/routes/featureToggles.js.map +1 -0
  165. package/dist/routes/health.d.ts +24 -0
  166. package/dist/routes/health.d.ts.map +1 -0
  167. package/dist/routes/health.js +37 -0
  168. package/dist/routes/health.js.map +1 -0
  169. package/dist/routes/metrics.d.ts +35 -0
  170. package/dist/routes/metrics.d.ts.map +1 -0
  171. package/dist/routes/metrics.js +106 -0
  172. package/dist/routes/metrics.js.map +1 -0
  173. package/dist/routes/sessions.d.ts +161 -0
  174. package/dist/routes/sessions.d.ts.map +1 -0
  175. package/dist/routes/sessions.js +1501 -0
  176. package/dist/routes/sessions.js.map +1 -0
  177. package/dist/routes/stubs.d.ts +25 -0
  178. package/dist/routes/stubs.d.ts.map +1 -0
  179. package/dist/routes/stubs.js +25 -0
  180. package/dist/routes/stubs.js.map +1 -0
  181. package/dist/state/cron.d.ts +58 -0
  182. package/dist/state/cron.d.ts.map +1 -0
  183. package/dist/state/cron.js +160 -0
  184. package/dist/state/cron.js.map +1 -0
  185. package/dist/state/rotationRunner.d.ts +96 -0
  186. package/dist/state/rotationRunner.d.ts.map +1 -0
  187. package/dist/state/rotationRunner.js +246 -0
  188. package/dist/state/rotationRunner.js.map +1 -0
  189. package/dist/state/rotationScheduler.d.ts +76 -0
  190. package/dist/state/rotationScheduler.d.ts.map +1 -0
  191. package/dist/state/rotationScheduler.js +124 -0
  192. package/dist/state/rotationScheduler.js.map +1 -0
  193. package/dist/state/turnRegistry.d.ts +95 -0
  194. package/dist/state/turnRegistry.d.ts.map +1 -0
  195. package/dist/state/turnRegistry.js +149 -0
  196. package/dist/state/turnRegistry.js.map +1 -0
  197. package/dist/state/turnRunner.d.ts +134 -0
  198. package/dist/state/turnRunner.d.ts.map +1 -0
  199. package/dist/state/turnRunner.js +476 -0
  200. package/dist/state/turnRunner.js.map +1 -0
  201. package/package.json +47 -0
  202. package/src/auth/argon2.ts +152 -0
  203. package/src/auth/encryption.ts +110 -0
  204. package/src/auth/jwt.ts +217 -0
  205. package/src/auth/middleware.ts +250 -0
  206. package/src/auth/passwordPolicy.ts +99 -0
  207. package/src/auth/roleRules.ts +497 -0
  208. package/src/auth/userView.ts +88 -0
  209. package/src/bootstrap.ts +254 -0
  210. package/src/db/connection.ts +75 -0
  211. package/src/db/migrate.ts +212 -0
  212. package/src/db/schema.ts +750 -0
  213. package/src/db/seed.ts +236 -0
  214. package/src/handlers/fetch.ts +251 -0
  215. package/src/handlers/nextjs.ts +49 -0
  216. package/src/handlers/responses.ts +95 -0
  217. package/src/handlers/routeTable.ts +188 -0
  218. package/src/handlers/router.ts +96 -0
  219. package/src/handlers/sse.ts +55 -0
  220. package/src/handlers/types.ts +104 -0
  221. package/src/index.ts +60 -0
  222. package/src/letta/client.ts +860 -0
  223. package/src/letta/eventTranslator.ts +667 -0
  224. package/src/letta/index.ts +46 -0
  225. package/src/letta/lettaCliShim.mjs +40 -0
  226. package/src/letta/sandbox.ts +167 -0
  227. package/src/letta/settingsFileSeed.ts +254 -0
  228. package/src/letta/subprocessThrottle.ts +251 -0
  229. package/src/letta/types.ts +65 -0
  230. package/src/observability/logger.ts +103 -0
  231. package/src/observability/metrics.ts +114 -0
  232. package/src/routes/admin/agents.ts +574 -0
  233. package/src/routes/admin/rotatingMessages.ts +351 -0
  234. package/src/routes/admin/sessions.ts +283 -0
  235. package/src/routes/admin/settings.ts +463 -0
  236. package/src/routes/admin/users.ts +455 -0
  237. package/src/routes/agents.ts +66 -0
  238. package/src/routes/auth.ts +513 -0
  239. package/src/routes/chat.ts +534 -0
  240. package/src/routes/featureToggles.ts +52 -0
  241. package/src/routes/health.ts +44 -0
  242. package/src/routes/metrics.ts +111 -0
  243. package/src/routes/sessions.ts +1878 -0
  244. package/src/state/cron.ts +179 -0
  245. package/src/state/rotationRunner.ts +326 -0
  246. package/src/state/rotationScheduler.ts +164 -0
  247. package/src/state/turnRegistry.ts +210 -0
  248. package/src/state/turnRunner.ts +588 -0
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Server-authoritative turn lifecycle (§6.6, §10).
3
+ *
4
+ * `runTurn(...)` is the one entry point that:
5
+ *
6
+ * 1. Inserts the `turn` row in status=running, phase=model_text.
7
+ * 2. Opens a live channel (`turnRegistry.openTurnChannel`) so concurrent
8
+ * resumption attempts can attach.
9
+ * 3. Emits `stream_start` (seq 0).
10
+ * 4. Calls `LettaClient.streamUserMessage(...)` which spawns a Letta
11
+ * Code subprocess, acquires the throttle lease, sends the user
12
+ * message. Returns a `ChatTurnHandle`.
13
+ * 5. Iterates the handle's `events` stream:
14
+ * - Non-terminal SDK messages → `translateSdkMessage(...)` →
15
+ * stamps + persists + broadcasts each emitted ai-hub event.
16
+ * - Terminal `result` SDK message → `finalizeTerminal(...)` →
17
+ * emits the closing `assistant_message_complete` + `stream_end`
18
+ * with the correct reason (`done` / `cancelled` / `error`).
19
+ * 6. On cancellation (caller signal OR `requestTurnCancellation`):
20
+ * calls `handle.abort()` to send `interrupt` to the CLI, keeps
21
+ * draining until terminal, then closes.
22
+ * 7. On throttle rejection (`ServerOverloadedError`): emits
23
+ * `error{code:"server_overloaded"}` + `stream_end{error}`.
24
+ * Transitions turn → failed.
25
+ * 8. On unhandled error: emits `error` + `stream_end{error}`,
26
+ * `turn.status = "failed"`.
27
+ * 9. Always calls `handle.close()` in finally (releases subprocess +
28
+ * throttle lease).
29
+ *
30
+ * @see SPECIFICATIONS.md §6.3.2 — chat / SSE
31
+ * @see SPECIFICATIONS.md §6.6 — state-machine ownership
32
+ * @see SPECIFICATIONS.md §6.9 — cancellation
33
+ * @see SPECIFICATIONS.md §9 — wire protocol
34
+ * @see SPECIFICATIONS.md §10 — state machine
35
+ */
36
+
37
+ import { randomUUID } from "node:crypto";
38
+
39
+ import { eq, sql } from "drizzle-orm";
40
+
41
+ import type {
42
+ AIHubEvent,
43
+ AIHubEventState,
44
+ RunningPhase,
45
+ TurnStatus,
46
+ } from "@data-club/ai-hub";
47
+
48
+ import { messageUsage, turn, turnEvent } from "../db/schema.js";
49
+ import type { DrizzleDB } from "../db/connection.js";
50
+ import { ServerOverloadedError, type ChatTurnHandle, type LettaClient } from "../letta/client.js";
51
+ import {
52
+ finalizeAssistantMessage,
53
+ finalizeTerminal,
54
+ initialTranslatorState,
55
+ translateSdkMessage,
56
+ type TranslatorState,
57
+ type UnsequencedEvent,
58
+ } from "../letta/eventTranslator.js";
59
+ import type { Logger } from "../observability/logger.js";
60
+ import * as metrics from "../observability/metrics.js";
61
+
62
+ /** The ai-hub SSE wire-protocol version this server speaks (§9.1). */
63
+ export const WIRE_PROTOCOL_VERSION = "1.0";
64
+
65
+ /* ────────────────────────────────────────────────────────────────────────── */
66
+ /* Inputs */
67
+ /* ────────────────────────────────────────────────────────────────────────── */
68
+
69
+ /** Parameters for `runTurn`. */
70
+ export interface RunTurnOpts {
71
+ /** Drizzle handle. */
72
+ db: DrizzleDB;
73
+ /** Letta client wrapper (production wraps the SDKs; tests mock). */
74
+ lettaClient: LettaClient;
75
+ /** Pino logger for this request. */
76
+ logger: Logger;
77
+ /** Per-request id for log correlation (stamped on `error` events). */
78
+ requestId: string;
79
+ /** Owner of the session. */
80
+ userId: string;
81
+ /** ai-hub session id this turn belongs to. */
82
+ sessionId: string;
83
+ /** The Letta conversation id stored on the session row. */
84
+ lettaConversationId: string;
85
+ /** The Letta agent id stored on the session row. */
86
+ lettaAgentId: string;
87
+ /**
88
+ * Step-12.5 — absolute path of the per-session sandbox folder, or
89
+ * `null` when the session has no sandbox (today's stateless shape).
90
+ * Threaded as `cwd` into the Letta Code subprocess spawn so the
91
+ * agent's filesystem tools operate inside the seeded folder. When
92
+ * non-null, also flips the disallowed-tools list to re-enable
93
+ * `Read` / `Write` / `Bash` / etc.
94
+ */
95
+ sandboxDir: string | null;
96
+ /** The user's typed message. */
97
+ userMessage: string;
98
+ /** Optional idempotency key from the request body. */
99
+ idempotencyKey?: string;
100
+ /**
101
+ * Listener that receives every event the runner emits. The chat
102
+ * handler subscribes its SSE writer here. `null` events signal
103
+ * "channel closed; no more events coming."
104
+ */
105
+ onEvent: (event: AIHubEvent | null) => void;
106
+ /**
107
+ * Caller-supplied AbortSignal. When aborted, the runner calls
108
+ * `handle.abort()` on the spawned Letta Code subprocess and emits
109
+ * `stream_end{reason:"cancelled"}` after the upstream terminal
110
+ * `result` arrives.
111
+ */
112
+ signal?: AbortSignal;
113
+ }
114
+
115
+ /** Returned by `runTurn`. The runner returns after `turn_event`s are committed. */
116
+ export interface RunTurnResult {
117
+ /** The new turn id (the `turn_id` carried in `stream_start`). */
118
+ turnId: string;
119
+ /** Terminal status (`done` / `cancelled` / `failed`). */
120
+ status: Exclude<TurnStatus, "idle" | "running">;
121
+ }
122
+
123
+ /* ────────────────────────────────────────────────────────────────────────── */
124
+ /* Runner */
125
+ /* ────────────────────────────────────────────────────────────────────────── */
126
+
127
+ /**
128
+ * Runs one turn from "user pressed Send" to terminal status. Returns a
129
+ * promise that resolves when the runner has persisted the terminal
130
+ * event(s), closed the live channel, and torn down the Letta Code
131
+ * subprocess.
132
+ *
133
+ * The caller is expected to keep `onEvent` alive for the entire run.
134
+ *
135
+ * @param opts - see `RunTurnOpts`.
136
+ * @returns the turn id and terminal status.
137
+ */
138
+ export async function runTurn(opts: RunTurnOpts): Promise<RunTurnResult> {
139
+ const turnId = randomUUID();
140
+ const startedAt = new Date().toISOString();
141
+ const startedMs = Date.now();
142
+
143
+ metrics.chatRequestsTotal.inc();
144
+
145
+ // Step 1 — insert turn row in running/model_text.
146
+ opts.db
147
+ .insert(turn)
148
+ .values({
149
+ id: turnId,
150
+ sessionId: opts.sessionId,
151
+ status: "running",
152
+ phase: "model_text",
153
+ lastSeq: 0,
154
+ idempotencyKey: opts.idempotencyKey ?? null,
155
+ startedAt,
156
+ })
157
+ .run();
158
+
159
+ const { openTurnChannel } = await import("./turnRegistry.js");
160
+ const publisher = openTurnChannel(turnId);
161
+
162
+ // Compose the caller's signal with the registry-supplied cancel
163
+ // signal: either side can fire to cancel the turn. The registry
164
+ // cancel is what `POST /chat/:id/cancel` triggers from a separate
165
+ // HTTP request.
166
+ const registryCancelSignal = publisher.cancelSignal;
167
+ const cancelController = new AbortController();
168
+ const propagateCancel = (): void => cancelController.abort();
169
+ if (opts.signal) {
170
+ if (opts.signal.aborted) cancelController.abort();
171
+ else opts.signal.addEventListener("abort", propagateCancel, { once: true });
172
+ }
173
+ if (registryCancelSignal.aborted) cancelController.abort();
174
+ else registryCancelSignal.addEventListener("abort", propagateCancel, { once: true });
175
+
176
+ let translatorState: TranslatorState = initialTranslatorState();
177
+ let nextSeq = 0;
178
+
179
+ /** Stamps + persists + broadcasts one event. Also fills in
180
+ * `request_id` on error events — the translator emits them without
181
+ * a request_id (it has no access to the request), so we add it here. */
182
+ const emit = (unsequenced: UnsequencedEvent): AIHubEvent => {
183
+ let withRequestId: UnsequencedEvent = unsequenced;
184
+ if (unsequenced.event === "error") {
185
+ withRequestId = {
186
+ ...unsequenced,
187
+ data: { ...unsequenced.data, request_id: opts.requestId },
188
+ } as UnsequencedEvent;
189
+ }
190
+ const event = { ...withRequestId, seq: nextSeq++ } as AIHubEvent;
191
+ const persistedData =
192
+ "data" in event ? (event.data as unknown as object) : ({} as object);
193
+ opts.db
194
+ .insert(turnEvent)
195
+ .values({
196
+ turnId,
197
+ seq: event.seq,
198
+ eventName: event.event,
199
+ data: persistedData,
200
+ state: event.state as unknown as object,
201
+ })
202
+ .run();
203
+ publisher.publish(event);
204
+ opts.onEvent(event);
205
+ return event;
206
+ };
207
+
208
+ /** Persists turn-row mutations driven by translator-state changes. */
209
+ const syncTurnRow = (status: TurnStatus, phase: RunningPhase | null): void => {
210
+ const sets: Record<string, unknown> = { lastSeq: nextSeq - 1 };
211
+ if (status !== "running") {
212
+ sets.status = status;
213
+ sets.phase = null;
214
+ sets.finishedAt = new Date().toISOString();
215
+ } else {
216
+ sets.status = "running";
217
+ sets.phase = phase;
218
+ }
219
+ opts.db.update(turn).set(sets).where(eq(turn.id, turnId)).run();
220
+ };
221
+
222
+ let handle: ChatTurnHandle | null = null;
223
+
224
+ try {
225
+ // Step 3 — stream_start (emitted once, at the top of the run).
226
+ emit({
227
+ event: "stream_start",
228
+ data: {
229
+ protocol_version: WIRE_PROTOCOL_VERSION,
230
+ session_id: opts.sessionId,
231
+ turn_id: turnId,
232
+ },
233
+ state: { status: "running", phase: "model_text" } satisfies AIHubEventState,
234
+ });
235
+ syncTurnRow("running", "model_text");
236
+
237
+ if (cancelController.signal.aborted) {
238
+ // Caller cancelled before we even spawned — emit cancelled and
239
+ // bail without ever calling streamUserMessage.
240
+ throw new AbortLikeError();
241
+ }
242
+
243
+ // Step 4 — spawn + send. Throttle gate runs inside
244
+ // streamUserMessage; ServerOverloadedError is caught below and
245
+ // turned into a `server_overloaded` SSE error event.
246
+ const lettaStartedMs = Date.now();
247
+ try {
248
+ handle = await opts.lettaClient.streamUserMessage({
249
+ conversationId: opts.lettaConversationId,
250
+ lettaAgentId: opts.lettaAgentId,
251
+ userMessage: opts.userMessage,
252
+ ...(opts.sandboxDir !== null ? { cwd: opts.sandboxDir } : {}),
253
+ });
254
+ } catch (err) {
255
+ metrics.lettaRequestDurationMs.set(Date.now() - lettaStartedMs);
256
+ throw err;
257
+ }
258
+
259
+ // Wire the cancel signal to the handle's abort once we have one.
260
+ // If the signal already fired during the await above, abort + bail
261
+ // immediately. Otherwise install a listener for future aborts.
262
+ if (cancelController.signal.aborted) {
263
+ void handle.abort();
264
+ throw new AbortLikeError();
265
+ }
266
+ const onCancel = (): void => {
267
+ void handle?.abort();
268
+ };
269
+ cancelController.signal.addEventListener("abort", onCancel, { once: true });
270
+
271
+ // Step 5 — drain the SDK message stream.
272
+ // Track the last Letta run id we see — used to fetch usage stats
273
+ // after the terminal `result`. The SDK strips Letta message ids
274
+ // from its translated payloads, so we re-fetch via REST
275
+ // (`getRunUsageStats(runId)`) to get both the usage_statistics
276
+ // and the last assistant message id we'll stamp the badge on.
277
+ let lastRunId: string | null = null;
278
+ try {
279
+ for await (const sdkMsg of handle.events) {
280
+ // SDKAssistantMessage / SDKReasoningMessage / SDKToolCallMessage
281
+ // each carry `runId`. Take the most recent — a multi-step turn
282
+ // can have several runs, and we want the latest.
283
+ if ((sdkMsg as { runId?: string }).runId) {
284
+ lastRunId = (sdkMsg as { runId: string }).runId;
285
+ }
286
+ if (sdkMsg.type === "result") {
287
+ // Terminal — finalizeTerminal emits the right closing events
288
+ // based on success / interrupted / error.
289
+ // Override the reason to `cancelled` when our cancel fired
290
+ // (even if the SDK reports success — see the cancel fixture
291
+ // open-question-5 finding: the SDK may complete the run if
292
+ // the interrupt arrived during a tool call).
293
+ const cancelOverride = cancelController.signal.aborted;
294
+ const terminalToUse = cancelOverride
295
+ ? { ...sdkMsg, success: false, errorCode: "interrupted" as const }
296
+ : sdkMsg;
297
+ const step = finalizeTerminal(terminalToUse, translatorState);
298
+ translatorState = step.state;
299
+ for (const ev of step.events) {
300
+ emit(ev);
301
+ }
302
+ syncTurnRow(translatorState.status, null);
303
+ break;
304
+ }
305
+
306
+ const step = translateSdkMessage(sdkMsg, translatorState);
307
+ translatorState = step.state;
308
+ for (const ev of step.events) {
309
+ emit(ev);
310
+ }
311
+ syncTurnRow(translatorState.status, translatorState.phase);
312
+ }
313
+ } finally {
314
+ cancelController.signal.removeEventListener("abort", onCancel);
315
+ metrics.lettaRequestDurationMs.set(Date.now() - lettaStartedMs);
316
+ }
317
+
318
+ // Step 5.5 — usage emission + persistence. Only fire on successful
319
+ // termination (cancelled / failed turns may or may not have a
320
+ // usable runId, and the badge isn't load-bearing). Best-effort:
321
+ // any failure here logs and continues — we already emitted
322
+ // stream_end above, so the turn is "done" from the client's POV.
323
+ if (
324
+ lastRunId !== null &&
325
+ translatorState.status === "done"
326
+ ) {
327
+ try {
328
+ const usage = await opts.lettaClient.getRunUsageStats(lastRunId);
329
+ if (usage && usage.lastAssistantMessageId) {
330
+ // Persist for /sessions/{id}/messages history rendering.
331
+ opts.db
332
+ .insert(messageUsage)
333
+ .values({
334
+ lettaMessageId: usage.lastAssistantMessageId,
335
+ sessionId: opts.sessionId,
336
+ promptTokens: usage.promptTokens,
337
+ completionTokens: usage.completionTokens,
338
+ totalTokens: usage.totalTokens,
339
+ cachedInputTokens: usage.cachedInputTokens,
340
+ cacheWriteTokens: usage.cacheWriteTokens,
341
+ reasoningTokens: usage.reasoningTokens,
342
+ contextTokens: usage.contextTokens,
343
+ runId: lastRunId,
344
+ })
345
+ .onConflictDoUpdate({
346
+ target: messageUsage.lettaMessageId,
347
+ set: {
348
+ promptTokens: usage.promptTokens,
349
+ completionTokens: usage.completionTokens,
350
+ totalTokens: usage.totalTokens,
351
+ cachedInputTokens: usage.cachedInputTokens,
352
+ cacheWriteTokens: usage.cacheWriteTokens,
353
+ reasoningTokens: usage.reasoningTokens,
354
+ contextTokens: usage.contextTokens,
355
+ runId: lastRunId,
356
+ },
357
+ })
358
+ .run();
359
+ // Live wire event — UI can attach the badge immediately
360
+ // without waiting for a /messages refetch.
361
+ opts.onEvent({
362
+ seq: 0, // emit() bypassed here; the stream's already drained.
363
+ event: "usage",
364
+ data: {
365
+ letta_message_id: usage.lastAssistantMessageId,
366
+ ...(usage.promptTokens !== null ? { input: usage.promptTokens } : {}),
367
+ ...(usage.completionTokens !== null ? { output: usage.completionTokens } : {}),
368
+ ...(usage.cachedInputTokens !== null ? { cache_read: usage.cachedInputTokens } : {}),
369
+ ...(usage.cacheWriteTokens !== null ? { cache_write: usage.cacheWriteTokens } : {}),
370
+ ...(usage.reasoningTokens !== null ? { reasoning: usage.reasoningTokens } : {}),
371
+ ...(usage.contextTokens !== null ? { context: usage.contextTokens } : {}),
372
+ ...(usage.totalTokens !== null ? { total: usage.totalTokens } : {}),
373
+ },
374
+ state: { status: "done", phase: null } satisfies AIHubEventState,
375
+ });
376
+ }
377
+ } catch (err) {
378
+ opts.logger.warn({ err, runId: lastRunId }, "usage-stats fetch/persist failed");
379
+ }
380
+ }
381
+
382
+ publisher.close();
383
+ opts.onEvent(null);
384
+ metrics.chatDurationMs.set(Date.now() - startedMs);
385
+
386
+ const finalStatus = translatorState.status as RunTurnResult["status"];
387
+ opts.logger.info(
388
+ {
389
+ event: `turn_${finalStatus}`,
390
+ turn_id: turnId,
391
+ session_id: opts.sessionId,
392
+ },
393
+ `Turn ${finalStatus}.`,
394
+ );
395
+ return { turnId, status: finalStatus };
396
+ } catch (err) {
397
+ if (err instanceof ServerOverloadedError) {
398
+ emit({
399
+ event: "error",
400
+ data: {
401
+ code: "server_overloaded",
402
+ message: err.message,
403
+ },
404
+ state: { status: "failed", phase: null },
405
+ });
406
+ emit({
407
+ event: "stream_end",
408
+ data: { reason: "error" },
409
+ state: { status: "failed", phase: null },
410
+ });
411
+ syncTurnRow("failed", null);
412
+ publisher.close();
413
+ opts.onEvent(null);
414
+ metrics.chatDurationMs.set(Date.now() - startedMs);
415
+ opts.logger.warn(
416
+ {
417
+ event: "turn_rejected_overloaded",
418
+ turn_id: turnId,
419
+ session_id: opts.sessionId,
420
+ gate: err.gate,
421
+ detail: err.detail,
422
+ },
423
+ "Turn rejected: server overloaded.",
424
+ );
425
+ return { turnId, status: "failed" };
426
+ }
427
+
428
+ if (cancelController.signal.aborted || err instanceof AbortLikeError) {
429
+ // Emit any partial assistant text the turn produced before the
430
+ // cancel landed, then the cancelled stream_end.
431
+ for (const ev of finalizeAssistantMessage(translatorState, "cancelled")) {
432
+ emit(ev);
433
+ }
434
+ emit({
435
+ event: "stream_end",
436
+ data: { reason: "cancelled" },
437
+ state: { status: "cancelled", phase: null },
438
+ });
439
+ syncTurnRow("cancelled", null);
440
+ publisher.close();
441
+ opts.onEvent(null);
442
+ metrics.chatDurationMs.set(Date.now() - startedMs);
443
+ opts.logger.info(
444
+ { event: "turn_cancelled", turn_id: turnId, session_id: opts.sessionId },
445
+ "Turn cancelled.",
446
+ );
447
+ return { turnId, status: "cancelled" };
448
+ }
449
+
450
+ const message = err instanceof Error ? err.message : String(err);
451
+ emit({
452
+ event: "error",
453
+ data: { code: classifyServerError(err), message },
454
+ state: { status: "failed", phase: null },
455
+ });
456
+ emit({
457
+ event: "stream_end",
458
+ data: { reason: "error" },
459
+ state: { status: "failed", phase: null },
460
+ });
461
+ syncTurnRow("failed", null);
462
+ publisher.close();
463
+ opts.onEvent(null);
464
+ metrics.chatDurationMs.set(Date.now() - startedMs);
465
+ opts.logger.error(
466
+ {
467
+ event: "turn_failed",
468
+ turn_id: turnId,
469
+ session_id: opts.sessionId,
470
+ err: message,
471
+ },
472
+ "Turn failed.",
473
+ );
474
+ return { turnId, status: "failed" };
475
+ } finally {
476
+ if (handle) {
477
+ try {
478
+ handle.close();
479
+ } catch {
480
+ // best-effort
481
+ }
482
+ }
483
+ if (opts.signal) opts.signal.removeEventListener("abort", propagateCancel);
484
+ registryCancelSignal.removeEventListener("abort", propagateCancel);
485
+ }
486
+ }
487
+
488
+ /* ────────────────────────────────────────────────────────────────────────── */
489
+ /* Helpers */
490
+ /* ────────────────────────────────────────────────────────────────────────── */
491
+
492
+ /** Sentinel for "the cancel path fired" — the catch arm checks
493
+ * `err instanceof AbortLikeError` plus the cancel signal. */
494
+ class AbortLikeError extends Error {
495
+ constructor() {
496
+ super("Turn aborted.");
497
+ this.name = "AbortLikeError";
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Maps an unhandled runner error onto an ai-hub `error.code`.
503
+ *
504
+ * Post-migration the set of error sources is much smaller — most of
505
+ * the provider/upstream signals come through the translator (which
506
+ * maps them via `classifyErrorCode`). What lands here is
507
+ * spawn-failure, network-down-to-Letta, decryption errors, and other
508
+ * "couldn't even start the turn" failures.
509
+ *
510
+ * @param err - the unhandled error.
511
+ * @returns the SSE `error.code` to emit.
512
+ */
513
+ function classifyServerError(err: unknown): "provider_error" | "internal" {
514
+ if (err instanceof Error) {
515
+ const msg = err.message.toLowerCase();
516
+ if (msg.includes("letta") || msg.includes("upstream")) return "provider_error";
517
+ }
518
+ return "internal";
519
+ }
520
+
521
+ /* ────────────────────────────────────────────────────────────────────────── */
522
+ /* Resumption helpers */
523
+ /* ────────────────────────────────────────────────────────────────────────── */
524
+
525
+ /**
526
+ * Replays persisted `turn_event` rows newer than `fromSeq`, returned as
527
+ * ai-hub-shaped events. Used by `GET /chat/:turn_id/events?from_seq=N`
528
+ * for the replay half of resumption.
529
+ *
530
+ * @param db - Drizzle handle.
531
+ * @param turnId - the turn id.
532
+ * @param fromSeq - replays events with `seq > fromSeq`.
533
+ * @returns the persisted events.
534
+ *
535
+ * @see SPECIFICATIONS.md §6.3.2 — resumption
536
+ */
537
+ export function replayPersistedEvents(
538
+ db: DrizzleDB,
539
+ turnId: string,
540
+ fromSeq: number,
541
+ ): ReadonlyArray<AIHubEvent> {
542
+ const rows = db
543
+ .select()
544
+ .from(turnEvent)
545
+ .where(sql`${turnEvent.turnId} = ${turnId} AND ${turnEvent.seq} > ${fromSeq}`)
546
+ .orderBy(turnEvent.seq)
547
+ .all();
548
+ return rows.map((row) => ({
549
+ event: row.eventName,
550
+ data: row.data as Record<string, unknown>,
551
+ state: row.state as AIHubEventState,
552
+ seq: row.seq,
553
+ })) as unknown as AIHubEvent[];
554
+ }
555
+
556
+ /**
557
+ * Idempotently marks a turn as cancelled in the DB. Returns the row's
558
+ * resulting status. Called by `POST /chat/:turn_id/cancel` after firing
559
+ * `requestTurnCancellation`. The runner's catch arm also persists
560
+ * `cancelled` via `syncTurnRow`; this helper just guarantees the row
561
+ * is updated when the runner has already exited (terminal-after-cancel
562
+ * race).
563
+ *
564
+ * Does not overwrite `done` / `failed` — those are terminal states.
565
+ *
566
+ * @param db - Drizzle handle.
567
+ * @param turnId - the turn to cancel.
568
+ * @returns the row's resulting status, or `null` if no such row.
569
+ */
570
+ export function markCancelled(
571
+ db: DrizzleDB,
572
+ turnId: string,
573
+ ): TurnStatus | null {
574
+ const row = db.select().from(turn).where(eq(turn.id, turnId)).get();
575
+ if (!row) return null;
576
+ if (row.status === "running") {
577
+ db.update(turn)
578
+ .set({
579
+ status: "cancelled",
580
+ phase: null,
581
+ finishedAt: new Date().toISOString(),
582
+ })
583
+ .where(eq(turn.id, turnId))
584
+ .run();
585
+ return "cancelled";
586
+ }
587
+ return row.status;
588
+ }