// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only
/**
 * WU-2642 (INIT-057 Phase 4): Spawn & stop the control-plane event-sync
 * sidecar around WU-lifecycle boundaries.
 *
 * ADR-058 requires sub-minute visibility between wu:claim and wu:done.
 * wu:claim spawns this sidecar after a successful claim; wu:done /
 * agent:session-end signals it to stop. The sidecar process itself is a
 * thin wrapper around {@link startControlPlaneEventSync} from
 * `@lumenflow/surfaces/http` (no HttpSurface boot).
 *
 * All subprocess/kill interactions are injectable so tests can exercise the
 * pidfile lifecycle without spawning real children.
 *
 * @module sidecar-manager
 */
import { readFile, writeFile, rm, mkdir } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import YAML from 'yaml';
import { CONFIG_FILES, ENV_VARS, LUMENFLOW_PATHS } from '@lumenflow/core/wu-constants';
import { createError, ErrorCodes, getErrorMessage } from '@lumenflow/core/error-handler';
export const DEFAULT_CONTROL_PLANE_SIDECAR_INTERVAL_MS = 30_000;
/** Pidfile path relative to the workspace root. */
export const CONTROL_PLANE_SIDECAR_PIDFILE = `${LUMENFLOW_PATHS.STATE_DIR}/control-plane-sidecar.pid`;
const WORKSPACE_CONTROL_PLANE_FIELD = 'control_plane';
/**
 * WU-2642: Reuses the existing {@link WorkspaceControlPlaneConfigSchema}
 * field (integer seconds). The sidecar operates in milliseconds, so
 * {@link extractSyncIntervalMs} converts seconds → ms. No schema change
 * required — WU-2643 is queued behind this WU and may add emission-scoped
 * fields, but this WU keeps its workspace.yaml footprint to the
 * already-schema-allowed `control_plane.sync_interval`.
 */
const WORKSPACE_SYNC_INTERVAL_SECONDS_FIELD = 'sync_interval';
const MILLISECONDS_PER_SECOND = 1_000;
const TEXT_ENCODING_UTF8 = 'utf-8';
const SIDECAR_STATE_DIR = LUMENFLOW_PATHS.STATE_DIR;
const LOG_PREFIX = '[sidecar-manager]';
const SIDECAR_RUNNER_FILENAME = 'control-plane-sidecar-runner.js';
const SIDECAR_ENV_WORKSPACE_ROOT = ENV_VARS.SIDECAR_WORKSPACE_ROOT;
const SIDECAR_ENV_INTERVAL_MS = ENV_VARS.SIDECAR_INTERVAL_MS;
function isRecord(value) {
    return typeof value === 'object' && value !== null;
}
async function readWorkspaceDoc(workspaceRoot) {
    const workspacePath = path.join(workspaceRoot, CONFIG_FILES.WORKSPACE_CONFIG);
    try {
        const content = await readFile(workspacePath, TEXT_ENCODING_UTF8);
        const parsed = YAML.parse(content);
        return isRecord(parsed) ? parsed : null;
    }
    catch {
        return null;
    }
}
function extractSyncIntervalMs(doc) {
    if (!doc)
        return null;
    const controlPlane = Reflect.get(doc, WORKSPACE_CONTROL_PLANE_FIELD);
    if (!isRecord(controlPlane))
        return null;
    const intervalSeconds = Reflect.get(controlPlane, WORKSPACE_SYNC_INTERVAL_SECONDS_FIELD);
    if (typeof intervalSeconds !== 'number' ||
        !Number.isFinite(intervalSeconds) ||
        intervalSeconds <= 0) {
        return null;
    }
    return intervalSeconds * MILLISECONDS_PER_SECOND;
}
/**
 * Resolve the sidecar poll interval from workspace.yaml, falling back to
 * {@link DEFAULT_CONTROL_PLANE_SIDECAR_INTERVAL_MS} if the key is absent,
 * invalid, or workspace.yaml is missing.
 */
