import { log, error as logError } from '../../logger' import { Hono } from 'hono' import { sessionAuth } from '../../auth/middleware' import { SYSTEM_USER_ID } from '../../auth/constants' import { getAutomationStateSnapshot } from '../../services/automationState' import { archiveSession, canManageSession, createSession, getSession, isSessionClosedStatus, isSessionVisibleToUser, listSessionSummaries, listSessions, listWebSessionSummariesByOwnerUuid, listWebSessionsByOwnerUuid, resolveOwnedWebSessionId, toWebSessionResponse, } from '../../services/session' import { getDb } from '../../db/sqlite' import { storeBindSession, storeGetEnvironment, storeGetSessionWorker, storeGetActiveWorkItemBySession, storeListSessionsByEnvironment, } from '../../store' import { createWorkItem, stopWork } from '../../services/work-dispatch' import { createSSEStream } from '../../transport/sse-writer' import { getEventBus } from '../../transport/event-bus' const app = new Hono() /** * Resolve the user ID to bind a session to. * When the request comes via API key (userId = __system__), check the * X-User-Id header for a real user identity. If no valid identity is * found, returns null — the session will be orphaned (admin-visible). */ function resolveBindUserId(c: import('hono').Context): string | null { const userId = c.get('userId') if (!userId || userId === SYSTEM_USER_ID) { // API key auth — only admin can use X-User-Id to bind on behalf of a user const isAdmin = c.get('isAdmin') if (!isAdmin) { return null // non-admin API key cannot create sessions as another user } const headerUserId = c.req.header('X-User-Id') if (headerUserId && headerUserId !== SYSTEM_USER_ID) { console.log(`[audit] API key created session as user ${headerUserId}`) return headerUserId } return null // API key without X-User-Id → orphan session (admin-visible) } return userId } /** POST /web/sessions — Create a session from web UI */ app.post('/sessions', sessionAuth, async c => { const bindUserId = resolveBindUserId(c) const body = await c.req.json() // Pre-validate BEFORE creating the session record. if (body.environment_id) { const env = storeGetEnvironment(body.environment_id) // ACP environments: registerEnvironment already auto-created a // source:'acp' session for this env. acp-link workers don't poll work // items (they hold a /acp/ws WebSocket long-connection), so creating a // source:'web' session + dispatching work would orphan it — the UI // would render native RCS chat instead of ACPSessionDetail and // messages would never reach the agent. Bind the existing ACP session // to this user and return it directly. if (env?.workerType === 'acp') { const existing = storeListSessionsByEnvironment(env.id).find( s => s.source === 'acp' && !isSessionClosedStatus(s.status), ) const sessionId = existing?.id ?? createSession({ environment_id: env.id, title: body.title || env.machineName || 'ACP Agent', source: 'acp', }).id if (bindUserId) { storeBindSession(sessionId, bindUserId) } const session = getSession(sessionId) return c.json(toWebSessionResponse(session!), 200) } } const teamId = typeof body.team_id === 'string' ? body.team_id : null const session = createSession({ environment_id: body.environment_id || null, title: body.title || 'New Session', source: 'web', permission_mode: body.permission_mode || 'default', visibility: teamId ? 'team' : 'private', }) // Bind team owner first (if creating in team context), then user owner. // Team ownership makes the session visible to all team members; user // ownership gives the creator explicit access. if (teamId) { storeBindSession(session.id, teamId, 'team') } if (bindUserId) { storeBindSession(session.id, bindUserId) } // Dispatch work to environment if specified if (body.environment_id) { // In-process (replBridge) workers register with workerType='repl' and // maxSessions=1 — they can only serve one session at a time. Reject // multi-session creation with a helpful error guiding the user to // `slz rc` (spawn mode). const env = storeGetEnvironment(body.environment_id) if (env?.workerType === 'repl') { const existing = storeListSessionsByEnvironment(env.id).filter( s => !isSessionClosedStatus(s.status), ) if (existing.length > 0) { return c.json( { error: { type: 'worker_at_capacity', message: '该 worker 为 in-process 模式(slz 默认),只支持单会话。请用 `slz rc` 启动 spawn 模式 worker 支持多会话。', }, }, 409, ) } } try { const spawnMode = body.spawn_mode === 'worktree' || body.spawn_mode === 'same-dir' ? body.spawn_mode : undefined await createWorkItem(body.environment_id, session.id, spawnMode) } catch (err) { logError(`[RCS] Failed to create work item: ${(err as Error).message}`) } } return c.json(session, 200) }) /** GET /web/sessions — List sessions visible to the requesting user. * Admin bypasses scope and sees all sessions. Non-admin sees sessions * owned by them, owned by their teams, shared with them, or public. * Optional ?team_id= filters to a specific team's sessions (server-side * scope, mirrors GET /web/environments). */ app.get('/sessions', sessionAuth, async c => { const uuid = c.get('userId')! const isAdmin = c.get('isAdmin') as boolean if (isAdmin) { return c.json(listSessions(), 200) } const teamId = c.req.query('team_id') if (teamId) { const sessions = listWebSessionsByOwnerUuid(uuid).filter( s => s.owner_type === 'team' && s.owner_id === teamId, ) return c.json(sessions, 200) } const sessions = listWebSessionsByOwnerUuid(uuid) return c.json(sessions, 200) }) /** GET /web/sessions/all — Summary list, same visibility rules as /sessions */ app.get('/sessions/all', sessionAuth, async c => { const uuid = c.get('userId')! const isAdmin = c.get('isAdmin') as boolean if (isAdmin) { return c.json(listSessionSummaries(), 200) } const teamId = c.req.query('team_id') if (teamId) { const sessions = listWebSessionSummariesByOwnerUuid(uuid).filter( s => s.owner_type === 'team' && s.owner_id === teamId, ) return c.json(sessions, 200) } const sessions = listWebSessionSummariesByOwnerUuid(uuid) return c.json(sessions, 200) }) /** GET /web/sessions/:id — Session detail */ app.get('/sessions/:id', sessionAuth, async c => { const uuid = c.get('userId')! const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid) if (!sessionId) { return c.json( { error: { type: 'forbidden', message: 'Not your session' } }, 403, ) } const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const worker = storeGetSessionWorker(sessionId) const workItem = storeGetActiveWorkItemBySession(sessionId) const env = session.environment_id ? storeGetEnvironment(session.environment_id) : null const automationState = getAutomationStateSnapshot(worker?.externalMetadata) const response = toWebSessionResponse(session) const accessLevel = isSessionVisibleToUser(sessionId, uuid, getDb()) return c.json( { ...response, access_level: accessLevel, ...(automationState !== undefined ? { automation_state: automationState } : {}), worker: worker ? { status: worker.workerStatus, last_heartbeat_at: worker.lastHeartbeatAt ? worker.lastHeartbeatAt.getTime() : null, } : null, work_item: workItem ? { id: workItem.id, state: workItem.state, spawn_mode: workItem.spawnMode ?? null, } : null, environment: env ? { id: env.id, machine_name: env.machineName, worker_type: env.workerType, } : null, }, 200, ) }) /** GET /web/sessions/:id/history — Historical events for session */ app.get('/sessions/:id/history', sessionAuth, async c => { const uuid = c.get('userId')! const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid) if (!sessionId) { return c.json( { error: { type: 'forbidden', message: 'Not your session' } }, 403, ) } const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const bus = getEventBus(sessionId) const events = bus.getEventsSince(0) return c.json({ events }, 200) }) /** SSE /web/sessions/:id/events — Real-time event stream */ app.get('/sessions/:id/events', sessionAuth, async c => { const uuid = c.get('userId')! const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid) if (!sessionId) { return c.json( { error: { type: 'forbidden', message: 'Not your session' } }, 403, ) } const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } if (isSessionClosedStatus(session.status)) { return c.json( { error: { type: 'session_closed', message: `Session is ${session.status}`, }, }, 409, ) } const lastEventId = c.req.header('Last-Event-ID') const fromSeqNum = lastEventId ? parseInt(lastEventId, 10) : 0 return createSSEStream(c, sessionId, fromSeqNum) }) /** DELETE /web/sessions/:id — Archive (soft-delete) a session. * Archived sessions disappear from the list but remain in storage for audit. * Also stops the associated work item so the worker's heartbeat detects 410 * and kills the child process, releasing capacity. */ app.delete('/sessions/:id', sessionAuth, async c => { const uuid = c.get('userId')! const rawSessionId = c.req.param('id')! // Resolve the session ID first (handles web/code prefix variants) using // visibility check — read access is enough to discover the session, but // deletion requires management permission (owner or team admin). const sessionId = resolveOwnedWebSessionId(rawSessionId, uuid) if (!sessionId) { return c.json( { error: { type: 'forbidden', message: 'Not your session' } }, 403, ) } if (!canManageSession(sessionId, uuid, getDb())) { return c.json( { error: { type: 'forbidden', message: 'Only session owner or team admin can delete', }, }, 403, ) } const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } // Stop the work item first so the worker's next heartbeat gets a 410 and // tears down the child process. Without this the worker keeps the child // alive and capacity stays consumed until the session times out. const workItem = storeGetActiveWorkItemBySession(sessionId) if (workItem) { stopWork(workItem.id) log( `[RCS] Stopped work item ${workItem.id} for archived session ${sessionId}`, ) } archiveSession(sessionId) return c.json({ status: 'ok' }, 200) }) export default app