/**
 * 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 SHUTDOWN_DRAIN_MS = 5_000; // max wait for in-flight turns at shutdown
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();
    /**
     * Turn-scoped binding: the Agent instance currently executing a turn → that
     * turn's session id. Set for the duration of `fn` in `withAgent`, kept
     * INDEPENDENT of `pool`/`contextMap` so a mid-turn eviction (invalidateUser /
     * invalidateAll / reap) can't strip a running turn of its context.
     *
     * Tool closures resolve their session via `findSessionForAgent`, which scans
     * `pool` by agent identity — but eviction deletes the pool entry, so that
     * scan would return null and every session-scoped tool would silently lose
     * context (create_artifact fails with "No artifact canvas is available in
     * this context"; getUser falls back to a stale default; onMutation/onHighlight
     * no-op). This map is the durable fallback findSessionForAgent checks first.
     */
    activeTurns = 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);
        // Mark this entry in-use BEFORE the lock chain so a mid-turn eviction
        // defers removal instead of deleting the lock anchor (which would let a
        // concurrent same-session request fork a second agent that races to
        // persist history). Counts queued turns too — both increment here before
        // either runs. Balanced by the decrement in the finally below.
        entry.refCount++;
        // 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);
            // Durable session binding for this turn — survives mid-turn eviction so
            // findSessionForAgent keeps resolving even if the pool entry is deleted
            // while the turn runs (e.g. a connector OAuth write → invalidateUser).
            this.activeTurns.set(entry.agent, sessionId);
            try {
                return await fn(entry.agent);
            }
            finally {
                this.activeTurns.delete(entry.agent);
                this.contextMap.delete(sessionId);
                // Persist in an inner try so the refCount release below ALWAYS runs —
                // if compaction or saveSession throws, a skipped decrement would strand
                // the entry at refCount > 0 forever (reap skips it, invalidate only
                // re-marks it stale), leaking the agent and never rebuilding.
                try {
                    // Slim artifact source out of history BEFORE persisting/next turn, so a
                    // large bundle read or written this turn doesn't ride in context forever
                    // and re-overflow the model. Mutates the live messages array.
                    this.compactArtifactHistory(entry.agent);
                    // Save after each call. saveSession itself skips a deleted session, so
                    // a chat the user deleted mid-turn is not resurrected here.
                    this.saveSession(entry);
                }
                finally {
                    // Release this turn's hold. When we're the last turn, finish whatever
                    // eviction was deferred while the entry was in use: a deleted entry is
                    // dropped and its on-disk JSON re-unlinked (in case a queued turn
                    // re-saved it before the flag was set); a stale entry is dropped so
                    // the next turn rebuilds with fresh tools/model. Deferring to here
                    // (rather than deleting at invalidate/delete time) keeps the lock
                    // anchor alive for queued turns so they serialize on this same agent
                    // instead of forking a second one.
                    entry.refCount--;
                    if (entry.refCount === 0 && this.pool.get(sessionId) === entry) {
                        if (entry.deleted) {
                            this.pool.delete(sessionId);
                            this.removeSessionFile(entry.userId, sessionId);
                        }
                        else if (entry.stale) {
                            this.pool.delete(sessionId);
                        }
                    }
                }
            }
        });
        // 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 [sessionId, entry] of this.pool.entries()) {
            this.saveSession(entry);
            // Defer eviction of an in-flight entry (see invalidateUser / refCount):
            // mark it stale so withAgent's finally removes it once the last turn
            // drains, instead of deleting the lock anchor mid-turn.
            if (entry.refCount > 0) {
                entry.stale = true;
            }
            else {
                this.pool.delete(sessionId);
            }
        }
    }
    /**
     * 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);
                // Defer eviction while a turn is in flight. Deleting the entry now
                // would (a) strip the running turn's context — findSessionForAgent
                // survives via activeTurns, but the closures then read getContext(sid)
                // — and (b) remove the lock anchor, so a concurrent same-session
                // request would fork a second agent and the two turns would race to
                // persist history. Marking stale keeps the entry serving queued turns
                // on its existing lock; withAgent's finally removes it at refCount 0,
                // forcing a fresh rebuild on the next turn. Note contextMap is NOT
                // deleted here either — it is turn-scoped and owned by withAgent.
                if (entry.refCount > 0) {
                    entry.stale = true;
                }
                else {
                    this.pool.delete(sessionId);
                }
            }
        }
    }
    /** Save all sessions and stop the reaper. */
    async shutdown() {
        if (this.reapTimer) {
            clearInterval(this.reapTimer);
            this.reapTimer = null;
        }
        // Drain in-flight turns so their FINAL history (and deferred deleted/stale
        // cleanup) lands before we persist and clear. Each turn's own finally runs
        // saveSession + the deferred-eviction handling; awaiting the locks lets that
        // complete instead of racing a clear that would orphan the last turn's
        // messages. Locks never reject (swallowed at assignment). Bounded so a stuck
        // turn can't hang process shutdown — past the bound we persist best-effort.
        const drained = Promise.all([...this.pool.values()].map((e) => e.lock));
        let drainTimer;
        await Promise.race([
            drained,
            new Promise((resolve) => { drainTimer = setTimeout(resolve, SHUTDOWN_DRAIN_MS); }),
        ]);
        if (drainTimer)
            clearTimeout(drainTimer); // don't leave the bound timer pending when drain wins
        for (const entry of this.pool.values()) {
            this.saveSession(entry); // self-guards deleted entries
        }
        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) {
        // Prefer the turn-scoped binding: a turn in flight must resolve to its
        // session even after a mid-turn eviction removed it from `pool`.
        const active = this.activeTurns.get(agent);
        if (active !== undefined)
            return active;
        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,
            refCount: 0,
        };
        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;
    }
    /**
     * Bound chat-context growth from artifact source. An ArtifactBundle inlines
     * all of its HTML/CSS/JS, so it's large — and it rides in history in two
     * places: `read_artifact` / `inspect_artifact` tool RESULTS, and the bundle
     * ARGUMENT of `create_artifact` calls. Left alone, the bundle reinflates the
     * prompt every turn until the provider overflows and returns an empty turn
     * (the "I didn't have a response" bug). After each turn we replace those
     * payloads in-place with a compact stub; the agent calls read_artifact again
     * when it needs the current source. Only large payloads are touched, so a
     * small artifact stays inline (no needless re-read). Defensive throughout —
     * a malformed message must never break session persistence.
     */
    compactArtifactHistory(agent) {
        const MIN_ELIDE_BYTES = 2000;
        const RESULT_TOOLS = new Set(['read_artifact', 'inspect_artifact']);
        const STUB = '[Artifact source from an earlier tool call was removed to save context. Call read_artifact to get the current source.]';
        try {
            const messages = (agent.state?.messages ?? []);
            for (const msg of messages) {
                if (!msg || typeof msg !== 'object')
                    continue;
                if (msg.role === 'toolResult' && RESULT_TOOLS.has(msg.toolName)) {
                    if (!Array.isArray(msg.content))
                        continue;
                    const already = msg.content.length === 1 && msg.content[0]?.text === STUB;
                    if (already)
                        continue;
                    // inspect_artifact now returns its screenshots as IMAGE content blocks.
                    // Vision images must NOT persist across turns (that's the whole point —
                    // they'd re-bloat every prompt), so strip the moment ANY image block is
                    // present, regardless of the byte threshold.
                    const hasImage = msg.content.some((b) => b?.type === 'image');
                    let size = 0;
                    for (const b of msg.content)
                        size += typeof b?.text === 'string' ? b.text.length : (b?.data?.length ?? 0);
                    if (!hasImage && size < MIN_ELIDE_BYTES)
                        continue;
                    msg.content = [{ type: 'text', text: STUB }];
                    if ('details' in msg)
                        delete msg.details;
                }
                else if (msg.role === 'assistant' && Array.isArray(msg.content)) {
                    for (const block of msg.content) {
                        if (block?.type === 'toolCall' && block.name === 'create_artifact' && block.arguments?.bundle) {
                            if (block.arguments.bundle._elided === true)
                                continue;
                            if (JSON.stringify(block.arguments.bundle).length < MIN_ELIDE_BYTES)
                                continue;
                            block.arguments = { ...block.arguments, bundle: { _elided: true } };
                        }
                    }
                }
            }
        }
        catch { /* never let history compaction break persistence */ }
    }
    saveSession(entry) {
        // A deleted chat must never be re-persisted, no matter who calls saveSession
        // (the draining turn, invalidate*, reap, or shutdown). Its JSON — and the
        // base64 vision images it holds — was unlinked by deleteSession; rewriting
        // it here would resurrect the chat the user deleted.
        if (entry.deleted)
            return;
        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;
    }
    /**
     * Delete a session's persisted state: evict the warm in-memory entry AND
     * unlink its on-disk JSON. Used when a chat is deleted — the per-session JSON
     * holds base64 copies of any vision images the agent saw, so it must be
     * removed for the "delete chat → images gone" promise to hold (composer-vision).
     * `sessionId` is the RESOLVED id the pool keys by (e.g. `user:7:abc`).
     */
    deleteSession(userId, sessionId) {
        const entry = this.pool.get(sessionId);
        if (entry && entry.refCount > 0) {
            // A turn is in flight: tombstone it instead of deleting. The draining
            // turn won't re-save (no resurrect) and removes the entry + re-unlinks
            // the file at refCount 0; meanwhile the entry stays as the lock anchor so
            // a concurrent send serializes on it rather than forking a second agent.
            entry.deleted = true;
        }
        else {
            this.pool.delete(sessionId);
        }
        this.removeSessionFile(userId, sessionId);
    }
    /** Delete EVERY persisted session for a user (the reset / delete-all path). */
    deleteAllSessions(userId) {
        for (const [key, entry] of this.pool) {
            if (entry.userId !== userId)
                continue;
            if (entry.refCount > 0)
                entry.deleted = true; // defer; turn drains, then cleans up
            else
                this.pool.delete(key);
        }
        const dir = this.getUserDir(userId);
        if (!existsSync(dir))
            return;
        for (const f of readdirSync(dir)) {
            if (f.endsWith('.json')) {
                try {
                    unlinkSync(join(dir, f));
                }
                catch { /* best-effort */ }
            }
        }
    }
    /** Unlink a session's on-disk JSON (best-effort, path-traversal guarded). */
    removeSessionFile(userId, sessionId) {
        const userDir = this.getUserDir(userId);
        const path = join(userDir, `${sessionId}.json`);
        // Path-traversal guard: a resolved id is server-built, but never trust it.
        if (!resolve(path).startsWith(resolve(userDir)))
            return;
        try {
            if (existsSync(path))
                unlinkSync(path);
        }
        catch { /* best-effort */ }
    }
    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) {
            // Never reap an entry that's in use — a turn can outlive the idle window
            // (long tool chains), and deleting it would let the next call spin up a
            // second agent for the same session, breaking the per-session
            // serialization the lock guarantees. refCount covers both the running
            // turn and any turns queued on the lock.
            if (entry.refCount > 0)
                continue;
            if (now - entry.lastUsedAt > IDLE_TIMEOUT_MS) {
                this.saveSession(entry);
                this.pool.delete(key);
            }
        }
    }
}
//# sourceMappingURL=agent-pool.js.map