/**
 * KgServiceContainerController — lifecycle for olam-kg-service.
 *
 * Mirrors McpAuthContainerController (./services.ts) since kg-service, like
 * mcp-auth-service, is not a @olam/core package. The persistent HTTP classifier
 * inside the container holds bge-small-en-v1.5 in memory; we pre-warm at
 * start() to eliminate the first-call cold cliff (see Phase A latency bench at
 * ~/kg-benchmark/kg-service-poc/latency-results.json).
 */
import { execFileSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import { KG_PATHS_INTERNALS } from '@olam/core/src/kg/storage-paths.js';
export const KG_SERVICE_PORT = 9997;
export const KG_SERVICE_CONTAINER = 'olam-kg-service';
export const KG_SERVICE_LOCAL_TAG = 'olam-kg-service:local';
export const KG_SERVICE_PUBLISHED_TAG = 'ghcr.io/pleri/olam-kg-service:latest';
export const KG_SERVICE_DEV_TAG = 'olam-kg-service:dev';
export const KG_SERVICE_HEALTH_URL = `http://127.0.0.1:${KG_SERVICE_PORT}/health`;
export const KG_SERVICE_CLASSIFY_URL = `http://127.0.0.1:${KG_SERVICE_PORT}/classify`;
// Cold-start path measured at ~1.3s (bge-small load + warmup probe). On flaky
// CI runners with no Docker layer cache we've seen 60s+; mirror McpAuth's
// 60s timeout so the failure mode is the same operator-visible "took too long"
// rather than spurious flake.
export const KG_SERVICE_HEALTH_TIMEOUT_MS = 60_000;
// kg-data is the host-mounted `~/.olam/kg/` so the container's /build can
// write graphs back to the operator's pristine workspace KG store.
const KG_SERVICE_VOLUME_TARGET = '/kg-data';
/**
 * Resolves the operator's KG storage root for the host bind-mount.
 *
 * Consumes `KG_PATHS_INTERNALS.kgRoot()` from @olam/core/src/kg/storage-paths.js
 * so this controller honours OLAM_HOME redirection (tests, per-world
 * overrides). Naively reading $HOME would cause split-brain: kg-service's
 * /build endpoint would write to ~/.olam/kg while host-side `olam kg
 * status` (which DOES consult OLAM_HOME via kgPristinePath) reports the
 * KG as missing.
 *
 * Caller is responsible for ensuring the directory exists pre-bind-mount
 * (Linux dockerd auto-creates as root:root if absent, which breaks
 * subsequent host-side writes from the operator's UID).
 */
function kgDataHostPath() {
    return KG_PATHS_INTERNALS.kgRoot();
}
export class KgServiceContainerController {
    imageTag = KG_SERVICE_LOCAL_TAG;
    status() {
        const r = spawnSync('docker', ['inspect', '--format', '{{.State.Status}}|{{.Id}}', KG_SERVICE_CONTAINER], { encoding: 'utf-8' });
        if (r.status === 0) {
            const [stateRaw, id] = r.stdout.trim().split('|');
            return {
                state: stateRaw === 'running' ? 'running' : 'stopped',
                port: KG_SERVICE_PORT,
                containerId: id,
            };
        }
        return { state: 'missing', port: KG_SERVICE_PORT };
    }
    imageExists(tag = this.imageTag) {
        return spawnSync('docker', ['image', 'inspect', tag], { encoding: 'utf-8' }).status === 0;
    }
    /**
     * Resolve the first available image tag: local → dev → published.
     * Throws if none exist (operator should run `olam bootstrap` to pull).
     */
    resolveImage() {
        for (const tag of [KG_SERVICE_LOCAL_TAG, KG_SERVICE_DEV_TAG, KG_SERVICE_PUBLISHED_TAG]) {
            if (this.imageExists(tag))
                return tag;
        }
        throw new Error(`kg-service image not found. Tried: ${KG_SERVICE_LOCAL_TAG}, ${KG_SERVICE_DEV_TAG}, ${KG_SERVICE_PUBLISHED_TAG}. ` +
            'Run `olam bootstrap` to pull the published image, or `npm --workspace @olam/kg-service run build` to build locally.');
    }
    /**
     * Idempotent start. If already running → return. If stopped → docker start.
     * If missing → docker run with port-forward + kg-data volume mount + restart
     * policy. The container's CMD is `python -u /app/src/server.py` which begins
     * loading bge-small immediately; the model is warm by the time /health
     * returns ready=true. We additionally fire a synthetic /classify after
     * readiness to JIT-warm the embedding hot path.
     */
    start() {
        const current = this.status();
        if (current.state === 'running')
            return;
        if (current.state === 'stopped') {
            execFileSync('docker', ['start', KG_SERVICE_CONTAINER], { stdio: 'pipe' });
            return;
        }
        this.imageTag = this.resolveImage();
        const kgData = kgDataHostPath();
        // Pre-create the host path with the operator's UID. Without this,
        // dockerd on Linux auto-creates the bind-mount target as root:root,
        // which breaks subsequent host-side `olam kg build` writes from the
        // operator's UID (EACCES). macOS Docker Desktop's user-mapped binds
        // don't bite here but the explicit mkdir is portable + cheap.
        fs.mkdirSync(kgData, { recursive: true });
        execFileSync('docker', [
            'run',
            '--detach',
            '--name', KG_SERVICE_CONTAINER,
            '--restart', 'unless-stopped',
            // Bind to 127.0.0.1 ONLY at the host port level — local-only by design.
            '--publish', `127.0.0.1:${KG_SERVICE_PORT}:${KG_SERVICE_PORT}`,
            // host-gateway lets this container reach the host (e.g. for diagnostic
            // probes). Worlds reach kg-service via the published 127.0.0.1:9997
            // PLUS their OWN --add-host flag configured in the world-create code path.
            '--add-host', 'host.docker.internal:host-gateway',
            // Mount the operator's KG root (OLAM_HOME-aware) so /build can persist graphs.
            '--volume', `${kgData}:${KG_SERVICE_VOLUME_TARGET}`,
            // kg-service-container-v2 Phase F: read-only mount of $HOME so /build
            // can ingest any project under the operator's home as the source repo.
            // Read-only is sufficient: server-side scratch in /tmp/ holds the
            // working copy + receives graphify's output before persisting to
            // /kg-data. Operator's source tree is never written. Mount path
            // `/host-home` is referenced by kg-build.ts when translating cwd
            // → container-side path. Closes Samuel's multi-submodule cp-r bug
            // by construction: copy runs INSIDE the container as root, not on
            // the host with the operator's UID hitting locked .git/objects/pack/*.
            '--volume', `${process.env.HOME ?? '/root'}:/host-home:ro`,
            this.imageTag,
        ], { stdio: 'pipe' });
    }
    /**
     * Returns true when no kg-service image exists locally — useful for callers
     * (e.g. servicesUp) to distinguish "operator opted out of kg-service" from
     * "kg-service was supposed to work but is broken". The opted-out case
     * should be silent; the broken case should warn.
     */
    imageMissingEverywhere() {
        return (!this.imageExists(KG_SERVICE_LOCAL_TAG) &&
            !this.imageExists(KG_SERVICE_DEV_TAG) &&
            !this.imageExists(KG_SERVICE_PUBLISHED_TAG));
    }
    stop() {
        const current = this.status();
        if (current.state !== 'running')
            return;
        execFileSync('docker', ['stop', KG_SERVICE_CONTAINER], { stdio: 'pipe' });
    }
    remove() {
        spawnSync('docker', ['rm', '-f', KG_SERVICE_CONTAINER], { stdio: 'pipe' });
    }
    /**
     * Poll /health until ready=true OR timeoutMs elapses. The container reports
     * ready=true only after the bge-small model + 31 exemplars have loaded
     * (~1.3s on a warm machine; longer on first pull where the image needs to
     * unpack).
     */
    async waitForReady(timeoutMs = KG_SERVICE_HEALTH_TIMEOUT_MS) {
        const deadline = Date.now() + timeoutMs;
        while (Date.now() < deadline) {
            try {
                const res = await fetch(KG_SERVICE_HEALTH_URL, { signal: AbortSignal.timeout(1_500) });
                if (res.ok) {
                    const body = (await res.json());
                    if (body.ready === true)
                        return true;
                }
            }
            catch {
                // not ready yet
            }
            await sleep(500);
        }
        return false;
    }
    /**
     * Fire a synthetic classify against the warm server. Called by `servicesUp`
     * after `waitForReady` succeeds — extra insurance that the embedding
     * inference hot path is JITted before the first operator-driven call.
     * Errors are swallowed; pre-warm is best-effort.
     */
    async prewarm() {
        try {
            await fetch(KG_SERVICE_CLASSIFY_URL, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ q: 'prewarm probe' }),
                signal: AbortSignal.timeout(5_000),
            });
        }
        catch {
            // best-effort
        }
    }
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
//# sourceMappingURL=kg-service-container.js.map