import { config } from '../config' import { storeCreateEnvironment, storeCreateSession, storeGetEnvironment, storeUpdateEnvironment, storeListActiveEnvironments, storeListActiveEnvironmentsWithTeam, storeListActiveEnvironmentsByUsername, storeListAcpAgentsByChannelGroup, storeListSessionsByEnvironment, storeBindSession, storeGetEnvironmentOwnership, } from '../store' import type { RegisterEnvironmentRequest, EnvironmentResponse, } from '../types/api' import type { EnvironmentRecord } from '../store' function toResponse( row: EnvironmentRecord & { teamId?: string | null teamName?: string | null ownerUserId?: string | null }, ): EnvironmentResponse { return { id: row.id, machine_name: row.machineName, directory: row.directory, branch: row.branch, status: row.status, username: row.username, last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null, worker_type: row.workerType, channel_group_id: row.bridgeId, capabilities: row.capabilities, team_id: row.teamId ?? null, team_name: row.teamName ?? null, owner_user_id: row.ownerUserId ?? null, } } export function registerEnvironment( req: RegisterEnvironmentRequest & { metadata?: { worker_type?: string } username?: string ownerUserId?: string | null teamId?: string | null claimToken?: string | null claimExpiresAt?: Date | null }, ) { const secret = config.apiKeys[0] || '' const workerType = req.worker_type || req.metadata?.worker_type // ACP agents with a bridge_id (channel group): reuse the most recent // offline env in the same group instead of creating a new one on every // reconnect. acp-link's WS gets closed by the inactivity timeout (or any // network blip), triggering REST re-registration — without reuse, each // reconnect created a fresh env with a new agentId, orphaning the old // one (marked offline but never deleted) and breaking // /acp/relay/ for any frontend that remembered the old id. // Reuse stabilizes the agentId across reconnects. Active envs in the // same group belong to other acp-link instances and are NOT reused. if (workerType === 'acp' && req.bridge_id) { const offline = storeListAcpAgentsByChannelGroup(req.bridge_id) .filter(e => e.status === 'offline') .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) if (offline.length > 0) { const record = offline[0]! storeUpdateEnvironment(record.id, { status: 'active', lastPollAt: new Date(), capabilities: req.capabilities ?? null, machineName: req.machine_name, maxSessions: req.max_sessions, }) const sessions = storeListSessionsByEnvironment(record.id) let sessionId: string if (sessions.length > 0) { sessionId = sessions[0]!.id } else { // Inherit ownership from the env (may still be null if the reused // offline env is in the claim flow — claim flow backfills later). const ownership = storeGetEnvironmentOwnership(record.id) const session = storeCreateSession({ environmentId: record.id, title: req.machine_name || 'ACP Agent', source: 'acp', username: req.username, visibility: ownership?.teamId ? 'team' : 'private', }) sessionId = session.id if (ownership?.ownerUserId) { storeBindSession(sessionId, ownership.ownerUserId, 'user') } if (ownership?.teamId) { storeBindSession(sessionId, ownership.teamId, 'team') } } return { environment_id: record.id, environment_secret: record.secret, status: 'active' as const, session_id: sessionId, } } } const record = storeCreateEnvironment({ secret, machineName: req.machine_name, directory: req.directory, branch: req.branch, gitRepoUrl: req.git_repo_url, maxSessions: req.max_sessions, workerType, bridgeId: req.bridge_id, username: req.username, capabilities: req.capabilities, ownerUserId: req.ownerUserId, claimToken: req.claimToken, claimExpiresAt: req.claimExpiresAt, }) let sessionId: string | undefined // ACP agents: reuse existing session or create one if (workerType === 'acp') { const existing = storeListSessionsByEnvironment(record.id) if (existing.length > 0) { sessionId = existing[0].id } else { const session = storeCreateSession({ environmentId: record.id, title: req.machine_name || 'ACP Agent', source: 'acp', username: req.username, visibility: req.teamId ? 'team' : 'private', }) sessionId = session.id // Inherit ownership from the env at creation time. If the env is // still unowned (claim flow pending), session_owners stays empty // and the claim flow backfills it when a user claims the env. if (req.ownerUserId) { storeBindSession(sessionId, req.ownerUserId, 'user') } if (req.teamId) { storeBindSession(sessionId, req.teamId, 'team') } } } return { environment_id: record.id, environment_secret: record.secret, status: record.status as 'active', session_id: sessionId, } } export function deregisterEnvironment(envId: string) { storeUpdateEnvironment(envId, { status: 'deregistered' }) } export function getEnvironment(envId: string) { return storeGetEnvironment(envId) } export function updatePollTime(envId: string) { storeUpdateEnvironment(envId, { lastPollAt: new Date() }) } export function listActiveEnvironments() { return storeListActiveEnvironments() } export function listActiveEnvironmentsResponse(): EnvironmentResponse[] { return storeListActiveEnvironmentsWithTeam().map(r => toResponse(r)) } export function listActiveEnvironmentsByUsername( username: string, ): EnvironmentResponse[] { return storeListActiveEnvironmentsByUsername(username).map(r => toResponse(r)) } export function reconnectEnvironment(envId: string) { storeUpdateEnvironment(envId, { status: 'active' }) }