// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only
/**
 * Stream Heartbeat Helper (WU-2665, INIT-059 Phase 1)
 *
 * Agent stream-watchdogs kill orchestrator-spawned sub-agents after a fixed
 * window of stdout silence (observed 600s in INIT-057 P1 / INIT-058 release).
 * Long, legitimate CLI phases — gate runs, docs rebuilds, push-retry loops,
 * sidecar cleanup — go silent for minutes and trigger false-positive kills
 * even though the agent has committed productive RED/GREEN work.
 *
 * The fix is two-part:
 *
 *   1. Make the watchdog threshold configurable via workspace.yaml at
 *      `software_delivery.agents.stream_watchdog.timeout_seconds` /
 *      `heartbeat_cadence_seconds`.
 *   2. Emit a tiny stdout heartbeat line from wu:prep / wu:done / runGates
 *      at the configured cadence while a long phase is in flight.
 *
 * This module exposes the vendor-agnostic primitive: a plain `console.log`
 * (or writable-stream write) at a fixed cadence, driven by `setInterval`.
 * No Claude-specific / Codex-specific / Cursor-specific APIs. The line
 * format is human-readable so an operator can also see progress.
 *
 * @module stream-heartbeat
 */
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import YAML from 'yaml';
import { CONFIG_FILES } from '@lumenflow/core/wu-constants';
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
/**
 * Default watchdog/heartbeat values.
 *
 * - `timeout_seconds = 900`: the observed false-positive kill threshold was
 *   600s, so we raise to 900s (15 min) to cover long gate runs + docs
 *   rebuilds while still letting orchestrators kill truly-hung agents.
 * - `heartbeat_cadence_seconds = 60`: matches the WU-2665 acceptance criteria
 *   ("default 60s") and is frequent enough to stay well under a 600s watchdog
 *   in legacy orchestrators that haven't adopted the new threshold yet.
 */
export const STREAM_WATCHDOG_DEFAULTS = {
    timeout_seconds: 900,
    heartbeat_cadence_seconds: 60,
};
const SECOND_IN_MS = 1000;
const HEARTBEAT_PREFIX = '[heartbeat]';
/**
 * Start a periodic stdout heartbeat. Returns a handle with a stop() that is
 * idempotent — safe to call from the finally block of the wrapped operation.
 *
 * When `cadenceMs <= 0` no timer is installed and stop() is a no-op (lets
 * callers disable heartbeats via config without branching at each call site).
 */
export function startStdoutHeartbeat(options) {
    const { cadenceMs, label } = options;
    const stream = options.stream ?? process.stdout;
    const now = options.now ?? Date.now;
    if (!Number.isFinite(cadenceMs) || cadenceMs <= 0) {
        return { stop: () => { } };
    }
    const startedAt = now();
    const timer = setInterval(() => {
        const elapsedSeconds = Math.round((now() - startedAt) / SECOND_IN_MS);
        stream.write(`${HEARTBEAT_PREFIX} ${label} +${elapsedSeconds}s\n`);
    }, cadenceMs);
    // Allow the Node.js process to exit even if this interval is still pending
    // (e.g. in tests / short-lived wrapper scripts). Guard against mocked
    // intervals that don't expose `unref` (vitest fake timers).
    if (typeof timer.unref === 'function') {
        timer.unref();
    }
    let stopped = false;
    return {
        stop: () => {
            if (stopped)
                return;
            stopped = true;
            clearInterval(timer);
        },
    };
}
// ---------------------------------------------------------------------------
// withStdoutHeartbeat
// ---------------------------------------------------------------------------
/**
 * Run `fn` with a stdout heartbeat active for its duration. The heartbeat
 * timer is always cleared — even when `fn` throws — so gate/merge failures
 * can't leak intervals into the main Node event loop.
 */
export async function withStdoutHeartbeat(options, fn) {
    const handle = startStdoutHeartbeat(options);
    try {
        return await fn();
    }
    finally {
        handle.stop();
    }
}
/**
 * Read `software_delivery.agents.stream_watchdog.*` from workspace.yaml,
 * falling back to {@link STREAM_WATCHDOG_DEFAULTS} when the file is
 * missing, unreadable, malformed, or omits the block.
 *
 * Graceful degradation is mandatory: this helper is called from long-running
 * CLI phases where a config-read exception would defeat the entire purpose
 * of the WU (the agent would die silently before the first heartbeat).
 */
export function loadStreamWatchdogConfig(options = {}) {
    const cwd = options.cwd ?? process.cwd();
    const workspacePath = join(cwd, CONFIG_FILES.WORKSPACE_CONFIG);
    if (!existsSync(workspacePath)) {
        return { ...STREAM_WATCHDOG_DEFAULTS };
    }
    let doc;
    try {
        doc = YAML.parse(readFileSync(workspacePath, 'utf-8'));
    }
    catch {
        return { ...STREAM_WATCHDOG_DEFAULTS };
    }
    const watchdog = readStreamWatchdogBlock(doc);
    return {
        timeout_seconds: resolvePositiveInt(watchdog?.timeout_seconds, STREAM_WATCHDOG_DEFAULTS.timeout_seconds),
        heartbeat_cadence_seconds: resolvePositiveInt(watchdog?.heartbeat_cadence_seconds, STREAM_WATCHDOG_DEFAULTS.heartbeat_cadence_seconds),
    };
}
function isRecord(value) {
    return typeof value === 'object' && value !== null;
}
function readStreamWatchdogBlock(doc) {
    if (!isRecord(doc))
        return null;
    const softwareDelivery = doc.software_delivery;
    if (!isRecord(softwareDelivery))
        return null;
    const agents = softwareDelivery.agents;
    if (!isRecord(agents))
        return null;
    const watchdog = agents.stream_watchdog;
    return isRecord(watchdog) ? watchdog : null;
}
function resolvePositiveInt(value, fallback) {
    if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
        return Math.floor(value);
    }
    return fallback;
}
//# sourceMappingURL=stream-heartbeat.js.map