/**
 * UpdateGate — Session-aware restart gating.
 *
 * Checks whether it's safe to restart the server for an update.
 * Only 'healthy' (actively producing output) sessions block restarts.
 * 'unresponsive', 'idle', and 'dead' sessions don't — blocking an update
 * for a broken session serves no user interest.
 *
 * Healthy sessions are NEVER killed for an update. The gate defers indefinitely
 * while healthy sessions exist, sending warnings at the configured thresholds.
 */
export class UpdateGate {
    config;
    deferralStartedAt = null;
    deferralReason = null;
    blockingSessions = [];
    firstWarningSent = false;
    firstWarningPending = false;
    finalWarningSent = false;
    finalWarningPending = false;
    constructor(config) {
        this.config = {
            maxDeferralHours: config?.maxDeferralHours ?? 4,
            firstWarningMinutes: config?.firstWarningMinutes ?? 30,
            finalWarningMinutes: config?.finalWarningMinutes ?? 5,
            retryIntervalMs: config?.retryIntervalMs ?? 5 * 60_000,
            alwaysRestartImmediately: config?.alwaysRestartImmediately ?? false,
        };
    }
    /**
     * Toggle primary-developer mode at runtime (config may change on disk
     * without a restart). When enabled, the next `canRestart` allows immediately
     * and clears any in-flight deferral so a held restart proceeds at once.
     */
    setAlwaysRestartImmediately(value) {
        if (this.config.alwaysRestartImmediately === value)
            return;
        this.config.alwaysRestartImmediately = value;
        if (value)
            this.reset();
    }
    /**
     * Check if it's safe to restart now.
     *
     * Returns { allowed: true } if restart can proceed.
     * Returns { allowed: false, retryInMs, reason } if sessions are blocking.
     */
    canRestart(sessionManager, sessionMonitor) {
        // Primary-developer mode: never defer for active sessions. A server restart
        // does NOT kill the agent's tmux sessions (they resume via CONTINUATION) —
        // this agent simply chooses being-on-latest over avoiding the brief restart
        // blip. Short-circuit BEFORE listing sessions so the deferral clock never
        // starts. Default false, so the fleet's session-aware deferral is unchanged.
        if (this.config.alwaysRestartImmediately) {
            this.reset();
            return { allowed: true };
        }
        const sessions = sessionManager.listRunningSessions();
        // No sessions → restart immediately
        if (sessions.length === 0) {
            this.reset();
            return { allowed: true };
        }
        const { activeSessions, unresponsiveSessions, nonBlockingJobSessions } = this.classifyRunningSessions(sessions, sessionManager, sessionMonitor);
        // No active sessions → restart (idle/dead/unresponsive don't block)
        if (activeSessions.length === 0) {
            this.reset();
            return {
                allowed: true,
                unresponsiveSessions: unresponsiveSessions.length > 0 ? unresponsiveSessions : undefined,
                nonBlockingJobSessions: nonBlockingJobSessions.length > 0 ? nonBlockingJobSessions : undefined,
            };
        }
        // Active sessions exist — start or continue deferral
        if (!this.deferralStartedAt) {
            this.deferralStartedAt = Date.now();
        }
        const elapsedMs = Date.now() - this.deferralStartedAt;
        const maxDeferralMs = this.config.maxDeferralHours * 60 * 60_000;
        const remainingMs = maxDeferralMs - elapsedMs;
        this.deferralReason = `${activeSessions.length} active session(s): ${activeSessions.join(', ')}`;
        this.blockingSessions = activeSessions;
        // Max deferral exceeded — but only force restart if no HEALTHY sessions.
        // Active, healthy sessions should NEVER be killed for an update.
        // The update can wait — the user's work cannot.
        if (remainingMs <= 0) {
            console.log(`[UpdateGate] Max deferral (${this.config.maxDeferralHours}h) exceeded, but ${activeSessions.length} healthy session(s) still running — continuing to defer`);
        }
        // Check warning thresholds
        const remainingMinutes = remainingMs / 60_000;
        if (remainingMinutes <= this.config.finalWarningMinutes && !this.finalWarningSent) {
            this.finalWarningSent = true;
            this.finalWarningPending = true;
        }
        if (remainingMinutes <= this.config.firstWarningMinutes && !this.firstWarningSent) {
            this.firstWarningSent = true;
            this.firstWarningPending = true;
        }
        return {
            allowed: false,
            reason: this.deferralReason,
            retryInMs: this.config.retryIntervalMs,
            blockingSessions: activeSessions,
            unresponsiveSessions: unresponsiveSessions.length > 0 ? unresponsiveSessions : undefined,
            nonBlockingJobSessions: nonBlockingJobSessions.length > 0 ? nonBlockingJobSessions : undefined,
        };
    }
    /**
     * Pure, side-effect-free probe: the names of active (healthy, non-job)
     * sessions that would block a restart right now.
     *
     * Unlike {@link canRestart}, this does NOT start or continue the deferral
     * clock, set warning flags, or call reset() — it is a read-only check for
     * callers that need "is the box idle?" without perturbing deferral state.
     * The restart-window gate uses it to skip the window wait when nothing is
     * active (an idle restart is invisible, so there is nothing for the window
     * to protect). Returns [] when there are no running sessions.
     */
    getBlockingSessions(sessionManager, sessionMonitor) {
        const sessions = sessionManager.listRunningSessions();
        if (sessions.length === 0)
            return [];
        return this.classifyRunningSessions(sessions, sessionManager, sessionMonitor).activeSessions;
    }
    /**
     * Classify running sessions into active (blocking), unresponsive, and
     * non-blocking idle job sessions. Pure — no instance-state mutation. Shared
     * by {@link canRestart} (which then does deferral bookkeeping on the result)
     * and {@link getBlockingSessions} (read-only) so the classification can never
     * drift between the gating decision and the idle probe.
     */
    classifyRunningSessions(sessions, sessionManager, sessionMonitor) {
        // Check session health if monitor is available
        const health = sessionMonitor?.getStatus().sessionHealth ?? [];
        const healthMap = new Map(health.map(h => [h.sessionName, h]));
        const activeSessions = [];
        const unresponsiveSessions = [];
        const nonBlockingJobSessions = [];
        for (const session of sessions) {
            if (this.isSafeIdleJobSession(session, sessionManager)) {
                nonBlockingJobSessions.push(session.name);
                continue;
            }
            // Health is keyed by the tmux session name (the slug SessionMonitor
            // tracks, e.g. "echo-codey-collaboration"), NOT the human-facing display
            // name (e.g. "Codey Collaboration"). Look up by tmuxSession first, then
            // fall back to name. Without the tmuxSession key, every interactive
            // session misses the health map and hits the conservative "treat as
            // active" default below — which silently turned the idle/dead exclusion
            // into dead code and meant restart-when-idle (#41) never fired while ANY
            // session existed (the day-long version-lag root cause).
            const h = (session.tmuxSession ? healthMap.get(session.tmuxSession) : undefined) ??
                healthMap.get(session.name);
            if (!h) {
                // No health data — be conservative, treat as active
                activeSessions.push(session.name);
            }
            else if (h.status === 'healthy') {
                activeSessions.push(session.name);
            }
            else if (h.status === 'unresponsive') {
                unresponsiveSessions.push(session.name);
            }
            // 'idle' and 'dead' sessions don't block
        }
        return { activeSessions, unresponsiveSessions, nonBlockingJobSessions };
    }
    isSafeIdleJobSession(session, sessionManager) {
        if (!session.jobSlug || !session.tmuxSession || !sessionManager.hasActiveProcesses) {
            return false;
        }
        return !sessionManager.hasActiveProcesses(session.tmuxSession);
    }
    /**
     * Get current gate status for observability.
     */
    getStatus() {
        const elapsedMs = this.deferralStartedAt ? Date.now() - this.deferralStartedAt : 0;
        return {
            deferring: this.deferralStartedAt !== null,
            deferralStartedAt: this.deferralStartedAt ? new Date(this.deferralStartedAt).toISOString() : null,
            deferralElapsedMinutes: Math.round(elapsedMs / 60_000),
            maxDeferralHours: this.config.maxDeferralHours,
            deferralReason: this.deferralReason,
            blockingSessions: this.blockingSessions,
            firstWarningSent: this.firstWarningSent,
            finalWarningSent: this.finalWarningSent,
            alwaysRestartImmediately: this.config.alwaysRestartImmediately,
        };
    }
    /**
     * Whether the first warning (T-30min before forced restart) should fire.
     * Returns true exactly once — consumes the flag on read.
     */
    shouldSendFirstWarning() {
        if (this.firstWarningPending) {
            this.firstWarningPending = false;
            return true;
        }
        return false;
    }
    /**
     * Whether the final warning (T-5min before forced restart) should fire.
     * Returns true exactly once — consumes the flag on read.
     */
    shouldSendFinalWarning() {
        if (this.finalWarningPending) {
            this.finalWarningPending = false;
            return true;
        }
        return false;
    }
    /**
     * Reset deferral state (called after restart proceeds or update is cancelled).
     */
    reset() {
        this.deferralStartedAt = null;
        this.deferralReason = null;
        this.blockingSessions = [];
        this.firstWarningSent = false;
        this.firstWarningPending = false;
        this.finalWarningSent = false;
        this.finalWarningPending = false;
    }
}
//# sourceMappingURL=UpdateGate.js.map