/**
 * Agent Registry — unified agent tracking and port allocation.
 *
 * Maintains a machine-wide registry at ~/.instar/registry.json tracking
 * ALL agents on the machine (both standalone and project-bound).
 *
 * Replaces the older PortRegistry with:
 *   - Agent type awareness (standalone vs project-bound)
 *   - Status tracking with heartbeat
 *   - File locking for safe concurrent access
 *   - Migration from legacy port-registry.json
 *
 * The canonical unique key is `path` (absolute path to the project directory).
 * Agent names are display labels only — NOT unique.
 */
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import lockfile from 'proper-lockfile';
import { getInstarVersion } from './Config.js';
import { SafeFsExecutor } from './SafeFsExecutor.js';
// Paths are computed lazily from os.homedir() so they pick up mocks in tests
function registryDir() { return path.join(os.homedir(), '.instar'); }
function registryPath() { return path.join(registryDir(), 'registry.json'); }
function legacyRegistryPath() { return path.join(registryDir(), 'port-registry.json'); }
const DEFAULT_PORT_RANGE_START = 4040;
const DEFAULT_PORT_RANGE_END = 4099;
/**
 * WHATWG Fetch "bad ports" — node's global `fetch` (undici) throws
 * `TypeError: fetch failed { cause: 'bad port' }` for any URL on one of these
 * ports. Instar's multi-machine mesh does ALL of its cross-machine I/O over node
 * fetch (pairing `POST /api/pair`, lease broadcast/pull, heartbeat), so an agent
 * whose server lands on a blocked port is SILENTLY unreachable over the mesh —
 * curl works (it ignores the blocklist), so the failure is invisible. Observed
 * live 2026-06-02: a paired test agent on port 4045 (NFS `lockd`, on this list)
 * could never be joined ("fetch failed: bad port") despite curl reaching it. The
 * default allocation range (4040–4099) contains 4045, so the allocator MUST skip
 * these. Source: the WHATWG Fetch spec "bad ports" table.
 */
export const FETCH_BLOCKED_PORTS = new Set([
    1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87,
    95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139,
    143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548,
    554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659,
    4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697,
    10080,
]);
/** True if `port` is on the WHATWG fetch bad-ports list (node `fetch` refuses it). */
export function isFetchBlockedPort(port) {
    return FETCH_BLOCKED_PORTS.has(port);
}
/** Agent name validation: alphanumeric, underscore, hyphen. Max 64 chars. */
const AGENT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
const LOCK_OPTIONS_ASYNC = {
    stale: 10_000,
    retries: { retries: 5, factor: 2, minTimeout: 100 },
};
// lockSync doesn't support retries natively — we implement manual retries below
const LOCK_OPTIONS_SYNC = {
    stale: 10_000,
};
const SYNC_LOCK_RETRIES = 5;
const SYNC_LOCK_BASE_DELAY_MS = 100;
/**
 * Validate an agent name for use in paths and registration.
 * Rejects names with path separators, null bytes, or `..`.
 */
export function validateAgentName(name) {
    if (!name || name.includes('/') || name.includes('\\') || name.includes('\0') || name.includes('..')) {
        return false;
    }
    return AGENT_NAME_PATTERN.test(name);
}
/**
 * Check if a process with the given PID is running.
 */
function isProcessAlive(pid) {
    if (pid <= 0)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        // @silent-fallback-ok — signal 0 process check
        return false;
    }
}
/**
 * Ensure the registry directory exists.
 */
function ensureRegistryDir() {
    fs.mkdirSync(registryDir(), { recursive: true });
}
/**
 * Load the agent registry from disk.
 * On first load, migrates from legacy port-registry.json if present.
 * Returns an empty registry if no file exists.
 */
