/**
 * Daemon-warmed cross-surface cache for live session status (RUSH-2062).
 *
 * Problem: menubar / Factory / watchdog / CLI each independently run a full
 * `sessions --active` gather (~9s wall / ~170MB) with no sharing. Measured on
 * zion: back-to-back runs were 9.22s then 8.16s — no speedup, full SSH fan-out
 * every call.
 *
 * Pattern mirrors {@link ../devices/stats-cache.ts} / {@link ../fleet-status.ts}:
 *
 * - **Daemon publish:** each daemon publishes THIS host's local active sessions
 *   (`publishLocalActiveSessions`). Each write appends row deltas to the journal
 *   consumed by long-lived watchers. Publish-own only — no
 *   cross-host SSH from the daemon (avoids the N² fan-out of RUSH-2061).
 * - **Readers** (`loadLocalActiveSessions`, fleet path in `gatherActiveSessions`)
 *   serve the warm snapshot when it is within {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS};
 *   `forceRefresh` / age expiry re-gathers live.
 * - **Immutable memo:** per-session fields that only change when the transcript
 *   does (topic, label, cwd, …) are keyed on `(sessionId, transcriptMtimeMs)`.
 *   Live status fields (`status`, `activity`, `preview`, `pidAlive`, …) are
 *   NEVER stored in that memo — they only ride the short-lived snapshot.
 *
 * Best-effort disk IO: a missing/corrupt cache never throws; the caller falls
 * through to a live gather.
 */
import * as fs from 'fs';
import * as path from 'path';
import { createMemoryCache } from '../memory-cache.js';
import { getCacheDir } from '../state.js';
import { backfillActiveRowsFromIndex, sessionProcessIsLocal } from './active.js';
import { readSessionSummaryAny } from './db.js';
import { isSummarizerReady } from '../summarizer/config.js';
/** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
const SNAPSHOT_FILE = '.active-sessions.json';
/** Immutable-field memo file (keyed by sessionId + transcript mtime). */
const IMMUTABLE_FILE = '.active-session-immutable.json';
const JOURNAL_FILE = '.active-sessions.journal.jsonl';
/**
 * How long a snapshot may be served before a reader re-gathers.
 * Short on purpose: live status (running/idle/waiting) must not go stale.
 * One-shot readers may re-gather after this ceiling. Long-lived watchers never
 * do: they read one reset then tail the canonical writer journal.
 */
export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15_000;
export function activeSessionJournalIdentity(row) {
    return row.sessionId ?? `${row.context}:${row.kind}:${row.pid ?? ''}:${row.startedAtMs ?? ''}`;
}
/** Process-local L1. The atomic snapshot remains the cross-process source. */
const activeSnapshotMemory = createMemoryCache({
    max: 2,
    ttlMs: DEFAULT_ACTIVE_CACHE_MAX_AGE_MS,
});
/** Keys stored in the immutable memo (transcript-stable). */
export const IMMUTABLE_FIELD_KEYS = [
    'topic',
    'firstUserMessage',
    'label',
    'name',
    'cwd',
    'project',
    'attachments',
    'startedAtMs',
    'version',
    'pr',
    'worktree',
    'ticket',
    'createdTickets',
    'spawnedTeam',
    'sessionFile',
    'owner',
    'assignedTask',
    'kind',
    'context',
];
/**
 * Live / volatile fields that MUST NOT be served from the immutable memo.
 * They either change without a transcript write (pid death, attach state) or
 * are short-window signals (preview, tok/s). The short snapshot TTL is the
 * only cache that may carry them — and only as a whole-row snapshot.
 */
