import { randomUUID, createHash } from 'node:crypto' import { getDb } from './db/sqlite' // ---------- Types ---------- export interface UserRecord { username: string createdAt: Date } export interface EnvironmentRecord { id: string secret: string machineName: string | null directory: string | null branch: string | null gitRepoUrl: string | null maxSessions: number workerType: string bridgeId: string | null capabilities: Record | null status: string username: string | null lastPollAt: Date | null createdAt: Date updatedAt: Date } export interface SessionRecord { id: string environmentId: string | null title: string | null status: string source: string permissionMode: string | null workerEpoch: number username: string | null visibility: string createdAt: Date updatedAt: Date } export interface WorkItemRecord { id: string environmentId: string sessionId: string state: string secret: string // Per-session spawn mode chosen in Web UI at creation time. Undefined for // legacy work items or when the client didn't specify (worker falls back // to its startup default). spawnMode?: 'same-dir' | 'worktree' createdAt: Date updatedAt: Date } export interface SessionWorkerRecord { sessionId: string workerStatus: string | null externalMetadata: Record | null requiresActionDetails: Record | null lastHeartbeatAt: Date | null createdAt: Date updatedAt: Date } // ---------- Stores (in-memory Maps) ---------- const users = new Map() const tokenToUser = new Map() const environments = new Map() const sessions = new Map() const workItems = new Map() const sessionWorkers = new Map() // Session ownership tracked in SQLite session_owners table // ---------- User ---------- export function storeCreateUser(username: string): UserRecord { const existing = users.get(username) if (existing) return existing const record: UserRecord = { username, createdAt: new Date() } users.set(username, record) return record } export function storeGetUser(username: string): UserRecord | undefined { return users.get(username) } export function storeCreateToken(username: string, token: string): void { tokenToUser.set(token, { username, createdAt: new Date() }) } export function storeGetUserByToken( token: string, ): { username: string; createdAt: Date } | undefined { return tokenToUser.get(token) } export function storeDeleteToken(token: string): boolean { return tokenToUser.delete(token) } // ---------- Environment ---------- export function storeCreateEnvironment(req: { secret: string machineName?: string directory?: string branch?: string gitRepoUrl?: string maxSessions?: number workerType?: string bridgeId?: string username?: string capabilities?: Record ownerUserId?: string | null claimToken?: string | null claimExpiresAt?: Date | null }): EnvironmentRecord { const id = `env_${randomUUID().replace(/-/g, '')}` const now = new Date() const record: EnvironmentRecord = { id, secret: req.secret, machineName: req.machineName ?? null, directory: req.directory ?? null, branch: req.branch ?? null, gitRepoUrl: req.gitRepoUrl ?? null, maxSessions: req.maxSessions ?? 1, workerType: req.workerType ?? 'claude_code', bridgeId: req.bridgeId ?? null, capabilities: req.capabilities ?? null, status: 'active', username: req.username ?? null, lastPollAt: now, createdAt: now, updatedAt: now, } environments.set(id, record) try { const db = getDb() const secretHash = createHash('sha256').update(record.secret).digest('hex') db.query( `INSERT OR REPLACE INTO environments (id, secret_hash, machine_name, worker_type, status, last_poll_at, created_at, owner_user_id, claim_token, claim_expires_at) VALUES ($id, $sh, $mn, $wt, $st, $lp, $ca, $ou, $ct, $ce)`, ).run({ $id: id, $sh: secretHash, $mn: record.machineName, $wt: record.workerType, $st: record.status, $lp: record.lastPollAt?.toISOString() ?? null, $ca: record.createdAt.toISOString(), $ou: req.ownerUserId ?? null, $ct: req.claimToken ?? null, $ce: req.claimExpiresAt?.toISOString() ?? null, }) } catch (err) { console.error('[store] DB write failed in storeCreateEnvironment:', err) throw err } return record } export function storeGetEnvironment(id: string): EnvironmentRecord | undefined { return environments.get(id) } export function storeUpdateEnvironment( id: string, patch: Partial< Pick< EnvironmentRecord, | 'status' | 'lastPollAt' | 'updatedAt' | 'capabilities' | 'machineName' | 'maxSessions' | 'bridgeId' > >, ): boolean { const rec = environments.get(id) if (!rec) return false Object.assign(rec, patch, { updatedAt: new Date() }) try { const db = getDb() const sets: string[] = [] const params: Record = { $id: id } if (patch.status !== undefined) { sets.push('status = $status') params.$status = patch.status } if (patch.lastPollAt !== undefined) { sets.push('last_poll_at = $lastPollAt') params.$lastPollAt = patch.lastPollAt?.toISOString() ?? null } if (patch.machineName !== undefined) { sets.push('machine_name = $machineName') params.$machineName = patch.machineName } // bridgeId, maxSessions, capabilities, updatedAt are in-memory only — // the environments table has no corresponding columns. if (sets.length > 0) { db.query(`UPDATE environments SET ${sets.join(', ')} WHERE id = $id`).run( params, ) } } catch (err) { console.error('[store] DB write failed in storeUpdateEnvironment:', err) throw err } return true } export function storeListActiveEnvironments(): EnvironmentRecord[] { return [...environments.values()].filter(e => e.status === 'active') } /** * Return active environments enriched with team_id and team_name from SQLite. * LEFT JOINs team_environments + teams (excluding soft-deleted teams). */ export function storeListActiveEnvironmentsWithTeam(): Array< EnvironmentRecord & { teamId: string | null teamName: string | null ownerUserId: string | null } > { const active = [...environments.values()].filter(e => e.status === 'active') if (active.length === 0) return [] // Build a map of env_id → { teamId, teamName } from SQLite const teamMap = new Map() // Build a map of env_id → ownerUserId from SQLite const ownerMap = new Map() try { const db = getDb() const rows = db .query( `SELECT te.environment_id, te.team_id, t.name AS team_name FROM team_environments te JOIN teams t ON t.id = te.team_id AND t.deleted_at IS NULL`, ) .all() as Array<{ environment_id: string team_id: string team_name: string }> for (const row of rows) { teamMap.set(row.environment_id, { teamId: row.team_id, teamName: row.team_name, }) } const ownerRows = db .query( `SELECT id, owner_user_id FROM environments WHERE owner_user_id IS NOT NULL`, ) .all() as Array<{ id: string; owner_user_id: string }> for (const row of ownerRows) { ownerMap.set(row.id, row.owner_user_id) } } catch (err) { console.error( '[store] DB read failed in storeListActiveEnvironmentsWithTeam:', err, ) // Fall through: return envs without team/owner info } return active.map(env => { const team = teamMap.get(env.id) return { ...env, teamId: team?.teamId ?? null, teamName: team?.teamName ?? null, ownerUserId: ownerMap.get(env.id) ?? null, } }) } /** * Look up environment ownership fields from SQLite. * Returns null if the environment row doesn't exist. */ export function storeGetEnvironmentOwnership(envId: string): { ownerUserId: string | null teamId: string | null claimToken: string | null claimExpiresAt: string | null } | null { try { const db = getDb() const row = db .query( `SELECT e.owner_user_id, e.claim_token, e.claim_expires_at, (SELECT te.team_id FROM team_environments te WHERE te.environment_id = e.id) AS team_id FROM environments e WHERE e.id = $id`, ) .get({ $id: envId }) as { owner_user_id: string | null claim_token: string | null claim_expires_at: string | null team_id: string | null } | null if (!row) return null return { ownerUserId: row.owner_user_id, teamId: row.team_id, claimToken: row.claim_token, claimExpiresAt: row.claim_expires_at, } } catch (err) { console.error( '[store] DB read failed in storeGetEnvironmentOwnership:', err, ) throw err } } /** * Claim an unowned environment: write owner_user_id and clear claim_token. * Returns true if the row was updated, false if env not found or already * owned (idempotent — caller checks ownership state first). */ export function storeClaimEnvironment(envId: string, userId: string): boolean { try { const db = getDb() const result = db .query( `UPDATE environments SET owner_user_id = $userId, claim_token = NULL, claim_expires_at = NULL WHERE id = $envId AND owner_user_id IS NULL`, ) .run({ $envId: envId, $userId: userId }) return result.changes > 0 } catch (err) { console.error('[store] DB write failed in storeClaimEnvironment:', err) throw err } } /** * Bind all sessions under an environment to a user as 'user' owner. * * Used after storeClaimEnvironment: when an env is claimed, any sessions * already auto-created under it (e.g. ACP sessions created at env register * time before the env had an owner) are orphan — they have no * session_owners rows. This backfills them in one shot so the claimer can * see and access them in the Web UI. * * Idempotent: INSERT OR IGNORE skips sessions that already have a user * owner row. Returns the number of newly inserted rows. */ export function storeBindEnvSessionsToUser( envId: string, userId: string, ): number { try { const db = getDb() const result = db .query( `INSERT OR IGNORE INTO session_owners (session_id, owner_type, owner_id) SELECT s.id, 'user', $userId FROM sessions s WHERE s.environment_id = $envId AND NOT EXISTS ( SELECT 1 FROM session_owners so WHERE so.session_id = s.id AND so.owner_type = 'user' AND so.owner_id = $userId )`, ) .run({ $envId: envId, $userId: userId }) return result.changes } catch (err) { console.error('[store] DB write failed in storeBindEnvSessionsToUser:', err) throw err } } export function storeListActiveEnvironmentsByUsername( username: string, ): EnvironmentRecord[] { return [...environments.values()].filter( e => e.status === 'active' && e.username === username, ) } // ---------- Session ---------- export function storeCreateSession(req: { environmentId?: string | null title?: string | null source?: string permissionMode?: string | null idPrefix?: string username?: string | null visibility?: string }): SessionRecord { const id = `${req.idPrefix || 'session_'}${randomUUID().replace(/-/g, '')}` const now = new Date() const record: SessionRecord = { id, environmentId: req.environmentId ?? null, title: req.title ?? null, status: 'idle', source: req.source ?? 'remote-control', permissionMode: req.permissionMode ?? null, workerEpoch: 0, username: req.username ?? null, visibility: req.visibility ?? 'private', createdAt: now, updatedAt: now, } sessions.set(id, record) try { const db = getDb() db.query( `INSERT OR REPLACE INTO sessions (id, environment_id, title, status, source, visibility, permission_mode, created_at, updated_at) VALUES ($id, $eid, $title, $status, $source, $vis, $pm, $ca, $ua)`, ).run({ $id: id, $eid: record.environmentId, $title: record.title, $status: record.status, $source: record.source, $vis: record.visibility, $pm: record.permissionMode, $ca: record.createdAt.toISOString(), $ua: record.updatedAt.toISOString(), }) } catch (err) { console.error('[store] DB write failed in storeCreateSession:', err) throw err } return record } export function storeGetSession(id: string): SessionRecord | undefined { return sessions.get(id) } export function storeUpdateSession( id: string, patch: Partial< Pick >, ): boolean { const rec = sessions.get(id) if (!rec) return false Object.assign(rec, patch, { updatedAt: new Date() }) try { const db = getDb() db.query( `UPDATE sessions SET title = COALESCE($title, title), status = COALESCE($status, status), updated_at = $ua WHERE id = $id`, ).run({ $id: id, $title: patch.title ?? null, $status: patch.status ?? null, $ua: rec.updatedAt.toISOString(), }) } catch (err) { console.error('[store] DB write failed in storeUpdateSession:', err) throw err } return true } export function storeListSessions(): SessionRecord[] { return [...sessions.values()] } export function storeListSessionsByUsername(username: string): SessionRecord[] { return [...sessions.values()].filter(s => s.username === username) } export function storeListSessionsByEnvironment(envId: string): SessionRecord[] { return [...sessions.values()].filter(s => s.environmentId === envId) } export function storeDeleteSession(id: string): boolean { sessionWorkers.delete(id) const deleted = sessions.delete(id) try { const db = getDb() db.query('DELETE FROM sessions WHERE id = $id').run({ $id: id }) } catch (err) { console.error('[store] DB write failed in storeDeleteSession:', err) throw err } return deleted } // ---------- Session Worker ---------- export function storeGetSessionWorker( sessionId: string, ): SessionWorkerRecord | undefined { return sessionWorkers.get(sessionId) } export function storeUpsertSessionWorker( sessionId: string, patch: { workerStatus?: string | null externalMetadata?: Record | null requiresActionDetails?: Record | null lastHeartbeatAt?: Date | null }, ): SessionWorkerRecord { const now = new Date() const existing = sessionWorkers.get(sessionId) const record: SessionWorkerRecord = existing ?? { sessionId, workerStatus: null, externalMetadata: null, requiresActionDetails: null, lastHeartbeatAt: null, createdAt: now, updatedAt: now, } if (patch.workerStatus !== undefined) { record.workerStatus = patch.workerStatus } if (patch.externalMetadata !== undefined) { if (patch.externalMetadata === null) { record.externalMetadata = null } else { record.externalMetadata = { ...(record.externalMetadata ?? {}), ...patch.externalMetadata, } } } if (patch.requiresActionDetails !== undefined) { record.requiresActionDetails = patch.requiresActionDetails } if (patch.lastHeartbeatAt !== undefined) { record.lastHeartbeatAt = patch.lastHeartbeatAt } record.updatedAt = now sessionWorkers.set(sessionId, record) return record } // ---------- Work Items ---------- // ---------- Session Ownership (SQLite-backed) ---------- export function storeBindSession( sessionId: string, ownerId: string, ownerType: 'user' | 'team' = 'user', ): void { try { const db = getDb() db.query( `INSERT OR IGNORE INTO session_owners (session_id, owner_type, owner_id) VALUES ($sessionId, $ownerType, $ownerId)`, ).run({ $sessionId: sessionId, $ownerType: ownerType, $ownerId: ownerId, }) } catch (err) { console.error('[store] DB write failed in storeBindSession:', err) throw err } } export function storeIsSessionOwner(sessionId: string, uuid: string): boolean { try { const db = getDb() const row = db .query( `SELECT 1 FROM session_owners WHERE session_id = $sessionId AND owner_id = $ownerId LIMIT 1`, ) .get({ $sessionId: sessionId, $ownerId: uuid }) return !!row } catch (err) { console.error('[store] DB read failed in storeIsSessionOwner:', err) throw err } } export function storeGetSessionOwners( sessionId: string, ): Set | undefined { try { const db = getDb() const rows = db .query( `SELECT owner_id FROM session_owners WHERE session_id = $sessionId`, ) .all({ $sessionId: sessionId }) as Array<{ owner_id: string }> if (rows.length === 0) return undefined return new Set(rows.map(r => r.owner_id)) } catch (err) { console.error('[store] DB read failed in storeGetSessionOwners:', err) throw err } } export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] { const result: SessionRecord[] = [] const resultIds = new Set() // Collect sessions owned by this user from SQLite try { const db = getDb() // Direct user ownership const ownedRows = db .query( `SELECT session_id FROM session_owners WHERE owner_type = 'user' AND owner_id = $ownerId`, ) .all({ $ownerId: uuid }) as Array<{ session_id: string }> for (const row of ownedRows) { const session = sessions.get(row.session_id) if (session) { result.push(session) resultIds.add(row.session_id) } } // Team ownership: sessions owned by a team the user belongs to const teamOwnedRows = db .query( `SELECT so.session_id FROM session_owners so JOIN team_members tm ON tm.team_id = so.owner_id WHERE so.owner_type = 'team' AND tm.user_id = $userId`, ) .all({ $userId: uuid }) as Array<{ session_id: string }> for (const row of teamOwnedRows) { if (resultIds.has(row.session_id)) continue const session = sessions.get(row.session_id) if (session) { result.push(session) resultIds.add(row.session_id) } } // Include public sessions const publicRows = db .query(`SELECT id FROM sessions WHERE visibility = 'public'`) .all() as Array<{ id: string }> for (const row of publicRows) { if (resultIds.has(row.id)) continue const session = sessions.get(row.id) if (session) { result.push(session) resultIds.add(row.id) } } // Include sessions explicitly shared with this user (non-expired) const directShareRows = db .query( `SELECT session_id FROM session_shares WHERE granted_to_user = $userId AND (expires_at IS NULL OR expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now'))`, ) .all({ $userId: uuid }) as Array<{ session_id: string }> for (const row of directShareRows) { if (resultIds.has(row.session_id)) continue const session = sessions.get(row.session_id) if (session) { result.push(session) resultIds.add(row.session_id) } } // Include sessions shared with a team the user belongs to (non-expired) const teamShareRows = db .query( `SELECT ss.session_id FROM session_shares ss JOIN team_members tm ON tm.team_id = ss.granted_to_team WHERE tm.user_id = $userId AND (ss.expires_at IS NULL OR ss.expires_at > strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now'))`, ) .all({ $userId: uuid }) as Array<{ session_id: string }> for (const row of teamShareRows) { if (resultIds.has(row.session_id)) continue const session = sessions.get(row.session_id) if (session) { result.push(session) resultIds.add(row.session_id) } } } catch (err) { console.error( '[store] DB read failed in storeListSessionsByOwnerUuid:', err, ) throw err } return result } /** * Bulk-lookup session owners for a list of session IDs. * Returns a Map of session_id → { owner_type, owner_id } (first owner wins). */ export function storeGetSessionOwnersBulk( sessionIds: string[], ): Map { const result = new Map< string, { owner_type: 'user' | 'team'; owner_id: string } >() if (sessionIds.length === 0) return result try { const db = getDb() // Use a subquery with IN clause — safe because sessionIds are internal const placeholders = sessionIds.map((_, i) => `$s${i}`).join(',') const params: Record = {} sessionIds.forEach((id, i) => { params[`$s${i}`] = id }) const rows = db .query( `SELECT session_id, owner_type, owner_id FROM session_owners WHERE session_id IN (${placeholders})`, ) .all(params) as Array<{ session_id: string owner_type: 'user' | 'team' owner_id: string }> for (const row of rows) { // First owner wins (a session may have multiple owners) if (!result.has(row.session_id)) { result.set(row.session_id, { owner_type: row.owner_type, owner_id: row.owner_id, }) } } } catch (err) { console.error('[store] DB read failed in storeGetSessionOwnersBulk:', err) // Return empty map — caller will treat as null owner } return result } // ---------- Work Items (cont.) ---------- export function storeCreateWorkItem(req: { environmentId: string sessionId: string secret: string spawnMode?: 'same-dir' | 'worktree' }): WorkItemRecord { const id = `work_${randomUUID().replace(/-/g, '')}` const now = new Date() const record: WorkItemRecord = { id, environmentId: req.environmentId, sessionId: req.sessionId, state: 'pending', secret: req.secret, spawnMode: req.spawnMode, createdAt: now, updatedAt: now, } workItems.set(id, record) return record } export function storeGetWorkItem(id: string): WorkItemRecord | undefined { return workItems.get(id) } /** Find the active (non-completed) work item for a given session. */ export function storeGetActiveWorkItemBySession( sessionId: string, ): WorkItemRecord | undefined { for (const item of workItems.values()) { if (item.sessionId === sessionId && item.state !== 'completed') { return item } } return undefined } export function storeListWorkItems(): WorkItemRecord[] { return Array.from(workItems.values()) } export function storeGetPendingWorkItem( environmentId: string, ): WorkItemRecord | undefined { for (const item of workItems.values()) { if (item.environmentId === environmentId && item.state === 'pending') { return item } } return undefined } export function storeUpdateWorkItem( id: string, patch: Partial>, ): boolean { const rec = workItems.get(id) if (!rec) return false Object.assign(rec, patch, { updatedAt: new Date() }) return true } // ---------- ACP Agent (reuses EnvironmentRecord with workerType="acp") ---------- /** List all ACP agents (environments with workerType="acp") */ export function storeListAcpAgents(): EnvironmentRecord[] { return [...environments.values()].filter(e => e.workerType === 'acp') } /** List ACP agents by channel group (stored in bridgeId field) */ export function storeListAcpAgentsByChannelGroup( channelGroupId: string, ): EnvironmentRecord[] { return [...environments.values()].filter( e => e.workerType === 'acp' && e.bridgeId === channelGroupId, ) } /** List online ACP agents */ export function storeListOnlineAcpAgents(): EnvironmentRecord[] { return [...environments.values()].filter( e => e.workerType === 'acp' && e.status === 'active', ) } /** Mark an ACP agent as offline */ export function storeMarkAcpAgentOffline(id: string): boolean { const rec = environments.get(id) if (!rec || rec.workerType !== 'acp') return false Object.assign(rec, { status: 'offline', updatedAt: new Date() }) return true } /** Mark an ACP agent as online (on reconnect) */ export function storeMarkAcpAgentOnline(id: string): boolean { const rec = environments.get(id) if (!rec || rec.workerType !== 'acp') return false Object.assign(rec, { status: 'active', lastPollAt: new Date(), updatedAt: new Date(), }) return true } // ---------- Reset (for tests) ---------- export function storeReset() { users.clear() tokenToUser.clear() environments.clear() sessions.clear() workItems.clear() sessionWorkers.clear() try { const db = getDb() // Delete in FK-safe order: child tables before parent tables db.query('DELETE FROM session_tokens').run() db.query('DELETE FROM session_owners').run() db.query('DELETE FROM session_shares').run() db.query('DELETE FROM team_members').run() db.query('DELETE FROM team_environments').run() db.query('DELETE FROM invitations').run() db.query('DELETE FROM sessions').run() db.query('DELETE FROM environments').run() db.query('DELETE FROM teams').run() db.query('DELETE FROM users').run() } catch (err) { console.error('[store] DB cleanup failed in storeReset:', err) } }