/**
 * bundle-freshness.ts — Phase A1 of olam-world-bundle-freshness.
 *
 * Surfaces whether each running olam world is running the SAME agent-stream
 * bundle that the operator has built locally (`packages/intelligence/dist/
 * agent-stream/`).
 *
 * The bake mechanism (devbox.runtime.glibc.Dockerfile:263-287) builds the
 * bundle INSIDE the world image at world-image-build time, so subsequent
 * fixes to agent-stream do NOT reach running worlds via `olam restart` —
 * they need either a docker cp (manual workaround) or the Phase B
 * bind-mount overlay (the real fix).
 *
 * This probe makes the staleness gap visible BEFORE Phase B lands.
 *
 * Verdict:
 *   - fresh   — every checked file's sha256 matches host + host dist ≥ container.
 *   - stale   — at least one file's sha differs OR host dist is newer by >60s.
 *   - unknown — no running worlds, OR host dist missing, OR docker unreachable.
 */
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
/**
 * Files the probe checks inside each world's
 * `/opt/olam/agent-stream/dist/`. Mirrors the brainstorm's docker-cp
 * recovery list (PR #653 + PR #665).
 */
export const BUNDLE_FILES = [
    'driver-runner.js',
    'codex-runner.js',
    'agent-sdk-to-chunks.js',
    'agent-stream-launch.js',
    'operator-subscription.js',
    'chunks-subscriber-transport.js',
];
/** Mtime drift tolerance — host can be up to this much newer than world without flagging stale. */
const MTIME_TOLERANCE_MS = 60_000;
/**
 * Aggregate doctor probe.
 *
 * Returns:
 *   - PASS (`ok: true, no warn`) when every running world is `fresh`.
 *   - WARN (`ok: true, warn: true`) when at least one world is `stale`.
 *   - PASS-with-unknown (`ok: true, no warn`) when no running worlds OR no
 *     host dist exists — nothing to be stale against, so no alarm.
 *
 * Never FAILs — bundle staleness is an informational signal, not a
 * gate. Operators land Phase B by reading this row before Phase B
 * makes the row redundant.
 */