export const LIVE_STATUS_KEYS = [
    'status',
    'activity',
    'preview',
    'tokPerSec',
    'awaitingReason',
    'question',
    'todos',
    'tail',
    'lastActivityMs',
    'hostLink',
    'presence',
    'pidAlive',
    'tmuxClients',
    'windowHeartbeatMs',
    'provenance',
    'rateLimited',
    'plan',
];
/**
 * How long after the last reader heartbeat the daemon considers the journal
 * to have no active consumer and skips the expensive `ps`+`lsof` gather.
 *
 * Must be significantly larger than {@link SESSION_WATCH_HEARTBEAT_MS} (15s)
 * so a single delayed heartbeat does not cause a false-idle. Three tick-lengths
 * gives adequate margin while still cutting load within ~45s of the last
 * watcher disconnecting.
 */
export const ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS = 45_000;
// ── path overrides (test seam) ─────────────────────────────────────────────
let snapshotPathOverride = null;
let immutablePathOverride = null;
let journalPathOverride = null;
let readerPresencePathOverride = null;
/** Test seam: redirect the snapshot file. Returns the previous override. */
export function setActiveSessionsSnapshotPathForTest(p) {
    const prev = snapshotPathOverride;
    snapshotPathOverride = p;
    activeSnapshotMemory.clear();
    return prev;
}
/** Test seam: isolate process-local entries between fixtures. */
export function clearActiveSnapshotMemoryForTest() {
    activeSnapshotMemory.clear();
}
/** Test seam: redirect the immutable-memo file. Returns the previous override. */
export function setImmutableMemoPathForTest(p) {
    const prev = immutablePathOverride;
    immutablePathOverride = p;
    return prev;
}
/** Test seam: redirect the append-only live-state journal. */
export function setActiveSessionsJournalPathForTest(p) {
    const prev = journalPathOverride;
    journalPathOverride = p;
    return prev;
}
/** Test seam: redirect the reader-presence file. Returns the previous override. */
export function setActiveSessionsReaderPresencePathForTest(p) {
    const prev = readerPresencePathOverride;
    readerPresencePathOverride = p;
    return prev;
}
function snapshotPath() {
    return snapshotPathOverride ?? path.join(getCacheDir(), SNAPSHOT_FILE);
}
function immutablePath() {
    return immutablePathOverride ?? path.join(getCacheDir(), IMMUTABLE_FILE);
}
export function activeSessionsJournalPath() {
    return journalPathOverride
        ?? (snapshotPathOverride ? `${snapshotPathOverride}.journal.jsonl` : path.join(getCacheDir(), JOURNAL_FILE));
}
const READER_PRESENCE_FILE = '.active-sessions-reader.presence';
function readerPresencePath() {
    return readerPresencePathOverride ?? path.join(getCacheDir(), READER_PRESENCE_FILE);
}
/**
 * Record that a journal consumer is active right now.
 *
 * Called by {@link watchLocalSessions} on startup and on each heartbeat so
 * the daemon warm tick can gate the expensive `ps`+`lsof` gather on reader
 * presence and skip it when no watcher has checked in recently.
 */
export function noteActiveSessionsJournalReader(nowMs = Date.now()) {
    try {
        const p = readerPresencePath();
        fs.mkdirSync(path.dirname(p), { recursive: true });
        fs.writeFileSync(p, String(nowMs));
    }
    catch { /* best-effort: a failed write does not block the caller */ }
}
/**
 * True when a journal consumer has signalled its presence within
 * {@link ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS}.
 *
 * Used by the daemon warm tick to decide whether to run the expensive
 * `ps`+`lsof` gather. On an idle box with no watcher the tick skips the
 * gather entirely; the moment a new watcher connects it calls
 * {@link noteActiveSessionsJournalReader} and the following tick gathers.
 */
