/**
 * PendingInjectStore — durable record of in-flight initial-message injects.
 *
 * THE GAP THIS CLOSES (finding 8d300555, 2026-06-06): when a session is
 * spawned with an initial message, the inject happens AFTER an async
 * readiness wait (codex can take tens of seconds to boot). That pending
 * inject was process-local state — a server restart in the window between
 * spawn and inject silently dropped the user's message. The live incident:
 * the auto-updater's scheduled restart (60s delay) killed the server while
 * the fresh-spawned codex session was still booting; tmux survived showing
 * an idle prompt, the bootstrap file sat unconsumed on disk, the operator
 * waited 50+ minutes, and nothing anywhere knew a message had been lost.
 *
 * The store is one JSON file per pending inject, keyed by tmux session name,
 * under `<stateDir>/pending-injects/`. Records are written at spawn time and
 * cleared ONLY after the inject actually runs. Boot-time recovery
 * (SessionManager.recoverPendingInjects) sweeps survivors: a still-alive
 * session gets the message re-delivered through the normal readiness path; a
 * dead session is reported loudly (DegradationReporter) and expired — never
 * silently dropped.
 *
 * Delivery semantics are deliberately AT-LEAST-ONCE: if the server dies
 * after the inject but before the clear, the next boot re-injects a
 * duplicate. A duplicated message to an agent session is recoverable noise;
 * a silently dropped user message is not.
 */
import fs from 'node:fs';
import path from 'node:path';
import { SafeFsExecutor } from './SafeFsExecutor.js';
export class PendingInjectStore {
    dir;
    constructor(stateDir) {
        this.dir = path.join(stateDir, 'pending-injects');
    }
    /** Key → filename. tmux session names are already shell-safe, but clamp anyway. */
    fileFor(tmuxSession) {
        const safe = tmuxSession.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
        return path.join(this.dir, `${safe}.json`);
    }
    /** Write (or overwrite) the pending record for a session. Never throws —
     *  a failed durability write must not block the spawn itself. */
    record(entry) {
        try {
            fs.mkdirSync(this.dir, { recursive: true });
            const full = {
                tmuxSession: entry.tmuxSession,
                initialMessage: entry.initialMessage,
                ...(entry.telegramTopicId !== undefined ? { telegramTopicId: entry.telegramTopicId } : {}),
                createdAt: entry.createdAt ?? new Date().toISOString(),
            };
            fs.writeFileSync(this.fileFor(entry.tmuxSession), JSON.stringify(full, null, 2));
        }
        catch (err) {
            // @silent-fallback-ok a durable-ledger write must NEVER break the spawn it
            // is protecting — warn with context and continue; a missed record only
            // means this one inject isn't restart-recoverable (the pre-fix behavior).
            console.warn(`[PendingInjectStore] record failed for "${entry.tmuxSession}" (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /** Remove the record after a verified inject. Never throws. */
    clear(tmuxSession) {
        try {
            SafeFsExecutor.safeUnlinkSync(this.fileFor(tmuxSession), {
                operation: 'PendingInjectStore.clear',
            });
        }
        catch (err) {
            if (err?.code !== 'ENOENT') {
                // Already-cleared (ENOENT) is the expected no-op; anything else is
                // non-fatal — a stale record is re-examined (and expired) by the next
                // boot sweep, so we report rather than throw into the inject path.
                console.warn(`[PendingInjectStore] clear failed for "${tmuxSession}" (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    /** All surviving records (corrupt files are skipped, reported via return). */
    list() {
        const records = [];
        const corrupt = [];
        let files = [];
        try {
            files = fs.readdirSync(this.dir).filter((f) => f.endsWith('.json'));
        }
        catch {
            // @silent-fallback-ok no pending-injects dir yet (fresh install / nothing
            // ever queued) is the normal empty case, not a degradation — return empty.
            return { records, corrupt };
        }
        for (const f of files) {
            try {
                const parsed = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
                if (parsed && typeof parsed === 'object' &&
                    typeof parsed.tmuxSession === 'string' &&
                    typeof parsed.initialMessage === 'string' &&
                    typeof parsed.createdAt === 'string') {
                    records.push(parsed);
                }
                else {
                    corrupt.push(f);
                }
            }
            catch {
                corrupt.push(f);
            }
        }
        return { records, corrupt };
    }
}
/**
 * Boot-time recovery over surviving records. Decision per record:
 *  - older than maxAge       → expire + reportLoss (stale enough that re-injecting
 *                              mid-conversation would confuse more than help)
 *  - tmux session still alive → redeliver via the readiness path, clear on success
 *  - tmux session gone        → reportLoss + clear (the bridge respawns on the
 *                              user's next message; the loss is now VISIBLE)
 */
export async function sweepPendingInjects(store, deps) {
    const result = { redelivered: [], expired: [], deadSession: [], failed: [] };
    const now = deps.now ?? (() => Date.now());
    const maxAgeMs = deps.maxAgeMs ?? 6 * 60 * 60 * 1000;
    const { records, corrupt } = store.list();
    for (const f of corrupt) {
        console.warn(`[PendingInjectStore] corrupt pending-inject file skipped: ${f}`);
    }
    for (const record of records) {
        const age = now() - Date.parse(record.createdAt);
        if (!Number.isFinite(age) || age > maxAgeMs) {
            deps.reportLoss(record, `record expired (age ${Math.round(age / 60000)}m > ${Math.round(maxAgeMs / 60000)}m)`);
            store.clear(record.tmuxSession);
            result.expired.push(record.tmuxSession);
            continue;
        }
        if (!deps.sessionAlive(record.tmuxSession)) {
            deps.reportLoss(record, 'target tmux session no longer exists');
            store.clear(record.tmuxSession);
            result.deadSession.push(record.tmuxSession);
            continue;
        }
        try {
            await deps.redeliver(record);
            store.clear(record.tmuxSession);
            result.redelivered.push(record.tmuxSession);
        }
        catch (err) {
            // Keep the record — the NEXT boot retries. Failing loudly beats dropping.
            deps.reportLoss(record, `redeliver failed: ${err instanceof Error ? err.message : String(err)}`);
            result.failed.push(record.tmuxSession);
        }
    }
    return result;
}
//# sourceMappingURL=PendingInjectStore.js.map