export async function probeBundleFreshness(deps = {}) {
    const dockerExec = deps.dockerExec;
    // Host dist path. CLI's install-root pattern: <cli-pkg>/../../.
    // In source-mode this resolves to <repo>/packages/intelligence/dist/agent-stream.
    // In install-mode there's no host dist; return unknown.
    const repoRoot = deps.repoRootOverride ?? resolveRepoRootBestEffort();
    if (!repoRoot) {
        return {
            ok: true,
            message: 'bundle: unknown (repo root not detected; install-mode CLI cannot probe host dist)',
        };
    }
    const hostDistDir = join(repoRoot, 'packages', 'intelligence', 'dist', 'agent-stream');
    if (!existsSync(hostDistDir)) {
        return {
            ok: true,
            message: `bundle: unknown (host dist missing at ${hostDistDir})`,
        };
    }
    // Enumerate running olam worlds. Match container names like `olam-<id>-devbox`.
    if (!dockerExec) {
        return { ok: true, message: 'bundle: unknown (no docker exec injected)' };
    }
    const psResult = dockerExec('docker', ['ps', '--format', '{{.Names}}']);
    if (psResult.status !== 0) {
        return {
            ok: true,
            message: `bundle: unknown (docker ps failed: ${psResult.stderr.trim() || 'no stderr'})`,
        };
    }
    const containers = psResult.stdout
        .split('\n')
        .map((l) => l.trim())
        .filter((l) => /^olam-[a-z0-9][a-z0-9-]*-devbox$/.test(l));
    if (containers.length === 0) {
        return { ok: true, message: 'bundle: unknown (no running olam worlds)' };
    }
    // Compute host shas once.
    const hostShas = new Map();
    for (const f of BUNDLE_FILES) {
        const p = join(hostDistDir, f);
        if (!existsSync(p))
            continue;
        const buf = readFileSync(p);
        const sha = createHash('sha256').update(buf).digest('hex');
        const mtimeMs = statSync(p).mtimeMs;
        hostShas.set(f, { sha, mtimeMs });
    }
    if (hostShas.size === 0) {
        return {
            ok: true,
            message: `bundle: unknown (no BUNDLE_FILES present at ${hostDistDir})`,
        };
    }
    // Per-world comparison.
    const perWorld = [];
    for (const container of containers) {
        perWorld.push(checkWorld(container, hostShas, dockerExec));
    }
    const staleWorlds = perWorld.filter((w) => w.verdict === 'stale');
    if (staleWorlds.length > 0) {
        const remedy = `Run \`olam restart <worldId>\` after Phase B lands, OR for now: ` +
            `\`for f in ${BUNDLE_FILES.join(' ')}; do docker cp packages/intelligence/dist/agent-stream/$f <container>:/opt/olam/agent-stream/dist/$f; done\``;
        return {
            ok: true,
            warn: true,
            message: `bundle: stale in ${staleWorlds.length}/${perWorld.length} world(s) — ${staleWorlds
                .map((w) => `${w.container} (${w.mismatchedFiles.length} file(s) differ)`)
                .join(', ')}`,
            remedy,
        };
    }
    const unknownCount = perWorld.filter((w) => w.verdict === 'unknown').length;
    if (unknownCount === perWorld.length) {
        return {
            ok: true,
            message: `bundle: unknown (could not probe ${unknownCount}/${perWorld.length} world(s); docker exec failed)`,
        };
    }
    return {
        ok: true,
        message: `bundle: fresh in ${perWorld.length} world(s)`,
    };
}
function checkWorld(container, hostShas, dockerExec) {
    // Single exec sha256s all bundle files at once for efficiency.
    // Format: `<sha>  <path>` per line.
    const filePaths = Array.from(hostShas.keys()).map((f) => `/opt/olam/agent-stream/dist/${f}`);
    const r = dockerExec('docker', [
        'exec',
        container,
        'sh',
        '-c',
        `for f in ${filePaths.join(' ')}; do if [ -f "$f" ]; then sha256sum "$f"; stat -c '%Y' "$f"; fi; done`,
    ]);
    if (r.status !== 0) {
        return { container, verdict: 'unknown', mismatchedFiles: [] };
    }
    // Parse: alternating lines of `<sha>  <path>` and `<mtime_epoch_seconds>`.
    const lines = r.stdout.trim().split('\n');
    const containerShas = new Map();
    for (let i = 0; i < lines.length; i += 2) {
        const shaLine = lines[i];
        const mtimeLine = lines[i + 1];
        if (!shaLine || !mtimeLine)
            continue;
        const m = shaLine.match(/^([a-f0-9]{64})\s+(.+)$/);
        if (!m)
            continue;
        const sha = m[1];
        const path = m[2];
        const filename = path?.split('/').pop();
        if (!filename)
            continue;
        const mtimeSecs = parseInt(mtimeLine.trim(), 10);
        if (Number.isNaN(mtimeSecs) || !sha)
            continue;
        containerShas.set(filename, { sha, mtimeMs: mtimeSecs * 1000 });
    }
    const mismatched = [];
    let maxMtimeDelta = 0;
    for (const [filename, hostEntry] of hostShas) {
        const containerEntry = containerShas.get(filename);
        if (!containerEntry) {
            // File present on host, absent in world → mismatch.
            mismatched.push(filename);
            continue;
        }
        if (containerEntry.sha !== hostEntry.sha) {
            mismatched.push(filename);
        }
        const delta = hostEntry.mtimeMs - containerEntry.mtimeMs;
        if (delta > maxMtimeDelta)
            maxMtimeDelta = delta;
    }
    // Fresh = no sha mismatches AND mtime drift within tolerance.
    if (mismatched.length === 0 && maxMtimeDelta <= MTIME_TOLERANCE_MS) {
        return { container, verdict: 'fresh', mismatchedFiles: [], mtimeDeltaMs: maxMtimeDelta };
    }
    return {
        container,
        verdict: 'stale',
        mismatchedFiles: mismatched,
        mtimeDeltaMs: maxMtimeDelta,
    };
}
/**
 * Best-effort repo root resolution. Mirrors install-root.ts's pattern:
 * the CLI lives at `<repo>/packages/cli` in source-mode; walking up two
 * levels lands at the repo root iff `packages/` AND `package.json` exist
 * there.
 */
function resolveRepoRootBestEffort() {
    // The probe is module-level; we can't reach __dirname in ESM directly,
    // but the CLI loads from <repo>/packages/cli/dist/lib/bundle-freshness.js
    // (or src/lib in dev), so process.argv[1] points into the CLI tree.
    // Fall back to cwd if argv resolution fails.
    const start = process.argv[1] ?? process.cwd();
    // Walk up looking for a parent that has BOTH `packages/` AND `package.json`.
    let dir = resolve(start);
    for (let i = 0; i < 6; i++) {
        const parent = resolve(dir, '..');
        if (parent === dir)
            break;
        if (existsSync(join(parent, 'packages')) && existsSync(join(parent, 'package.json'))) {
            return parent;
        }
        dir = parent;
    }
    return null;
}
//# sourceMappingURL=bundle-freshness.js.map