// Task 552 — non-PTY spawn primitive for claude scope units.
//
// Wraps `child_process.spawn` around `systemd-run --user --scope ...` so
// the manager allocates ZERO file descriptors per spawn (stdio: 'ignore',
// detached + unref). The previous `spawnPty` adapter wrapped node-pty
// around the same systemd-run argv, which opened a master pty fd in the
// manager process — one fd leaked per /rc-spawn click and one per
// rc-daemon respawn until V8 GC fired.
//
// This primitive is for spawns the manager never talks to: `/rc-spawn`
// (fire-and-forget, the route never writes back to the child) and the
// rc-daemon supervisor. Channel-bridge spawns (`/spawn`,
// `:sessionId/input`, WhatsApp / public-agent / channel-pty-bridge) keep
// node-pty because they genuinely need bidirectional IO with the
// manager.
//
// Argv composition reuses `buildSystemdRunArgv` from systemd-scope.ts so
// the SIGHUP wrap, per-spawn unit token, slice membership,
// `--setenv=KEY=VAL` env block, and `script(1)`-based TTY allocation
// (Task 556) are byte-identical with the PTY spawn path.
//
// Task 556 — child output capture is the typescript file written by the
// inner `script -q -f -c "$1" "$2"` wrapper (see buildSystemdRunArgv).
// The prior Task 555 attempt to capture via `StandardOutput=journal`
// scope properties was a no-op: those properties are `Exec*=`
// service-unit fields that systemd silently ignores on `--scope` units,
// which inherit the launcher's fds. With the manager's `stdio: 'ignore'`
// the child received `/dev/null` on every stream and `claude
// --remote-control --resume` / `claude rc --spawn` early-exited because
// no TTY / live stdin was present. The `script` wrap allocates a real
// pty, claude stays resident, and the per-spawn capture file holds the
// stdout/stderr for retrieval after the scope is reaped.
//
// Task 554 — activation gate reads ActiveState + ControlGroup + cgroup.procs,
// NOT MainPID. `systemctl show -p MainPID` returns nothing for transient
// scope units on systemd 255 (Ubuntu 24.04), so the prior MainPID poll
// timed out at 5 s even though the scope was already live. The new gate:
//
//   - One `systemctl show -p ActiveState -p ControlGroup` call per poll
//     iteration.
//   - The pid is read from `cgroup.procs` under the path systemd reports
//     in `ControlGroup`. The minimum pid wins — the in-place exec chain
//     `systemd-run → /bin/sh → claude` preserves the original pid, which
//     is the lowest in the cgroup.
//   - Three terminal outcomes distinguish how a spawn failed:
//       systemd-start-failure — ActiveState terminated and cgroup.procs
//         was never populated (bad cwd, missing binary, slice not loaded).
//       boot-crash            — cgroup.procs was populated then emptied
//         before ActiveState reached `active`. Claude itself crashed at
//         init; observed pid is captured in the error message.
//       activation-timeout    — deadline reached without ActiveState ever
//         leaving the activating set. Reserved for genuine systemd hangs;
//         the terminal ActiveState value is logged.
//   - Before any terminal reject, `stopScopeUnit` is called against the
//     unit so a partially-registered scope cannot leak. The eight
//     `claude-session-rc-daemon-*.scope` units leaked on the laptop by
//     the prior bug are reaped at session-manager startup via
//     `reapOrphanNonPtyScopes` (systemd-scope.ts).
import { spawn } from 'node:child_process';
import { mkdirSync, openSync, readFileSync, readSync, statSync, closeSync } from 'node:fs';
import { dirname } from 'node:path';
import { buildSystemdRunArgv, defaultSystemctlRunner, scopeUnitName, stopScopeUnit, } from './systemd-scope.js';
const ACTIVE_STATES = new Set(['active', 'activating', 'reloading']);
/** Spawn `systemd-run --user --scope ... <claudeBin> <claudeArgs>` so the
 *  manager holds no file descriptors for the resulting claude process.
 *  Returns once cgroup.procs is non-empty AND ActiveState is `active`.
 *  On terminal failure, stops the partially-registered scope and rejects
 *  with one of three distinct error tokens. */