export async function resolveSidecarIntervalMs(options = {}) {
    const workspaceRoot = options.workspaceRoot ?? process.cwd();
    const doc = await readWorkspaceDoc(workspaceRoot);
    const configured = extractSyncIntervalMs(doc);
    return configured ?? DEFAULT_CONTROL_PLANE_SIDECAR_INTERVAL_MS;
}
function resolveRunnerScriptPath() {
    // This module lives at <dist>/sidecar-manager.js after tsc; the runner is
    // co-located as <dist>/control-plane-sidecar-runner.js. When this module is
    // loaded directly from src (tests), the runner resolves to the src copy —
    // tests inject their own spawner so this path is not exercised there.
    const here = fileURLToPath(import.meta.url);
    return path.join(path.dirname(here), SIDECAR_RUNNER_FILENAME);
}
function defaultSpawner(input) {
    const runnerScript = resolveRunnerScriptPath();
    const child = spawn(process.execPath, [runnerScript], {
        cwd: input.workspaceRoot,
        detached: true,
        stdio: 'ignore',
        env: {
            ...process.env,
            [SIDECAR_ENV_WORKSPACE_ROOT]: input.workspaceRoot,
            [SIDECAR_ENV_INTERVAL_MS]: String(input.syncIntervalMs),
        },
    });
    if (typeof child.pid !== 'number') {
        throw createError(ErrorCodes.COMMAND_EXECUTION_FAILED, `${LOG_PREFIX} failed to spawn sidecar: no pid returned`);
    }
    // Detach so the child survives parent exit (wu:claim returns immediately).
    child.unref();
    return { pid: child.pid, detached: true };
}
function defaultKiller(pid, signal) {
    try {
        process.kill(pid, signal);
        return true;
    }
    catch {
        return false;
    }
}
async function ensureStateDir(workspaceRoot) {
    await mkdir(path.join(workspaceRoot, SIDECAR_STATE_DIR), { recursive: true });
}
async function writePidfile(workspaceRoot, pid) {
    await ensureStateDir(workspaceRoot);
    const pidfile = path.join(workspaceRoot, CONTROL_PLANE_SIDECAR_PIDFILE);
    await writeFile(pidfile, String(pid), TEXT_ENCODING_UTF8);
}
async function readPidfile(workspaceRoot) {
    const pidfile = path.join(workspaceRoot, CONTROL_PLANE_SIDECAR_PIDFILE);
    try {
        const raw = await readFile(pidfile, TEXT_ENCODING_UTF8);
        const parsed = Number.parseInt(raw.trim(), 10);
        return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
    }
    catch {
        return null;
    }
}
async function removePidfile(workspaceRoot) {
    const pidfile = path.join(workspaceRoot, CONTROL_PLANE_SIDECAR_PIDFILE);
    try {
        await rm(pidfile, { force: true });
    }
    catch {
        // best-effort cleanup
    }
}
function hasControlPlaneBlock(doc) {
    if (!doc)
        return false;
    const controlPlane = Reflect.get(doc, WORKSPACE_CONTROL_PLANE_FIELD);
    return isRecord(controlPlane);
}
/**
 * Spawn the control-plane sidecar for the current workspace.
 *
 * No-op (returns `spawned: false`) when the workspace has no `control_plane`
 * block — there's nothing to sync to. Writes the sidecar pid to
 * {@link CONTROL_PLANE_SIDECAR_PIDFILE} so {@link stopControlPlaneSidecar}
 * can shut it down.
 */
export async function spawnControlPlaneSidecar(options = {}) {
    const workspaceRoot = options.workspaceRoot ?? process.cwd();
    const doc = await readWorkspaceDoc(workspaceRoot);
    if (!hasControlPlaneBlock(doc)) {
        return { spawned: false, reason: 'control-plane-not-configured' };
    }
    const syncIntervalMs = extractSyncIntervalMs(doc) ?? DEFAULT_CONTROL_PLANE_SIDECAR_INTERVAL_MS;
    const spawner = options.spawner ?? defaultSpawner;
    let handle;
    try {
        handle = spawner({ workspaceRoot, syncIntervalMs });
    }
    catch (error) {
        options.logger?.warn?.(`${LOG_PREFIX} sidecar spawn failed: ${getErrorMessage(error)}`);
        return { spawned: false, reason: 'spawn-failed' };
    }
    try {
        await writePidfile(workspaceRoot, handle.pid);
    }
    catch (error) {
        options.logger?.warn?.(`${LOG_PREFIX} failed to write pidfile: ${getErrorMessage(error)}`);
        return { spawned: false, pid: handle.pid, reason: 'pidfile-write-failed' };
    }
    return { spawned: true, pid: handle.pid };
}
/**
 * Stop the control-plane sidecar previously spawned for this workspace.
 *
 * Reads the pidfile, signals the pid with SIGTERM, and removes the pidfile.
 * Silent no-op when no pidfile exists (e.g. sidecar never started).
 */
export async function stopControlPlaneSidecar(options = {}) {
    const workspaceRoot = options.workspaceRoot ?? process.cwd();
    const pid = await readPidfile(workspaceRoot);
    if (pid === null) {
        return { stopped: false, reason: 'no-pidfile' };
    }
    const killer = options.killer ?? defaultKiller;
    const killed = killer(pid, 'SIGTERM');
    await removePidfile(workspaceRoot);
    if (!killed) {
        options.logger?.warn?.(`${LOG_PREFIX} sidecar pid ${pid} could not be signaled (already exited?)`);
        return { stopped: false, pid, reason: 'kill-failed' };
    }
    return { stopped: true, pid };
}
//# sourceMappingURL=sidecar-manager.js.map