export function isActiveSessionsJournalReaderRecent(nowMs = Date.now(), idleWindowMs = ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS) {
    try {
        const content = fs.readFileSync(readerPresencePath(), 'utf8').trim();
        const ts = Number(content);
        if (!Number.isFinite(ts))
            return false;
        return nowMs - ts < idleWindowMs;
    }
    catch {
        return false;
    }
}
/** Default poll cadence for {@link watchActiveSessionsReaderPresence}. */
export const ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS = 1_000;
/**
 * Watch for a reader going from absent/idle to present and fire `onConnect`
 * out of band, instead of waiting for the next scheduled warm tick (RUSH-2484).
 *
 * {@link noteActiveSessionsJournalReader} only writes a timestamp — it has no
 * path back to the daemon process that owns the warm tick's `setInterval`. A
 * watcher connecting to a cold or long-idle daemon therefore used to wait up
 * to one full {@link ACTIVE_SESSIONS_WARM_TICK_MS} tick for its first fresh
 * rows. This polls the tiny presence file (not the expensive `ps`+`lsof`
 * gather) at {@link ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS} and calls
 * `onConnect` only on the idle→recent edge, so a steadily-connected reader's
 * repeated heartbeats never re-trigger it and an idle box with no reader never
 * gathers — the CPU win from RUSH-3193 is preserved.
 *
 * Returns a disposer that stops the poll.
 */
export function watchActiveSessionsReaderPresence(onConnect, opts = {}) {
    const pollMs = opts.pollMs ?? ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS;
    const now = opts.nowMs ?? Date.now;
    let lastRecent = isActiveSessionsJournalReaderRecent(now(), opts.idleWindowMs);
    const timer = setInterval(() => {
        const recent = isActiveSessionsJournalReaderRecent(now(), opts.idleWindowMs);
        if (recent && !lastRecent)
            onConnect();
        lastRecent = recent;
    }, pollMs);
    timer.unref?.();
    return () => clearInterval(timer);
}
// ── snapshot read / write ──────────────────────────────────────────────────
/** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
export function readActiveSessionsCache(scope) {
    const memory = activeSnapshotMemory.get(scope);
    if (memory)
        return memory;
    try {
        const parsed = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
        if (!parsed || parsed.version !== 1 || !parsed.entries)
            return null;
        const entry = parsed.entries[scope];
        if (!entry || !Array.isArray(entry.sessions) || typeof entry.capturedAt !== 'number')
            return null;
        activeSnapshotMemory.set(scope, entry);
        return entry;
    }
    catch {
        return null;
    }
}
/**
 * Persist a snapshot for one scope (best-effort). Other scopes are preserved
 * so a local warm never drops a fleet snapshot a reader just wrote.
 */
export function writeActiveSessionsCache(scope, sessions, opts = {}) {
    const snap = {
        version: 1,
        scope,
        capturedAt: opts.capturedAt ?? Date.now(),
        sessions,
        ...(opts.remoteDeviceCount !== undefined ? { remoteDeviceCount: opts.remoteDeviceCount } : {}),
    };
    try {
        const dir = path.dirname(snapshotPath());
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir, { recursive: true });
        let entries = {};
        try {
            const prev = JSON.parse(fs.readFileSync(snapshotPath(), 'utf-8'));
            if (prev?.entries && typeof prev.entries === 'object')
                entries = { ...prev.entries };
        }
        catch {
            // empty
        }
        const hadPrevious = Boolean(entries[scope]);
        const previousSessions = entries[scope]?.sessions ?? [];
        entries[scope] = snap;
        const body = { version: 1, entries };
        // Atomic replace so a concurrent reader never sees a partial write.
        const tmp = `${snapshotPath()}.tmp`;
        fs.writeFileSync(tmp, JSON.stringify(body));
        fs.renameSync(tmp, snapshotPath());
        // The snapshot writer is the canonical publisher. Watchers tail this
        // append-only journal and compute stream deltas; they never trigger their
        // own full active-session gather on a timer.
        const previous = new Map(previousSessions.map((row) => [activeSessionJournalIdentity(row), row]));
        const next = new Map(sessions.map((row) => [activeSessionJournalIdentity(row), row]));
        const upserts = sessions.filter((row) => {
            const before = previous.get(activeSessionJournalIdentity(row));
            return !before || JSON.stringify(before) !== JSON.stringify(row);
        });
        const removes = [...previous.keys()].filter((key) => !next.has(key));
        try {
            if (!hadPrevious || upserts.length > 0 || removes.length > 0) {
                fs.appendFileSync(activeSessionsJournalPath(), `${JSON.stringify({
                    version: 1, scope, capturedAt: snap.capturedAt, upserts, removes,
                })}\n`);
            }
        }
        catch {
            // Journal delivery is best-effort; the canonical snapshot remains valid.
        }
        activeSnapshotMemory.set(scope, snap);
    }
    catch {
        // best-effort
    }
    return snap;
}
/**
 * True when a snapshot is still within the freshness window. Pure — the
 * staleness invariant for live status lives here.
 */
