@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.
- package/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- 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
|
+
}
|