export async function systemdSpawn(p) {
    const run = p.runSystemctl ?? defaultSystemctlRunner;
    const readCgroupProcs = p.readCgroupProcs ?? defaultReadCgroupProcs;
    const readCaptureTailFn = p.readCaptureTail ?? readCaptureFile;
    const spawnChild = p.spawnChild ?? defaultSpawnChild;
    const activationTimeoutMs = p.activationTimeoutMs ?? 5_000;
    const pollIntervalMs = p.activationPollIntervalMs ?? 50;
    // script(1) opens the typescript file for write at exec time. ENOENT on
    // the parent dir would surface as a fast-exit with no capture — make
    // the dir before spawn so the capture path is always writable.
    try {
        mkdirSync(dirname(p.captureFilePath), { recursive: true });
    }
    catch (err) {
        throw new Error(`[systemd-spawn] capture-dir-create-failed unitToken=${p.unitToken} captureFilePath=${p.captureFilePath} err=${err instanceof Error ? err.message : String(err)}`);
    }
    const wrapped = buildSystemdRunArgv({
        unitToken: p.unitToken,
        claudeBin: p.claudeBin,
        claudeArgs: p.claudeArgs,
        env: p.env,
        cwd: p.cwd,
        captureFilePath: p.captureFilePath,
    });
    // Spawn systemd-run as a detached child with stdio:'ignore'. The manager
    // process holds zero fds to it; user systemd owns the scope cgroup's
    // lifetime. `detached: true` puts the child in its own process group so
    // it survives manager exit (systemd already owns it once registered, but
    // the detach is belt-and-braces against PG signal forwarding).
    let child;
    const errorBox = { err: null };
    try {
        child = spawnChild(wrapped.cmd, wrapped.args, {
            cwd: p.cwd,
            env: process.env,
            stdio: 'ignore',
            detached: true,
        });
    }
    catch (err) {
        throw new Error(`[systemd-spawn] spawn-failed unitToken=${p.unitToken} err=${err instanceof Error ? err.message : String(err)}`);
    }
    child.on('error', (err) => {
        errorBox.err = err;
    });
    child.unref();
    const unit = scopeUnitName(p.unitToken);
    const deadline = Date.now() + activationTimeoutMs;
    let seenPid = null;
    let lastControlGroup = '';
    let lastActiveState = '';
    while (Date.now() < deadline) {
        if (errorBox.err) {
            // Pair with stop so the scope (if it managed to register before the
            // child errored on the manager side) doesn't leak. systemctl's stop
            // is harmless when the unit was never loaded.
            await safeStop(run, p.unitToken);
            throw new Error(`[systemd-spawn] child-error unitToken=${p.unitToken} err=${errorBox.err.message}`);
        }
        const show = run([
            '--user', 'show',
            '-p', 'ActiveState',
            '-p', 'ControlGroup',
            '-p', 'Result',
            '-p', 'SubState',
            unit,
        ]);
        const fields = show.exitCode === 0 ? parseShowFields(show.stdout) : {};
        const activeState = fields.ActiveState ?? '';
        const controlGroup = fields.ControlGroup ?? '';
        const result = fields.Result ?? '';
        const subState = fields.SubState ?? '';
        if (activeState)
            lastActiveState = activeState;
        if (controlGroup)
            lastControlGroup = controlGroup;
        if (controlGroup) {
            const pids = readCgroupProcs(controlGroup);
            if (pids.length > 0) {
                seenPid = Math.min(...pids);
                if (activeState === 'active') {
                    return buildHandle(seenPid, p.unitToken, p.captureFilePath, run);
                }
            }
        }
        // Terminal non-active state — classify by (seenPid, Result). systemd's
        // `Result` is the truthful signal for whether the start succeeded:
        // `success` ⇒ unit started and the child terminated normally;
        // anything else ⇒ start or runtime failure recognised by systemd.
        // The seenPid branch separates "child died during boot" from "child
        // exited before the poll loop ever observed it in cgroup.procs".
        //
        //   seenPid observed                  → boot-crash
        //   no seenPid + Result=success       → child-exited-during-activation
        //                                       (the race this gate was missing;
        //                                       systemd is happy, the child just
        //                                       exited faster than 50 ms)
        //   no seenPid + Result!=success      → systemd-start-failure
        //                                       (genuine systemd-side start fail
        //                                       — bad cwd, missing binary, slice
        //                                       not loaded, etc.)
        //
        // Every branch carries the journal tail in the thrown message so the
        // admin-UI log line surfaces the child's last stderr/stdout without an
        // SSH-into-the-box step.
        if (activeState && !ACTIVE_STATES.has(activeState)) {
            const capture = await safeReadCaptureTail(readCaptureTailFn, p.captureFilePath, 2_048);
            await safeStop(run, p.unitToken, seenPid);
            const journalSuffix = capture ? ` captureTail=${JSON.stringify(capture)}` : '';
            if (seenPid !== null) {
                throw new Error(`[systemd-spawn] boot-crash unitToken=${p.unitToken} observedPid=${seenPid} controlGroup=${lastControlGroup} activeState=${activeState}${journalSuffix}`);
            }
            if (result === 'success') {
                throw new Error(`[systemd-spawn] child-exited-during-activation unitToken=${p.unitToken} activeState=${activeState} subState=${subState || 'unknown'}${journalSuffix}`);
            }
            throw new Error(`[systemd-spawn] systemd-start-failure unitToken=${p.unitToken} activeState=${activeState} result=${result || 'unknown'}${journalSuffix}`);
        }
        await sleep(pollIntervalMs);
    }
    // Deadline hit while still activating (or no controlGroup ever
    // reported). Stop the scope to avoid leaking it, then reject.
    await safeStop(run, p.unitToken, seenPid);
    throw new Error(`[systemd-spawn] activation-timeout unitToken=${p.unitToken} activeState=${lastActiveState || 'unknown'} timeoutMs=${activationTimeoutMs}`);
}
function parseShowFields(stdout) {
    const out = {};
    for (const line of stdout.split('\n')) {
        const eq = line.indexOf('=');
        if (eq < 0)
            continue;
        out[line.slice(0, eq)] = line.slice(eq + 1).trim();
    }
    return out;
}
/** Read /sys/fs/cgroup<controlGroup>/cgroup.procs. Returns the parsed pids
 *  (one per line). Empty list on ENOENT / parse failure — the caller treats
 *  empty as "scope not yet populated", which is the truthful default. */
