SPB Git

Repository Public

index.ts 13.1 KB 231 lines typescript

1/** Terminal session REST endpoints. Claude CLI arguments are built here from validated fields only. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { CONVERSATION_ID_RE, ClaudeConversation, CreateSession, Id, type AgentId, TerminalSession, UpdateSession, type RtSessionInfo, type RtSessionSpec } from '@cr/shared';5import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';6import { absUnder, rootByLabel, runtimeToHttp, validateRel } from '../../paths.js';7import { RuntimeError } from '../../runtime-client.js';8import { SESSION_SELECT, isLive, type SessionRow } from './service.js';910/**11 * Conversation ids are placed right after `--resume` in the claude argv, so they must never be12 * parseable as an option: the first character has to be alphanumeric (a bare `[A-Za-z0-9-]{8,64}`13 * would accept `--dangerously-skip-permissions`).14 */15export const CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;16const IdParams = z.object({ id: Id });17const ListQuery = z.object({ archived: z.enum(['0', '1', 'true', 'false']).optional() });18const ReconnectBody = z19  .object({20    restart: z.boolean().optional(),21    cols: z.number().int().min(20).max(500).default(120),22    rows: z.number().int().min(5).max(200).default(32),23  })24  .default({ cols: 120, rows: 32 });2526/**27 * The ONLY place agent CLI arguments are produced. The browser can never pass raw CLI args.28 * `kind: 'claude'` is the wire name of "the instance's agent"; `agent` says which CLI that is:29 *   claude → `--resume <id>` / `--continue`        codex → `resume <uuid>` / `resume --last`30 * (Codex session ids are UUIDs: a session *name* or an option can never be passed.)31 */32export function buildClaudeArgs(kind: 'claude' | 'shell', o: { resumeConversationId?: string | null; continueLast?: boolean }, agent: AgentId = 'claude'): string[] {33  if (kind !== 'claude') {34    if (o.resumeConversationId || o.continueLast) throw new HttpError(400, 'invalid_request', `resume/continue only apply to ${agent} sessions`);35    return [];36  }37  if (o.resumeConversationId && o.continueLast) throw new HttpError(400, 'invalid_request', 'choose either resume or continue');38  if (o.resumeConversationId) {39    if (!CONVERSATION_ID_RE[agent].test(o.resumeConversationId)) throw new HttpError(400, 'invalid_request', 'invalid conversation id');40    return agent === 'codex' ? ['resume', o.resumeConversationId] : ['--resume', o.resumeConversationId];41  }42  if (!o.continueLast) return [];43  return agent === 'codex' ? ['resume', '--last'] : ['--continue'];44}4546export async function projectDir(ctx: Ctx, ownerId: string, projectId: string | null): Promise<{ abs: string; name: string | null }> {47  if (!projectId) return { abs: rootByLabel(ctx.config).path, name: null };48  const p = await ctx.db.one<{ name: string; root_label: string; rel_path: string }>(49    'SELECT name, root_label, rel_path FROM projects WHERE id = $1 AND owner_id = $2', [projectId, ownerId],50  );51  if (!p) throw notFound('project');52  return { abs: absUnder(rootByLabel(ctx.config, p.root_label).path, validateRel(p.rel_path)), name: p.name };53}5455export default async function sessionsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {56  const { db, rt, audit } = ctx;57  const svc = ctx.sessions;58  const owned = async (id: string, ownerId: string): Promise<SessionRow> => {59    const row = await svc.owned(id, ownerId);60    if (!row) throw notFound('session');61    return row;62  };6364  /** Starts the runtime process for a row in STARTING and records the outcome. */65  async function start(row: SessionRow, args: string[], cols: number, rows: number): Promise<SessionRow> {66    const spec: RtSessionSpec = { id: row.id, kind: row.kind, cwd: row.cwd_abs, args, env: {}, cols, rows };67    let info: RtSessionInfo;68    try {69      info = await rt.json<RtSessionInfo>('POST', '/sessions', spec);70    } catch (err) {71      if (err instanceof RuntimeError && err.status === 409) {72        // Already running under this id (e.g. restart raced with the reconciler): adopt it.73        info = await rt.json<RtSessionInfo>('GET', `/sessions/${row.id}`).catch(runtimeToHttp);74      } else {75        await svc.transition(row.id, 'FAILED', {}, { source: 'start', reason: err instanceof RuntimeError ? err.code : 'error' });76        return runtimeToHttp(err);77      }78    }79    svc.attached.set(row.id, info.attached ?? 0);80    const next = await svc.transition(row.id, (info.attached ?? 0) > 0 ? 'RUNNING' : 'DETACHED', { pid: info.pid ?? null, lastActivityMs: Date.now() }, { source: 'start' });81    return next ?? row;82  }8384  app.get('/sessions', { config: { doc: { summary: 'List terminal sessions', query: ListQuery, response: z.array(TerminalSession), responseName: 'TerminalSessionList' } } }, async (req) => {85    const a = authOf(req);86    const q = parse(ListQuery, req.query);87    const archived = q.archived === '1' || q.archived === 'true';88    const rows = await db.q<SessionRow>(89      `${SESSION_SELECT} WHERE s.owner_id = $1 AND s.archived = $2 ORDER BY s.pinned DESC, coalesce(s.last_activity_at, s.created_at) DESC LIMIT 500`,90      [a.userId, archived],91    );92    return rows.map((r) => svc.dto(r));93  });9495  app.post('/sessions', { config: { doc: { summary: 'Create and start a terminal session', body: CreateSession, bodyName: 'CreateSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req, reply) => {96    const a = authOf(req);97    const body = parse(CreateSession, req.body);98    const args = buildClaudeArgs(body.kind, body, ctx.config.agent.id);99    const dir = await projectDir(ctx, a.userId, body.projectId);100    const rel = validateRel(body.cwd);101    const title = body.title?.trim() || `${body.kind === 'claude' ? ctx.config.agent.shortName : 'Shell'}${dir.name ? ` · ${dir.name}` : ''}`;102    const created = await db.withTx(async (tx) => {103      const r = await tx.one<{ id: string }>(104        `INSERT INTO terminal_sessions (owner_id, project_id, kind, title, state, cwd_abs, cwd_rel, claude_conversation_id, launch_args)105         VALUES ($1, $2, $3, $4, 'CREATED', $5, $6, $7, $8) RETURNING id`,106        [a.userId, body.projectId, body.kind, title, absUnder(dir.abs, rel), rel, body.resumeConversationId ?? null, JSON.stringify(args)],107      );108      if (!r) throw new Error('insert failed');109      await tx.q("INSERT INTO terminal_events (session_id, kind, detail) VALUES ($1, 'state', $2)", [r.id, JSON.stringify({ from: null, to: 'CREATED' })]);110      if (body.projectId) await tx.q('UPDATE projects SET last_activity_at = now() WHERE id = $1', [body.projectId]);111      return r.id;112    });113    const starting = await svc.transition(created, 'STARTING', {}, { source: 'create' });114    if (!starting) throw new HttpError(500, 'internal', 'could not start session');115    let row: SessionRow;116    try {117      row = await start(starting, args, body.cols, body.rows);118    } catch (err) {119      await audit.req(req, 'session.create', { target: created, ok: false });120      throw err;121    }122    await audit.req(req, 'session.create', { target: created, detail: { kind: body.kind, projectId: body.projectId } });123    void reply.code(201);124    return svc.dto(row);125  });126127  app.get('/sessions/:id', { config: { doc: { summary: 'Get one session', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {128    const { id } = parse(IdParams, req.params);129    return svc.dto(await owned(id, authOf(req).userId));130  });131132  app.patch('/sessions/:id', { config: { doc: { summary: 'Rename / pin / archive (archive only when not live)', body: UpdateSession, bodyName: 'UpdateSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {133    const a = authOf(req);134    const { id } = parse(IdParams, req.params);135    const body = parse(UpdateSession, req.body);136    const row = await owned(id, a.userId);137    if (body.archived === true && (isLive(row.state) || row.state === 'CREATED')) {138      throw new HttpError(409, 'session_live', 'a live session cannot be archived; terminate it first');139    }140    await db.q(141      `UPDATE terminal_sessions SET title = coalesce($2, title), pinned = coalesce($3, pinned), archived = coalesce($4, archived) WHERE id = $1`,142      [id, body.title ?? null, body.pinned ?? null, body.archived ?? null],143    );144    const next = await owned(id, a.userId);145    svc.emit(next);146    await audit.req(req, 'session.update', { target: id, detail: { fields: Object.keys(body) } });147    return svc.dto(next);148  });149150  app.post('/sessions/:id/terminate', { config: { doc: { summary: 'Stop the session process', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {151    const a = authOf(req);152    const { id } = parse(IdParams, req.params);153    const row = await owned(id, a.userId);154    let next: SessionRow | null = row;155    if (row.state === 'RECOVERABLE' || row.state === 'CREATED') {156      next = await svc.transition(id, 'STOPPED', {}, { source: 'user' });157    } else if (isLive(row.state)) {158      // Refuse (503) rather than record a stop we cannot perform.159      await rt.json('GET', '/health', undefined, { timeoutMs: 5000 }).catch(runtimeToHttp);160      const stopping = await svc.transition(id, 'STOPPING', {}, { source: 'user' });161      if (!stopping) throw new HttpError(409, 'invalid_state', 'session cannot be stopped in its current state');162      try {163        await rt.json('DELETE', `/sessions/${id}`);164      } catch (err) {165        if (!(err instanceof RuntimeError) || err.status !== 404) {166          // Stays STOPPING; the reconciler settles it once the runtime answers again.167          await audit.req(req, 'session.terminate', { target: id, ok: false });168          return runtimeToHttp(err);169        }170      }171      next = await svc.transition(id, 'STOPPED', {}, { source: 'user' });172    }173    await audit.req(req, 'session.terminate', { target: id });174    return svc.dto(next ?? (await owned(id, a.userId)));175  });176177  app.post('/sessions/:id/reconnect', { config: { doc: { summary: 'Re-sync with the runtime; {restart:true} restarts a RECOVERABLE session', body: ReconnectBody, bodyName: 'ReconnectSession', response: TerminalSession, responseName: 'TerminalSession' } } }, async (req) => {178    const a = authOf(req);179    const { id } = parse(IdParams, req.params);180    const body = parse(ReconnectBody, req.body ?? undefined);181    let row = await owned(id, a.userId);182    if (isLive(row.state)) {183      try {184        const info = await rt.json<RtSessionInfo>('GET', `/sessions/${id}`, undefined, { timeoutMs: 5000 });185        svc.markRuntime(true);186        row = (await svc.applyInfo(row, info)) ?? row;187      } catch (err) {188        if (err instanceof RuntimeError && err.status === 404) {189          svc.markRuntime(true);190          row = (await svc.applyMissing(row)) ?? row;191        } else {192          if (err instanceof RuntimeError && err.unreachable) svc.markRuntime(false);193          return runtimeToHttp(err); // unreachable ⇒ 503, state untouched194        }195      }196      row = await owned(id, a.userId);197    }198    if (body.restart && row.state === 'RECOVERABLE') {199      // A fresh process in the same cwd; process memory never survives, only the conversation.200      // A shell session can carry a conversation id too (hooks of a `claude` typed inside it):201      // only claude sessions are restarted with --resume / --continue.202      const args = row.kind === 'claude' ? buildClaudeArgs('claude', row.claude_conversation_id ? { resumeConversationId: row.claude_conversation_id } : { continueLast: true }, ctx.config.agent.id) : [];203      await rt.json('GET', '/health', undefined, { timeoutMs: 5000 }).catch(runtimeToHttp);204      const starting = await svc.transition(id, 'STARTING', {}, { source: 'restart' });205      if (!starting) throw new HttpError(409, 'invalid_state', 'session cannot be restarted');206      await db.q('UPDATE terminal_sessions SET launch_args = $2, last_activity_at = now() WHERE id = $1', [id, JSON.stringify(args)]);207      row = await start(starting, args, body.cols, body.rows);208      await audit.req(req, 'session.restart', { target: id });209    }210    return svc.dto(row);211  });212213  app.get('/sessions/:id/snapshot', { config: { doc: { summary: 'Last screen lines (explicit user request only)', response: z.object({ text: z.string() }), responseName: 'SessionSnapshot' } } }, async (req) => {214    const a = authOf(req);215    const { id } = parse(IdParams, req.params);216    const row = await owned(id, a.userId);217    if (!isLive(row.state)) throw new HttpError(409, 'session_not_live', 'session is not running');218    const out = await rt.json<{ text?: unknown }>('GET', `/sessions/${id}/snapshot`).catch(runtimeToHttp);219    await audit.req(req, 'session.snapshot', { target: id });220    return { text: typeof out?.text === 'string' ? out.text : '' };221  });222223  app.get('/projects/:id/conversations', { config: { doc: { summary: 'Claude conversations recorded for the project directory', response: z.array(ClaudeConversation), responseName: 'ClaudeConversationList' } } }, async (req) => {224    const a = authOf(req);225    const { id } = parse(IdParams, req.params);226    const dir = await projectDir(ctx, a.userId, id);227    const list = await rt.json<unknown>('POST', '/claude/conversations', { cwd: dir.abs }).catch(runtimeToHttp);228    return parse(z.array(ClaudeConversation), list);229  });230}231
Files main
Menu

Theme