export function isActiveSnapshotFresh(capturedAt, nowMs, maxAgeMs = DEFAULT_ACTIVE_CACHE_MAX_AGE_MS) {
    if (!Number.isFinite(capturedAt) || !Number.isFinite(maxAgeMs))
        return false;
    if (maxAgeMs < 0)
        return false;
    return nowMs - capturedAt <= maxAgeMs;
}
// ── immutable memo ─────────────────────────────────────────────────────────
/** Pull only the transcript-stable fields from a live row. */
export function pickImmutableFields(s) {
    const out = {};
    for (const k of IMMUTABLE_FIELD_KEYS) {
        const v = s[k];
        if (v !== undefined)
            out[k] = v;
    }
    return out;
}
/**
 * Transcript mtime used as the memo key. Prefer {@link ActiveSession.lastActivityMs}
 * (the transcript's last write); fall back to `startedAtMs` when no activity
 * stamp exists. Returns null when neither is known — caller must not memoize.
 */
export function transcriptMtimeMs(s) {
    if (typeof s.lastActivityMs === 'number' && Number.isFinite(s.lastActivityMs))
        return s.lastActivityMs;
    if (typeof s.startedAtMs === 'number' && Number.isFinite(s.startedAtMs))
        return s.startedAtMs;
    return null;
}
function readImmutableFile() {
    try {
        const parsed = JSON.parse(fs.readFileSync(immutablePath(), 'utf-8'));
        if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
            return parsed;
        }
    }
    catch {
        // missing/corrupt
    }
    return { version: 1, entries: {} };
}
/**
 * Read memoized immutable fields for `(sessionId, mtimeMs)`.
 * Returns null when missing OR when the stored mtime does not match — a
 * transcript rewrite must force re-derivation of topic/label/etc.
 */
export function readImmutableMemo(sessionId, mtimeMs) {
    if (!sessionId || !Number.isFinite(mtimeMs))
        return null;
    const file = readImmutableFile();
    const entry = file.entries[sessionId];
    if (!entry || entry.mtimeMs !== mtimeMs)
        return null;
    // Defence in depth: strip any live-status key that snuck into a bad write.
    return stripLiveStatusKeys({ ...entry.fields });
}
/** Persist immutable fields for `(sessionId, mtimeMs)`. Live keys are stripped. */
export function writeImmutableMemo(sessionId, mtimeMs, fields, nowMs = Date.now()) {
    if (!sessionId || !Number.isFinite(mtimeMs))
        return;
    try {
        const dir = path.dirname(immutablePath());
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir, { recursive: true });
        const file = readImmutableFile();
        file.entries[sessionId] = {
            mtimeMs,
            fields: stripLiveStatusKeys({ ...fields }),
            writtenAt: nowMs,
        };
        const tmp = `${immutablePath()}.tmp`;
        fs.writeFileSync(tmp, JSON.stringify(file));
        fs.renameSync(tmp, immutablePath());
    }
    catch {
        // best-effort
    }
}
/** Drop every live-status key from a field bag (defence in depth). */
export function stripLiveStatusKeys(fields) {
    const out = { ...fields };
    for (const k of LIVE_STATUS_KEYS) {
        if (k in out)
            delete out[k];
    }
    return out;
}
/**
 * True when `fields` contains no live-status key. The invariant the tests pin:
 * the immutable memo never carries status/activity/preview/etc.
 */
