/**
 * install/install-semaphore.mjs — filesystem-semaphore for max_parallel_installs (C54 R1.4).
 *
 * Enforces the global cap on concurrent install children. Implementation:
 * each acquired slot drops a `{task-id}.slot` file into
 * `.aperant/locks/install-semaphore/`. The directory's entry count is the
 * current live-install count. When we're at the cap, acquire returns
 * `acquired: false` with a queue-position hint; the caller marks the
 * install as `queued` and the executor preflight later polls.
 *
 * We do NOT block: a true blocking semaphore across detached children would
 * require a daemon or named-mutex abstraction that doesn't portably exist
 * on all agent runtimes. Instead, the `queued` state is cooperative — any
 * agent that sees `install_status: "queued"` retries via the preflight
 * poll loop.
 *
 * Liveness: slot files carry the owning PID. A slot whose PID is dead is
 * treated as expired (`process.kill(pid, 0)` → ESRCH) and is swept on the
 * next acquire attempt. This mirrors the R4 team-status TTL pattern.
 */
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
import { join, resolve } from 'node:path';
import { withFileLock } from '../util/fs-lock.mjs';
const SEMAPHORE_SUBDIR = ['.aperant', 'locks', 'install-semaphore'];
/**
 * Resolve the semaphore directory, creating it if needed.
 *
 * @param {string} projectDir
 * @returns {string}
 */
function semaphoreDir(projectDir) {
    const dir = join(resolve(projectDir), ...SEMAPHORE_SUBDIR);
    mkdirSync(dir, { recursive: true });
    return dir;
}
/**
 * Check whether a PID is alive. Returns false on Windows systems where
 * `process.kill(pid, 0)` semantics differ (it still throws ESRCH or EPERM
 * when the PID is dead, which is what we need). We only treat an ESRCH
 * error as "dead".
 *
 * @param {number} pid
 * @returns {boolean}
 */
function isPidAlive(pid) {
    if (!pid || typeof pid !== 'number' || pid <= 0)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        if (err.code === 'ESRCH')
            return false;
        // EPERM means the process exists but we can't signal it — treat as alive.
        if (err.code === 'EPERM')
            return true;
        return false;
    }
}
/**
 * Sweep dead slot files. Returns the count of surviving slots.
 *
 * @param {string} dir
 * @returns {number}
 */
function sweepAndCount(dir) {
    if (!existsSync(dir))
        return 0;
    let alive = 0;
    let entries;
    try {
        entries = readdirSync(dir);
    }
    catch {
        return 0;
    }
    for (const f of entries) {
        if (!f.endsWith('.slot'))
            continue;
        const fpath = join(dir, f);
        let record = null;
        try {
            record = JSON.parse(readFileSync(fpath, 'utf-8'));
        }
        catch {
            // Corrupt slot file — remove it.
            try {
                unlinkSync(fpath);
            }
            catch {
                /* best-effort */
            }
            continue;
        }
        if (!isPidAlive(record?.pid)) {
            try {
                unlinkSync(fpath);
            }
            catch {
                /* best-effort */
            }
            continue;
        }
        alive++;
    }
    return alive;
}
/**
 * Try to acquire a semaphore slot. Non-blocking — returns `{ acquired:
 * false, queue_position: N }` when the cap is hit. The caller marks the
 * install as `queued`.
 *
 * @param {{ projectDir: string, max: number, taskId: string, ecosystem: string }} opts
 * @returns {{ acquired: boolean, released_by: (() => void) | null, queue_position: number, live_count: number }}
 */
export function acquireInstallSlot({ projectDir, max, taskId, ecosystem }) {
    const dir = semaphoreDir(projectDir);
    const slotFile = join(dir, `${taskId}-${ecosystem}.slot`);
    const lockFile = join(dir, '.lock');
    let acquired = false;
    let liveCount = 0;
    let queuePosition = 0;
    withFileLock(lockFile, () => {
        liveCount = sweepAndCount(dir);
        if (liveCount < max) {
            const record = {
                task_id: taskId,
                ecosystem,
                pid: process.pid,
                acquired_at: new Date().toISOString(),
            };
            writeFileSync(slotFile, JSON.stringify(record, null, 2));
            acquired = true;
            liveCount += 1;
        }
        else {
            queuePosition = liveCount - max + 1;
        }
    }, { projectRoot: resolve(projectDir) });
    const releasedBy = acquired
        ? () => {
            try {
                if (existsSync(slotFile))
                    unlinkSync(slotFile);
            }
            catch {
                /* best-effort */
            }
        }
        : null;
    return {
        acquired,
        released_by: releasedBy,
        queue_position: queuePosition,
        live_count: liveCount,
    };
}
/**
 * Release a slot by taskId+ecosystem. Idempotent — unlink-or-ignore.
 *
 * @param {{ projectDir: string, taskId: string, ecosystem: string }} opts
 * @returns {{ released: boolean }}
 */
export function releaseInstallSlot({ projectDir, taskId, ecosystem }) {
    const dir = semaphoreDir(projectDir);
    const slotFile = join(dir, `${taskId}-${ecosystem}.slot`);
    let released = false;
    try {
        if (existsSync(slotFile)) {
            unlinkSync(slotFile);
            released = true;
        }
    }
    catch {
        /* best-effort */
    }
    return { released };
}
/**
 * Current live-install count (dead slots swept). Useful for the statusline
 * chip and for R10 executor preflight polling.
 *
 * @param {string} projectDir
 * @returns {number}
 */
export function liveInstallCount(projectDir) {
    const dir = semaphoreDir(projectDir);
    return sweepAndCount(dir);
}
//# sourceMappingURL=install-semaphore.mjs.map