function defaultReadCgroupProcs(controlGroup) {
    if (!controlGroup)
        return [];
    // ControlGroup is the systemd-relative path (e.g.
    // `/user.slice/user-1000.slice/.../foo.scope`). Concatenate with
    // /sys/fs/cgroup for the unified hierarchy.
    const path = `/sys/fs/cgroup${controlGroup}/cgroup.procs`;
    try {
        const raw = readFileSync(path, 'utf8');
        const pids = [];
        for (const line of raw.split('\n')) {
            const trimmed = line.trim();
            if (!trimmed)
                continue;
            const n = Number.parseInt(trimmed, 10);
            if (Number.isFinite(n) && n > 0)
                pids.push(n);
        }
        return pids;
    }
    catch {
        return [];
    }
}
/** Best-effort cleanup of a partially-registered scope on terminal
 *  failure. Swallows stop errors — the caller is already about to reject
 *  with the original cause; surfacing a secondary stop failure would mask
 *  it. */
async function safeStop(run, unitToken, pid = null) {
    try {
        await stopScopeUnit(run, unitToken, { pid: pid ?? undefined });
    }
    catch {
        /* deliberately swallow — original failure is what the caller needs */
    }
}
/** Best-effort capture-file tail read for inclusion in activation-gate
 *  failure envelopes. A read failure must not mask the underlying
 *  activation-gate error, so any throw degrades to an empty string. */
async function safeReadCaptureTail(reader, captureFilePath, maxBytes) {
    try {
        return await reader(captureFilePath, maxBytes);
    }
    catch {
        return '';
    }
}
function buildHandle(pid, unitToken, captureFilePath, run) {
    const unit = scopeUnitName(unitToken);
    return {
        pid,
        unitToken,
        captureFilePath,
        async waitForExit(opts) {
            const interval = opts?.pollIntervalMs ?? 250;
            while (true) {
                const r = run(['--user', 'show', '-p', 'ActiveState', '--value', unit]);
                const state = r.exitCode === 0 ? r.stdout.trim() : '';
                // Empty state OR exit code 4 (unit not loaded) means the transient
                // unit has been collected — treat as exited.
                if (!state || !ACTIVE_STATES.has(state)) {
                    return readScopeExit(run, unit);
                }
                await sleep(interval);
            }
        },
        async readCaptureTail(maxBytes) {
            return readCaptureFile(captureFilePath, maxBytes);
        },
        async stop() {
            await stopScopeUnit(run, unitToken, { pid });
        },
    };
}
function readScopeExit(run, unit) {
    const r = run([
        '--user',
        'show',
        '-p', 'Result',
        '-p', 'ExecMainStatus',
        '-p', 'ExecMainCode',
        unit,
    ]);
    if (r.exitCode !== 0) {
        return { result: 'unknown', exitCode: null, code: null };
    }
    const fields = {};
    for (const line of r.stdout.split('\n')) {
        const eq = line.indexOf('=');
        if (eq < 0)
            continue;
        fields[line.slice(0, eq)] = line.slice(eq + 1);
    }
    const statusRaw = fields.ExecMainStatus ?? '';
    const status = statusRaw === '' ? null : Number.parseInt(statusRaw, 10);
    return {
        result: fields.Result || 'unknown',
        exitCode: Number.isFinite(status) ? status : null,
        code: fields.ExecMainCode || null,
    };
}
/** Read the last `maxBytes` bytes of the script(1) typescript file.
 *  Returns '' when the file does not exist (the child died before
 *  script(1) opened it) or on any read failure — the caller treats
 *  empty as "no capture available" and falls through to the bare error
 *  envelope. */
async function readCaptureFile(captureFilePath, maxBytes) {
    try {
        const st = statSync(captureFilePath);
        const size = st.size;
        if (size === 0)
            return '';
        const start = size > maxBytes ? size - maxBytes : 0;
        const length = size - start;
        const fd = openSync(captureFilePath, 'r');
        try {
            const buf = Buffer.alloc(length);
            readSync(fd, buf, 0, length, start);
            return buf.toString('utf8');
        }
        finally {
            closeSync(fd);
        }
    }
    catch {
        return '';
    }
}
function defaultSpawnChild(cmd, args, opts) {
    const child = spawn(cmd, [...args], opts);
    return {
        pid: child.pid,
        unref: () => child.unref(),
        on: (event, cb) => { child.on(event, cb); },
    };
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
//# sourceMappingURL=systemd-spawn.js.map