/**
 * Cron daemon — background timer loop that executes due jobs.
 *
 * Runs as a detached Node process (spawned via daemon-ipc.ts).
 * On each tick: load store → find due jobs → execute sequentially → save.
 */
import { spawn as nodeSpawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { resolveOwnBinary } from '../../config.js';
import { getDaemonPidPath } from './daemon-ipc.js';
import { applyJobResult, isJobDue, normalizeJobState } from './jobs.js';
import { appendRunLog, truncateRunOutput } from './run-log.js';
import { clearStoreCache, getCronStorePath, loadCronStore, saveCronStore } from './store.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Tick interval — poll this often. */
const TICK_INTERVAL_MS = 60_000;
/** Timeout for a single job execution. */
const JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
/** Max captured output per stream (stdout/stderr) to prevent OOM. */
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MB
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let tickTimer = null;
let stopping = false;
let activeChild = null;
// ---------------------------------------------------------------------------
// Job execution
// ---------------------------------------------------------------------------
/** Spawn the agent binary in print mode for a job's payload. */
function spawnAgentForJob(job) {
    return new Promise((resolve) => {
        const [cmd, ...baseArgs] = resolveOwnBinary();
        const args = [
            ...baseArgs,
            '-p',
            '--no-session',
            ...(job.payload.model ? ['--model', job.payload.model] : []),
            job.payload.message
        ];
        let stdout = '';
        let stderr = '';
        let settled = false;
        const child = nodeSpawn(cmd, args, {
            stdio: ['ignore', 'pipe', 'pipe'],
            cwd: process.env.HOME ?? process.cwd()
        });
        activeChild = child;
        child.stdout?.on('data', (chunk) => {
            if (stdout.length < MAX_OUTPUT_BYTES)
                stdout += chunk.toString();
        });
        child.stderr?.on('data', (chunk) => {
            if (stderr.length < MAX_OUTPUT_BYTES)
                stderr += chunk.toString();
        });
        let escalation = null;
        const timeout = setTimeout(() => {
            child.kill('SIGTERM');
            stderr += '\n[daemon] Job timed out';
            // Escalate to SIGKILL if process doesn't exit within 5s
            escalation = setTimeout(() => child.kill('SIGKILL'), 5_000);
            escalation.unref();
        }, JOB_TIMEOUT_MS);
        child.on('close', (code) => {
            if (settled)
                return;
            settled = true;
            activeChild = null;
            clearTimeout(timeout);
            if (escalation)
                clearTimeout(escalation);
            resolve({
                exitCode: code ?? 1,
                output: stdout.trim(),
                error: stderr.trim()
            });
        });
        child.on('error', (err) => {
            if (settled)
                return;
            settled = true;
            activeChild = null;
            clearTimeout(timeout);
            if (escalation)
                clearTimeout(escalation);
            resolve({
                exitCode: 1,
                output: '',
                error: err.message
            });
        });
    });
}
async function executeJob(job, storePath) {
    const startedAt = Date.now();
    log(`Executing job "${job.name}" (${job.id})`);
    const { exitCode, output, error } = await spawnAgentForJob(job);
    const endedAt = Date.now();
    const result = {
        status: exitCode === 0 ? 'ok' : 'error',
        error: exitCode !== 0 ? error || `exit code ${exitCode}` : undefined,
        startedAt,
        endedAt
    };
    const shouldDelete = applyJobResult(job, result);
    // Reload store before saving to pick up any concurrent changes
    clearStoreCache();
    const store = await loadCronStore(storePath);
    const storeJob = store.jobs.find((j) => j.id === job.id);
    if (storeJob) {
        // Selectively merge only fields that applyJobResult modifies,
        // preserving any concurrent user changes to other state fields
        storeJob.state.runningAtMs = job.state.runningAtMs;
        storeJob.state.lastRunAtMs = job.state.lastRunAtMs;
        storeJob.state.lastRunStatus = job.state.lastRunStatus;
        storeJob.state.lastDurationMs = job.state.lastDurationMs;
        storeJob.state.lastError = job.state.lastError;
        storeJob.state.consecutiveErrors = job.state.consecutiveErrors;
        storeJob.state.nextRunAtMs = job.state.nextRunAtMs;
        storeJob.enabled = job.enabled;
        storeJob.updatedAtMs = job.updatedAtMs;
        if (shouldDelete) {
            store.jobs = store.jobs.filter((j) => j.id !== job.id);
        }
    }
    await saveCronStore(storePath, store);
    await appendRunLog({
        jobId: job.id,
        jobName: job.name,
        startedAtMs: startedAt,
        endedAtMs: endedAt,
        durationMs: endedAt - startedAt,
        status: result.status,
        error: result.error,
        output: output ? truncateRunOutput(output) : undefined
    });
    const statusMsg = result.status === 'ok' ? `completed in ${endedAt - startedAt}ms` : `failed: ${result.error}`;
    log(`Job "${job.name}" ${statusMsg}`);
}
// ---------------------------------------------------------------------------
// Tick loop
// ---------------------------------------------------------------------------
async function tick(storePath) {
    if (stopping)
        return;
    try {
        clearStoreCache();
        const store = await loadCronStore(storePath);
        const now = Date.now();
        // Normalize all jobs (clear stuck runs, repair missing schedules)
        let storeChanged = false;
        for (const job of store.jobs) {
            if (normalizeJobState(job, now))
                storeChanged = true;
        }
        if (storeChanged) {
            await saveCronStore(storePath, store);
        }
        // Find and execute due jobs sequentially
        const dueJobs = store.jobs.filter((j) => isJobDue(j, now));
        for (const job of dueJobs) {
            if (stopping)
                break;
            // Mark as running
            job.state.runningAtMs = Date.now();
            await saveCronStore(storePath, store);
            await executeJob(job, storePath);
        }
    }
    catch (err) {
        log(`Tick error: ${err instanceof Error ? err.message : String(err)}`);
    }
    armTimer(storePath);
}
function armTimer(storePath) {
    if (stopping)
        return;
    if (tickTimer)
        clearTimeout(tickTimer);
    tickTimer = setTimeout(() => void tick(storePath), TICK_INTERVAL_MS);
    tickTimer.unref();
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function log(msg) {
    const ts = new Date().toISOString();
    process.stdout.write(`[${ts}] ${msg}\n`);
}
/** Start the daemon tick loop. Call from the daemon entry point. */
export async function startDaemonLoop(storePath = getCronStorePath()) {
    log('Cron daemon starting');
    // Write PID file
    await fs.promises.mkdir(path.dirname(getDaemonPidPath()), { recursive: true, mode: 0o700 });
    await fs.promises.writeFile(getDaemonPidPath(), String(process.pid), { mode: 0o600 });
    // Handle shutdown signals
    const shutdown = () => {
        log('Cron daemon shutting down');
        stopping = true;
        if (tickTimer) {
            clearTimeout(tickTimer);
            tickTimer = null;
        }
        if (activeChild) {
            activeChild.kill('SIGTERM');
        }
        try {
            fs.unlinkSync(getDaemonPidPath());
        }
        catch {
            // best effort
        }
        process.exit(0);
    };
    process.on('SIGTERM', shutdown);
    process.on('SIGINT', shutdown);
    // Run first tick immediately
    await tick(storePath);
}
//# sourceMappingURL=daemon.js.map