Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

claude-web-session.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

claude-web-session.tsBlame427 lines · 1 contributor
90c7531Claude1/**
2 * Claude-on-the-web runtime (Block CW).
3 *
4 * Drives one turn of an interactive Claude Code session running on the
5 * gluecron web server. Each turn:
6 * 1. Resolves (and lazily clones) the session's working directory.
7 * 2. Spawns `claude` as a subprocess pointed at that workdir, with
8 * --resume <session-uuid> for follow-up turns so prior context is
9 * preserved without us re-sending the transcript.
10 * 3. Streams stdout chunks back via an AsyncIterable the route layer
11 * hands to an SSE response.
12 * 4. Persists the assistant turn + updates the Claude session UUID +
13 * the last_active_at watermark.
14 *
15 * v1 design rules:
16 * - Admin-only — gated by the route, not this lib.
17 * - Single shared compute: every session shares the web server's CPU
18 * and disk. We cap a single turn at MAX_TURN_MS so a runaway prompt
19 * can't pin the box forever.
20 * - No container: workdir is a plain directory under CLAUDE_WEB_WORKDIR
21 * (default /var/lib/gluecron/claude-web). Caller is responsible for
22 * not pointing this at a tenant-shared filesystem.
23 * - Anthropic creds: we DO NOT pass ANTHROPIC_API_KEY through the
24 * environment if the operator has logged in `claude` interactively
25 * (the CLI manages its own creds in ~/.claude). If the env var is
26 * set, we pass it through unchanged.
27 *
28 * Test seam: `__setSpawnForTests` lets unit tests intercept the spawn
29 * without actually running the Claude CLI.
30 */
31
32import { mkdir, stat } from "fs/promises";
33import { join } from "path";
34import { db } from "../db";
35import {
36 claudeWebMessages,
37 claudeWebSessions,
38 type ClaudeWebSession,
39} from "../db/schema";
40import { and, eq } from "drizzle-orm";
41import { getRepoPath } from "../git/repository";
42
43const MAX_TURN_MS = 5 * 60_000;
44
45export function claudeWebRoot(): string {
46 return (
47 process.env.CLAUDE_WEB_WORKDIR ||
48 "/var/lib/gluecron/claude-web"
49 ).replace(/\/$/, "");
50}
51
52export function claudeBinary(): string {
53 return process.env.CLAUDE_BIN || "claude";
54}
55
56// ---------------------------------------------------------------------------
57// Spawn seam
58// ---------------------------------------------------------------------------
59
60export interface SpawnHandle {
61 /** Async iterable of utf-8 stdout chunks. */
62 stdout: AsyncIterable<string>;
63 /** Resolves to the final exit code + collected stderr after stdout ends. */
64 done: Promise<{ exitCode: number; stderr: string }>;
65 /** Kills the underlying subprocess. */
66 kill: () => void;
67}
68
69export type SpawnFn = (
70 cmd: string[],
71 opts: { cwd: string; env: Record<string, string> }
72) => SpawnHandle;
73
74const defaultSpawn: SpawnFn = (cmd, opts) => {
75 const proc = Bun.spawn(cmd, {
76 cwd: opts.cwd,
77 env: opts.env,
78 stdin: "ignore",
79 stdout: "pipe",
80 stderr: "pipe",
81 });
82 const stdout = (async function* () {
83 const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
84 const dec = new TextDecoder();
85 try {
86 while (true) {
87 const { done, value } = await reader.read();
88 if (done) break;
89 if (value && value.length) yield dec.decode(value, { stream: true });
90 }
91 const tail = dec.decode();
92 if (tail) yield tail;
93 } finally {
94 reader.releaseLock();
95 }
96 })();
97 const done = (async () => {
98 const [exitCode, stderr] = await Promise.all([
99 proc.exited,
100 new Response(proc.stderr).text(),
101 ]);
102 return { exitCode, stderr };
103 })();
104 return { stdout, done, kill: () => proc.kill() };
105};
106
107let _spawn: SpawnFn = defaultSpawn;
108export function __setSpawnForTests(fn: SpawnFn | null): void {
109 _spawn = fn ?? defaultSpawn;
110}
111
112// ---------------------------------------------------------------------------
113// Workdir
114// ---------------------------------------------------------------------------
115
116export function sessionWorkdir(sessionId: string): string {
117 return join(claudeWebRoot(), sessionId);
118}
119
120/**
121 * Make sure the session's working dir exists as a fresh clone of the
122 * repo's bare store at the given branch. Idempotent — if the dir already
123 * has a `.git` folder, we leave it alone and the operator's existing
124 * working state is preserved across turns.
125 *
126 * Uses `git clone --branch <branch> --depth 1 <bare> <workdir>` so the
127 * initial materialisation is fast even on huge repos. Operators can
128 * `git fetch --unshallow` from inside the session if they need history.
129 */
130export async function ensureWorkdir(
131 session: ClaudeWebSession,
132 ownerName: string,
133 repoName: string,
134 spawn: SpawnFn = _spawn
135): Promise<{ ok: true } | { ok: false; error: string }> {
136 const root = claudeWebRoot();
137 try {
138 await mkdir(root, { recursive: true });
139 } catch (err) {
140 return {
141 ok: false,
142 error: `mkdir ${root}: ${err instanceof Error ? err.message : err}`,
143 };
144 }
145 const workdir = session.workdirPath;
146 // If .git already exists in the workdir, the clone is done.
147 try {
148 const s = await stat(join(workdir, ".git"));
149 if (s.isDirectory()) return { ok: true };
150 } catch {
151 /* fall through to clone */
152 }
153
154 const bare = getRepoPath(ownerName, repoName);
155 const handle = spawn(
156 ["git", "clone", "--branch", session.branch, "--depth", "1", bare, workdir],
157 { cwd: root, env: scrubbedEnv() }
158 );
159 // Drain stdout (clone is quiet, but we still need to consume).
160 for await (const _ of handle.stdout) {
161 /* ignore */
162 }
163 const { exitCode, stderr } = await handle.done;
164 if (exitCode !== 0) {
165 return { ok: false, error: stderr.trim() || `git clone exit ${exitCode}` };
166 }
167 return { ok: true };
168}
169
170// ---------------------------------------------------------------------------
171// Turn driver
172// ---------------------------------------------------------------------------
173
174export interface TurnInput {
175 session: ClaudeWebSession;
176 ownerName: string;
177 repoName: string;
178 prompt: string;
179}
180
181export interface TurnEvent {
182 /** Streaming text chunk from Claude's stdout. */
183 chunk?: string;
184 /** Terminal event — call once, after stdout ends. */
185 done?: {
186 exitCode: number;
187 durationMs: number;
188 /** Claude CLI session UUID extracted from the run (when present). */
189 claudeSessionId?: string;
190 stderr: string;
191 };
192}
193
194/**
195 * Run a single conversational turn against Claude. Yields stdout chunks
196 * as they arrive, then a single `done` event with metadata. The caller
197 * (typically the SSE route) is responsible for serialising events onto
198 * the wire and persisting the final assistant body.
199 *
200 * Honours `MAX_TURN_MS` — if the subprocess hasn't finished by then we
201 * call kill() and emit done with exitCode=124 (matching `timeout`).
202 */
203export async function* runTurn(
204 input: TurnInput,
205 spawn: SpawnFn = _spawn
206): AsyncGenerator<TurnEvent, void, void> {
207 const start = Date.now();
208 const cmd = [
209 claudeBinary(),
210 "--print",
211 "--output-format",
212 "stream-json",
213 ...(input.session.claudeSessionId
214 ? ["--resume", input.session.claudeSessionId]
215 : []),
216 input.prompt,
217 ];
218
219 const handle = spawn(cmd, {
220 cwd: input.session.workdirPath,
221 env: passthroughEnv(),
222 });
223
224 const timer = setTimeout(() => {
225 try {
226 handle.kill();
227 } catch {
228 /* ignore */
229 }
230 }, MAX_TURN_MS);
231
232 let sessionId: string | undefined;
233 try {
234 for await (const chunk of handle.stdout) {
235 // Best-effort parse for the Claude session UUID inside stream-json
236 // events. The CLI emits a `"session_id":"<uuid>"` key on its init
237 // event; we just grep the chunk text so format drift doesn't break.
238 if (!sessionId) {
239 const m = chunk.match(/"session_id"\s*:\s*"([0-9a-f-]{32,36})"/i);
240 if (m) sessionId = m[1];
241 }
242 yield { chunk };
243 }
244 } finally {
245 clearTimeout(timer);
246 }
247
248 const { exitCode, stderr } = await handle.done;
249 yield {
250 done: {
251 exitCode,
252 durationMs: Date.now() - start,
253 claudeSessionId: sessionId,
254 stderr,
255 },
256 };
257}
258
259/**
260 * Env scrubbing for child subprocesses. Drops Postgres / SMTP / SSH
261 * creds the Claude binary doesn't need. Keeps ANTHROPIC_API_KEY, PATH,
262 * HOME, and the CLAUDE_* family.
263 */
264function passthroughEnv(): Record<string, string> {
265 const out: Record<string, string> = {};
266 const passlist = new Set([
267 "PATH",
268 "HOME",
269 "USER",
270 "LANG",
271 "LC_ALL",
272 "TERM",
273 "ANTHROPIC_API_KEY",
274 "ANTHROPIC_BASE_URL",
275 "ANTHROPIC_AUTH_TOKEN",
276 ]);
277 for (const [k, v] of Object.entries(process.env)) {
278 if (typeof v !== "string") continue;
279 if (passlist.has(k) || k.startsWith("CLAUDE_")) out[k] = v;
280 }
281 return out;
282}
283
284function scrubbedEnv(): Record<string, string> {
285 // For `git clone` we want even fewer — just PATH + HOME so git can
286 // find its config and ssh wrappers.
287 const out: Record<string, string> = {};
288 for (const k of ["PATH", "HOME", "USER", "LANG"]) {
289 if (typeof process.env[k] === "string") out[k] = process.env[k] as string;
290 }
291 // Disable any interactive prompt — clone should be non-interactive.
292 out.GIT_TERMINAL_PROMPT = "0";
293 return out;
294}
295
296// ---------------------------------------------------------------------------
297// Transcript persistence
298// ---------------------------------------------------------------------------
299
300export async function listMessages(sessionId: string) {
301 return db
302 .select()
303 .from(claudeWebMessages)
304 .where(eq(claudeWebMessages.sessionId, sessionId))
305 .orderBy(claudeWebMessages.createdAt);
306}
307
308export async function appendMessage(input: {
309 sessionId: string;
310 role: "user" | "assistant" | "system";
311 body: string;
312 exitCode?: number;
313 durationMs?: number;
314}): Promise<void> {
315 await db.insert(claudeWebMessages).values({
316 sessionId: input.sessionId,
317 role: input.role,
318 body: input.body,
319 exitCode: input.exitCode ?? null,
320 durationMs: input.durationMs ?? null,
321 });
322}
323
324export async function touchSession(input: {
325 sessionId: string;
326 claudeSessionId?: string;
327 status?: string;
328}): Promise<void> {
329 const set: Record<string, unknown> = { lastActiveAt: new Date() };
330 if (input.claudeSessionId) set.claudeSessionId = input.claudeSessionId;
331 if (input.status) set.status = input.status;
332 await db
333 .update(claudeWebSessions)
334 .set(set)
335 .where(eq(claudeWebSessions.id, input.sessionId));
336}
337
338export async function createSession(input: {
339 repositoryId: string;
340 ownerUserId: string;
341 title?: string;
342 branch?: string;
343}): Promise<ClaudeWebSession> {
344 // Insert first so we have the id for the workdir path.
345 const [row] = await db
346 .insert(claudeWebSessions)
347 .values({
348 repositoryId: input.repositoryId,
349 ownerUserId: input.ownerUserId,
350 title: input.title ?? "New session",
351 branch: input.branch ?? "main",
352 workdirPath: "pending",
353 status: "cold",
354 })
355 .returning();
356 if (!row) throw new Error("createSession: insert returned no row");
357 const workdir = sessionWorkdir(row.id);
358 const [updated] = await db
359 .update(claudeWebSessions)
360 .set({ workdirPath: workdir })
361 .where(eq(claudeWebSessions.id, row.id))
362 .returning();
363 return updated ?? { ...row, workdirPath: workdir };
364}
365
366export async function getSession(
367 id: string,
368 ownerUserId?: string
369): Promise<ClaudeWebSession | null> {
370 const conds = ownerUserId
371 ? and(
372 eq(claudeWebSessions.id, id),
373 eq(claudeWebSessions.ownerUserId, ownerUserId)
374 )
375 : eq(claudeWebSessions.id, id);
376 const [row] = await db
377 .select()
378 .from(claudeWebSessions)
379 .where(conds)
380 .limit(1);
381 return row ?? null;
382}
383
384export async function listSessionsForRepo(
385 repositoryId: string,
386 limit = 50
387): Promise<ClaudeWebSession[]> {
388 return db
389 .select()
390 .from(claudeWebSessions)
391 .where(eq(claudeWebSessions.repositoryId, repositoryId))
392 .limit(limit);
393}
394
f1dc38bClaude395/**
396 * List sessions for a specific user in a specific repo, ordered by most
397 * recently active first. Used by the customer-facing /:owner/:repo/claude
398 * page so each user sees only their own sessions.
399 */
400export async function listSessionsForUser(
401 repositoryId: string,
402 ownerUserId: string,
403 limit = 50
404): Promise<ClaudeWebSession[]> {
405 return db
406 .select()
407 .from(claudeWebSessions)
408 .where(
409 and(
410 eq(claudeWebSessions.repositoryId, repositoryId),
411 eq(claudeWebSessions.ownerUserId, ownerUserId)
412 )
413 )
414 .orderBy(claudeWebSessions.lastActiveAt)
415 .limit(limit);
416}
417
90c7531Claude418export async function deleteSession(id: string): Promise<void> {
419 await db.delete(claudeWebSessions).where(eq(claudeWebSessions.id, id));
420}
421
422/** Test-only access to internals. */
423export const __test = {
424 passthroughEnv,
425 scrubbedEnv,
426 MAX_TURN_MS,
427};