export function loadRegistry() {
    ensureRegistryDir();
    // If registry.json doesn't exist, try migrating from legacy
    if (!fs.existsSync(registryPath())) {
        if (fs.existsSync(legacyRegistryPath())) {
            return migrateFromPortRegistry();
        }
        return { version: 1, entries: [] };
    }
    try {
        const data = JSON.parse(fs.readFileSync(registryPath(), 'utf-8'));
        return {
            version: data.version ?? 1,
            entries: Array.isArray(data.entries) ? data.entries : [],
        };
    }
    catch {
        return { version: 1, entries: [] };
    }
}
/**
 * Save the registry to disk (atomic write via temp file + rename).
 */
export function saveRegistry(registry) {
    ensureRegistryDir();
    const tmpPath = `${registryPath()}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
    try {
        fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2));
        fs.renameSync(tmpPath, registryPath());
    }
    catch (err) {
        try {
            SafeFsExecutor.safeUnlinkSync(tmpPath, { operation: 'src/core/AgentRegistry.ts:119' });
        }
        catch { /* ignore */ }
        throw err;
    }
}
/**
 * Execute a read-modify-write cycle on the registry with file locking.
 * Uses proper-lockfile with stale detection to prevent deadlocks.
 */
async function withLock(fn) {
    ensureRegistryDir();
    // Ensure the registry file exists before locking (proper-lockfile needs it)
    if (!fs.existsSync(registryPath())) {
        const initial = loadRegistry();
        saveRegistry(initial);
    }
    let release;
    try {
        release = await lockfile.lock(registryPath(), LOCK_OPTIONS_ASYNC);
        const registry = loadRegistry();
        const result = fn(registry);
        saveRegistry(registry);
        return result;
    }
    catch (err) {
        // If lock acquisition fails after retries, provide actionable error
        if (err instanceof Error && err.message.includes('ELOCKED')) {
            throw new Error('Registry is locked by another process. If no other instar process is running, ' +
                'delete ~/.instar/registry.json.lock and retry.');
        }
        throw err;
    }
    finally {
        if (release) {
            try {
                await release();
            }
            catch { /* ignore unlock errors */ }
        }
    }
}
/**
 * Synchronous read-modify-write (for use in sync contexts like CLI commands).
 * Uses lockfileSync variant.
 */
function withLockSync(fn) {
    ensureRegistryDir();
    // Ensure the registry file exists
    if (!fs.existsSync(registryPath())) {
        const initial = loadRegistry();
        saveRegistry(initial);
    }
    let release;
    let lastErr;
    // Manual retry loop since lockSync doesn't support retries natively
    for (let attempt = 0; attempt <= SYNC_LOCK_RETRIES; attempt++) {
        try {
            release = lockfile.lockSync(registryPath(), LOCK_OPTIONS_SYNC);
            const registry = loadRegistry();
            const result = fn(registry);
            saveRegistry(registry);
            return result;
        }
        catch (err) {
            lastErr = err;
            if (release) {
                try {
                    release();
                }
                catch { /* ignore */ }
                release = undefined;
            }
            if (err instanceof Error && err.message.includes('ELOCKED') && attempt < SYNC_LOCK_RETRIES) {
                // Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
                const delay = SYNC_LOCK_BASE_DELAY_MS * Math.pow(2, attempt);
                const end = Date.now() + delay;
                while (Date.now() < end) { /* busy-wait for sync context */ }
                continue;
            }
            break;
        }
        finally {
            if (release) {
                try {
                    release();
                }
                catch { /* ignore */ }
            }
        }
    }
    // Last resort: if lock is stuck after all retries, try force-removing it and retry once.
    // This handles crash-induced stale locks where proper-lockfile's stale detection
    // didn't trigger (e.g., clock skew, NFS, or the locking process died mid-write).
    if (lastErr instanceof Error && lastErr.message.includes('ELOCKED')) {
        console.warn('[AgentRegistry] Lock stuck after retries — attempting force recovery');
        forceRemoveRegistryLock();
        try {
            release = lockfile.lockSync(registryPath(), LOCK_OPTIONS_SYNC);
            const registry = loadRegistry();
            const result = fn(registry);
            saveRegistry(registry);
            return result;
        }
        catch (recoveryErr) {
            if (release) {
                try {
                    release();
                }
                catch { /* ignore */ }
            }
            throw new Error('Registry is locked by another process after retries and recovery. If no other instar process is running, ' +
                'delete ~/.instar/registry.json.lock and retry.');
        }
        finally {
            if (release) {
                try {
                    release();
                }
                catch { /* ignore */ }
            }
        }
    }
    throw lastErr;
}
/**
 * Migrate from legacy port-registry.json to the new agent registry.
 */
function migrateFromPortRegistry() {
    try {
        const legacyData = JSON.parse(fs.readFileSync(legacyRegistryPath(), 'utf-8'));
        const legacyEntries = Array.isArray(legacyData.entries) ? legacyData.entries : [];
        const registry = {
            version: 1,
            entries: legacyEntries.map(e => ({
                name: e.projectName,
                type: 'project-bound',
                path: e.projectDir,
                port: e.port,
                pid: e.pid,
                status: 'stopped',
                createdAt: e.registeredAt,
                lastHeartbeat: e.lastHeartbeat,
            })),
        };
        saveRegistry(registry);
        // Rename legacy file to mark migration complete
        fs.renameSync(legacyRegistryPath(), legacyRegistryPath() + '.migrated');
        console.log('[AgentRegistry] Migrated from port-registry.json');
        return registry;
    }
    catch {
        return { version: 1, entries: [] };
    }
}
/**
 * Check if a path is in a temporary/ephemeral directory.
 */
function isEphemeralPath(p) {
    const normalized = path.resolve(p);
    const tmpDir = os.tmpdir();
    return normalized.startsWith(tmpDir) ||
        normalized.startsWith('/tmp/') ||
        normalized.startsWith('/var/folders/') ||
        normalized.startsWith('/private/tmp/') ||
        normalized.startsWith('/private/var/folders/');
}
const STALE_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
/**
 * Remove stale entries where the process is no longer running.
 * Prunes entries that are stale AND either from ephemeral paths or expired (>1 hour).
 * Returns the cleaned registry (mutates in-place for efficiency).
 */
export function cleanStaleEntries(registry) {
    const now = Date.now();
    registry.entries = registry.entries.filter(entry => {
        // Mark running entries with dead processes as stale
        if (entry.status === 'running' && !isProcessAlive(entry.pid)) {
            console.log(`[AgentRegistry] Marking stale: ${entry.name} (port ${entry.port}, pid ${entry.pid} dead)`);
            entry.status = 'stale';
        }
        // Remove stale entries from ephemeral paths (test runs, temp dirs)
        if (entry.status === 'stale' && isEphemeralPath(entry.path)) {
            console.log(`[AgentRegistry] Pruning ephemeral: ${entry.name} (${entry.path})`);
            return false;
        }
        // Remove stale entries that have been dead for over 1 hour
        if (entry.status === 'stale') {
            const heartbeat = entry.lastHeartbeat ? new Date(entry.lastHeartbeat).getTime() : 0;
            if (now - heartbeat > STALE_EXPIRY_MS) {
                console.log(`[AgentRegistry] Pruning expired: ${entry.name} (stale for ${Math.round((now - heartbeat) / 60_000)}m)`);
                return false;
            }
        }
        return true;
    });
    return registry;
}
/**
 * Register an agent (add or update by canonical path).
 */
export function registerAgent(agentPath, name, port, type = 'project-bound', pid) {
    withLockSync(registry => {
        cleanStaleEntries(registry);
        const canonicalPath = path.resolve(agentPath);
        // Check for port conflicts with other RUNNING agents (stale entries can't own ports)
        const conflict = registry.entries.find(e => e.port === port && e.path !== canonicalPath && e.status === 'running');
        if (conflict) {
            throw new Error(`Port ${port} is already in use by "${conflict.name}" (pid ${conflict.pid}). ` +
                `Change the port in .instar/config.json or use a different port.`);
        }
        // Find existing entry by canonical path
        const existingIdx = registry.entries.findIndex(e => e.path === canonicalPath);
        const now = new Date().toISOString();
        if (existingIdx >= 0) {
            // Update existing
            const existing = registry.entries[existingIdx];
            existing.name = name;
            existing.port = port;
            existing.pid = pid ?? process.pid;
            existing.status = 'running';
            existing.lastHeartbeat = now;
            existing.type = type;
            existing.instarVersion = getInstarVersion();
        }
        else {
            // New entry
            registry.entries.push({
                name,
                type,
                path: canonicalPath,
                port,
                pid: pid ?? process.pid,
                status: 'running',
                createdAt: now,
                lastHeartbeat: now,
                instarVersion: getInstarVersion(),
            });
        }
    });
}
/**
 * Unregister an agent by its canonical path.
 *
 * `opts.onlyIfPid` guards the removal: the entry is only removed when its
 * recorded pid matches. Server shutdown MUST pass its own pid — otherwise an
 * old server generation's late shutdown (auto-update restart) deletes the
 * successor's fresh registration by path, and the agent silently vanishes
 * from the registry (the registry lost-update race). An entry whose pid is
 * missing or different is presumed to belong to the successor and is left
 * intact. Operator/CLI removal (no opts) still removes unconditionally.
 */
export function unregisterAgent(agentPath, opts) {
    const canonicalPath = path.resolve(agentPath);
    withLockSync(registry => {
        registry.entries = registry.entries.filter(e => {
            if (e.path !== canonicalPath)
                return true;
            if (opts?.onlyIfPid !== undefined && e.pid !== opts.onlyIfPid) {
                console.log(`[AgentRegistry] Skipped unregister of ${canonicalPath}: entry pid ${e.pid} != caller pid ${opts.onlyIfPid} (entry belongs to a newer generation)`);
                return true;
            }
            return false;
        });
    });
}
/**
 * Update an agent's status and optionally its PID.
 */
export function updateStatus(agentPath, status, pid) {
    const canonicalPath = path.resolve(agentPath);
    withLockSync(registry => {
        const entry = registry.entries.find(e => e.path === canonicalPath);
        if (entry) {
            entry.status = status;
            if (pid !== undefined)
                entry.pid = pid;
            entry.lastHeartbeat = new Date().toISOString();
        }
    });
}
/**
 * Update the heartbeat for an agent by canonical path.
 *
 * Returns whether the entry was found. A `false` return means the entry has
 * vanished (e.g. an old generation's shutdown raced the registration away) —
 * callers that own a live server should re-register rather than silently
 * no-op forever.
 */
export function heartbeat(agentPath) {
    const canonicalPath = path.resolve(agentPath);
    return withLockSync(registry => {
        const entry = registry.entries.find(e => e.path === canonicalPath);
        if (entry) {
            entry.lastHeartbeat = new Date().toISOString();
            entry.pid = process.pid;
            return true;
        }
        return false;
    });
}
/**
 * Force-remove a stale registry lock file.
 * Used as a recovery mechanism when proper-lockfile's stale detection fails
 * (e.g., after a mutex crash leaves the lock in an inconsistent state).
 */
export function forceRemoveRegistryLock() {
    const lockPath = registryPath() + '.lock';
    try {
        if (fs.existsSync(lockPath)) {
            SafeFsExecutor.safeRmSync(lockPath, { recursive: true, force: true, operation: 'src/core/AgentRegistry.ts:426' });
            console.log(`[AgentRegistry] Force-removed stale lock: ${lockPath}`);
            return true;
        }
    }
    catch (err) { // @silent-fallback-ok — best-effort recovery, retried on next heartbeat
        console.error(`[AgentRegistry] Failed to force-remove lock: ${err}`);
    }
    return false;
}
/**
 * Start a periodic heartbeat. Returns a cleanup function.
 * Tracks consecutive failures and force-removes the registry lock after
 * repeated failures — recovers from crash-induced stale locks.
 *
 * `reRegister` (optional) is invoked when a heartbeat finds the entry MISSING
 * — i.e. something deleted this agent's registration while its server is
 * still alive (the classic case: an old generation's pid-unguarded shutdown
 * unregister racing a back-to-back update restart). The callback should
 * re-create the registration (e.g. `() => registerAgent(...)`); the next
 * heartbeat then confirms it. Without the callback the missing entry is only
 * logged — preserving the old behavior for callers that don't own a server.
 */
export function startHeartbeat(agentPath, intervalMs = 60_000, reRegister) {
    const canonicalPath = path.resolve(agentPath);
    let consecutiveFailures = 0;
    const MAX_FAILURES_BEFORE_RECOVERY = 3;
    const beatOnce = () => {
        const found = heartbeat(canonicalPath);
        if (!found) {
            console.warn(`[AgentRegistry] Heartbeat found no entry for ${canonicalPath} — registration was removed while this server is alive`);
            if (reRegister) {
                try {
                    reRegister();
                    console.log(`[AgentRegistry] Re-registered ${canonicalPath} after missing-entry heartbeat`);
                }
                catch (reErr) {
                    console.error(`[AgentRegistry] Re-registration failed for ${canonicalPath}: ${reErr}`);
                }
            }
        }
    };
    const interval = setInterval(() => {
        try {
            beatOnce();
            consecutiveFailures = 0;
        }
        catch (err) {
            consecutiveFailures++;
            if (consecutiveFailures >= MAX_FAILURES_BEFORE_RECOVERY) {
                console.warn(`[AgentRegistry] ${consecutiveFailures} consecutive heartbeat failures — forcing lock recovery`);
                forceRemoveRegistryLock();
                try {
                    heartbeat(canonicalPath);
                    consecutiveFailures = 0;
                    console.log('[AgentRegistry] Heartbeat recovered after lock cleanup');
                }
                catch (retryErr) {
                    console.error(`[AgentRegistry] Heartbeat still failing after recovery: ${retryErr}`);
                }
            }
            else {
                console.error(`[AgentRegistry] Heartbeat failed (${consecutiveFailures}/${MAX_FAILURES_BEFORE_RECOVERY}): ${err}`);
            }
        }
    }, intervalMs);
    // Initial heartbeat (also resurrects immediately if the entry is missing)
    try {
        beatOnce();
    }
    catch { /* ignore */ }
    return () => clearInterval(interval);
}
/**
 * List all agents, optionally filtered by type and/or status.
 * Cleans stale entries before returning.
 */
export function listAgents(filter) {
    return withLockSync(registry => {
        cleanStaleEntries(registry);
        let entries = [...registry.entries];
        if (filter?.type) {
            entries = entries.filter(e => e.type === filter.type);
        }
        if (filter?.status) {
            entries = entries.filter(e => e.status === filter.status);
        }
        return entries;
    });
}
/**
 * Get a specific agent by canonical path.
 */
export function getAgent(agentPath) {
    const canonicalPath = path.resolve(agentPath);
    const registry = loadRegistry();
    return registry.entries.find(e => e.path === canonicalPath) ?? null;
}
/**
 * Synchronously check if a TCP port is free by probing for listeners.
 * Uses lsof on macOS/Linux to detect if any process is bound to the port.
 * Returns true if the port is available, false if it's in use.
 */
function isPortFreeSync(port) {
    try {
        // lsof exits 0 if a listener is found, 1 if not
        execSync(`lsof -iTCP:${port} -sTCP:LISTEN -P -n`, { stdio: 'ignore' });
        return false; // lsof found a listener → port is in use
    }
    catch {
        return true; // lsof found nothing → port is free
    }
}
/**
 * Allocate a free port from the range, avoiding conflicts.
 * If the agent already has a port, return that port.
 */
export function allocatePort(agentPath, rangeStart = DEFAULT_PORT_RANGE_START, rangeEnd = DEFAULT_PORT_RANGE_END) {
    const canonicalPath = path.resolve(agentPath);
    return withLockSync(registry => {
        cleanStaleEntries(registry);
        // Check if this agent already has a port
        const existing = registry.entries.find(e => e.path === canonicalPath);
        if (existing) {
            return existing.port;
        }
        // Collect ALL registered ports (any status) to avoid conflicts with
        // agents that may still be running but have stale registry entries
        const usedPorts = new Set(registry.entries.map(e => e.port));
        for (let port = rangeStart; port <= rangeEnd; port++) {
            if (!usedPorts.has(port) && !FETCH_BLOCKED_PORTS.has(port) && isPortFreeSync(port)) {
                return port;
            }
        }
        throw new Error(`No free ports available in range ${rangeStart}-${rangeEnd}. ` +
            `${registry.entries.length} Instar instances are registered.`);
    });
}
// ── Backward Compatibility Wrappers ───────────────────────────────
// These functions provide the PortRegistry API using AgentRegistry internals,
// allowing a smooth migration where callers use projectName-based lookups.
/**
 * Register a port for a project (PortRegistry compatibility).
 * Uses projectDir as the canonical path key.
 */
export function registerPort(projectName, port, projectDir, pid) {
    registerAgent(projectDir, projectName, port, 'project-bound', pid);
}
/**
 * Unregister a port by project name (PortRegistry compatibility).
 * Looks up the agent by name and removes it.
 */
export function unregisterPort(projectName) {
    withLockSync(registry => {
        registry.entries = registry.entries.filter(e => e.name !== projectName);
    });
}
/**
 * Start a heartbeat by project name (PortRegistry compatibility).
 * Looks up the agent's path by name.
 */
export function startHeartbeatByName(projectName, intervalMs = 60_000) {
    const registry = loadRegistry();
    const entry = registry.entries.find(e => e.name === projectName);
    if (!entry) {
        // Fall back: just do a name-based heartbeat update with recovery
        let consecutiveFailures = 0;
        const MAX_FAILURES_BEFORE_RECOVERY = 3;
        const interval = setInterval(() => {
            try {
                withLockSync(reg => {
                    const e = reg.entries.find(en => en.name === projectName);
                    if (e) {
                        e.lastHeartbeat = new Date().toISOString();
                        e.pid = process.pid;
                    }
                });
                consecutiveFailures = 0;
            }
            catch (err) {
                consecutiveFailures++;
                if (consecutiveFailures >= MAX_FAILURES_BEFORE_RECOVERY) {
                    console.warn(`[AgentRegistry] ${consecutiveFailures} consecutive heartbeat failures — forcing lock recovery`);
                    forceRemoveRegistryLock();
                    consecutiveFailures = 0;
                }
                else {
                    console.error(`[AgentRegistry] Heartbeat failed (${consecutiveFailures}/${MAX_FAILURES_BEFORE_RECOVERY}): ${err}`);
                }
            }
        }, intervalMs);
        return () => clearInterval(interval);
    }
    return startHeartbeat(entry.path, intervalMs);
}
/**
 * List all instances (PortRegistry compatibility).
 * Returns entries in the legacy PortEntry-compatible shape.
 */
export function listInstances() {
    return listAgents();
}
/**
 * Allocate a port by project name (PortRegistry compatibility).
 */
export function allocatePortByName(projectName, rangeStart = DEFAULT_PORT_RANGE_START, rangeEnd = DEFAULT_PORT_RANGE_END) {
    return withLockSync(registry => {
        cleanStaleEntries(registry);
        // Check if this project already has a port
        const existing = registry.entries.find(e => e.name === projectName);
        if (existing) {
            return existing.port;
        }
        // Collect ALL registered ports (any status) to avoid conflicts with
        // agents that may still be running but have stale registry entries
        const usedPorts = new Set(registry.entries.map(e => e.port));
        for (let port = rangeStart; port <= rangeEnd; port++) {
            if (!usedPorts.has(port) && !FETCH_BLOCKED_PORTS.has(port) && isPortFreeSync(port)) {
                return port;
            }
        }
        throw new Error(`No free ports available in range ${rangeStart}-${rangeEnd}. ` +
            `${registry.entries.length} Instar instances are registered.`);
    });
}
//# sourceMappingURL=AgentRegistry.js.map