/**
 * Agent Pool — manages per-session Agent instances for concurrent access.
 *
 * Each session (folder chat, private chat, capture) gets its own Agent
 * with independent conversation history. Shared resources (tools, system
 * prompt, model) are created once and passed to each new agent.
 *
 * Per-session locking ensures only one call runs per session at a time,
 * while different sessions can run in parallel.
 */
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
import { join, resolve } from 'path';
const IDLE_TIMEOUT_MS = 10 * 60_000; // 10 minutes
const REAP_INTERVAL_MS = 60_000; // check every 60s
const MAX_SESSIONS_PER_USER = 20;
export class AgentPool {
    pool = new Map();
    reapTimer = null;
    config;
    /** Per-session context — set before each call, read by tool closures. */
    contextMap = new Map();
    constructor(config) {
        this.config = config;
        this.reapTimer = setInterval(() => this.reap(), REAP_INTERVAL_MS);
    }
    /**
     * Get the current context for a session (used by tool closures).
     * Returns null user if no context is set.
     */
    getContext(sessionId) {
        return this.contextMap.get(sessionId) ?? { user: null };
    }
    /**
     * Acquire an agent for a session, run a callback, then release.
     * Per-session locking ensures serialized access within the same session.
     * Different sessions run in parallel.
     */
    async withAgent(sessionId, ctx, fn, opts) {
        const entry = this.getOrCreate(sessionId, opts?.userId, opts?.fallbackHistory);
        // Chain on the session's lock to serialize calls. Also await
        // toolsLoaded so the first turn's prompt-to-Anthropic sees the
        // full tool list (bundled + custom + per-user connector). On
        // subsequent turns this promise is already resolved — no wait.
        // Toolloading errors were caught upstream; the await is purely
        // for ordering, never throws.
        const result = entry.lock.then(async () => {
            await entry.toolsLoaded;
            entry.lastUsedAt = Date.now();
            this.contextMap.set(sessionId, ctx);
            try {
                return await fn(entry.agent);
            }
            finally {
                this.contextMap.delete(sessionId);
                // Save session after each call
                this.saveSession(entry);
            }
        });
        // Update the lock — next caller for this session waits for this one
        entry.lock = result.then(() => { }, () => { });
        return result;
    }
    /** Get the raw Agent for a session (for subscribe/streaming). Must be used with withAgent. */
    getAgent(sessionId) {
        return this.pool.get(sessionId)?.agent ?? null;
    }
    /** Abort the agent for a session. */
    abort(sessionId) {
        this.pool.get(sessionId)?.agent.abort();
    }
    /**
     * Evict every cached agent instance. Persists each session's history
     * first so no in-memory transcript is lost. Next chat for any session
     * spins up a fresh agent (via `createAgent` from the config), which
     * re-reads model/provider/api_key via `resolveApiKey` — picking up
     * whatever the store now has.
     *
     * Used as the eviction hook on secret/setting mutations: when the
     * operator rotates an API key or switches providers via Settings UI,
     * the running agent's captured apiKey is stale until restart. This
     * is the surgical fix instead of restarting the whole process.
     */
    invalidateAll() {
        for (const entry of this.pool.values()) {
            this.saveSession(entry);
        }
        this.pool.clear();
    }
    /**
     * Evict every pool entry owned by a specific user. Used as the eviction
     * hook on per-user OAuth mutations: when a user OAuths a new connector
     * (or revokes one), their running agent's tool list — captured at
     * createAgentInstance time — no longer matches reality. Sessions
     * belonging to OTHER users stay in the pool untouched.
     *
     * Saved sessions are written to disk before removal, same as
     * `invalidateAll`, so no transcript is lost.
     */
    invalidateUser(userId) {
        for (const [sessionId, entry] of this.pool.entries()) {
            if (entry.userId === userId) {
                this.saveSession(entry);
                this.pool.delete(sessionId);
                this.contextMap.delete(sessionId);
            }
        }
    }
    /** Save all sessions and stop the reaper. */
    async shutdown() {
        if (this.reapTimer) {
            clearInterval(this.reapTimer);
            this.reapTimer = null;
        }
        for (const entry of this.pool.values()) {
            this.saveSession(entry);
        }
        this.pool.clear();
    }
    /** Clear all saved session files for a user. */
    clearSessions(userId) {
        const dir = this.getUserDir(userId);
        if (!existsSync(dir))
            return 0;
        const files = readdirSync(dir).filter(f => f.endsWith('.json'));
        for (const f of files) {
            try {
                unlinkSync(join(dir, f));
            }
            catch { /* ignore */ }
        }
        return files.length;
    }
    get size() {
        return this.pool.size;
    }
    /** Find the session ID for a given agent instance. */
    findSessionForAgent(agent) {
        for (const [sid, entry] of this.pool) {
            if (entry.agent === agent)
                return sid;
        }
        return null;
    }
    // ── Private ──
    getOrCreate(sessionId, userId, fallbackHistory) {
        const existing = this.pool.get(sessionId);
        if (existing)
            return existing;
        // Create a fresh agent instance (pass userId for per-user context)
        const agent = this.config.createAgent(userId);
        // Kick off async tool loading. The returned promise gets stashed on
        // the pool entry so the first withAgent call can await it — that's
        // what closes the timing race between agent construction and the
        // user's first chat turn referencing a connector tool. If the
        // callback returns undefined (legacy or test surfaces that don't
        // need to gate on it), treat it as already-resolved.
        const toolsLoaded = (this.config.loadCustomTools?.(agent, userId) ?? Promise.resolve()).catch((err) => {
            // Swallow the rejection here — the agent should proceed with
            // whatever subset of tools landed. The orchestrator's own
            // catch path already logged the underlying failure.
            // eslint-disable-next-line no-console
            console.warn(`[agent-pool] tool loading rejected, proceeding with bundled tools: ${err instanceof Error ? err.message : err}`);
        });
        // Try loading session from disk
        const loaded = this.loadSession(agent, sessionId, userId);
        // Fallback: rebuild the transcript as proper role-tagged messages when
        // the saved session file is missing (cleared, corrupted, or first turn
        // on a fresh container). The previous implementation flattened the
        // entire history into a single role: 'user' message with "Assistant:" /
        // "User:" prefixes embedded as plain text — the model then had to parse
        // those textual prefixes to know what was its own output, and would
        // sometimes mis-attribute its prior turns to the user (e.g. "you
        // mentioned that Watowa consults" when the user had just said "we
        // consult on..."). Mapping each turn to its actual role tag lets the
        // model distinguish reliably without parsing-by-prefix.
        if (!loaded && fallbackHistory && fallbackHistory.length > 0) {
            const model = agent.state.model;
            const baseTime = Date.now() - fallbackHistory.length * 1000;
            agent.state.messages = fallbackHistory.map((m, i) => {
                const timestamp = baseTime + i * 1000;
                if (m.role === 'assistant') {
                    return {
                        role: 'assistant',
                        content: [{ type: 'text', text: m.content }],
                        api: model.api,
                        provider: model.provider,
                        model: model.id,
                        usage: {
                            input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
                            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
                        },
                        stopReason: 'stop',
                        timestamp,
                    };
                }
                return {
                    role: 'user',
                    content: [{ type: 'text', text: m.content }],
                    timestamp,
                };
            });
        }
        const entry = {
            agent,
            sessionId,
            userId,
            lastUsedAt: Date.now(),
            lock: Promise.resolve(),
            toolsLoaded,
        };
        this.pool.set(sessionId, entry);
        return entry;
    }
    loadSession(agent, sessionId, userId) {
        const userDir = this.getUserDir(userId);
        const sessionPath = join(userDir, `${sessionId}.json`);
        // Path traversal guard
        if (!resolve(sessionPath).startsWith(resolve(userDir)))
            return false;
        if (existsSync(sessionPath)) {
            try {
                const data = JSON.parse(readFileSync(sessionPath, 'utf-8'));
                if (Array.isArray(data.messages) && data.messages.length > 0) {
                    agent.state.messages = data.messages;
                    return true;
                }
            }
            catch { /* corrupted — fall through */ }
        }
        return false;
    }
    saveSession(entry) {
        const messages = entry.agent.state.messages;
        if (messages.length === 0)
            return;
        const userDir = this.getUserDir(entry.userId);
        if (!existsSync(userDir)) {
            mkdirSync(userDir, { recursive: true });
        }
        const sessionPath = join(userDir, `${entry.sessionId}.json`);
        writeFileSync(sessionPath, JSON.stringify({
            sessionId: entry.sessionId,
            updatedAt: new Date().toISOString(),
            messages,
        }), 'utf-8');
        this.pruneSessions(userDir, MAX_SESSIONS_PER_USER);
    }
    getUserDir(userId) {
        if (userId)
            return join(this.config.sessionsDir, String(userId));
        return this.config.sessionsDir;
    }
    pruneSessions(dir, keep) {
        if (!existsSync(dir))
            return;
        const files = readdirSync(dir)
            .filter(f => f.endsWith('.json'))
            .map(f => {
            try {
                return { name: f, mtime: statSync(join(dir, f)).mtimeMs };
            }
            catch {
                return null;
            }
        })
            .filter(Boolean);
        if (files.length <= keep)
            return;
        files.sort((a, b) => b.mtime - a.mtime);
        for (const f of files.slice(keep)) {
            try {
                unlinkSync(join(dir, f.name));
            }
            catch { /* ignore */ }
        }
    }
    reap() {
        const now = Date.now();
        for (const [key, entry] of this.pool) {
            if (now - entry.lastUsedAt > IDLE_TIMEOUT_MS) {
                this.saveSession(entry);
                this.pool.delete(key);
            }
        }
    }
}
//# sourceMappingURL=agent-pool.js.map