import fs from "node:fs";
import path from "node:path";
/** 24h in ms — recency threshold for `shouldKeepDespiteOrphan`. */
const RECENT_TRANSCRIPT_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
 * Tightened orphan-cleanup gate. Returns true when an apparently-orphaned
 * session must NOT be archived because its worktree directory still exists
 * AND its agent JSONL has been written within the last 24h. Caller is
 * `SessionManager`'s boot orphan sweep — leave the session live; the user
 * can archive manually from the UI if it really is dead.
 *
 * `now` is injectable for testability.
 */
export function shouldKeepDespiteOrphan(ps, now = Date.now()) {
    const wtAlive = !!ps.worktreePath && (() => {
        try {
            return fs.existsSync(ps.worktreePath);
        }
        catch {
            return false;
        }
    })();
    if (!wtAlive)
        return false;
    const recentTranscript = !!ps.agentSessionFile && (() => {
        try {
            return now - fs.statSync(ps.agentSessionFile).mtimeMs < RECENT_TRANSCRIPT_WINDOW_MS;
        }
        catch {
            return false;
        }
    })();
    return recentTranscript;
}
/**
 * Simple JSON file store for gateway session metadata.
 * Allows sessions to survive server restarts.
 */
export class SessionStore {
    storeDir;
    storeFile;
    sessions = new Map();
    saveTimer = null;
    static SAVE_DEBOUNCE_MS = 1000;
    static BACKUP_COUNT = 5;
    /** Monotonically increasing counter — bumped on every mutation. Resets to 0 on server restart. */
    generation = 0;
    /** Epoch read from disk on construction (or 0 for legacy/missing). */
    loadedEpoch = 0;
    /** Epoch we have successfully written to disk this process. */
    writtenEpoch = 0;
    /** One-shot latch: once tripped, no further saveNow() writes to disk. */
    staleGuardTripped = false;
    constructor(stateDir) {
        this.storeDir = stateDir;
        this.storeFile = path.join(stateDir, "sessions.json");
        this.load();
    }
    /** Normalise a single PersistedSession-shaped row read from disk (legacy field migration). */
    seedFromArray(rows) {
        for (const row of rows) {
            if (!row || typeof row !== "object")
                continue;
            const s = row;
            if (!s.id)
                continue;
            // Migrate legacy 'swarmGoalId' field to 'teamGoalId'
            if (s.swarmGoalId !== undefined && s.teamGoalId === undefined) {
                s.teamGoalId = s.swarmGoalId;
                delete s.swarmGoalId;
            }
            // Lenient parse: silently drop legacy `personalities` field (feature removed)
            if ("personalities" in s) {
                delete s.personalities;
            }
            // Normalize legacy boolean flags to assistantType
            if (!s.assistantType) {
                if (s.goalAssistant)
                    s.assistantType = "goal";
                else if (s.roleAssistant)
                    s.assistantType = "role";
                else if (s.toolAssistant)
                    s.assistantType = "tool";
            }
            this.sessions.set(s.id, s);
        }
    }
    /** Backup-file path for index 1..N. */
    bakPath(n) {
        return `${this.storeFile}.bak.${n}`;
    }
    load() {
        this.loadedEpoch = 0;
        this.writtenEpoch = 0;
        const candidates = [this.storeFile];
        for (let i = 1; i <= SessionStore.BACKUP_COUNT; i++)
            candidates.push(this.bakPath(i));
        for (const file of candidates) {
            try {
                if (!fs.existsSync(file))
                    continue;
                const raw = fs.readFileSync(file, "utf-8");
                const parsed = JSON.parse(raw);
                if (Array.isArray(parsed)) {
                    // Legacy v1 shape
                    this.seedFromArray(parsed);
                    this.loadedEpoch = 0;
                    if (file !== this.storeFile) {
                        console.warn(`[session-store] Loaded from backup ${path.basename(file)} — primary missing/corrupt`);
                    }
                    return;
                }
                if (parsed && typeof parsed === "object" && parsed.version === 2 && Array.isArray(parsed.sessions)) {
                    const obj = parsed;
                    this.seedFromArray(obj.sessions);
                    this.loadedEpoch = typeof obj.epoch === "number" ? obj.epoch : 0;
                    if (file !== this.storeFile) {
                        console.warn(`[session-store] Loaded from backup ${path.basename(file)} (epoch ${this.loadedEpoch}) — primary missing/corrupt`);
                    }
                    return;
                }
                console.warn(`[session-store] ${file}: unrecognised shape, skipping`);
            }
            catch (err) {
                console.warn(`[session-store] Failed to parse ${file}:`, err);
            }
        }
        // No file readable — start empty.
    }
    /**
     * Synchronously read just `sessions.json` and return its `epoch`.
     * Returns 0 for legacy v1 array shape; -1 if the file is missing or
     * unparseable. Used by saveNow() to detect external rewrites.
     */
    peekDiskEpoch() {
        try {
            if (!fs.existsSync(this.storeFile))
                return -1;
            const raw = fs.readFileSync(this.storeFile, "utf-8");
            const parsed = JSON.parse(raw);
            if (Array.isArray(parsed))
                return 0;
            if (parsed && typeof parsed === "object" && typeof parsed.epoch === "number") {
                return parsed.epoch;
            }
            return -1;
        }
        catch {
            return -1;
        }
    }
    /** Rotate sessions.json → .bak.1 → .bak.2 → … → .bak.N. Best-effort. */
    rotateBackups() {
        try {
            if (!fs.existsSync(this.storeFile))
                return;
            const N = SessionStore.BACKUP_COUNT;
            // Drop the oldest if it exists.
            try {
                if (fs.existsSync(this.bakPath(N)))
                    fs.unlinkSync(this.bakPath(N));
            }
            catch { /* non-fatal */ }
            // Shift .bak.{i} -> .bak.{i+1} for i = N-1 down to 1.
            for (let i = N - 1; i >= 1; i--) {
                try {
                    if (fs.existsSync(this.bakPath(i))) {
                        fs.renameSync(this.bakPath(i), this.bakPath(i + 1));
                    }
                }
                catch { /* non-fatal */ }
            }
            // Copy current sessions.json → .bak.1 (copy, not rename — saveNow will
            // overwrite via tmp+rename and we want to keep the current file present
            // in case the new write fails).
            try {
                fs.copyFileSync(this.storeFile, this.bakPath(1));
            }
            catch { /* non-fatal */ }
        }
        catch {
            // Backup failure must never block a save.
        }
    }
    /** True if the most recent saveNow() refused to write due to the stale-snapshot guard. */
    isStaleGuardTripped() {
        return this.staleGuardTripped;
    }
    /** Epoch read from disk at construction. Test-visible. */
    getLoadedEpoch() {
        return this.loadedEpoch;
    }
    /** Epoch most recently written to disk this process. Test-visible. */
    getWrittenEpoch() {
        return this.writtenEpoch;
    }
    /** Write sessions to disk immediately (synchronous). */
    saveNow() {
        if (this.staleGuardTripped)
            return;
        try {
            if (!fs.existsSync(this.storeDir)) {
                fs.mkdirSync(this.storeDir, { recursive: true });
            }
            // Stale-snapshot guard: if the on-disk epoch is HIGHER than what we
            // last loaded AND we have not yet written anything this process, refuse
            // — we'd be clobbering newer state (e.g. cloud-sync / antivirus / manual
            // restore from .pre-migration backup under a running gateway).
            const onDiskEpoch = this.peekDiskEpoch();
            if (onDiskEpoch > this.loadedEpoch && this.writtenEpoch === 0) {
                console.error(`[session-store] REFUSING to save: on-disk epoch ${onDiskEpoch} is ` +
                    `newer than loaded epoch ${this.loadedEpoch}. Possible stale-snapshot ` +
                    `recovery (cloud sync / antivirus / .pre-migration). ` +
                    `In-memory state has ${this.sessions.size} sessions; on-disk has more recent. ` +
                    `Manual intervention required: inspect ${this.storeFile} and ${this.storeFile}.bak.*`);
                this.staleGuardTripped = true;
                return;
            }
            const nextEpoch = Math.max(this.loadedEpoch, this.writtenEpoch, onDiskEpoch < 0 ? 0 : onDiskEpoch) + 1;
            const payload = {
                version: 2,
                epoch: nextEpoch,
                sessions: Array.from(this.sessions.values()),
            };
            const json = JSON.stringify(payload, null, 2);
            // Rotate .bak (keep N=5) before writing — best-effort.
            this.rotateBackups();
            // Atomic: write to .tmp, fsync, rename.
            const tmp = `${this.storeFile}.tmp`;
            const fd = fs.openSync(tmp, "w");
            try {
                fs.writeFileSync(fd, json, "utf-8");
                try {
                    fs.fsyncSync(fd);
                }
                catch { /* fsync may fail on Windows network shares — non-fatal */ }
            }
            finally {
                fs.closeSync(fd);
            }
            fs.renameSync(tmp, this.storeFile);
            this.writtenEpoch = nextEpoch;
        }
        catch (err) {
            console.error("[session-store] Failed to save sessions:", err);
            // Best-effort cleanup of stray .tmp from a failed write.
            try {
                const tmp = `${this.storeFile}.tmp`;
                if (fs.existsSync(tmp))
                    fs.unlinkSync(tmp);
            }
            catch { /* ignore */ }
        }
    }
    /**
     * Walk `agentSessionsRoot` for `*.jsonl` transcripts that are not referenced
     * by any persisted session (`agentSessionFile`) and whose mtime is newer
     * than the most recent `lastActivity` in the store. Useful as a divergence
     * signal after crash recovery — the agent CLI may have written transcripts
     * that never made it into the session-metadata index.
     *
     * Does NOT auto-import. Caps the returned `paths` at `maxPaths` (default 50)
     * and emits at most 20 `[session-store] WARN: orphaned transcript: …` log
     * lines.
     */
    scanOrphanedTranscripts(agentSessionsRoot, options = {}) {
        const maxPaths = options.maxPaths ?? 50;
        const maxLogLines = options.maxLogLines ?? 20;
        const threshold = options.mostRecentLastActivity ?? this.computeMostRecentLastActivity();
        const tracked = new Set();
        for (const s of this.sessions.values()) {
            if (s.agentSessionFile) {
                tracked.add(path.resolve(s.agentSessionFile));
            }
        }
        const paths = [];
        let count = 0;
        let logged = 0;
        const walk = (dir) => {
            let entries;
            try {
                entries = fs.readdirSync(dir, { withFileTypes: true });
            }
            catch {
                return;
            }
            for (const ent of entries) {
                const full = path.join(dir, ent.name);
                if (ent.isDirectory()) {
                    walk(full);
                    continue;
                }
                if (!ent.isFile())
                    continue;
                if (!ent.name.endsWith(".jsonl"))
                    continue;
                const resolved = path.resolve(full);
                if (tracked.has(resolved))
                    continue;
                try {
                    const st = fs.statSync(full);
                    if (st.mtimeMs < threshold)
                        continue;
                }
                catch {
                    continue;
                }
                count++;
                if (paths.length < maxPaths)
                    paths.push(resolved);
                if (logged < maxLogLines) {
                    console.warn(`[session-store] WARN: orphaned transcript: ${resolved}`);
                    logged++;
                }
            }
        };
        try {
            if (fs.existsSync(agentSessionsRoot))
                walk(agentSessionsRoot);
        }
        catch {
            // non-fatal — return whatever we collected
        }
        return { count, paths };
    }
    computeMostRecentLastActivity() {
        let max = 0;
        for (const s of this.sessions.values()) {
            if (typeof s.lastActivity === "number" && s.lastActivity > max)
                max = s.lastActivity;
        }
        return max;
    }
    /** Schedule a debounced save — coalesces rapid writes into one disk flush. */
    save() {
        if (this.saveTimer)
            return; // already scheduled
        this.saveTimer = setTimeout(() => {
            this.saveTimer = null;
            this.saveNow();
        }, SessionStore.SAVE_DEBOUNCE_MS);
    }
    /** Current generation counter — bumped on every mutation. */
    getGeneration() {
        return this.generation;
    }
    /** Optional callback invoked after any session mutation (put/update/archive). */
    onIndexUpdate;
    put(session) {
        this.generation++;
        this.sessions.set(session.id, session);
        this.saveNow(); // immediate — structural change
        this.onIndexUpdate?.(session);
    }
    get(id) {
        return this.sessions.get(id);
    }
    remove(id) {
        this.generation++;
        this.sessions.delete(id);
        this.saveNow(); // immediate — structural change
    }
    getAll() {
        return Array.from(this.sessions.values());
    }
    /**
     * Fields whose persistence is required for the session to survive a hard
     * restart (kill -9, OS crash, container OOM). When any of these change we
     * flush synchronously instead of going through the 1s save debounce —
     * otherwise the gateway can advertise the session as `idle` to the API
     * before the recovery-critical disk write has landed, and a kill in that
     * window archives the session on next boot.
     *
     * Lower-frequency by nature, so synchronous writes are not a perf concern.
     * `lastActivity` / `lastReadAt` are intentionally excluded — they fire on
     * every event and benefit from coalescing.
     */
    static RECOVERY_CRITICAL_FIELDS = [
        "agentSessionFile", "branch", "worktreePath", "cwd", "repoPath",
        "repoWorktrees", "archived", "archivedAt",
        "sandboxed", "projectId", "goalId", "delegateOf",
        "parentSessionId", "childKind", "readOnly", "walkthroughJobId",
        "walkthroughChangesetId", "walkthroughTargetKey",
        "role", "assistantType", "taskId", "staffId",
        "teamGoalId", "teamLeadSessionId",
        "modelProvider", "modelId",
    ];
    /** Update a subset of fields for an existing session */
    update(id, updates) {
        const existing = this.sessions.get(id);
        if (!existing)
            return;
        this.generation++;
        Object.assign(existing, updates);
        // Recovery-critical fields must survive a hard kill — flush synchronously.
        const critical = SessionStore.RECOVERY_CRITICAL_FIELDS.some(f => f in updates);
        if (critical) {
            // If a debounced save is pending, cancel it — saveNow supersedes it.
            if (this.saveTimer) {
                clearTimeout(this.saveTimer);
                this.saveTimer = null;
            }
            this.saveNow();
        }
        else {
            this.save(); // debounced — high-frequency, non-critical (lastActivity, lastReadAt, drafts, queue)
        }
        // Only notify on meaningful field changes (skip high-frequency activity updates)
        if (updates.title !== undefined || updates.archived !== undefined || updates.role !== undefined || updates.goalId !== undefined) {
            this.onIndexUpdate?.(existing);
        }
    }
    /** Get a draft for a session by type. */
    getDraft(sessionId, type) {
        const session = this.sessions.get(sessionId);
        if (!session?.drafts)
            return undefined;
        return session.drafts[type];
    }
    /** Set a draft for a session by type. Triggers debounced save. */
    setDraft(sessionId, type, data) {
        const session = this.sessions.get(sessionId);
        if (!session)
            return false;
        // Reject stale writes: if both the incoming and existing drafts carry a
        // `gen` field, only accept the write when the incoming gen is >= existing.
        // This prevents out-of-order HTTP requests from resurrecting a draft that
        // was already cleared by a newer tombstone (e.g. send clears with gen=2,
        // but a delayed save from gen=1 arrives after).
        if (data && typeof data === "object" && "gen" in data) {
            const incomingGen = data.gen;
            const existing = session.drafts?.[type];
            if (existing && typeof existing === "object" && "gen" in existing) {
                const existingGen = existing.gen;
                if (typeof incomingGen === "number" && typeof existingGen === "number" && incomingGen < existingGen) {
                    return true; // Silently discard stale write — not an error
                }
            }
        }
        this.generation++;
        if (!session.drafts)
            session.drafts = {};
        session.drafts[type] = data;
        this.save();
        return true;
    }
    /** Delete a draft for a session by type. Triggers debounced save. */
    deleteDraft(sessionId, type) {
        const session = this.sessions.get(sessionId);
        if (!session?.drafts)
            return false;
        this.generation++;
        delete session.drafts[type];
        // Clean up empty drafts object
        if (Object.keys(session.drafts).length === 0) {
            delete session.drafts;
        }
        this.save();
        return true;
    }
    /** Mark a session as archived. */
    archive(id) {
        const existing = this.sessions.get(id);
        if (!existing)
            return false;
        this.generation++;
        existing.archived = true;
        existing.archivedAt = Date.now();
        this.saveNow(); // immediate — structural change
        this.onIndexUpdate?.(existing);
        return true;
    }
    /** Get all archived sessions. */
    getArchived() {
        return Array.from(this.sessions.values()).filter(s => s.archived === true);
    }
    /**
     * Paginated listing of archived sessions, sorted by archivedAt DESC.
     * @param limit Max items per page
     * @param afterCursor archivedAt timestamp — return items with archivedAt < cursor
     */
    listArchivedSessionsPaginated(limit, afterCursor) {
        let archived = this.getArchived().sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0));
        const total = archived.length;
        if (afterCursor !== undefined) {
            archived = archived.filter(s => (s.archivedAt ?? 0) < afterCursor);
        }
        const page = archived.slice(0, limit);
        const hasMore = archived.length > limit;
        const nextCursor = page.length > 0 ? page[page.length - 1].archivedAt : undefined;
        return { sessions: page, total, hasMore, nextCursor };
    }
    /** Get all live (non-archived) sessions. */
    getLive() {
        return Array.from(this.sessions.values()).filter(s => !s.archived);
    }
    /** Permanently remove an archived session from the store. */
    purge(id) {
        const existing = this.sessions.get(id);
        if (!existing)
            return false;
        this.generation++;
        this.sessions.delete(id);
        this.saveNow();
        return true;
    }
    /** Flush any pending debounced save immediately (e.g. before shutdown). */
    flush() {
        if (this.saveTimer) {
            clearTimeout(this.saveTimer);
            this.saveTimer = null;
            this.saveNow();
        }
    }
}
//# sourceMappingURL=session-store.js.map