// Task 562 — isolated rc-spawn path via `systemdSpawn` + `script(1)`.
//
// Mirrors the rc-daemon's spawn mechanism (rc-daemon.ts:312) so the
// manager holds ZERO file descriptors per spawn: stdio:'ignore' on the
// outer systemd-run child, real TTY allocated by `script -q -f -c` inside
// the scope, child's stdout/stderr captured to a retained typescript file
// at `~/.<brand>/logs/spawn-rc-resume-<unitToken>.log`.
//
// Shares no mutable state with the legacy node-pty rc-spawn path:
//   - No `spawnPty`, `registerRcSpawnPty`, or `livePtys` interaction.
//   - No master fd in the manager → no `releasePtyMasterFd` lifecycle.
//   - Own `liveScriptScopeRcSpawns` Map keyed on unitToken for census +
//     liveness observability.
//
// Observability follows the cross-arm `[rc-life]` schema (see Task 562
// brief). Every line carries `source=rc-spawn path=script-scope
// mode=<new|resume> unitToken=<token>` so a `grep source=rc-spawn`
// directly compares with `grep source=rc-daemon` lines emitted from
// rc-daemon.ts under the same schema. The capture file is never
// unlinked by this module — post-mortem is always possible.
import { dirname, join } from 'node:path';
import { existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { findExistingJsonlForSessionId } from './jsonl-path.js';
import { buildRcChildEnv } from './rc-daemon.js';
const liveScriptScopeRcSpawns = new Map();
/** For tests + the census sweep. Read-only iteration. */
export function forEachLiveScriptScopeRcSpawn(cb) {
    for (const e of liveScriptScopeRcSpawns.values())
        cb(e);
}
export function liveScriptScopeRcSpawnCount() {
    return liveScriptScopeRcSpawns.size;
}
/** Clear the tracker map. Test-only seam — production never calls. */
export function __resetScriptScopeRcSpawnsForTests() {
    liveScriptScopeRcSpawns.clear();
}
/** Schema-tagged line emitter. Every consumer of the cross-arm `[rc-life]`
 *  surface must use this so the field order + null-handling stays
 *  identical between rc-spawn and rc-daemon. */
export function emitRcLife(logger, op, source, path, mode, unitToken, fields = {}) {
    const base = `[rc-life] op=${op} source=${source} path=${path} mode=${mode} unitToken=${unitToken}`;
    const extras = Object.entries(fields)
        .map(([k, v]) => `${k}=${v === null ? 'null' : typeof v === 'string' && v.includes(' ') ? JSON.stringify(v) : v}`)
        .join(' ');
    logger(extras ? `${base} ${extras}` : base);
}
/** Run the script-scope rc-spawn. Returns once `systemdSpawn` resolves
 *  (scope is active, cgroup pid captured). Liveness polling, pid-file
 *  binding, and exit observation run in the background — the route
 *  responds immediately with `spawnedPid` like the node-pty arm does. */
export async function handleRcSpawnScriptScope(deps, req) {
    const now = deps.now ?? (() => Date.now());
    const livenessIntervalMs = deps.livenessPollIntervalMs ?? 15_000;
    const start = now();
    const unitToken = `rc-resume-${randomUUID()}`;
    const existingJsonl = req.sessionId
        ? findExistingJsonlForSessionId(deps.claudeConfigDir, req.sessionId)
        : null;
    const mode = existingJsonl ? 'resume' : 'new';
    const argv = ['--remote-control'];
    if (req.name)
        argv.push(req.name);
    if (req.sessionId)
        argv.push(mode === 'resume' ? '--resume' : '--session-id', req.sessionId);
    const env = buildRcChildEnv({
        accountId: deps.accountId,
        uiPort: deps.uiPort,
        platformRoot: deps.platformRoot,
        claudeConfigDir: deps.claudeConfigDir,
        brandedNeo4jUri: deps.brandedNeo4jUri,
        brandedNeo4jPassword: deps.brandedNeo4jPassword,
    });
    const cleanEnv = {};
    for (const [k, v] of Object.entries(env)) {
        if (typeof v === 'string')
            cleanEnv[k] = v;
    }
    // Mirror rc-daemon.ts:323 — capture file under <accountDir>/logs/.
    // dirname(claudeConfigDir) is the accountDir (e.g. /home/<user>/.maxy-code/).
    const captureFilePath = join(dirname(deps.claudeConfigDir), 'logs', `spawn-${unitToken}.log`);
    emitRcLife(deps.logger, 'request', 'rc-spawn', 'script-scope', mode, unitToken, {
        sessionId: req.sessionId ?? 'new',
        name: req.name ?? 'none',
        existingJsonl: existingJsonl ?? 'none',
    });
    emitRcLife(deps.logger, 'argv', 'rc-spawn', 'script-scope', mode, unitToken, {
        cwd: deps.spawnCwd,
        captureFile: captureFilePath,
        argv: JSON.stringify([deps.claudeBin, ...argv]),
    });
    let handle;
    try {
        handle = await deps.systemdSpawn({
            claudeBin: deps.claudeBin,
            claudeArgs: argv,
            env: cleanEnv,
            cwd: deps.spawnCwd,
            unitToken,
            captureFilePath,
        });
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        emitRcLife(deps.logger, 'spawn-failed', 'rc-spawn', 'script-scope', mode, unitToken, {
            err: JSON.stringify(msg),
        });
        throw err;
    }
    emitRcLife(deps.logger, 'spawned', 'rc-spawn', 'script-scope', mode, unitToken, {
        pid: handle.pid,
        scopeUnit: `claude-session-${unitToken}.scope`,
        captureFile: captureFilePath,
        // Manager holds no fd for this child — this is the isolation invariant.
        // A rising value across script-scope spawns is the leak signature.
        managerOpenFds: 'unchanged',
    });
    const entry = {
        unitToken,
        pid: handle.pid,
        sessionId: req.sessionId,
        mode,
        spawnedAt: start,
        captureFilePath,
        handle,
        pidFileSeen: false,
        remoteBound: false,
        exited: false,
    };
    liveScriptScopeRcSpawns.set(unitToken, entry);
    // Capture-file head emit — first ≤1 KB of the script(1) typescript,
    // settled by either reaching 1 KB or by a short post-spawn delay. The
    // child's own words are the signal whose absence cost 5 occasions
    // (Task 562 brief). Read once and forget; subsequent failures land in
    // op=early-exit's captureTail.
    void readCaptureHead(handle, 500, 1_024).then((head) => {
        emitRcLife(deps.logger, 'child-output', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            head: JSON.stringify(head),
        });
    });
    // pid-file binding — claude writes <sessions>/<pid>.json once Remote
    // Control registration finishes. This is the `ready` signal: pid file
    // present means claude is alive and reachable from claude.ai/code.
    void deps.waitForPid(handle.pid, deps.pidFileTimeoutMs)
        .then((row) => {
        entry.pidFileSeen = true;
        entry.remoteBound = Boolean(row.bridgeSessionId);
        emitRcLife(deps.logger, 'alive', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            ranMs: now() - start,
        });
        emitRcLife(deps.logger, 'ready', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            pidFile: row.pidFilePath ?? 'unknown',
            bridgeId: row.bridgeSessionId ?? 'unknown',
            remoteSession: row.bridgeSessionId ?? 'unknown',
        });
    })
        .catch((err) => {
        emitRcLife(deps.logger, 'wait-pid-failed', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            err: JSON.stringify(err instanceof Error ? err.message : String(err)),
        });
    });
    // Liveness poll — runs for the session's whole intended life. Catches
    // a session that dies quietly between events without a reproduction.
    // Both arms emit this so endurance is directly comparable.
    const livenessTimer = setInterval(() => {
        if (entry.exited) {
            clearInterval(livenessTimer);
            return;
        }
        const pidAlive = pidIsAlive(entry.pid);
        emitRcLife(deps.logger, 'liveness', 'rc-spawn', 'script-scope', mode, unitToken, {
            ageMs: now() - start,
            pidAlive,
            // scopeActive is captured from handle.waitForExit's terminal — true
            // means we have not yet observed the scope leave the active set.
            // A pidAlive=false here while exited=false is an early death the
            // event flow missed.
            scopeActive: !entry.exited,
            remoteBound: entry.remoteBound,
        });
    }, livenessIntervalMs);
    // Don't block process exit on the liveness timer.
    livenessTimer.unref?.();
    // Exit observation — `handle.waitForExit` polls systemctl. On exit,
    // tag the lifetime, attach captureTail bytes, and remove from the
    // live tracker.
    void handle.waitForExit()
        .then(async (exit) => {
        entry.exited = true;
        clearInterval(livenessTimer);
        const lifetimeMs = now() - start;
        let tail = '';
        try {
            tail = await handle.readCaptureTail(2_048);
        }
        catch {
            tail = '';
        }
        if (!entry.pidFileSeen && lifetimeMs < 2_000) {
            emitRcLife(deps.logger, 'early-exit', 'rc-spawn', 'script-scope', mode, unitToken, {
                pid: handle.pid,
                ranMs: lifetimeMs,
                exitCode: exit.exitCode ?? 'null',
                signal: exit.code ?? 'null',
                captureTail: JSON.stringify(tail),
            });
        }
        const reason = inferExitReason(exit.result, lifetimeMs, entry.pidFileSeen);
        emitRcLife(deps.logger, 'exit', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            lifetimeMs,
            reason,
            result: exit.result,
            captureTail: JSON.stringify(tail.slice(-512)),
        });
        liveScriptScopeRcSpawns.delete(unitToken);
    })
        .catch((err) => {
        entry.exited = true;
        clearInterval(livenessTimer);
        emitRcLife(deps.logger, 'wait-exit-failed', 'rc-spawn', 'script-scope', mode, unitToken, {
            pid: handle.pid,
            err: JSON.stringify(err instanceof Error ? err.message : String(err)),
        });
        liveScriptScopeRcSpawns.delete(unitToken);
    });
    return {
        spawnedPid: handle.pid,
        sessionId: req.sessionId,
        unitToken,
        mode,
    };
}
function inferExitReason(result, lifetimeMs, pidFileSeen) {
    if (result === 'success' && pidFileSeen && lifetimeMs >= 2_000)
        return 'archive';
    if (result !== 'success')
        return 'crash';
    if (!pidFileSeen && lifetimeMs < 2_000)
        return 'crash';
    return 'unknown';
}
function pidIsAlive(pid) {
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
/** Reads the head of the script(1) typescript. Returns at most `maxBytes`
 *  from the start of the file; waits up to `settleMs` for the file to
 *  appear, since systemd-run + script open the typescript asynchronously
 *  after the scope is active. */
async function readCaptureHead(handle, settleMs, maxBytes) {
    const deadline = Date.now() + settleMs;
    while (Date.now() < deadline) {
        if (existsSync(handle.captureFilePath))
            break;
        await new Promise((resolve) => setTimeout(resolve, 50));
    }
    // readCaptureTail returns the last `maxBytes` bytes; for a small early
    // file (< maxBytes) tail == head. The boot sequence is well under 1 KB.
    try {
        return await handle.readCaptureTail(maxBytes);
    }
    catch {
        return '';
    }
}
/** Census sweep — reconciles expected (dashboard-initiated sessions) vs
 *  actual (live tracked entries). Detects a leaked entry whose pid is
 *  gone but the tracker still holds it, or a tracker whose handle's
 *  scope vanished without an exit event. Traffic-independent — runs on
 *  a timer in index.ts so a quiet failure surfaces within one interval.
 *
 *  The daemon's live handle is supplied via `daemonAlive`; the daemon
 *  itself owns the canonical accounting (a single handle reference),
 *  but exposing it here keeps the schema unified. */
export function runRcLifeCensus(logger, daemonAlive) {
    let pidAlive = 0;
    let pidDeadButTracked = 0;
    for (const entry of liveScriptScopeRcSpawns.values()) {
        if (pidIsAlive(entry.pid)) {
            pidAlive += 1;
        }
        else {
            pidDeadButTracked += 1;
            // Independent reconciliation defect — entry exists with a dead pid.
            emitRcLife(logger, 'orphan', 'rc-spawn', 'script-scope', entry.mode, entry.unitToken, {
                pid: entry.pid,
                kind: 'pid-dead-but-tracked',
                ageMs: Date.now() - entry.spawnedAt,
            });
        }
    }
    logger(`[rc-census] source-breakdown=rc-spawn:${liveScriptScopeRcSpawns.size},rc-daemon:${daemonAlive}` +
        ` rcSpawnPidAlive=${pidAlive} rcSpawnPidDead=${pidDeadButTracked}`);
}
//# sourceMappingURL=rc-script-spawn.js.map