@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,574 @@
1
+ /**
2
+ * `/admin/agents/*` — power_user + admin + super_admin catalog management.
3
+ *
4
+ * Three endpoints (no DELETE by design — agents are archive-only in v1):
5
+ *
6
+ * - `GET /admin/agents` — every agent in the deployment, including archived
7
+ * ones. Lets the admin UI render the catalog with the archived rows
8
+ * filtered in/out via the `[☐ Show archived]` toggle ([06-admin-agents.md]).
9
+ * - `POST /admin/agents` — body `{ display_name, letta_agent_id, tpm_recovery_enabled?, max_recovery_attempts?, default_retry_after_seconds? }`.
10
+ * Validates `letta_agent_id` against Letta (Step 5 wires the real check;
11
+ * Step 4 stubs it through with a `TODO`).
12
+ * - `PATCH /admin/agents/:id` — body `{ display_name?, letta_agent_id?, tpm_recovery_enabled?, max_recovery_attempts?, default_retry_after_seconds?, is_archived? }`.
13
+ * Archive / unarchive is just `{ is_archived: true | false }`. All fields
14
+ * optional; empty patch is a 200 no-op.
15
+ *
16
+ * **No DELETE.** Per §6.3.4 / §11, agents cannot be hard-deleted in v1 —
17
+ * archiving keeps existing sessions readable + writable while preventing new
18
+ * sessions from starting against the agent. Step 12's UI exposes only
19
+ * archive/unarchive; this server enforces the same.
20
+ *
21
+ * @see SPECIFICATIONS.md §6.3.4 — admin agents endpoints
22
+ * @see SPECIFICATIONS.md §6.5 — TPM recovery defaults
23
+ * @see ui-sketches/06-admin-agents.md
24
+ */
25
+
26
+ import { randomUUID } from "node:crypto";
27
+
28
+ import { asc, eq } from "drizzle-orm";
29
+ import { z } from "zod";
30
+
31
+ import { protectedRoute } from "../../auth/middleware.js";
32
+ import { agent, rotatingMessage } from "../../db/schema.js";
33
+ import type { AgentRow } from "../../db/schema.js";
34
+ import {
35
+ errorResponse,
36
+ jsonResponse,
37
+ notFound,
38
+ validationError,
39
+ } from "../../handlers/responses.js";
40
+ import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
41
+ import { seedDirExists, validateSeedName } from "../../letta/sandbox.js";
42
+ import { seedAgentToolsetNone } from "../../letta/settingsFileSeed.js";
43
+
44
+ /* ────────────────────────────────────────────────────────────────────────── */
45
+ /* Helpers */
46
+ /* ────────────────────────────────────────────────────────────────────────── */
47
+
48
+ /**
49
+ * Wire-format view of an `agent` row for admin consumption. Differs from the
50
+ * user-facing `/agents` response: this one includes `letta_agent_id` (admins
51
+ * need to see + edit it), the TPM-recovery fields, and the archive flag.
52
+ *
53
+ * @param row - the agent row.
54
+ * @returns the JSON-safe response shape.
55
+ */
56
+ function adminAgentView(row: AgentRow): Record<string, unknown> {
57
+ return {
58
+ id: row.id,
59
+ display_name: row.displayName,
60
+ letta_agent_id: row.lettaAgentId,
61
+ tpm_recovery_enabled: row.tpmRecoveryEnabled,
62
+ max_recovery_attempts: row.maxRecoveryAttempts,
63
+ default_retry_after_seconds: row.defaultRetryAfterSeconds,
64
+ stream_idle_timeout_seconds: row.streamIdleTimeoutSeconds,
65
+ sandbox_seed_name: row.sandboxSeedName,
66
+ fork_source_conversation_id: row.forkSourceConversationId,
67
+ is_archived: row.isArchived,
68
+ created_at: row.createdAt,
69
+ updated_at: row.updatedAt,
70
+ };
71
+ }
72
+
73
+ /** ISO-8601 timestamp with millisecond precision. */
74
+ function nowIso(): string {
75
+ return new Date().toISOString();
76
+ }
77
+
78
+ /**
79
+ * Validates that `letta_agent_id` references a real Letta-side agent.
80
+ *
81
+ * Calls `ctx.lettaClient.retrieveAgent(...)` (which wraps
82
+ * `client.agents.retrieve` per §6.4 / §6.4.1) and returns `false` when the
83
+ * SDK reports the agent doesn't exist. The handler turns the `false` into
84
+ * a 400 `letta_agent_not_found` so the React modal can surface the error
85
+ * inline on the field ([06-admin-agents.md]). Any non-404 SDK error
86
+ * propagates so the route catch-all returns a 500.
87
+ *
88
+ * @param ctx - server context (carries the Letta client).
89
+ * @param lettaAgentId - candidate id.
90
+ * @returns `true` if the id resolves to a live Letta agent.
91
+ *
92
+ * @see SPECIFICATIONS.md §6.4.1 — first-class SDK surfaces
93
+ */
94
+ async function validateLettaAgentId(
95
+ ctx: AIHubServerContext,
96
+ lettaAgentId: string,
97
+ ): Promise<boolean> {
98
+ const found = await ctx.lettaClient.retrieveAgent(lettaAgentId);
99
+ return found !== null;
100
+ }
101
+
102
+ /**
103
+ * Step-12.5 — outcome of validating the two new specialized-agent
104
+ * fields (`sandbox_seed_name`, `fork_source_conversation_id`) before a
105
+ * `POST` / `PATCH /admin/agents` row write. Each non-ok variant maps to
106
+ * a single inline-on-field error code the React modal surfaces.
107
+ */
108
+ type SpecializedFieldsValidation =
109
+ | { kind: "ok" }
110
+ | { kind: "seed_name_invalid" }
111
+ | { kind: "seed_dir_not_found" }
112
+ | { kind: "fork_source_not_found" }
113
+ | { kind: "fork_source_agent_mismatch"; expectedAgentId: string; actualAgentId: string }
114
+ | { kind: "rotating_message_not_found" }
115
+ | { kind: "rotating_message_agent_mismatch"; expectedAgentId: string; actualAgentId: string };
116
+
117
+ /**
118
+ * Validates the (optional) `sandbox_seed_name` and (optional)
119
+ * `fork_source_conversation_id` against the filesystem + Letta. Pure for
120
+ * the success / regex-fail cases; talks to Letta only when a fork source
121
+ * was supplied. The caller maps the outcome to one of:
122
+ * - `400 seed_name_invalid`
123
+ * - `400 seed_dir_not_found`
124
+ * - `400 fork_source_not_found`
125
+ * - `400 fork_source_agent_mismatch`
126
+ *
127
+ * `null` / `undefined` values short-circuit the corresponding check —
128
+ * those fields are explicitly opt-in.
129
+ *
130
+ * @param ctx - server context (filesDir + lettaClient).
131
+ * @param fields - candidate values.
132
+ * @returns the resolved outcome.
133
+ */
134
+ async function validateSpecializedFields(
135
+ ctx: AIHubServerContext,
136
+ fields: {
137
+ sandboxSeedName: string | null | undefined;
138
+ forkSourceConversationId: string | null | undefined;
139
+ expectedLettaAgentId: string;
140
+ },
141
+ ): Promise<SpecializedFieldsValidation> {
142
+ if (
143
+ fields.sandboxSeedName !== undefined &&
144
+ fields.sandboxSeedName !== null &&
145
+ fields.sandboxSeedName.length > 0
146
+ ) {
147
+ const nameValidation = validateSeedName(fields.sandboxSeedName);
148
+ if (nameValidation !== "ok") {
149
+ return { kind: "seed_name_invalid" };
150
+ }
151
+ if (!seedDirExists(ctx.filesDir, fields.sandboxSeedName)) {
152
+ return { kind: "seed_dir_not_found" };
153
+ }
154
+ }
155
+ if (
156
+ fields.forkSourceConversationId !== undefined &&
157
+ fields.forkSourceConversationId !== null &&
158
+ fields.forkSourceConversationId.length > 0
159
+ ) {
160
+ const ref = fields.forkSourceConversationId;
161
+ if (ref.startsWith("rmsg-")) {
162
+ // The base is a rotating starting message. Validate against the
163
+ // `rotating_message` catalog instead of Letta — its prepared
164
+ // conversation may not exist yet (no rotation has run), which is fine
165
+ // at write time; `POST /sessions` handles the "not ready" case.
166
+ const rmsg = ctx.db
167
+ .select()
168
+ .from(rotatingMessage)
169
+ .where(eq(rotatingMessage.id, ref))
170
+ .get();
171
+ if (!rmsg || rmsg.isArchived) {
172
+ return { kind: "rotating_message_not_found" };
173
+ }
174
+ if (rmsg.lettaAgentId !== fields.expectedLettaAgentId) {
175
+ return {
176
+ kind: "rotating_message_agent_mismatch",
177
+ expectedAgentId: fields.expectedLettaAgentId,
178
+ actualAgentId: rmsg.lettaAgentId,
179
+ };
180
+ }
181
+ } else {
182
+ const conv = await ctx.lettaClient.retrieveConversation(ref);
183
+ if (!conv) {
184
+ return { kind: "fork_source_not_found" };
185
+ }
186
+ if (conv.agentId !== fields.expectedLettaAgentId) {
187
+ return {
188
+ kind: "fork_source_agent_mismatch",
189
+ expectedAgentId: fields.expectedLettaAgentId,
190
+ actualAgentId: conv.agentId,
191
+ };
192
+ }
193
+ }
194
+ }
195
+ return { kind: "ok" };
196
+ }
197
+
198
+ /**
199
+ * Maps a `SpecializedFieldsValidation` failure to the canonical
200
+ * `errorResponse(400, code, message)`. Keeps the create + patch handlers
201
+ * tidy.
202
+ */
203
+ function respondSpecializedFailure(
204
+ failure: Exclude<SpecializedFieldsValidation, { kind: "ok" }>,
205
+ ): Response {
206
+ switch (failure.kind) {
207
+ case "seed_name_invalid":
208
+ return errorResponse(
209
+ 400,
210
+ "seed_name_invalid",
211
+ "sandbox_seed_name must match ^[a-zA-Z0-9_-]+$ (no slashes, no dots).",
212
+ );
213
+ case "seed_dir_not_found":
214
+ return errorResponse(
215
+ 400,
216
+ "seed_dir_not_found",
217
+ "No seed folder with that name was found under AI_HUB_FILES_DIR/seeds/.",
218
+ );
219
+ case "fork_source_not_found":
220
+ return errorResponse(
221
+ 400,
222
+ "fork_source_not_found",
223
+ "No Letta conversation with that id was found.",
224
+ );
225
+ case "fork_source_agent_mismatch":
226
+ return errorResponse(
227
+ 400,
228
+ "fork_source_agent_mismatch",
229
+ `The fork source belongs to Letta agent ${failure.actualAgentId}, ` +
230
+ `but this ai-hub agent points at ${failure.expectedAgentId}. Letta's fork ` +
231
+ `API requires the new conversation to share the source's agent.`,
232
+ );
233
+ case "rotating_message_not_found":
234
+ return errorResponse(
235
+ 400,
236
+ "rotating_message_not_found",
237
+ "No active rotating starting message with that id was found.",
238
+ );
239
+ case "rotating_message_agent_mismatch":
240
+ return errorResponse(
241
+ 400,
242
+ "rotating_message_agent_mismatch",
243
+ `The rotating starting message runs against Letta agent ${failure.actualAgentId}, ` +
244
+ `but this ai-hub agent points at ${failure.expectedAgentId}. A rotating message ` +
245
+ `can only back agents that share its Letta agent (Letta forks must be same-agent).`,
246
+ );
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Best-effort seed of `~/.letta/settings.json` with `{toolset: "none"}` for
252
+ * the given Letta agent id. Idempotent. Logs the outcome but **does not
253
+ * throw**: if seeding fails (read-only home dir, permission denied, …) the
254
+ * agent creation should still succeed — the toolset suppression is then
255
+ * a runtime concern the operator notices when chats start producing weird
256
+ * file-edit tool calls. The intent here is hands-off automation, not a
257
+ * required precondition.
258
+ *
259
+ * @param ctx - server context (for logging).
260
+ * @param lettaAgentId - id to seed the toolset preference for.
261
+ *
262
+ * @see ../../letta/settingsFileSeed.ts
263
+ */
264
+ function seedToolsetNoneBestEffort(
265
+ ctx: AIHubServerContext,
266
+ lettaAgentId: string,
267
+ ): void {
268
+ try {
269
+ seedAgentToolsetNone({
270
+ agentId: lettaAgentId,
271
+ lettaBaseUrl: process.env.LETTA_BASE_URL,
272
+ logger: ctx.logger,
273
+ });
274
+ } catch (err) {
275
+ ctx.logger.warn(
276
+ {
277
+ agent_id: lettaAgentId,
278
+ err: err instanceof Error ? err.message : String(err),
279
+ event: "letta_code_settings_seed_failed",
280
+ },
281
+ "Failed to seed Letta Code toolset preference; continuing anyway.",
282
+ );
283
+ }
284
+ }
285
+
286
+ /* ────────────────────────────────────────────────────────────────────────── */
287
+ /* GET /admin/agents */
288
+ /* ────────────────────────────────────────────────────────────────────────── */
289
+
290
+ /**
291
+ * `GET /admin/agents` — every agent in the deployment, archived rows
292
+ * included. The admin UI filters them client-side via the
293
+ * `[☐ Show archived]` toggle ([06-admin-agents.md]).
294
+ *
295
+ * Sorted by `display_name` for stable rendering.
296
+ *
297
+ * Available to `power_user`+ per the §8.2 matrix.
298
+ *
299
+ * @see SPECIFICATIONS.md §6.3.4
300
+ */
301
+ export const handleListAgents: RouteHandler = protectedRoute(
302
+ { requiredRole: "power_user" },
303
+ async (_req, ctx) => {
304
+ const rows = ctx.db.select().from(agent).orderBy(asc(agent.displayName)).all();
305
+ return jsonResponse(200, { agents: rows.map(adminAgentView) });
306
+ },
307
+ );
308
+
309
+ /* ────────────────────────────────────────────────────────────────────────── */
310
+ /* POST /admin/agents */
311
+ /* ────────────────────────────────────────────────────────────────────────── */
312
+
313
+ /** Body for `POST /admin/agents`. */
314
+ const createAgentSchema = z
315
+ .object({
316
+ display_name: z.string().min(1).max(255),
317
+ letta_agent_id: z.string().min(1).max(255),
318
+ tpm_recovery_enabled: z.boolean().optional(),
319
+ max_recovery_attempts: z.number().int().min(0).max(10).optional(),
320
+ default_retry_after_seconds: z.number().int().min(1).max(3600).optional(),
321
+ /** Per-agent inter-event idle watchdog (seconds). Default 60. */
322
+ stream_idle_timeout_seconds: z.number().int().min(5).max(3600).optional(),
323
+ /**
324
+ * Step-12.5 — name of the seed folder under `{AI_HUB_FILES_DIR}/seeds/`.
325
+ * `null` or `""` clears the field; non-null strings are validated
326
+ * against the safe-name regex + existence on disk.
327
+ */
328
+ sandbox_seed_name: z.string().max(128).nullable().optional(),
329
+ /**
330
+ * Step-12.5 — Letta conversation id (`conv-…`) to fork from at session
331
+ * create. `null` or `""` clears the field; non-null strings are
332
+ * validated via `client.conversations.retrieve` + agent-id match.
333
+ */
334
+ fork_source_conversation_id: z.string().max(255).nullable().optional(),
335
+ })
336
+ .strict();
337
+
338
+ /**
339
+ * `POST /admin/agents` — create a new ai-hub agent (catalog entry).
340
+ *
341
+ * Validates the Letta-side id via `validateLettaAgentId` (Step 4 stub —
342
+ * always passes; Step 5 wires the real check against the Letta SDK). On a
343
+ * Letta-id miss returns 400 `letta_agent_not_found` with the offending field
344
+ * named, so the React modal can surface the error inline on the field
345
+ * ([06-admin-agents.md]).
346
+ *
347
+ * @see SPECIFICATIONS.md §6.3.4
348
+ * @see ui-sketches/06-admin-agents.md
349
+ */
350
+ export const handleCreateAgent: RouteHandler = protectedRoute(
351
+ { requiredRole: "power_user" },
352
+ async (req, ctx, auth) => {
353
+ let raw: unknown;
354
+ try {
355
+ raw = await req.json();
356
+ } catch {
357
+ return validationError("Request body must be valid JSON.");
358
+ }
359
+ const parsed = createAgentSchema.safeParse(raw);
360
+ if (!parsed.success) {
361
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
362
+ }
363
+ const body = parsed.data;
364
+
365
+ const ok = await validateLettaAgentId(ctx, body.letta_agent_id);
366
+ if (!ok) {
367
+ return errorResponse(
368
+ 400,
369
+ "letta_agent_not_found",
370
+ "No Letta agent with that id was found.",
371
+ );
372
+ }
373
+
374
+ // Step-12.5 — validate the optional sandbox + fork fields. Runs AFTER
375
+ // the letta_agent_id check so the agent-mismatch error has a clear
376
+ // expected value to compare against (and so an admin entering a bad
377
+ // letta_agent_id only sees that error, not the cascading 400 from
378
+ // the fork validator).
379
+ const specialized = await validateSpecializedFields(ctx, {
380
+ sandboxSeedName: body.sandbox_seed_name,
381
+ forkSourceConversationId: body.fork_source_conversation_id,
382
+ expectedLettaAgentId: body.letta_agent_id,
383
+ });
384
+ if (specialized.kind !== "ok") {
385
+ return respondSpecializedFailure(specialized);
386
+ }
387
+
388
+ const id = randomUUID();
389
+ // Empty strings normalize to null so the storage matches the
390
+ // "field cleared" semantics the React modal sends.
391
+ const normalizedSeedName =
392
+ body.sandbox_seed_name && body.sandbox_seed_name.length > 0
393
+ ? body.sandbox_seed_name
394
+ : null;
395
+ const normalizedForkSrc =
396
+ body.fork_source_conversation_id &&
397
+ body.fork_source_conversation_id.length > 0
398
+ ? body.fork_source_conversation_id
399
+ : null;
400
+ ctx.db
401
+ .insert(agent)
402
+ .values({
403
+ id,
404
+ displayName: body.display_name,
405
+ lettaAgentId: body.letta_agent_id,
406
+ tpmRecoveryEnabled: body.tpm_recovery_enabled ?? false,
407
+ maxRecoveryAttempts: body.max_recovery_attempts ?? 2,
408
+ defaultRetryAfterSeconds: body.default_retry_after_seconds ?? 65,
409
+ streamIdleTimeoutSeconds: body.stream_idle_timeout_seconds ?? 60,
410
+ sandboxSeedName: normalizedSeedName,
411
+ forkSourceConversationId: normalizedForkSrc,
412
+ isArchived: false,
413
+ })
414
+ .run();
415
+ const row = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
416
+ if (!row) throw new Error("Just-inserted agent vanished.");
417
+
418
+ // Seed `~/.letta/settings.json` so Letta Code's bundled CLI suppresses
419
+ // its auto-toolset (Read/Write/Bash/etc.) when chat sessions spawn
420
+ // against this agent. ai-hub wants only the tools the ADE admin
421
+ // explicitly attached. Best-effort: a write failure logs + continues.
422
+ seedToolsetNoneBestEffort(ctx, row.lettaAgentId);
423
+
424
+ ctx.logger.info(
425
+ {
426
+ actor_id: auth.user.id,
427
+ agent_id: row.id,
428
+ letta_agent_id: row.lettaAgentId,
429
+ event: "admin_agent_created",
430
+ },
431
+ "Admin created an ai-hub agent.",
432
+ );
433
+ return jsonResponse(201, adminAgentView(row));
434
+ },
435
+ );
436
+
437
+ /* ────────────────────────────────────────────────────────────────────────── */
438
+ /* PATCH /admin/agents/:id */
439
+ /* ────────────────────────────────────────────────────────────────────────── */
440
+
441
+ /** Body for `PATCH /admin/agents/:id`. All fields optional; empty patch is a no-op. */
442
+ const patchAgentSchema = z
443
+ .object({
444
+ display_name: z.string().min(1).max(255).optional(),
445
+ letta_agent_id: z.string().min(1).max(255).optional(),
446
+ tpm_recovery_enabled: z.boolean().optional(),
447
+ max_recovery_attempts: z.number().int().min(0).max(10).optional(),
448
+ default_retry_after_seconds: z.number().int().min(1).max(3600).optional(),
449
+ stream_idle_timeout_seconds: z.number().int().min(5).max(3600).optional(),
450
+ is_archived: z.boolean().optional(),
451
+ /** Step-12.5 — nullable to support explicit clear. */
452
+ sandbox_seed_name: z.string().max(128).nullable().optional(),
453
+ /** Step-12.5 — nullable to support explicit clear. */
454
+ fork_source_conversation_id: z.string().max(255).nullable().optional(),
455
+ })
456
+ .strict();
457
+
458
+ /**
459
+ * `PATCH /admin/agents/:id` — partial update.
460
+ *
461
+ * If `letta_agent_id` is in the patch, the same Letta-side validation runs
462
+ * as on create. `is_archived` is the archive / unarchive toggle (no separate
463
+ * endpoint). All fields optional; an empty patch (`{}`) is a 200 no-op that
464
+ * echoes the row unchanged.
465
+ *
466
+ * @see SPECIFICATIONS.md §6.3.4
467
+ * @see ui-sketches/06-admin-agents.md
468
+ */
469
+ export const handlePatchAgent: RouteHandler = protectedRoute(
470
+ { requiredRole: "power_user" },
471
+ async (req, ctx, auth, params) => {
472
+ const id = params.id;
473
+ if (!id) return notFound();
474
+ let raw: unknown;
475
+ try {
476
+ raw = await req.json();
477
+ } catch {
478
+ return validationError("Request body must be valid JSON.");
479
+ }
480
+ const parsed = patchAgentSchema.safeParse(raw);
481
+ if (!parsed.success) {
482
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
483
+ }
484
+ const body = parsed.data;
485
+
486
+ const existing = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
487
+ if (!existing) return notFound("Agent not found.");
488
+
489
+ if (body.letta_agent_id !== undefined && body.letta_agent_id !== existing.lettaAgentId) {
490
+ const ok = await validateLettaAgentId(ctx, body.letta_agent_id);
491
+ if (!ok) {
492
+ return errorResponse(
493
+ 400,
494
+ "letta_agent_not_found",
495
+ "No Letta agent with that id was found.",
496
+ );
497
+ }
498
+ }
499
+
500
+ // Step-12.5 — validate the specialized fields against the
501
+ // *effective* letta_agent_id (the patched value when supplied,
502
+ // otherwise the existing one). Only validates fields touched by
503
+ // this PATCH; unchanged fields are skipped.
504
+ const effectiveLettaAgentId = body.letta_agent_id ?? existing.lettaAgentId;
505
+ const seedTouched = body.sandbox_seed_name !== undefined;
506
+ const forkTouched = body.fork_source_conversation_id !== undefined;
507
+ if (seedTouched || forkTouched) {
508
+ const specialized = await validateSpecializedFields(ctx, {
509
+ sandboxSeedName: seedTouched ? body.sandbox_seed_name : null,
510
+ forkSourceConversationId: forkTouched
511
+ ? body.fork_source_conversation_id
512
+ : null,
513
+ expectedLettaAgentId: effectiveLettaAgentId,
514
+ });
515
+ if (specialized.kind !== "ok") {
516
+ return respondSpecializedFailure(specialized);
517
+ }
518
+ }
519
+
520
+ const update: Partial<typeof agent.$inferInsert> = { updatedAt: nowIso() };
521
+ if (body.display_name !== undefined) update.displayName = body.display_name;
522
+ if (body.letta_agent_id !== undefined) update.lettaAgentId = body.letta_agent_id;
523
+ if (body.tpm_recovery_enabled !== undefined)
524
+ update.tpmRecoveryEnabled = body.tpm_recovery_enabled;
525
+ if (body.max_recovery_attempts !== undefined)
526
+ update.maxRecoveryAttempts = body.max_recovery_attempts;
527
+ if (body.default_retry_after_seconds !== undefined)
528
+ update.defaultRetryAfterSeconds = body.default_retry_after_seconds;
529
+ if (body.stream_idle_timeout_seconds !== undefined)
530
+ update.streamIdleTimeoutSeconds = body.stream_idle_timeout_seconds;
531
+ if (body.is_archived !== undefined) update.isArchived = body.is_archived;
532
+ // Empty strings + null both normalize to null (consistent with create).
533
+ if (body.sandbox_seed_name !== undefined) {
534
+ update.sandboxSeedName =
535
+ body.sandbox_seed_name && body.sandbox_seed_name.length > 0
536
+ ? body.sandbox_seed_name
537
+ : null;
538
+ }
539
+ if (body.fork_source_conversation_id !== undefined) {
540
+ update.forkSourceConversationId =
541
+ body.fork_source_conversation_id &&
542
+ body.fork_source_conversation_id.length > 0
543
+ ? body.fork_source_conversation_id
544
+ : null;
545
+ }
546
+
547
+ ctx.db.update(agent).set(update).where(eq(agent.id, id)).run();
548
+ const fresh = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
549
+ if (!fresh) return notFound();
550
+
551
+ // If the patch re-pointed the ai-hub agent at a different Letta agent,
552
+ // seed the new agent's toolset preference too. (We deliberately do NOT
553
+ // remove the old agent's seeded record — it may still be backing live
554
+ // sessions per the §11 schema note: existing sessions keep their
555
+ // original `letta_conversation_id` against the original Letta agent.)
556
+ if (
557
+ body.letta_agent_id !== undefined &&
558
+ body.letta_agent_id !== existing.lettaAgentId
559
+ ) {
560
+ seedToolsetNoneBestEffort(ctx, body.letta_agent_id);
561
+ }
562
+
563
+ ctx.logger.info(
564
+ {
565
+ actor_id: auth.user.id,
566
+ agent_id: id,
567
+ patch_keys: Object.keys(body),
568
+ event: "admin_agent_patched",
569
+ },
570
+ "Admin updated an ai-hub agent.",
571
+ );
572
+ return jsonResponse(200, adminAgentView(fresh));
573
+ },
574
+ );