// session-registry.ts — in-memory store of live run-task subagent sessions.
//
// Single source of truth that the thread-switcher widget subscribes to.
// run-task.ts pushes events here; the widget renders from here. No disk I/O
// — the JSONL debug log at .forge/cache/run-task-debug-<taskId>.jsonl handles
// durable history. This registry is for the live in-process picture only.
//
// LRU-capped at MAX_SESSIONS to bound memory across long-running forge processes.
import { EventEmitter } from "node:events";
import { capTailEntry } from "./orchestrator-tree.js";
const MAX_SESSIONS = 20;
const MAX_EVENTS_PER_SESSION = 500;
const MAX_TAIL_LINES_PER_PHASE = 2048;
const MAX_TURN_LOG = 50;
export class SessionRegistry extends EventEmitter {
    sessions = new Map();
    /** Ring buffer of cross-session turn events. Capped at MAX_TURN_LOG.
     * Newest at the end. */
    turnLog = [];
    getSession(taskId) {
        return this.sessions.get(taskId);
    }
    listSessions() {
        // Most recently updated first.
        return [...this.sessions.values()].sort((a, b) => b.updatedAt - a.updatedAt);
    }
    startSession(taskId) {
        const now = Date.now();
        const existing = this.sessions.get(taskId);
        if (existing) {
            // Resume — refresh status, keep prior events for visibility.
            existing.status = "running";
            existing.updatedAt = now;
            // Re-create AbortController on resume (previous one was from a
            // prior run that may have been aborted or completed).
            existing.abortController = new AbortController();
        }
        else {
            this.sessions.set(taskId, {
                taskId,
                startedAt: now,
                updatedAt: now,
                status: "running",
                phases: [],
                events: [],
                abortController: new AbortController(),
            });
            this.evictIfNeeded();
        }
        this.emit("change", taskId);
    }
    startPhase(taskId, role, phaseIndex) {
        const s = this.sessions.get(taskId);
        if (!s)
            return;
        s.currentPhaseRole = role;
        s.updatedAt = Date.now();
        s.phases.push({
            role,
            index: phaseIndex,
            startedAt: Date.now(),
            status: "running",
            turn: 0,
            toolCount: 0,
            errCount: 0,
            tailBuffer: [],
            unreadWarnings: 0,
        });
        this.emit("change", taskId);
    }
    bumpTurn(taskId) {
        const s = this.sessions.get(taskId);
        const p = s?.phases[s.phases.length - 1];
        if (!s || !p)
            return;
        p.turn++;
        s.updatedAt = Date.now();
        this.emit("change", taskId);
    }
    recordToolStart(taskId, toolCallId, toolName, args) {
        const s = this.sessions.get(taskId);
        const p = s?.phases[s.phases.length - 1];
        if (!s || !p)
            return;
        p.toolCount++;
        s.updatedAt = Date.now();
        s.events.push({
            ts: Date.now(),
            kind: "tool_start",
            phaseRole: p.role,
            toolName,
            toolCallId,
            args,
        });
        this.trimEvents(s);
        this.emit("change", taskId);
    }
    recordToolEnd(taskId, toolCallId, toolName, isError, result) {
        const s = this.sessions.get(taskId);
        const p = s?.phases[s.phases.length - 1];
        if (!s || !p)
            return;
        if (isError)
            p.errCount++;
        s.updatedAt = Date.now();
        s.events.push({
            ts: Date.now(),
            kind: "tool_end",
            phaseRole: p.role,
            toolName,
            toolCallId,
            isError,
            result,
        });
        this.trimEvents(s);
        this.emit("change", taskId);
    }
    completePhase(taskId, role, status) {
        const s = this.sessions.get(taskId);
        const p = s?.phases[s.phases.length - 1];
        if (!s || !p || p.role !== role)
            return;
        p.endedAt = Date.now();
        p.status = status;
        s.updatedAt = Date.now();
        this.emit("change", taskId);
    }
    completeSession(taskId, status) {
        const s = this.sessions.get(taskId);
        if (!s)
            return;
        // Idempotent: once a session is in a terminal state, don't transition
        // it again. This lets every early-return path in run-task.ts blindly
        // call completeSession("failed") without clobbering the success-path's
        // prior "completed" mark.
        // "cancelling" is treated as non-terminal here — the phase loop will
        // call confirmCancelled() to transition it to "cancelled".
        if (s.status !== "running" && s.status !== "cancelling")
            return;
        s.status = status;
        s.currentPhaseRole = undefined;
        s.abortController = undefined; // clear on any terminal transition
        s.updatedAt = Date.now();
        this.emit("change", taskId);
    }
    trimEvents(s) {
        if (s.events.length > MAX_EVENTS_PER_SESSION) {
            s.events.splice(0, s.events.length - MAX_EVENTS_PER_SESSION);
        }
    }
    /**
     * Request cancellation of a running session. Transitions status to
     * "cancelling" and fires the session's AbortController.abort().
     * Returns false if session not found or already terminal.
     */
    requestCancel(taskId) {
        const s = this.sessions.get(taskId);
        if (!s)
            return false;
        // Only running sessions can be cancelled.
        if (s.status !== "running")
            return false;
        s.status = "cancelling";
        s.abortController?.abort();
        s.updatedAt = Date.now();
        this.emit("change", taskId);
        return true;
    }
    /**
     * Confirm that cancellation has fully unwound. Called by the orchestrator
     * after abort is detected and the pipeline exits. Transitions
     * "cancelling" → "cancelled".
     */
    confirmCancelled(taskId) {
        const s = this.sessions.get(taskId);
        if (!s)
            return;
        if (s.status !== "cancelling")
            return;
        s.status = "cancelled";
        s.currentPhaseRole = undefined;
        s.abortController = undefined;
        s.updatedAt = Date.now();
        this.emit("change", taskId);
    }
    /**
     * Convenience accessor — returns the AbortSignal from the session's
     * AbortController, or undefined if the session doesn't exist or has
     * no controller.
     */
    getAbortSignal(taskId) {
        const s = this.sessions.get(taskId);
        return s?.abortController?.signal;
    }
    // ── Per-phase tail buffer + unread tracking ──────────────────────────────
    //
    // These feed the thread-switcher widget. They are decoupled from the
    // events[] log (which is structured tool-call telemetry); the tail buffer
    // holds already-formatted lines ready to render in the chat viewport when
    // the user focuses a subagent.
    findPhase(taskId, phaseRole) {
        const s = this.sessions.get(taskId);
        if (!s)
            return undefined;
        // Most recent phase with this role — handles re-runs (e.g. review-plan
        // iteration). Earlier instances' buffers stay queryable via list inspection
        // but new appends always target the current attempt.
        for (let i = s.phases.length - 1; i >= 0; i--) {
            if (s.phases[i].role === phaseRole)
                return s.phases[i];
        }
        return undefined;
    }
    /**
     * Append a per-turn bubble-up event from any subagent. The observer in
     * viewport/events.ts calls this once on every `turn_end`. ChipStrip
     * subscribes to the emitted `turn` event to refresh its global preview
     * panel; other consumers can read `getRecentTurnEvents()`.
     */
    recordTurnEvent(evt) {
        this.turnLog.push(evt);
        if (this.turnLog.length > MAX_TURN_LOG) {
            this.turnLog.splice(0, this.turnLog.length - MAX_TURN_LOG);
        }
        this.emit("turn", evt);
    }
    /** Most-recent turn events across all sessions, newest first. */
    getRecentTurnEvents(limit = MAX_TURN_LOG) {
        const n = Math.min(limit, this.turnLog.length);
        const out = [];
        for (let i = this.turnLog.length - 1; i >= this.turnLog.length - n; i--) {
            out.push(this.turnLog[i]);
        }
        return out;
    }
    /** Convenience: latest turn event from any subagent, or undefined. */
    getLatestTurnEvent() {
        return this.turnLog.length > 0 ? this.turnLog[this.turnLog.length - 1] : undefined;
    }
    /**
     * Sum of `phase.usage` across every phase of every active session in the
     * registry. Drives the top-level `Σ ↑X ↓Y ⇪Z` meter so users see the
     * process-wide token burn regardless of which subagent is foregrounded.
     */
    getAggregateUsage() {
        const agg = { input: 0, output: 0, cacheRead: 0 };
        for (const s of this.sessions.values()) {
            for (const p of s.phases) {
                if (!p.usage)
                    continue;
                agg.input += p.usage.input;
                agg.output += p.usage.output;
                agg.cacheRead += p.usage.cacheRead;
            }
        }
        return agg;
    }
    setPhaseUsage(taskId, phaseRole, usage) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return;
        p.usage = { input: usage.input, output: usage.output, cacheRead: usage.cacheRead };
        const s = this.sessions.get(taskId);
        if (s)
            s.updatedAt = Date.now();
        // "tail" → TailViewComponent re-renders the per-phase footer.
        // "change" → ChipStripComponent re-renders the aggregate Σ meter.
        this.emit("tail", { taskId, phaseRole });
        this.emit("change", taskId);
    }
    setPhaseModel(taskId, phaseRole, modelInfo) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return;
        // Only emit when the value actually changes — turn_end fires every
        // turn and the (provider, model) is stable across the phase, so this
        // keeps the tail re-render fan-out quiet.
        const nextProvider = modelInfo.provider ?? p.provider;
        const nextModel = modelInfo.model ?? p.model;
        if (nextProvider === p.provider && nextModel === p.model)
            return;
        p.provider = nextProvider;
        p.model = nextModel;
        const s = this.sessions.get(taskId);
        if (s)
            s.updatedAt = Date.now();
        this.emit("tail", { taskId, phaseRole });
        this.emit("change", taskId);
    }
    setPhaseCompression(taskId, phaseRole, compression) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return;
        p.compression = { calls: compression.calls, tokensSaved: compression.tokensSaved };
        const s = this.sessions.get(taskId);
        if (s)
            s.updatedAt = Date.now();
        this.emit("tail", { taskId, phaseRole });
        this.emit("change", taskId);
    }
    getAggregateCompression() {
        const agg = { calls: 0, tokensSaved: 0 };
        for (const s of this.sessions.values()) {
            for (const p of s.phases) {
                if (!p.compression)
                    continue;
                agg.calls += p.compression.calls;
                agg.tokensSaved += p.compression.tokensSaved;
            }
        }
        return agg;
    }
    appendTail(taskId, phaseRole, line, opts) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return;
        // Entries are stored verbatim (one entry per producer event, newlines
        // and all) — display normalization is the views' job. Only a memory
        // cap applies here (see orchestrator-tree.ts).
        p.tailBuffer.push(capTailEntry(line));
        if (p.tailBuffer.length > MAX_TAIL_LINES_PER_PHASE) {
            p.tailBuffer.splice(0, p.tailBuffer.length - MAX_TAIL_LINES_PER_PHASE);
        }
        if (opts?.warning)
            p.unreadWarnings++;
        const s = this.sessions.get(taskId);
        if (s)
            s.updatedAt = Date.now();
        this.emit("tail", { taskId, phaseRole });
    }
    markRead(taskId, phaseRole) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return;
        if (p.unreadWarnings === 0)
            return;
        p.unreadWarnings = 0;
        this.emit("tail", { taskId, phaseRole });
    }
    getTailLines(taskId, phaseRole, limit) {
        const p = this.findPhase(taskId, phaseRole);
        if (!p)
            return [];
        if (limit === undefined || limit >= p.tailBuffer.length)
            return p.tailBuffer.slice();
        return p.tailBuffer.slice(p.tailBuffer.length - limit);
    }
    setTurnPreview(taskId, preview) {
        const s = this.sessions.get(taskId);
        if (!s)
            return;
        if (s.currentTurnPreview === preview)
            return;
        s.currentTurnPreview = preview;
        // Also stamp first/last on the current phase so the chip strip can
        // render "<first> … <last>" per phase — the first meaningful line a
        // subagent emits and the most recent one.
        const p = s.phases[s.phases.length - 1];
        if (p) {
            if (p.firstTurnPreview === undefined)
                p.firstTurnPreview = preview;
            p.lastTurnPreview = preview;
        }
        s.updatedAt = Date.now();
        this.emit("preview", { taskId });
    }
    evictIfNeeded() {
        if (this.sessions.size <= MAX_SESSIONS)
            return;
        const sorted = [...this.sessions.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
        const toRemove = sorted.slice(0, this.sessions.size - MAX_SESSIONS);
        for (const [id] of toRemove)
            this.sessions.delete(id);
    }
}
let _registry;
export function getSessionRegistry() {
    if (!_registry)
        _registry = new SessionRegistry();
    return _registry;
}
//# sourceMappingURL=session-registry.js.map