export function assertNoLiveStatusFields(fields) {
    for (const k of LIVE_STATUS_KEYS) {
        if (k in fields && fields[k] !== undefined)
            return false;
    }
    return true;
}
/**
 * Write immutable memos for every session that has an id + transcript mtime.
 * Called after a live gather so the next gather (when mtime is unchanged) can
 * refill identity fields without re-deriving them.
 */
export function updateImmutableMemos(sessions, nowMs = Date.now()) {
    for (const s of sessions) {
        if (!s.sessionId)
            continue;
        const mtime = transcriptMtimeMs(s);
        if (mtime === null)
            continue;
        writeImmutableMemo(s.sessionId, mtime, pickImmutableFields(s), nowMs);
    }
}
/**
 * Fill missing immutable fields on a live row from the memo when the transcript
 * mtime matches. Never overwrites a field the live gather already set, and
 * never copies live-status keys.
 */
/**
 * Merge the daemon-computed session summary (PHNX-3939) onto a live row from the
 * transcript-keyed `session_summaries` cache. This is the low-cost display merge:
 * one indexed by-id read, NEVER a model call — the background
 * SessionSummarizerService is the only producer. It fills only what the cache
 * holds; the `pending`/`skipped` default for a row with no summary yet is applied
 * at the watch-stream boundary ({@link resolveStreamSummaryState}), so a
 * non-stream ActiveSession consumer is unaffected. Best-effort: a DB error leaves
 * the row untouched.
 */
export function mergeSessionSummary(s) {
    if (!s.sessionId)
        return s;
    try {
        const stored = readSessionSummaryAny(s.sessionId);
        if (!stored)
            return s;
        if (s.goal === undefined && stored.goal !== undefined)
            s.goal = stored.goal;
        if (s.checkpoints === undefined && stored.checkpoints !== undefined)
            s.checkpoints = stored.checkpoints;
        if (s.summaryChecklist === undefined && stored.summaryChecklist !== undefined)
            s.summaryChecklist = stored.summaryChecklist;
        if (s.summaryState === undefined)
            s.summaryState = stored.summaryState;
    }
    catch {
        // best-effort: never let the summary merge break the gather path.
    }
    return s;
}
/**
 * The `summaryState` a watch-stream row carries when the merge left it unset:
 * `pending` while the summarizer is READY (enabled AND has an endpoint, so a
 * summary is genuinely coming), `skipped` when it is off OR enabled-but-
 * unconfigured (nothing will ever populate the cache). This is the only place
 * the readiness flag is read on the read path, and it is memoized (PHNX-3939).
 */
export function resolveStreamSummaryState(current, nowMs = Date.now()) {
    if (current)
        return current;
    return isSummarizerReady(nowMs) ? 'pending' : 'skipped';
}
export function applyImmutableMemo(s) {
    if (!s.sessionId)
        return s;
    mergeSessionSummary(s);
    const mtime = transcriptMtimeMs(s);
    if (mtime === null)
        return s;
    const memo = readImmutableMemo(s.sessionId, mtime);
    if (!memo)
        return s;
    for (const k of IMMUTABLE_FIELD_KEYS) {
        if (s[k] === undefined && memo[k] !== undefined) {
            s[k] = memo[k];
        }
    }
    return s;
}
/**
 * Cache-first load of THIS host's active sessions. Default path serves the
 * daemon-warmed snapshot when fresh; `forceRefresh` or an expired snapshot
 * re-gathers and rewrites the cache.
 */
