"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HostedDemoSessionService = void 0;
const child_process_1 = require("child_process");
const crypto_1 = require("crypto");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const util_1 = require("util");
const logger_1 = require("../utils/logger");
const portUtils_1 = require("../utils/portUtils");
const HostedDemoSessionStore_1 = require("./HostedDemoSessionStore");
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
const log = (0, logger_1.createLogger)('HostedDemoSessionService');
const CLEANUP_INTERVAL_MS = 30_000;
const MIN_PERSIST_TTL_MS = 1_000;
const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_SESSION_IDLE_TTL_MS = 15 * 60 * 1000;
const DEFAULT_MAX_ACTIVE_SESSIONS = 25;
const DEFAULT_BASE_RIVET_PORT = 5000;
class HostedDemoSessionService {
    config;
    deps;
    telemetry;
    sessionStore;
    sessions = new Map();
    pendingSessionCreations = 0;
    cleanupTimer;
    rehydrationLocks = new Map();
    constructor(config, deps) {
        const { telemetry, sessionMetadataStore, ...runtimeConfig } = config;
        const worktreesRootPath = runtimeConfig.worktreesRootPath ??
            path_1.default.join(runtimeConfig.repoPath, '.rivet-demo-worktrees');
        this.telemetry = telemetry;
        this.sessionStore = sessionMetadataStore ?? new HostedDemoSessionStore_1.InMemoryHostedDemoSessionStore();
        this.config = {
            ...runtimeConfig,
            worktreesRootPath,
            sessionTtlMs: runtimeConfig.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
            sessionIdleTtlMs: runtimeConfig.sessionIdleTtlMs ?? DEFAULT_SESSION_IDLE_TTL_MS,
            maxActiveSessions: runtimeConfig.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_SESSIONS,
            baseRivetPort: runtimeConfig.baseRivetPort ?? DEFAULT_BASE_RIVET_PORT,
            publicBaseUrl: runtimeConfig.publicBaseUrl ?? '',
        };
        this.deps = {
            now: deps?.now ?? (() => Date.now()),
            runGitCommand: deps?.runGitCommand ?? this.defaultRunGitCommand,
            startRuntime: deps?.startRuntime ?? this.defaultStartRuntime,
            stopRuntime: deps?.stopRuntime ?? this.defaultStopRuntime,
            waitForRuntimeHealth: deps?.waitForRuntimeHealth ?? this.defaultWaitForRuntimeHealth,
        };
        fs_1.default.mkdirSync(this.config.worktreesRootPath, { recursive: true });
        this.cleanupTimer = setInterval(() => {
            void this.cleanupExpiredSessions();
        }, CLEANUP_INTERVAL_MS);
        this.cleanupTimer.unref();
    }
    /**
     * Loads durable session rows into memory so stopped sessions can be rehydrated after deploy.
     */
    async initialize() {
        await this.sessionStore.initialize();
        const records = await this.sessionStore.loadAll();
        for (const { sessionId, value } of records) {
            if (value.expiresAt <= this.deps.now()) {
                await this.sessionStore.delete(sessionId);
                continue;
            }
            if (!fs_1.default.existsSync(value.worktreePath)) {
                await this.sessionStore.delete(sessionId);
                continue;
            }
            const internal = this.internalFromPersisted(value);
            this.sessions.set(sessionId, internal);
            log.info(`Hosted demo session restored from store ${sessionId}`, {
                activeSessions: this.sessions.size,
            });
        }
    }
    async createSession(userId) {
        await this.cleanupExpiredSessions();
        const runningOrStartingCount = this.countRunningOrStartingSessions();
        const activeAndPendingRuntimeCount = runningOrStartingCount + this.pendingSessionCreations;
        if (activeAndPendingRuntimeCount >= this.config.maxActiveSessions) {
            this.telemetry?.trackTryoutSessionCapacityRejected({
                activeSessions: activeAndPendingRuntimeCount,
                maxActiveSessions: this.config.maxActiveSessions,
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            throw new Error('Maximum active demo sessions reached');
        }
        this.pendingSessionCreations += 1;
        const sessionId = (0, crypto_1.randomUUID)();
        const worktreePath = path_1.default.join(this.config.worktreesRootPath, sessionId);
        const projectPath = path_1.default.join(worktreePath, this.config.templateProjectPath);
        const rivetPort = await (0, portUtils_1.findAvailablePort)(this.config.baseRivetPort);
        const createdAt = this.deps.now();
        const expiresAt = createdAt + this.config.sessionTtlMs;
        await this.deps.runGitCommand(this.config.repoPath, [
            'worktree',
            'add',
            '--detach',
            worktreePath,
            'HEAD',
        ]);
        try {
            if (!fs_1.default.existsSync(projectPath)) {
                throw new Error(`Template project path not found: ${this.config.templateProjectPath}`);
            }
            const runtimeProcess = await this.deps.startRuntime({
                sessionId,
                projectPath,
                rivetPort,
            });
            try {
                await this.deps.waitForRuntimeHealth(rivetPort);
            }
            catch (error) {
                try {
                    await this.deps.stopRuntime(runtimeProcess);
                }
                catch (runtimeStopError) {
                    this.telemetry?.trackTryoutSessionCleanupFailed({
                        demoSessionId: sessionId,
                        errorType: this.toErrorType(runtimeStopError),
                        stage: 'runtime_stop',
                        deploymentEnv: process.env.NODE_ENV ?? 'development',
                        templateProject: this.config.templateProjectPath,
                    });
                    log.warn(`Failed to stop runtime for hosted demo session ${sessionId} after health check failure`, runtimeStopError);
                }
                throw error;
            }
            const session = {
                sessionId,
                userId,
                createdAt,
                expiresAt,
                lastActionAt: createdAt,
                worktreePath,
                projectPath,
                rivetPort,
                runtimeState: 'running',
                runtimeProcess,
                lastRuntimeStartAt: createdAt,
            };
            this.sessions.set(sessionId, session);
            await this.persistSession(session);
            log.info(`Hosted demo session created ${sessionId}`, {
                activeSessions: this.sessions.size,
            });
            this.telemetry?.trackTryoutRuntimeStarted({
                demoSessionId: sessionId,
                rivetPort,
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            return {
                ...this.toPublicSession(session),
                sessionUrl: this.getSessionUrl(sessionId),
            };
        }
        catch (error) {
            await this.safeRemoveWorktree(worktreePath);
            this.telemetry?.trackTryoutSessionProvisionFailed({
                errorType: this.toErrorType(error),
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            throw error;
        }
        finally {
            this.pendingSessionCreations -= 1;
        }
    }
    getActiveSessionCount() {
        return this.sessions.size;
    }
    getTemplateProjectPath() {
        return this.config.templateProjectPath;
    }
    getSession(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return null;
        }
        return this.toPublicSession(session);
    }
    getSessionOwnerUserId(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return null;
        }
        return session.userId;
    }
    /**
     * Records a Rivet user action for idle TTL (does not extend idle on passive polling).
     */
    async recordSessionAction(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return;
        }
        session.lastActionAt = this.deps.now();
        await this.persistSession(session);
    }
    getSessionProxyTarget(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return null;
        }
        if (session.runtimeState !== 'running' || !session.runtimeProcess) {
            return null;
        }
        return `http://127.0.0.1:${session.rivetPort}`;
    }
    /**
     * Starts the per-session runtime when it was stopped for idle or after process restart.
     */
    async ensureRuntimeReady(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            throw new Error('Session not found');
        }
        while (true) {
            const current = this.sessions.get(sessionId);
            if (!current) {
                throw new Error('Session not found');
            }
            if (current.runtimeState === 'running' &&
                current.runtimeProcess &&
                !current.runtimeProcess.killed) {
                return;
            }
            const inFlight = this.rehydrationLocks.get(sessionId);
            if (inFlight) {
                await inFlight;
                continue;
            }
            const work = this.runRehydration(sessionId);
            this.rehydrationLocks.set(sessionId, work);
            try {
                await work;
                return;
            }
            finally {
                this.rehydrationLocks.delete(sessionId);
            }
        }
    }
    async closeSession(sessionId, options) {
        const removeWorktree = options?.removeWorktree ?? true;
        const session = this.sessions.get(sessionId);
        if (!session) {
            return;
        }
        this.sessions.delete(sessionId);
        await this.sessionStore.delete(sessionId);
        log.info(`Hosted demo session closed ${sessionId}`, {
            activeSessions: this.sessions.size,
        });
        if (session.runtimeProcess && !session.runtimeProcess.killed) {
            try {
                await this.deps.stopRuntime(session.runtimeProcess);
            }
            catch (error) {
                this.telemetry?.trackTryoutSessionCleanupFailed({
                    demoSessionId: sessionId,
                    errorType: this.toErrorType(error),
                    stage: 'runtime_stop',
                    deploymentEnv: process.env.NODE_ENV ?? 'development',
                    templateProject: this.config.templateProjectPath,
                });
                log.warn(`Failed to stop runtime for hosted demo session ${sessionId}`, error);
            }
        }
        if (removeWorktree) {
            await this.safeRemoveWorktree(session.worktreePath, sessionId);
        }
    }
    async shutdown() {
        clearInterval(this.cleanupTimer);
        const sessionIds = [...this.sessions.keys()];
        for (const sessionId of sessionIds) {
            await this.closeSession(sessionId);
        }
        await this.sessionStore.shutdown();
    }
    countRunningOrStartingSessions = () => {
        let count = 0;
        for (const session of this.sessions.values()) {
            if (session.runtimeState === 'running' || session.runtimeState === 'starting') {
                count += 1;
            }
        }
        return count;
    };
    persistSession = async (session) => {
        const ttlMs = Math.max(MIN_PERSIST_TTL_MS, session.expiresAt - this.deps.now());
        await this.sessionStore.set(session.sessionId, this.toPersisted(session), ttlMs);
    };
    toPersisted = (session) => {
        return {
            sessionId: session.sessionId,
            userId: session.userId,
            createdAt: session.createdAt,
            expiresAt: session.expiresAt,
            lastActionAt: session.lastActionAt,
            lastRuntimeStartAt: session.lastRuntimeStartAt,
            worktreePath: session.worktreePath,
            projectPath: session.projectPath,
            rivetPort: session.rivetPort,
            runtimeState: session.runtimeState,
        };
    };
    internalFromPersisted = (value) => {
        return {
            sessionId: value.sessionId,
            userId: value.userId,
            createdAt: value.createdAt,
            expiresAt: value.expiresAt,
            lastActionAt: value.lastActionAt,
            worktreePath: value.worktreePath,
            projectPath: value.projectPath,
            rivetPort: value.rivetPort,
            runtimeState: 'stopped',
            runtimeProcess: null,
            lastRuntimeStartAt: value.lastRuntimeStartAt,
        };
    };
    runRehydration = async (sessionId) => {
        const session = this.sessions.get(sessionId);
        if (!session) {
            throw new Error('Session not found');
        }
        if (session.runtimeState === 'running' &&
            session.runtimeProcess &&
            !session.runtimeProcess.killed) {
            return;
        }
        const startedAt = this.deps.now();
        this.telemetry?.trackTryoutRuntimeRehydrationStarted({
            demoSessionId: sessionId,
            deploymentEnv: process.env.NODE_ENV ?? 'development',
            templateProject: this.config.templateProjectPath,
        });
        session.runtimeState = 'starting';
        await this.persistSession(session);
        try {
            if (session.runtimeProcess && !session.runtimeProcess.killed) {
                await this.deps.stopRuntime(session.runtimeProcess);
            }
            session.runtimeProcess = null;
            const rivetPort = await (0, portUtils_1.findAvailablePort)(this.config.baseRivetPort);
            session.rivetPort = rivetPort;
            const runtimeProcess = await this.deps.startRuntime({
                sessionId: session.sessionId,
                projectPath: session.projectPath,
                rivetPort,
            });
            session.runtimeProcess = runtimeProcess;
            await this.deps.waitForRuntimeHealth(rivetPort);
            session.runtimeState = 'running';
            session.lastRuntimeStartAt = this.deps.now();
            await this.persistSession(session);
            this.telemetry?.trackTryoutRuntimeRehydrationCompleted({
                demoSessionId: sessionId,
                timeToReadyMs: this.deps.now() - startedAt,
                rivetPort,
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            this.telemetry?.trackTryoutRuntimeStarted({
                demoSessionId: sessionId,
                rivetPort,
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
        }
        catch (error) {
            session.runtimeState = 'failed';
            session.runtimeProcess = null;
            await this.persistSession(session);
            this.telemetry?.trackTryoutRuntimeRehydrationFailed({
                demoSessionId: sessionId,
                errorType: this.toErrorType(error),
                timeToFailureMs: this.deps.now() - startedAt,
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            throw error;
        }
    };
    stopRuntimeForIdle = async (sessionId) => {
        const session = this.sessions.get(sessionId);
        if (!session || session.runtimeState !== 'running') {
            return;
        }
        if (!session.runtimeProcess || session.runtimeProcess.killed) {
            session.runtimeState = 'stopped';
            session.runtimeProcess = null;
            await this.persistSession(session);
            return;
        }
        try {
            await this.deps.stopRuntime(session.runtimeProcess);
        }
        catch (error) {
            this.telemetry?.trackTryoutSessionCleanupFailed({
                demoSessionId: sessionId,
                errorType: this.toErrorType(error),
                stage: 'runtime_stop',
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            log.warn(`Failed to stop idle runtime for hosted demo session ${sessionId}`, error);
        }
        session.runtimeProcess = null;
        session.runtimeState = 'stopped';
        await this.persistSession(session);
        this.telemetry?.trackTryoutRuntimeStoppedIdle({
            demoSessionId: sessionId,
            deploymentEnv: process.env.NODE_ENV ?? 'development',
            templateProject: this.config.templateProjectPath,
        });
    };
    cleanupExpiredSessions = async () => {
        const now = this.deps.now();
        const sessionEntries = [...this.sessions.entries()];
        for (const [sessionId, session] of sessionEntries) {
            if (session.expiresAt <= now) {
                const startedAt = this.deps.now();
                log.info(`Cleaning up expired hosted demo session ${sessionId}`);
                await this.closeSession(sessionId);
                this.telemetry?.trackTryoutSessionExpiredCleaned({
                    demoSessionId: sessionId,
                    cleanupDurationMs: this.deps.now() - startedAt,
                    deploymentEnv: process.env.NODE_ENV ?? 'development',
                    templateProject: this.config.templateProjectPath,
                });
            }
        }
        const idleEntries = [...this.sessions.entries()];
        for (const [sessionId, session] of idleEntries) {
            if (session.expiresAt <= now) {
                continue;
            }
            if (session.runtimeState === 'running' &&
                now - session.lastActionAt >= this.config.sessionIdleTtlMs) {
                log.info(`Stopping idle runtime for hosted demo session ${sessionId}`);
                await this.stopRuntimeForIdle(sessionId);
            }
        }
    };
    safeRemoveWorktree = async (worktreePath, sessionId) => {
        try {
            await this.deps.runGitCommand(this.config.repoPath, [
                'worktree',
                'remove',
                '--force',
                worktreePath,
            ]);
        }
        catch (error) {
            this.telemetry?.trackTryoutSessionCleanupFailed({
                demoSessionId: sessionId,
                errorType: this.toErrorType(error),
                stage: 'worktree_remove',
                deploymentEnv: process.env.NODE_ENV ?? 'development',
                templateProject: this.config.templateProjectPath,
            });
            log.warn(`Failed to remove worktree at ${worktreePath}`, error);
        }
    };
    toErrorType = (error) => {
        if (error instanceof Error) {
            return error.message;
        }
        return 'unknown_error';
    };
    getSessionUrl(sessionId) {
        const baseUrl = this.config.publicBaseUrl.replace(/\/$/, '');
        if (!baseUrl) {
            return `/try/${sessionId}`;
        }
        return `${baseUrl}/try/${sessionId}`;
    }
    toPublicSession = (session) => {
        return {
            sessionId: session.sessionId,
            userId: session.userId,
            createdAt: session.createdAt,
            expiresAt: session.expiresAt,
            lastActionAt: session.lastActionAt,
            worktreePath: session.worktreePath,
            projectPath: session.projectPath,
            rivetPort: session.rivetPort,
            runtimeState: session.runtimeState,
        };
    };
    defaultRunGitCommand = async (repoPath, args) => {
        await execFileAsync('git', args, { cwd: repoPath });
    };
    defaultStartRuntime = async (params) => {
        const runtimeScriptPath = path_1.default.join(this.config.repoPath, 'dist', 'demo', 'sessionRuntime.js');
        const runtimeProcess = (0, child_process_1.spawn)(process.execPath, [runtimeScriptPath], {
            env: {
                ...process.env,
                RIVET_SESSION_ID: params.sessionId,
                RIVET_SESSION_PROJECT_PATH: params.projectPath,
                RIVET_SESSION_RIVET_PORT: String(params.rivetPort),
            },
            stdio: 'pipe',
        });
        runtimeProcess.stderr?.on('data', (chunk) => {
            log.warn(`[${params.sessionId}] ${chunk.toString().trim()}`);
        });
        runtimeProcess.stdout?.on('data', (chunk) => {
            log.debug(`[${params.sessionId}] ${chunk.toString().trim()}`);
        });
        return runtimeProcess;
    };
    defaultStopRuntime = async (runtimeProcess) => {
        if (runtimeProcess.killed || runtimeProcess.exitCode !== null) {
            return;
        }
        await new Promise((resolve) => {
            const timeout = setTimeout(() => {
                runtimeProcess.kill('SIGKILL');
            }, 5_000);
            runtimeProcess.once('exit', () => {
                clearTimeout(timeout);
                resolve();
            });
            runtimeProcess.kill('SIGTERM');
        });
    };
    defaultWaitForRuntimeHealth = async (rivetPort) => {
        const deadline = this.deps.now() + 20_000;
        while (this.deps.now() < deadline) {
            try {
                const healthResponse = await fetch(`http://127.0.0.1:${rivetPort}/api/health`, {
                    signal: AbortSignal.timeout(1_000),
                });
                if (!healthResponse.ok) {
                    await new Promise((resolve) => setTimeout(resolve, 500));
                    continue;
                }
                const staticResponse = await fetch(`http://127.0.0.1:${rivetPort}/static/`, {
                    signal: AbortSignal.timeout(1_000),
                });
                if (staticResponse.ok) {
                    return;
                }
            }
            catch (error) {
                log.debug(`Hosted demo runtime health poll failed for port ${rivetPort}`, error);
            }
            await new Promise((resolve) => setTimeout(resolve, 500));
        }
        throw new Error(`Timed out waiting for runtime health on port ${rivetPort}`);
    };
}
exports.HostedDemoSessionService = HostedDemoSessionService;
//# sourceMappingURL=HostedDemoSessionService.js.map