@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,534 @@
1
+ /**
2
+ * `/chat/*` — SSE chat, resumption, cancel (§6.3.2).
3
+ *
4
+ * Three endpoints:
5
+ *
6
+ * - `POST /chat` — body `{ session_id, user_message, effort?, idempotency_key? }`.
7
+ * Streams events per §9. Owner-scoped: the caller must own the
8
+ * referenced session.
9
+ * - `GET /chat/:turn_id/events?from_seq=N` — resumption: replays
10
+ * persisted events with `seq > N`, then live-attaches if the turn is
11
+ * still active.
12
+ * - `POST /chat/:turn_id/cancel` — aborts an in-flight turn. Idempotent
13
+ * when the turn already terminated.
14
+ *
15
+ * The runner (`state/turnRunner.ts`) owns the actual state machine; this
16
+ * file is the HTTP/SSE shell. The runner publishes events to a live
17
+ * channel + persists them; the `POST /chat` writer subscribes to the
18
+ * same channel and ships bytes to the client. The runner's `onEvent`
19
+ * callback can write directly; this file uses
20
+ * `subscribeToTurn` because it shares the same plumbing the resumption
21
+ * endpoint uses.
22
+ *
23
+ * @see SPECIFICATIONS.md §6.3.2 — chat (SSE)
24
+ * @see SPECIFICATIONS.md §6.6 — server-authoritative state
25
+ * @see SPECIFICATIONS.md §6.9 — cancellation
26
+ */
27
+
28
+ import { eq } from "drizzle-orm";
29
+ import { z } from "zod";
30
+
31
+ import type { AIHubEvent, TurnStatus } from "@data-club/ai-hub";
32
+
33
+ import { protectedRoute } from "../auth/middleware.js";
34
+ import { messageOverride, session, turn, turnEvent } from "../db/schema.js";
35
+ import { formatSseEvent, formatSseHeartbeat } from "../handlers/sse.js";
36
+ import {
37
+ errorResponse,
38
+ jsonResponse,
39
+ notFound,
40
+ validationError,
41
+ } from "../handlers/responses.js";
42
+ import type { RouteHandler } from "../handlers/types.js";
43
+ import {
44
+ markCancelled,
45
+ replayPersistedEvents,
46
+ runTurn,
47
+ type RunTurnOpts,
48
+ } from "../state/turnRunner.js";
49
+ import {
50
+ isTurnLive,
51
+ requestTurnCancellation,
52
+ subscribeToTurn,
53
+ } from "../state/turnRegistry.js";
54
+
55
+ /* ────────────────────────────────────────────────────────────────────────── */
56
+ /* POST /chat */
57
+ /* ────────────────────────────────────────────────────────────────────────── */
58
+
59
+ /** Per-message wire body. `effort` is optional with the §7.4 enum. */
60
+ const postChatSchema = z
61
+ .object({
62
+ session_id: z.string().uuid(),
63
+ user_message: z.string().min(1),
64
+ effort: z
65
+ .enum(["none", "minimal", "low", "medium", "high", "extra_high"])
66
+ .optional(),
67
+ idempotency_key: z.string().min(1).max(255).optional(),
68
+ /**
69
+ * Optional tier-3 customization: the human-facing replacement for
70
+ * `user_message` in the chat UI. When set, AI hub sends
71
+ * `user_message` to Letta verbatim (system view) but, after the turn
72
+ * terminates `done`, writes an override row at the position the
73
+ * persisted user message landed at so subsequent
74
+ * `GET /sessions/:id/messages` calls display this string instead.
75
+ *
76
+ * Persisted only on `done`. On `cancelled` / `failed`, Letta drops
77
+ * the user message and we drop the override — no orphan row.
78
+ */
79
+ user_override: z.string().optional(),
80
+ })
81
+ .strict();
82
+
83
+ /**
84
+ * `POST /chat` — opens an SSE stream for the new turn.
85
+ *
86
+ * Owner-scoped: returns 404 for sessions not owned by the caller. The
87
+ * `power_user`+ "all-sessions overview" is a different surface.
88
+ *
89
+ * The handler returns a `Response` whose body is a `ReadableStream`. The
90
+ * runner runs in the background, posts events to the registry's live
91
+ * channel, and the stream subscriber writes them out as `event:`/`data:`
92
+ * pairs. The runner returns after the stream's terminal event is
93
+ * persisted; the writer closes the stream shortly after.
94
+ *
95
+ * @see SPECIFICATIONS.md §6.3.2
96
+ * @see SPECIFICATIONS.md §9 — wire protocol
97
+ */
98
+ export const handlePostChat: RouteHandler = protectedRoute(
99
+ {},
100
+ async (req, ctx, auth) => {
101
+ let raw: unknown;
102
+ try {
103
+ raw = await req.json();
104
+ } catch {
105
+ return validationError("Request body must be valid JSON.");
106
+ }
107
+ const parsed = postChatSchema.safeParse(raw);
108
+ if (!parsed.success) {
109
+ const issue = parsed.error.issues[0];
110
+ return validationError(issue?.message ?? "Invalid request body.");
111
+ }
112
+ const body = parsed.data;
113
+
114
+ // Owner-scope check.
115
+ const row = ctx.db
116
+ .select()
117
+ .from(session)
118
+ .where(eq(session.id, body.session_id))
119
+ .get();
120
+ if (!row || row.userId !== auth.user.id) return notFound();
121
+ if (row.isArchived) {
122
+ return errorResponse(
123
+ 409,
124
+ "session_archived",
125
+ "Cannot post to an archived session — unarchive it first.",
126
+ );
127
+ }
128
+
129
+ // Honor client disconnects: when the request's signal aborts, the
130
+ // runner's signal aborts too, which closes the upstream Letta call.
131
+ const requestSignal = req.signal;
132
+
133
+ // We need to know the turn id for the response headers. The runner
134
+ // assigns it at row-insert time; capture it from the `stream_start`
135
+ // event the runner emits first.
136
+ const stream = new ReadableStream<Uint8Array>({
137
+ async start(controller) {
138
+ let started = false;
139
+ const writeEvent = (event: AIHubEvent) => {
140
+ try {
141
+ controller.enqueue(formatSseEvent(event));
142
+ started = true;
143
+ } catch {
144
+ /* writer was closed (client disconnected) — ignore */
145
+ }
146
+ };
147
+
148
+ // Periodic heartbeat (§9.1) — every 10 s. Keeps proxies from
149
+ // closing the connection during long idle phases (tool exec,
150
+ // 429 wait).
151
+ const heartbeat = setInterval(() => {
152
+ try {
153
+ controller.enqueue(formatSseHeartbeat());
154
+ } catch {
155
+ /* writer was closed — clear and bail */
156
+ clearInterval(heartbeat);
157
+ }
158
+ }, 10_000);
159
+
160
+ const runnerOpts: RunTurnOpts = {
161
+ db: ctx.db,
162
+ lettaClient: ctx.lettaClient,
163
+ logger: ctx.logger,
164
+ requestId: (ctx.logger as unknown as { bindings?: () => Record<string, unknown> })
165
+ .bindings?.()
166
+ ?.["request_id"] as string ?? "",
167
+ userId: auth.user.id,
168
+ sessionId: row.id,
169
+ lettaConversationId: row.lettaConversationId,
170
+ lettaAgentId: row.lettaAgentId,
171
+ sandboxDir: row.sandboxDir,
172
+ userMessage: body.user_message,
173
+ ...(body.effort !== undefined ? { effort: body.effort } : {}),
174
+ ...(body.idempotency_key !== undefined ? { idempotencyKey: body.idempotency_key } : {}),
175
+ onEvent: (event) => {
176
+ if (event === null) {
177
+ clearInterval(heartbeat);
178
+ try {
179
+ controller.close();
180
+ } catch {
181
+ /* already closed */
182
+ }
183
+ return;
184
+ }
185
+ writeEvent(event);
186
+ },
187
+ signal: requestSignal,
188
+ };
189
+
190
+ // Run the turn. We don't await here — the runner pushes events
191
+ // via `onEvent`; we keep the stream open until it completes.
192
+ // But we DO need to catch errors so the stream doesn't dangle if
193
+ // the runner throws before emitting `stream_end`.
194
+ //
195
+ // The `.then` chain handles the tier-3 `user_override` parameter:
196
+ // after the turn terminates `done`, we list Letta's messages to
197
+ // find the position the persisted user message landed at, then
198
+ // upsert an override row so `GET /sessions/:id/messages` will
199
+ // display `user_override` in place of the wrapped Letta content.
200
+ // We deliberately don't write on `cancelled` / `failed` — in
201
+ // those cases Letta drops the user message and a persisted
202
+ // override would point at nothing.
203
+ runTurn(runnerOpts)
204
+ .then(async (result) => {
205
+ if (result.status !== "done") return;
206
+ if (body.user_override === undefined) return;
207
+ try {
208
+ const messages = await ctx.lettaClient.listMessages(row.lettaConversationId);
209
+ // Position = chronologically-sorted index of the latest
210
+ // user_message authored by this caller. Letta is the
211
+ // source of truth — we don't trust message ordering on
212
+ // any other axis.
213
+ const sorted = [...messages].sort((a, b) => {
214
+ const aT = Date.parse((a as { date?: string }).date ?? "");
215
+ const bT = Date.parse((b as { date?: string }).date ?? "");
216
+ if (Number.isNaN(aT) && Number.isNaN(bT)) return 0;
217
+ if (Number.isNaN(aT)) return 1;
218
+ if (Number.isNaN(bT)) return -1;
219
+ return aT - bT;
220
+ });
221
+ let position = -1;
222
+ for (let i = sorted.length - 1; i >= 0; i--) {
223
+ const msg = sorted[i] as { message_type?: string; sender_id?: string | null };
224
+ if (msg.message_type === "user_message" && msg.sender_id === auth.user.id) {
225
+ position = i;
226
+ break;
227
+ }
228
+ }
229
+ if (position < 0) {
230
+ ctx.logger.warn(
231
+ {
232
+ event: "user_override_post_turn_no_user_message",
233
+ session_id: row.id,
234
+ },
235
+ "Turn ended `done` but no matching user_message was found in Letta — skipping override write.",
236
+ );
237
+ return;
238
+ }
239
+ const now = new Date().toISOString();
240
+ ctx.db
241
+ .insert(messageOverride)
242
+ .values({
243
+ conversationId: row.lettaConversationId,
244
+ ordinalPosition: position,
245
+ systemOverride: null,
246
+ userOverride: body.user_override,
247
+ createdAt: now,
248
+ updatedAt: now,
249
+ })
250
+ .onConflictDoUpdate({
251
+ target: [messageOverride.conversationId, messageOverride.ordinalPosition],
252
+ // Merge: only overwrite the user_override layer. Preserve
253
+ // any system_override an earlier PUT might have written.
254
+ set: { userOverride: body.user_override, updatedAt: now },
255
+ })
256
+ .run();
257
+ ctx.logger.info(
258
+ {
259
+ event: "user_override_post_turn_written",
260
+ session_id: row.id,
261
+ ordinal_position: position,
262
+ },
263
+ "Wrote user_override after successful turn.",
264
+ );
265
+ } catch (err) {
266
+ // Non-fatal: the chat already succeeded. Log and move on.
267
+ ctx.logger.error(
268
+ {
269
+ event: "user_override_post_turn_failed",
270
+ session_id: row.id,
271
+ err: err instanceof Error ? err.message : String(err),
272
+ },
273
+ "Failed to write user_override post-turn.",
274
+ );
275
+ }
276
+ })
277
+ .catch((err) => {
278
+ ctx.logger.error(
279
+ { event: "turn_runner_threw", err: err instanceof Error ? err.message : String(err) },
280
+ "Turn runner threw outside the normal lifecycle.",
281
+ );
282
+ clearInterval(heartbeat);
283
+ if (!started) {
284
+ // No stream_start emitted — close abruptly. The client will
285
+ // see a connection-error and may retry.
286
+ try {
287
+ controller.close();
288
+ } catch {
289
+ /* ignored */
290
+ }
291
+ }
292
+ });
293
+ },
294
+ });
295
+
296
+ return new Response(stream, {
297
+ status: 200,
298
+ headers: {
299
+ "Content-Type": "text/event-stream; charset=utf-8",
300
+ "Cache-Control": "no-cache, no-transform",
301
+ "Connection": "keep-alive",
302
+ "X-Accel-Buffering": "no",
303
+ },
304
+ });
305
+ },
306
+ );
307
+
308
+ /* ────────────────────────────────────────────────────────────────────────── */
309
+ /* GET /chat/:turn_id/events?from_seq=N */
310
+ /* ────────────────────────────────────────────────────────────────────────── */
311
+
312
+ /**
313
+ * `GET /chat/:turn_id/events?from_seq=N` — resumption.
314
+ *
315
+ * Two-phase:
316
+ *
317
+ * 1. Replay persisted `turn_event` rows with `seq > N`, in order.
318
+ * 2. If the turn is still live, attach to the in-memory broadcaster so
319
+ * new events stream through. If the turn is terminal, the response
320
+ * closes after the replay.
321
+ *
322
+ * Owner-scoped: the caller must own the parent session (the admin
323
+ * "sessions overview" doesn't go through this endpoint — that's a
324
+ * separate JSON read).
325
+ *
326
+ * @see SPECIFICATIONS.md §6.3.2 — resumption
327
+ */
328
+ export const handleGetChatEvents: RouteHandler = protectedRoute(
329
+ {},
330
+ async (req, ctx, auth, params) => {
331
+ const turnId = params.turn_id;
332
+ if (!turnId) return notFound();
333
+
334
+ const turnRow = ctx.db.select().from(turn).where(eq(turn.id, turnId)).get();
335
+ if (!turnRow) return notFound();
336
+ const sessionRow = ctx.db
337
+ .select()
338
+ .from(session)
339
+ .where(eq(session.id, turnRow.sessionId))
340
+ .get();
341
+ if (!sessionRow || sessionRow.userId !== auth.user.id) return notFound();
342
+
343
+ const url = new URL(req.url);
344
+ const fromSeqRaw = url.searchParams.get("from_seq");
345
+ const fromSeq =
346
+ fromSeqRaw === null ? -1 : Number.parseInt(fromSeqRaw, 10);
347
+ if (Number.isNaN(fromSeq)) {
348
+ return validationError("from_seq must be an integer.", "invalid_from_seq");
349
+ }
350
+
351
+ const requestSignal = req.signal;
352
+ const turnIsLive = isTurnLive(turnId);
353
+
354
+ const stream = new ReadableStream<Uint8Array>({
355
+ start(controller) {
356
+ let closed = false;
357
+ const safeWrite = (event: AIHubEvent) => {
358
+ if (closed) return;
359
+ try {
360
+ controller.enqueue(formatSseEvent(event));
361
+ } catch {
362
+ closed = true;
363
+ }
364
+ };
365
+ const safeClose = () => {
366
+ if (closed) return;
367
+ closed = true;
368
+ try {
369
+ controller.close();
370
+ } catch {
371
+ /* ignored */
372
+ }
373
+ };
374
+
375
+ // Phase 1 — replay every persisted event with seq > fromSeq.
376
+ const replayed = replayPersistedEvents(ctx.db, turnId, fromSeq);
377
+ let highestSeqWritten = fromSeq;
378
+ for (const event of replayed) {
379
+ safeWrite(event);
380
+ if (event.seq > highestSeqWritten) highestSeqWritten = event.seq;
381
+ }
382
+
383
+ // Phase 2 — live-attach (only if the turn is still running).
384
+ if (!turnIsLive) {
385
+ safeClose();
386
+ return;
387
+ }
388
+
389
+ const unsubscribe = subscribeToTurn(
390
+ turnId,
391
+ (event) => {
392
+ // De-duplicate against the replay window. Events with seq <=
393
+ // highestSeqWritten were either already replayed or are stale
394
+ // re-broadcasts; the runner never re-emits, but we guard
395
+ // defensively.
396
+ if (event.seq <= highestSeqWritten) return;
397
+ highestSeqWritten = event.seq;
398
+ safeWrite(event);
399
+ },
400
+ () => {
401
+ safeClose();
402
+ },
403
+ );
404
+
405
+ if (!unsubscribe) {
406
+ // Turn went terminal between our `isTurnLive` check and
407
+ // subscription. Re-read any events that arrived in the gap
408
+ // and close.
409
+ const tail = replayPersistedEvents(ctx.db, turnId, highestSeqWritten);
410
+ for (const event of tail) safeWrite(event);
411
+ safeClose();
412
+ return;
413
+ }
414
+
415
+ const onAbort = () => {
416
+ unsubscribe();
417
+ safeClose();
418
+ };
419
+ if (requestSignal.aborted) onAbort();
420
+ else requestSignal.addEventListener("abort", onAbort, { once: true });
421
+ },
422
+ });
423
+
424
+ return new Response(stream, {
425
+ status: 200,
426
+ headers: {
427
+ "Content-Type": "text/event-stream; charset=utf-8",
428
+ "Cache-Control": "no-cache, no-transform",
429
+ "Connection": "keep-alive",
430
+ "X-Accel-Buffering": "no",
431
+ },
432
+ });
433
+ },
434
+ );
435
+
436
+ /* ────────────────────────────────────────────────────────────────────────── */
437
+ /* POST /chat/:turn_id/cancel */
438
+ /* ────────────────────────────────────────────────────────────────────────── */
439
+
440
+ /**
441
+ * `POST /chat/:turn_id/cancel` — aborts an in-flight turn authoritatively.
442
+ *
443
+ * Owner-scoped via the parent session. Idempotent — calling it on a
444
+ * terminal turn returns the same envelope as the first call (no error,
445
+ * the existing status is reported as `already_terminal`).
446
+ *
447
+ * Two cancellation paths fire here, in order:
448
+ *
449
+ * 1. **Letta-side cancel** — `client.agents.messages.cancel({ run_ids })`.
450
+ * This is the load-bearing one: it tells Letta to stop the run
451
+ * server-side and roll back the in-progress step from the
452
+ * conversation history (completed steps survive). **Without this,
453
+ * aborting only our SDK call's HTTP connection does nothing** —
454
+ * Letta keeps generating, persists the full output, and the user's
455
+ * refresh reveals it. Empirically verified.
456
+ *
457
+ * 2. **Local registry cancel** — `requestTurnCancellation(turnId)`. Fires
458
+ * our runner's AbortSignal so the in-process for-await loop exits
459
+ * cleanly + emits `stream_end{reason:"cancelled"}`. Also serves as
460
+ * the fallback when (1) fails (network blip, Letta-side error, run
461
+ * already terminal): the client still gets a clean terminal event.
462
+ *
463
+ * Race-free ordering: we fire (1) first so Letta has begun winding the
464
+ * run down by the time (2) fires; the few final events Letta delivers
465
+ * during that window flow through the runner's stream naturally before
466
+ * the abort lands.
467
+ *
468
+ * @see SPECIFICATIONS.md §6.3.2
469
+ * @see SPECIFICATIONS.md §6.9 — cancellation
470
+ */
471
+ export const handlePostChatCancel: RouteHandler = protectedRoute(
472
+ {},
473
+ async (_req, ctx, auth, params) => {
474
+ const turnId = params.turn_id;
475
+ if (!turnId) return notFound();
476
+
477
+ const turnRow = ctx.db.select().from(turn).where(eq(turn.id, turnId)).get();
478
+ if (!turnRow) return notFound();
479
+ const sessionRow = ctx.db
480
+ .select()
481
+ .from(session)
482
+ .where(eq(session.id, turnRow.sessionId))
483
+ .get();
484
+ if (!sessionRow || sessionRow.userId !== auth.user.id) return notFound();
485
+
486
+ if (turnRow.status !== "running") {
487
+ return jsonResponse(200, {
488
+ turn_id: turnId,
489
+ status: turnRow.status,
490
+ was_already_terminal: true,
491
+ });
492
+ }
493
+
494
+ // Cancel via the runner's `ChatTurnHandle.abort()` —
495
+ // `requestTurnCancellation` fires the registry's AbortController,
496
+ // the runner listens for it and translates into a Letta-Code-SDK
497
+ // `session.abort()` call (which sends `interrupt` to the spawned
498
+ // CLI subprocess). Pre-migration this also called Letta server's
499
+ // `agents.messages.cancel({run_ids})` directly via a captured
500
+ // `lettaRunId`; the SDK abstracts that now and we no longer
501
+ // thread run-ids through the runner. See migration §4.2.
502
+ //
503
+ // Behavioral caveat (migration §7 open question 5): if the spawned
504
+ // CLI is mid-tool-execution, the interrupt may be queued, not
505
+ // honored, and the tool runs to completion. The runner emits
506
+ // `stream_end{cancelled}` regardless once the upstream terminal
507
+ // result lands, so the React client gets a clean cancel event
508
+ // even when the run effectively completed.
509
+ const aborted = requestTurnCancellation(turnId);
510
+ let finalStatus: TurnStatus = turnRow.status;
511
+ if (!aborted) {
512
+ const fresh = ctx.db.select().from(turn).where(eq(turn.id, turnId)).get();
513
+ finalStatus = fresh?.status ?? "cancelled";
514
+ } else {
515
+ finalStatus = "cancelled";
516
+ markCancelled(ctx.db, turnId);
517
+ }
518
+ return jsonResponse(200, {
519
+ turn_id: turnId,
520
+ status: finalStatus,
521
+ was_already_terminal: false,
522
+ });
523
+ },
524
+ );
525
+
526
+ /* ────────────────────────────────────────────────────────────────────────── */
527
+ /* Re-exports */
528
+ /* ────────────────────────────────────────────────────────────────────────── */
529
+
530
+ // Re-exported here for the tests that exercise the persisted-event log
531
+ // directly without going through HTTP.
532
+ export { replayPersistedEvents };
533
+ // Used by integration tests that need to read turn_event rows directly.
534
+ export { turnEvent };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `GET /feature-toggles` — public read of the deployment-wide UI feature
3
+ * toggles set by `super_admin` (§8.4).
4
+ *
5
+ * Unlike `/admin/settings/feature-toggles`, this endpoint is **unauthenticated**
6
+ * — every page-load can fetch it before login completes, and every role can
7
+ * read it after login. The blob itself is non-sensitive: it controls which
8
+ * UI affordances render in the chat shell, not any data. Keeping the read
9
+ * public matches the precedence rule in §8.4 ("server toggle > React prop >
10
+ * built-in default") — the toggles need to apply at render time on every
11
+ * user's UI, not only super_admin's. Writes remain super_admin-only via
12
+ * `PATCH /admin/settings/feature-toggles`.
13
+ *
14
+ * Missing keys are returned as absent in the response (not coerced to false)
15
+ * so the client can distinguish "explicitly off" from "fall through to the
16
+ * React prop default."
17
+ *
18
+ * @see SPECIFICATIONS.md §8.4 — feature toggles
19
+ * @see ui-sketches/08-admin-settings.md — feature_toggles_section
20
+ */
21
+
22
+ import { eq } from "drizzle-orm";
23
+
24
+ import { adminSetting } from "../db/schema.js";
25
+ import { internalError, jsonResponse } from "../handlers/responses.js";
26
+ import type { RouteHandler } from "../handlers/types.js";
27
+
28
+ /**
29
+ * `GET /feature-toggles` — returns `{ feature_toggles: {...} }`.
30
+ *
31
+ * No auth required. Every authenticated AND unauthenticated request gets
32
+ * the same response. CORS is governed by the framework wrapper; the handler
33
+ * itself only writes JSON.
34
+ *
35
+ * @param _req - the inbound request (unused).
36
+ * @param ctx - the server context.
37
+ * @returns 200 with the toggles blob, or 500 if the singleton row is missing.
38
+ */
39
+ export const handleGetPublicFeatureToggles: RouteHandler = async (
40
+ _req,
41
+ ctx,
42
+ ) => {
43
+ const row = ctx.db
44
+ .select()
45
+ .from(adminSetting)
46
+ .where(eq(adminSetting.id, 1))
47
+ .get();
48
+ if (!row) return internalError();
49
+ return jsonResponse(200, {
50
+ feature_toggles: row.featureToggles ?? {},
51
+ });
52
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `/health` and `/health/ready` handlers.
3
+ *
4
+ * Step 2 ships the basic shapes:
5
+ *
6
+ * - `/health` — pure liveness, no checks. Returns 200 always (the process is
7
+ * up; that's the only signal a liveness probe needs).
8
+ * - `/health/ready` — readiness. Step 2 checks DB reachability via a trivial
9
+ * `SELECT 1`. **Letta reachability is intentionally NOT checked here** —
10
+ * the Letta SDK comes online in Step 5, which extends this handler.
11
+ *
12
+ * @see SPECIFICATIONS.md §6.3.5 — health endpoints
13
+ */
14
+
15
+ import { jsonResponse, errorResponse } from "../handlers/responses.js";
16
+ import type { RouteHandler } from "../handlers/types.js";
17
+
18
+ /** `GET /health` — returns 200 with a static body. */
19
+ export const handleHealth: RouteHandler = async () =>
20
+ jsonResponse(200, { status: "ok" });
21
+
22
+ /**
23
+ * `GET /health/ready` — runs each readiness probe in turn. Returns 503
24
+ * with the first failing probe's name on any failure.
25
+ *
26
+ * Step 2 checks `db`. Step 5 will append `letta`.
27
+ */
28
+ export const handleHealthReady: RouteHandler = async (_req, ctx) => {
29
+ try {
30
+ ctx.sqlite.prepare("SELECT 1 AS ok").get();
31
+ } catch (err) {
32
+ ctx.logger.error(
33
+ { component: "db", err: errMessage(err) },
34
+ "Readiness probe failed: db unreachable.",
35
+ );
36
+ return errorResponse(503, "not_ready", "Database unreachable");
37
+ }
38
+ return jsonResponse(200, { status: "ready", checks: { db: "ok" } });
39
+ };
40
+
41
+ /** Extracts a string for logging from any thrown value. */
42
+ function errMessage(err: unknown): string {
43
+ return err instanceof Error ? err.message : String(err);
44
+ }