export async function loadLocalActiveSessions(opts = {}) {
    const now = opts.nowMs ?? Date.now();
    const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
    const readCache = opts.readCache ?? readActiveSessionsCache;
    const writeCache = opts.writeCache ?? writeActiveSessionsCache;
    if (!opts.forceRefresh) {
        const cached = readCache('local');
        if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
            return {
                sessions: cached.sessions,
                servedFromCache: true,
                capturedAt: cached.capturedAt,
            };
        }
    }
    const gather = opts.gather ??
        (async () => {
            const { getActiveSessions } = await import('./active.js');
            return getActiveSessions({ localOnly: true });
        });
    const sessions = await gather();
    // The index owns transcript-derived labels (Claude custom-title / ai-title).
    // Enrich before publishing so the canonical watch snapshot carries the same
    // label as one-shot `sessions --active` output.
    backfillActiveRowsFromIndex(sessions);
    for (const s of sessions)
        applyImmutableMemo(s);
    updateImmutableMemos(sessions, now);
    const snap = writeCache('local', sessions, { capturedAt: now });
    return { sessions, servedFromCache: false, capturedAt: snap.capturedAt };
}
/**
 * Cache-first load of the fleet-wide active set. A prior gather (or any surface
 * that just paid the SSH cost) leaves a fleet snapshot; subsequent menubar /
 * Factory / CLI / watchdog calls within the freshness window share it.
 */
export async function loadFleetActiveSessions(opts) {
    const now = opts.nowMs ?? Date.now();
    const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
    const readCache = opts.readCache ?? readActiveSessionsCache;
    const writeCache = opts.writeCache ?? writeActiveSessionsCache;
    if (!opts.forceRefresh) {
        const cached = readCache('fleet');
        if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
            return {
                sessions: cached.sessions,
                remoteDeviceCount: cached.remoteDeviceCount ?? 0,
                servedFromCache: true,
                capturedAt: cached.capturedAt,
            };
        }
    }
    const live = await opts.gather();
    for (const s of live.sessions)
        applyImmutableMemo(s);
    updateImmutableMemos(live.sessions, now);
    const snap = writeCache('fleet', live.sessions, {
        capturedAt: now,
        remoteDeviceCount: live.remoteDeviceCount,
    });
    // Also refresh the local slice so a `--local` reader benefits from this gather.
    // Identity is machineId() when available; fall back to "rows with no machine
    // stamp" so a pure-local gather that never set machine still warms the cache.
    let self;
    try {
        const { machineId } = await import('../machine-id.js');
        self = machineId();
    }
    catch {
        self = undefined;
    }
    // Always rewrite the local slice when we know self — including the empty
    // case. If the last local session just died, leaving a still-fresh snapshot
    // with ghost rows would make watchdog / `--local` report sessions that are
    // gone (review on RUSH-2062 / PR #2116).
    if (self !== undefined) {
        // "Local" here means the PROCESS is on this box, which is not `machine ===
        // self` for an offloaded run: its shim runs here while the agent executes on
        // the peer (RUSH-2479). The other writer of this same key,
        // `loadLocalActiveSessions`, seeds from `getActiveSessions({localOnly:true})`
        // and keeps that shim — so comparing `machine` here would make the two
        // disagree and leave `--local` showing or hiding the row depending on which
        // surface last warmed the 15s cache.
        const localOnly = live.sessions.filter((s) => sessionProcessIsLocal(s, self));
        writeCache('local', localOnly, { capturedAt: now });
    }
    return {
        sessions: live.sessions,
        remoteDeviceCount: live.remoteDeviceCount,
        servedFromCache: false,
        capturedAt: snap.capturedAt,
        remoteSkipped: live.remoteSkipped,
        remoteDiscoveryFailed: live.remoteDiscoveryFailed,
    };
}
/**
 * Daemon warm entry point: live-gather THIS host and write the local snapshot.
 * Never SSHes. Returns the published row count for the daemon log line.
 */
export async function publishLocalActiveSessions(opts = {}) {
    const result = await loadLocalActiveSessions({
        forceRefresh: true,
        gather: opts.gather,
        nowMs: opts.nowMs,
    });
    return { sessions: result.sessions, capturedAt: result.capturedAt };
}
