@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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin/rotating-messages/*` — power_user + admin + super_admin management of
|
|
3
|
+
* **rotating starting messages** (auto-refreshing, forkable base conversations).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `/admin/agents` conventions (Zod `.strict()`, `power_user`+ gate,
|
|
6
|
+
* snake_case wire view, archive-only — no DELETE):
|
|
7
|
+
*
|
|
8
|
+
* - `GET /admin/rotating-messages` — every row, archived included.
|
|
9
|
+
* - `POST /admin/rotating-messages` — create; validates `letta_agent_id`
|
|
10
|
+
* against Letta + the schedule shape. Returns the row including its
|
|
11
|
+
* `rmsg-…` `id` — the identifier callers paste into an agent's Base
|
|
12
|
+
* conversation field. `next_rotation_at` is set to now so the first
|
|
13
|
+
* rotation fires on the next scheduler tick.
|
|
14
|
+
* - `PATCH /admin/rotating-messages/:id` — partial update; recomputes
|
|
15
|
+
* `next_rotation_at` when the schedule changes; re-validates the agent when
|
|
16
|
+
* it changes; `is_archived` toggle.
|
|
17
|
+
* - `POST /admin/rotating-messages/:id/rotate-now` — runs a rotation inline
|
|
18
|
+
* (preview / on-demand refresh) and returns the refreshed row.
|
|
19
|
+
*
|
|
20
|
+
* @see docs/server/rotating-messages.md
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
import { asc, eq } from "drizzle-orm";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
|
|
28
|
+
import { protectedRoute } from "../../auth/middleware.js";
|
|
29
|
+
import { rotatingMessage } from "../../db/schema.js";
|
|
30
|
+
import type { RotatingMessageRow } from "../../db/schema.js";
|
|
31
|
+
import {
|
|
32
|
+
errorResponse,
|
|
33
|
+
jsonResponse,
|
|
34
|
+
notFound,
|
|
35
|
+
validationError,
|
|
36
|
+
} from "../../handlers/responses.js";
|
|
37
|
+
import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
|
|
38
|
+
import {
|
|
39
|
+
computeNextRotation,
|
|
40
|
+
runRotation,
|
|
41
|
+
validateScheduleFields,
|
|
42
|
+
type RotationSchedule,
|
|
43
|
+
} from "../../state/rotationRunner.js";
|
|
44
|
+
|
|
45
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
46
|
+
/* Helpers */
|
|
47
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
48
|
+
|
|
49
|
+
/** Wire-format view of a `rotating_message` row for admin consumption. */
|
|
50
|
+
function rotatingMessageView(row: RotatingMessageRow): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
id: row.id,
|
|
53
|
+
display_name: row.displayName,
|
|
54
|
+
prompt: row.prompt,
|
|
55
|
+
letta_agent_id: row.lettaAgentId,
|
|
56
|
+
schedule_kind: row.scheduleKind,
|
|
57
|
+
interval_seconds: row.intervalSeconds,
|
|
58
|
+
daily_time: row.dailyTime,
|
|
59
|
+
cron_expression: row.cronExpression,
|
|
60
|
+
current_conversation_id: row.currentConversationId,
|
|
61
|
+
last_status: row.lastStatus,
|
|
62
|
+
last_error: row.lastError,
|
|
63
|
+
last_rotated_at: row.lastRotatedAt,
|
|
64
|
+
next_rotation_at: row.nextRotationAt,
|
|
65
|
+
is_archived: row.isArchived,
|
|
66
|
+
created_at: row.createdAt,
|
|
67
|
+
updated_at: row.updatedAt,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** ISO-8601 timestamp with millisecond precision. */
|
|
72
|
+
function nowIso(): string {
|
|
73
|
+
return new Date().toISOString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Validates that `lettaAgentId` resolves to a live Letta agent. */
|
|
77
|
+
async function lettaAgentExists(
|
|
78
|
+
ctx: AIHubServerContext,
|
|
79
|
+
lettaAgentId: string,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
return (await ctx.lettaClient.retrieveAgent(lettaAgentId)) !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reduces a schedule config to exactly one populated field for the chosen
|
|
86
|
+
* kind (the others forced to null), so storage matches the discriminator.
|
|
87
|
+
*/
|
|
88
|
+
function normalizeSchedule(s: RotationSchedule): RotationSchedule {
|
|
89
|
+
return {
|
|
90
|
+
scheduleKind: s.scheduleKind,
|
|
91
|
+
intervalSeconds: s.scheduleKind === "every" ? s.intervalSeconds : null,
|
|
92
|
+
dailyTime: s.scheduleKind === "daily" ? s.dailyTime : null,
|
|
93
|
+
cronExpression: s.scheduleKind === "cron" ? s.cronExpression : null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
98
|
+
/* GET /admin/rotating-messages */
|
|
99
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `GET /admin/rotating-messages` — every rotating message, archived included.
|
|
103
|
+
* Sorted by `display_name`. `power_user`+ per the §8.2 matrix.
|
|
104
|
+
*/
|
|
105
|
+
export const handleListRotatingMessages: RouteHandler = protectedRoute(
|
|
106
|
+
{ requiredRole: "power_user" },
|
|
107
|
+
async (_req, ctx) => {
|
|
108
|
+
const rows = ctx.db
|
|
109
|
+
.select()
|
|
110
|
+
.from(rotatingMessage)
|
|
111
|
+
.orderBy(asc(rotatingMessage.displayName))
|
|
112
|
+
.all();
|
|
113
|
+
return jsonResponse(200, { rotating_messages: rows.map(rotatingMessageView) });
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
118
|
+
/* POST /admin/rotating-messages */
|
|
119
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
120
|
+
|
|
121
|
+
const scheduleKindSchema = z.enum(["every", "daily", "cron"]);
|
|
122
|
+
|
|
123
|
+
/** Body for `POST /admin/rotating-messages`. */
|
|
124
|
+
const createSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
display_name: z.string().min(1).max(255),
|
|
127
|
+
prompt: z.string().min(1).max(10_000),
|
|
128
|
+
letta_agent_id: z.string().min(1).max(255),
|
|
129
|
+
schedule_kind: scheduleKindSchema,
|
|
130
|
+
interval_seconds: z.number().int().nullable().optional(),
|
|
131
|
+
daily_time: z.string().max(5).nullable().optional(),
|
|
132
|
+
cron_expression: z.string().max(255).nullable().optional(),
|
|
133
|
+
})
|
|
134
|
+
.strict();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `POST /admin/rotating-messages` — create a rotating starting message.
|
|
138
|
+
*
|
|
139
|
+
* Validation order mirrors `/admin/agents`: Letta agent first (so a bad agent
|
|
140
|
+
* id surfaces alone), then the schedule shape.
|
|
141
|
+
*/
|
|
142
|
+
export const handleCreateRotatingMessage: RouteHandler = protectedRoute(
|
|
143
|
+
{ requiredRole: "power_user" },
|
|
144
|
+
async (req, ctx, auth) => {
|
|
145
|
+
let raw: unknown;
|
|
146
|
+
try {
|
|
147
|
+
raw = await req.json();
|
|
148
|
+
} catch {
|
|
149
|
+
return validationError("Request body must be valid JSON.");
|
|
150
|
+
}
|
|
151
|
+
const parsed = createSchema.safeParse(raw);
|
|
152
|
+
if (!parsed.success) {
|
|
153
|
+
return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
|
|
154
|
+
}
|
|
155
|
+
const body = parsed.data;
|
|
156
|
+
|
|
157
|
+
if (!(await lettaAgentExists(ctx, body.letta_agent_id))) {
|
|
158
|
+
return errorResponse(400, "letta_agent_not_found", "No Letta agent with that id was found.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const schedule = normalizeSchedule({
|
|
162
|
+
scheduleKind: body.schedule_kind,
|
|
163
|
+
intervalSeconds: body.interval_seconds ?? null,
|
|
164
|
+
dailyTime: body.daily_time ?? null,
|
|
165
|
+
cronExpression: body.cron_expression ?? null,
|
|
166
|
+
});
|
|
167
|
+
const scheduleCheck = validateScheduleFields(schedule);
|
|
168
|
+
if (!scheduleCheck.ok) {
|
|
169
|
+
return errorResponse(400, "invalid_schedule", scheduleCheck.message);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const id = `rmsg-${randomUUID()}`;
|
|
173
|
+
const ts = nowIso();
|
|
174
|
+
ctx.db
|
|
175
|
+
.insert(rotatingMessage)
|
|
176
|
+
.values({
|
|
177
|
+
id,
|
|
178
|
+
displayName: body.display_name,
|
|
179
|
+
prompt: body.prompt,
|
|
180
|
+
lettaAgentId: body.letta_agent_id,
|
|
181
|
+
scheduleKind: schedule.scheduleKind,
|
|
182
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
183
|
+
dailyTime: schedule.dailyTime,
|
|
184
|
+
cronExpression: schedule.cronExpression,
|
|
185
|
+
currentConversationId: null,
|
|
186
|
+
lastStatus: "pending",
|
|
187
|
+
lastError: null,
|
|
188
|
+
lastRotatedAt: null,
|
|
189
|
+
// Due immediately — the scheduler picks it up on the next tick.
|
|
190
|
+
nextRotationAt: ts,
|
|
191
|
+
isArchived: false,
|
|
192
|
+
createdAt: ts,
|
|
193
|
+
updatedAt: ts,
|
|
194
|
+
})
|
|
195
|
+
.run();
|
|
196
|
+
const row = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();
|
|
197
|
+
if (!row) throw new Error("Just-inserted rotating message vanished.");
|
|
198
|
+
|
|
199
|
+
ctx.logger.info(
|
|
200
|
+
{
|
|
201
|
+
actor_id: auth.user.id,
|
|
202
|
+
rotating_message_id: id,
|
|
203
|
+
letta_agent_id: body.letta_agent_id,
|
|
204
|
+
event: "admin_rotating_message_created",
|
|
205
|
+
},
|
|
206
|
+
"Admin created a rotating starting message.",
|
|
207
|
+
);
|
|
208
|
+
return jsonResponse(201, rotatingMessageView(row));
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
213
|
+
/* PATCH /admin/rotating-messages/:id */
|
|
214
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
215
|
+
|
|
216
|
+
/** Body for `PATCH /admin/rotating-messages/:id`. All fields optional. */
|
|
217
|
+
const patchSchema = z
|
|
218
|
+
.object({
|
|
219
|
+
display_name: z.string().min(1).max(255).optional(),
|
|
220
|
+
prompt: z.string().min(1).max(10_000).optional(),
|
|
221
|
+
letta_agent_id: z.string().min(1).max(255).optional(),
|
|
222
|
+
schedule_kind: scheduleKindSchema.optional(),
|
|
223
|
+
interval_seconds: z.number().int().nullable().optional(),
|
|
224
|
+
daily_time: z.string().max(5).nullable().optional(),
|
|
225
|
+
cron_expression: z.string().max(255).nullable().optional(),
|
|
226
|
+
is_archived: z.boolean().optional(),
|
|
227
|
+
})
|
|
228
|
+
.strict();
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `PATCH /admin/rotating-messages/:id` — partial update.
|
|
232
|
+
*
|
|
233
|
+
* When any schedule-related field is present, the effective schedule (existing
|
|
234
|
+
* values overlaid with the patch) is re-validated and `next_rotation_at` is
|
|
235
|
+
* recomputed from now. When `letta_agent_id` changes it's re-validated against
|
|
236
|
+
* Letta.
|
|
237
|
+
*/
|
|
238
|
+
export const handlePatchRotatingMessage: RouteHandler = protectedRoute(
|
|
239
|
+
{ requiredRole: "power_user" },
|
|
240
|
+
async (req, ctx, auth, params) => {
|
|
241
|
+
const id = params.id;
|
|
242
|
+
if (!id) return notFound();
|
|
243
|
+
let raw: unknown;
|
|
244
|
+
try {
|
|
245
|
+
raw = await req.json();
|
|
246
|
+
} catch {
|
|
247
|
+
return validationError("Request body must be valid JSON.");
|
|
248
|
+
}
|
|
249
|
+
const parsed = patchSchema.safeParse(raw);
|
|
250
|
+
if (!parsed.success) {
|
|
251
|
+
return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
|
|
252
|
+
}
|
|
253
|
+
const body = parsed.data;
|
|
254
|
+
|
|
255
|
+
const existing = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();
|
|
256
|
+
if (!existing) return notFound("Rotating message not found.");
|
|
257
|
+
|
|
258
|
+
if (body.letta_agent_id !== undefined && body.letta_agent_id !== existing.lettaAgentId) {
|
|
259
|
+
if (!(await lettaAgentExists(ctx, body.letta_agent_id))) {
|
|
260
|
+
return errorResponse(400, "letta_agent_not_found", "No Letta agent with that id was found.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const update: Partial<typeof rotatingMessage.$inferInsert> = { updatedAt: nowIso() };
|
|
265
|
+
if (body.display_name !== undefined) update.displayName = body.display_name;
|
|
266
|
+
if (body.prompt !== undefined) update.prompt = body.prompt;
|
|
267
|
+
if (body.letta_agent_id !== undefined) update.lettaAgentId = body.letta_agent_id;
|
|
268
|
+
if (body.is_archived !== undefined) update.isArchived = body.is_archived;
|
|
269
|
+
|
|
270
|
+
const scheduleTouched =
|
|
271
|
+
body.schedule_kind !== undefined ||
|
|
272
|
+
body.interval_seconds !== undefined ||
|
|
273
|
+
body.daily_time !== undefined ||
|
|
274
|
+
body.cron_expression !== undefined;
|
|
275
|
+
if (scheduleTouched) {
|
|
276
|
+
const effective = normalizeSchedule({
|
|
277
|
+
scheduleKind: body.schedule_kind ?? existing.scheduleKind,
|
|
278
|
+
intervalSeconds:
|
|
279
|
+
body.interval_seconds !== undefined ? body.interval_seconds : existing.intervalSeconds,
|
|
280
|
+
dailyTime: body.daily_time !== undefined ? body.daily_time : existing.dailyTime,
|
|
281
|
+
cronExpression:
|
|
282
|
+
body.cron_expression !== undefined ? body.cron_expression : existing.cronExpression,
|
|
283
|
+
});
|
|
284
|
+
const scheduleCheck = validateScheduleFields(effective);
|
|
285
|
+
if (!scheduleCheck.ok) {
|
|
286
|
+
return errorResponse(400, "invalid_schedule", scheduleCheck.message);
|
|
287
|
+
}
|
|
288
|
+
update.scheduleKind = effective.scheduleKind;
|
|
289
|
+
update.intervalSeconds = effective.intervalSeconds;
|
|
290
|
+
update.dailyTime = effective.dailyTime;
|
|
291
|
+
update.cronExpression = effective.cronExpression;
|
|
292
|
+
update.nextRotationAt = computeNextRotation(effective, new Date());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
ctx.db.update(rotatingMessage).set(update).where(eq(rotatingMessage.id, id)).run();
|
|
296
|
+
const fresh = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();
|
|
297
|
+
if (!fresh) return notFound();
|
|
298
|
+
|
|
299
|
+
ctx.logger.info(
|
|
300
|
+
{
|
|
301
|
+
actor_id: auth.user.id,
|
|
302
|
+
rotating_message_id: id,
|
|
303
|
+
patch_keys: Object.keys(body),
|
|
304
|
+
event: "admin_rotating_message_patched",
|
|
305
|
+
},
|
|
306
|
+
"Admin updated a rotating starting message.",
|
|
307
|
+
);
|
|
308
|
+
return jsonResponse(200, rotatingMessageView(fresh));
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
313
|
+
/* POST /admin/rotating-messages/:id/rotate-now */
|
|
314
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `POST /admin/rotating-messages/:id/rotate-now` — runs a rotation inline so
|
|
318
|
+
* the operator can preview / force-refresh without waiting for the schedule.
|
|
319
|
+
* Returns the refreshed row plus the attempt's status. The run may take a
|
|
320
|
+
* while (it spawns the Letta Code subprocess like a real turn).
|
|
321
|
+
*/
|
|
322
|
+
export const handleRotateRotatingMessageNow: RouteHandler = protectedRoute(
|
|
323
|
+
{ requiredRole: "power_user" },
|
|
324
|
+
async (_req, ctx, auth, params) => {
|
|
325
|
+
const id = params.id;
|
|
326
|
+
if (!id) return notFound();
|
|
327
|
+
const row = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();
|
|
328
|
+
if (!row) return notFound("Rotating message not found.");
|
|
329
|
+
|
|
330
|
+
const outcome = await runRotation(
|
|
331
|
+
{ db: ctx.db, lettaClient: ctx.lettaClient, logger: ctx.logger },
|
|
332
|
+
row,
|
|
333
|
+
);
|
|
334
|
+
const fresh = ctx.db.select().from(rotatingMessage).where(eq(rotatingMessage.id, id)).get();
|
|
335
|
+
if (!fresh) return notFound();
|
|
336
|
+
|
|
337
|
+
ctx.logger.info(
|
|
338
|
+
{
|
|
339
|
+
actor_id: auth.user.id,
|
|
340
|
+
rotating_message_id: id,
|
|
341
|
+
outcome: outcome.status,
|
|
342
|
+
event: "admin_rotating_message_rotate_now",
|
|
343
|
+
},
|
|
344
|
+
"Admin triggered an on-demand rotation.",
|
|
345
|
+
);
|
|
346
|
+
return jsonResponse(200, {
|
|
347
|
+
rotating_message: rotatingMessageView(fresh),
|
|
348
|
+
outcome,
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
);
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin/sessions` — read-only sessions overview (power_user + admin +
|
|
3
|
+
* super_admin).
|
|
4
|
+
*
|
|
5
|
+
* Cross-user browsing of every session in the deployment. Useful for
|
|
6
|
+
* operators debugging agent behavior or quoting recent conversations to
|
|
7
|
+
* support a customer ([07-admin-sessions-overview.md]).
|
|
8
|
+
*
|
|
9
|
+
* Query parameters:
|
|
10
|
+
*
|
|
11
|
+
* - `owner_id` — optional user-id filter.
|
|
12
|
+
* - `agent_id` — optional ai-hub agent-id filter.
|
|
13
|
+
* - `status` — optional, one of `"active" | "archived" | "all"`. Default
|
|
14
|
+
* `"active"`. Mirrors the dropdown in [07-admin-sessions-overview.md]; no
|
|
15
|
+
* other values are accepted (turn-level outcomes like failed/cancelled are
|
|
16
|
+
* not surfaced as a session-level filter in v1).
|
|
17
|
+
* - `search` — optional free-text. Matched (case-insensitive) against the
|
|
18
|
+
* session title server-side.
|
|
19
|
+
* - `cursor` — pagination cursor (opaque base64 of `{updated_at, id}`).
|
|
20
|
+
* - `limit` — page size; default 50, max 200.
|
|
21
|
+
*
|
|
22
|
+
* Response: `{ sessions: [...], next_cursor: string | null }`. Each session
|
|
23
|
+
* carries the owner's username + the agent's display name in addition to the
|
|
24
|
+
* raw row, so the admin table can render labels without a follow-up join
|
|
25
|
+
* round-trip.
|
|
26
|
+
*
|
|
27
|
+
* @see SPECIFICATIONS.md §6.3.4 — admin sessions endpoint
|
|
28
|
+
* @see ui-sketches/07-admin-sessions-overview.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { and, asc, desc, eq, like, lt, or, sql } from "drizzle-orm";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
|
|
34
|
+
import { protectedRoute } from "../../auth/middleware.js";
|
|
35
|
+
import { agent, session, turn, user } from "../../db/schema.js";
|
|
36
|
+
import {
|
|
37
|
+
conflictError,
|
|
38
|
+
internalError,
|
|
39
|
+
jsonResponse,
|
|
40
|
+
notFound,
|
|
41
|
+
validationError,
|
|
42
|
+
} from "../../handlers/responses.js";
|
|
43
|
+
import type { RouteHandler } from "../../handlers/types.js";
|
|
44
|
+
import { purgeSandbox } from "../../letta/sandbox.js";
|
|
45
|
+
|
|
46
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
47
|
+
/* Pagination cursor */
|
|
48
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
/** Opaque cursor — base64 of `${updated_at}|${id}`. */
|
|
51
|
+
interface ParsedCursor {
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
id: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Encodes a cursor.
|
|
58
|
+
*
|
|
59
|
+
* @param updatedAt - the row's `updated_at`.
|
|
60
|
+
* @param id - the row's id.
|
|
61
|
+
* @returns the opaque cursor string.
|
|
62
|
+
*/
|
|
63
|
+
function encodeCursor(updatedAt: string, id: string): string {
|
|
64
|
+
return Buffer.from(`${updatedAt}|${id}`, "utf8").toString("base64url");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decodes a cursor. Returns `null` on a malformed input — the handler treats
|
|
69
|
+
* that as "no cursor" (no enumeration leakage; bad cursors just yield the
|
|
70
|
+
* first page).
|
|
71
|
+
*
|
|
72
|
+
* @param raw - the cursor query-param value.
|
|
73
|
+
* @returns the parsed cursor or `null`.
|
|
74
|
+
*/
|
|
75
|
+
function decodeCursor(raw: string | null): ParsedCursor | null {
|
|
76
|
+
if (!raw) return null;
|
|
77
|
+
try {
|
|
78
|
+
const decoded = Buffer.from(raw, "base64url").toString("utf8");
|
|
79
|
+
const sep = decoded.indexOf("|");
|
|
80
|
+
if (sep === -1) return null;
|
|
81
|
+
return {
|
|
82
|
+
updatedAt: decoded.slice(0, sep),
|
|
83
|
+
id: decoded.slice(sep + 1),
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
91
|
+
/* Handler */
|
|
92
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
93
|
+
|
|
94
|
+
/** Allowed values for the `status` filter — see file-level comment. */
|
|
95
|
+
const statusEnum = z.enum(["active", "archived", "all"]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `GET /admin/sessions` — paginated sessions overview.
|
|
99
|
+
*
|
|
100
|
+
* Power_user + above per §8.2. Returns `{ sessions, next_cursor }`. Each row
|
|
101
|
+
* is joined to its owner + agent so the admin table can render names without
|
|
102
|
+
* follow-up requests.
|
|
103
|
+
*
|
|
104
|
+
* @see SPECIFICATIONS.md §6.3.4
|
|
105
|
+
* @see ui-sketches/07-admin-sessions-overview.md
|
|
106
|
+
*/
|
|
107
|
+
export const handleListSessionsOverview: RouteHandler = protectedRoute(
|
|
108
|
+
{ requiredRole: "power_user" },
|
|
109
|
+
async (req, ctx) => {
|
|
110
|
+
const url = new URL(req.url);
|
|
111
|
+
const ownerId = url.searchParams.get("owner_id");
|
|
112
|
+
const agentId = url.searchParams.get("agent_id");
|
|
113
|
+
const statusRaw = url.searchParams.get("status") ?? "active";
|
|
114
|
+
const statusParsed = statusEnum.safeParse(statusRaw);
|
|
115
|
+
if (!statusParsed.success) {
|
|
116
|
+
return validationError(
|
|
117
|
+
"status must be one of: active, archived, all.",
|
|
118
|
+
"invalid_status",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const status = statusParsed.data;
|
|
122
|
+
const search = url.searchParams.get("search")?.trim() ?? "";
|
|
123
|
+
const limitParam = url.searchParams.get("limit");
|
|
124
|
+
let limit = 50;
|
|
125
|
+
if (limitParam !== null) {
|
|
126
|
+
const n = Number.parseInt(limitParam, 10);
|
|
127
|
+
if (!Number.isFinite(n) || n < 1 || n > 200) {
|
|
128
|
+
return validationError(
|
|
129
|
+
"limit must be an integer in [1, 200].",
|
|
130
|
+
"invalid_limit",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
limit = n;
|
|
134
|
+
}
|
|
135
|
+
const cursor = decodeCursor(url.searchParams.get("cursor"));
|
|
136
|
+
|
|
137
|
+
const conditions = [];
|
|
138
|
+
if (ownerId) conditions.push(eq(session.userId, ownerId));
|
|
139
|
+
if (agentId) conditions.push(eq(session.agentId, agentId));
|
|
140
|
+
if (status === "active") conditions.push(eq(session.isArchived, false));
|
|
141
|
+
if (status === "archived") conditions.push(eq(session.isArchived, true));
|
|
142
|
+
if (search.length > 0) {
|
|
143
|
+
conditions.push(like(sql`lower(${session.title})`, `%${search.toLowerCase()}%`));
|
|
144
|
+
}
|
|
145
|
+
if (cursor) {
|
|
146
|
+
// Strictly after `(updated_at, id)` in descending order.
|
|
147
|
+
conditions.push(
|
|
148
|
+
or(
|
|
149
|
+
lt(session.updatedAt, cursor.updatedAt),
|
|
150
|
+
and(eq(session.updatedAt, cursor.updatedAt), lt(session.id, cursor.id)),
|
|
151
|
+
)!,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rows = ctx.db
|
|
156
|
+
.select({
|
|
157
|
+
sessionId: session.id,
|
|
158
|
+
sessionUserId: session.userId,
|
|
159
|
+
sessionAgentId: session.agentId,
|
|
160
|
+
sessionTitle: session.title,
|
|
161
|
+
sessionIsArchived: session.isArchived,
|
|
162
|
+
sessionSandboxDir: session.sandboxDir,
|
|
163
|
+
sessionCreatedAt: session.createdAt,
|
|
164
|
+
sessionUpdatedAt: session.updatedAt,
|
|
165
|
+
ownerUsername: user.username,
|
|
166
|
+
agentDisplayName: agent.displayName,
|
|
167
|
+
})
|
|
168
|
+
.from(session)
|
|
169
|
+
.innerJoin(user, eq(user.id, session.userId))
|
|
170
|
+
.innerJoin(agent, eq(agent.id, session.agentId))
|
|
171
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
172
|
+
.orderBy(desc(session.updatedAt), asc(session.id))
|
|
173
|
+
.limit(limit + 1)
|
|
174
|
+
.all();
|
|
175
|
+
|
|
176
|
+
const hasMore = rows.length > limit;
|
|
177
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
178
|
+
const nextCursor =
|
|
179
|
+
hasMore && page.length > 0
|
|
180
|
+
? encodeCursor(
|
|
181
|
+
page[page.length - 1]!.sessionUpdatedAt,
|
|
182
|
+
page[page.length - 1]!.sessionId,
|
|
183
|
+
)
|
|
184
|
+
: null;
|
|
185
|
+
|
|
186
|
+
return jsonResponse(200, {
|
|
187
|
+
sessions: page.map((r) => ({
|
|
188
|
+
id: r.sessionId,
|
|
189
|
+
user_id: r.sessionUserId,
|
|
190
|
+
agent_id: r.sessionAgentId,
|
|
191
|
+
title: r.sessionTitle,
|
|
192
|
+
is_archived: r.sessionIsArchived,
|
|
193
|
+
// Step-12.5 — surfaces whether the session has a sandbox folder
|
|
194
|
+
// to purge. We deliberately don't surface the absolute path (it
|
|
195
|
+
// leaks the server's filesystem layout); the boolean is enough
|
|
196
|
+
// for the React UI to decide whether to render the button.
|
|
197
|
+
has_sandbox: r.sessionSandboxDir !== null,
|
|
198
|
+
created_at: r.sessionCreatedAt,
|
|
199
|
+
updated_at: r.sessionUpdatedAt,
|
|
200
|
+
owner_username: r.ownerUsername,
|
|
201
|
+
agent_display_name: r.agentDisplayName,
|
|
202
|
+
})),
|
|
203
|
+
next_cursor: nextCursor,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
209
|
+
/* POST /admin/sessions/:id/purge-sandbox — Step 12.5 */
|
|
210
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `POST /admin/sessions/:id/purge-sandbox` — super_admin only.
|
|
214
|
+
*
|
|
215
|
+
* Removes the per-session sandbox folder from disk + clears
|
|
216
|
+
* `session.sandbox_dir`. Idempotent: a 204 is returned both when the
|
|
217
|
+
* folder was present (and is now gone) and when the session never had a
|
|
218
|
+
* sandbox in the first place.
|
|
219
|
+
*
|
|
220
|
+
* Rejects with `409 turn_running` if any turn for the session is
|
|
221
|
+
* currently `status = "running"` — purging the cwd out from under a
|
|
222
|
+
* live subprocess would be unfriendly to the agent and risks
|
|
223
|
+
* partial-write debris.
|
|
224
|
+
*
|
|
225
|
+
* Subsequent chat turns spawn with no `cwd` and the with-no-sandbox
|
|
226
|
+
* disallow-list, reverting the session to today's stateless shape.
|
|
227
|
+
*
|
|
228
|
+
* @see docs/server/session-sandboxes.md
|
|
229
|
+
*/
|
|
230
|
+
export const handlePurgeAdminSessionSandbox: RouteHandler = protectedRoute(
|
|
231
|
+
{ requiredRole: "super_admin" },
|
|
232
|
+
async (_req, ctx, _auth, params) => {
|
|
233
|
+
const id = params.id;
|
|
234
|
+
if (!id) return notFound();
|
|
235
|
+
const row = ctx.db.select().from(session).where(eq(session.id, id)).get();
|
|
236
|
+
if (!row) return notFound();
|
|
237
|
+
|
|
238
|
+
// Reject while a turn is live — purging cwd mid-spawn would corrupt
|
|
239
|
+
// the running agent.
|
|
240
|
+
const liveTurn = ctx.db
|
|
241
|
+
.select({ id: turn.id })
|
|
242
|
+
.from(turn)
|
|
243
|
+
.where(and(eq(turn.sessionId, id), eq(turn.status, "running")))
|
|
244
|
+
.get();
|
|
245
|
+
if (liveTurn) {
|
|
246
|
+
return conflictError(
|
|
247
|
+
"turn_running",
|
|
248
|
+
"Cannot purge sandbox while a turn is running.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Idempotent: 204 whether or not the column had a value.
|
|
253
|
+
if (row.sandboxDir) {
|
|
254
|
+
try {
|
|
255
|
+
await purgeSandbox(row.sandboxDir);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
ctx.logger.error(
|
|
258
|
+
{
|
|
259
|
+
event: "session_sandbox_purge_failed",
|
|
260
|
+
session_id: id,
|
|
261
|
+
sandbox_dir: row.sandboxDir,
|
|
262
|
+
err: err instanceof Error ? err.message : String(err),
|
|
263
|
+
},
|
|
264
|
+
"Failed to purge per-session sandbox folder.",
|
|
265
|
+
);
|
|
266
|
+
return internalError("Failed to purge sandbox.");
|
|
267
|
+
}
|
|
268
|
+
ctx.db
|
|
269
|
+
.update(session)
|
|
270
|
+
.set({ sandboxDir: null, updatedAt: new Date().toISOString() })
|
|
271
|
+
.where(eq(session.id, id))
|
|
272
|
+
.run();
|
|
273
|
+
ctx.logger.info(
|
|
274
|
+
{
|
|
275
|
+
event: "session_sandbox_purged",
|
|
276
|
+
session_id: id,
|
|
277
|
+
},
|
|
278
|
+
"Per-session sandbox folder purged.",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return new Response(null, { status: 204 });
|
|
282
|
+
},
|
|
283
|
+
);
|