/**
 * Command runner service.
 *
 * Executes shell commands in a project directory using execa with
 * streaming output, timeout enforcement, and output size truncation.
 */
import { execa } from "execa";
import { COMMAND_TIMEOUT_MS, MAX_COMMAND_OUTPUT } from "@pocketcoder/shared/constants";
import { logger } from "../utils/logger.js";
/** Map of requestId → running command */
const runningCommands = new Map();
/**
 * Execute a command in the given project directory.
 *
 * Streams stdout/stderr chunks via the onOutput callback.
 * Returns the final result with exit code, signal, duration, and truncation info.
 */
export async function executeCommand(requestId, projectId, command, cwd, onOutput) {
    const startTime = Date.now();
    let totalOutputBytes = 0;
    let truncated = false;
    let outputPreview = "";
    const subprocess = execa(command, {
        cwd,
        shell: true,
        timeout: COMMAND_TIMEOUT_MS,
        reject: false,
        buffer: false,
    });
    runningCommands.set(requestId, { subprocess, requestId, projectId });
    // Stream stdout
    if (subprocess.stdout) {
        subprocess.stdout.on("data", (chunk) => {
            if (truncated)
                return;
            const chunkStr = chunk.toString("utf-8");
            const chunkBytes = chunk.length;
            if (totalOutputBytes + chunkBytes > MAX_COMMAND_OUTPUT) {
                truncated = true;
                // Send remaining portion that fits
                const remaining = MAX_COMMAND_OUTPUT - totalOutputBytes;
                if (remaining > 0) {
                    const partial = chunk.subarray(0, remaining).toString("utf-8");
                    onOutput({ stdoutChunk: partial });
                    outputPreview += partial;
                }
                return;
            }
            totalOutputBytes += chunkBytes;
            onOutput({ stdoutChunk: chunkStr });
            // Keep first portion for output_preview
            if (outputPreview.length < 1024) {
                outputPreview += chunkStr.slice(0, 1024 - outputPreview.length);
            }
        });
    }
    // Stream stderr
    if (subprocess.stderr) {
        subprocess.stderr.on("data", (chunk) => {
            if (truncated)
                return;
            const chunkStr = chunk.toString("utf-8");
            const chunkBytes = chunk.length;
            if (totalOutputBytes + chunkBytes > MAX_COMMAND_OUTPUT) {
                truncated = true;
                const remaining = MAX_COMMAND_OUTPUT - totalOutputBytes;
                if (remaining > 0) {
                    const partial = chunk.subarray(0, remaining).toString("utf-8");
                    onOutput({ stderrChunk: partial });
                    outputPreview += partial;
                }
                return;
            }
            totalOutputBytes += chunkBytes;
            onOutput({ stderrChunk: chunkStr });
            if (outputPreview.length < 1024) {
                outputPreview += chunkStr.slice(0, 1024 - outputPreview.length);
            }
        });
    }
    try {
        const result = await subprocess;
        const durationMs = Date.now() - startTime;
        runningCommands.delete(requestId);
        return {
            exitCode: result.exitCode ?? null,
            signal: result.signal ?? undefined,
            durationMs,
            truncated,
            outputPreview: outputPreview.slice(0, 1024),
        };
    }
    catch (err) {
        const durationMs = Date.now() - startTime;
        runningCommands.delete(requestId);
        // execa rejects on timeout when reject is false? Actually no —
        // reject: false means it won't reject on non-zero exit codes or signals.
        // It still might reject on truly exceptional cases (spawn errors).
        logger.error(`Command execution error for ${requestId}: ${err instanceof Error ? err.message : String(err)}`);
        return {
            exitCode: null,
            durationMs,
            truncated,
            outputPreview: outputPreview.slice(0, 1024),
        };
    }
}
/**
 * Cancel a running command by requestId.
 * Sends SIGTERM to the process.
 *
 * @returns true if the command was found and killed, false if not found
 */
export function cancelCommand(requestId) {
    const running = runningCommands.get(requestId);
    if (!running) {
        return false;
    }
    logger.info(`Cancelling command ${requestId}`);
    running.subprocess.kill("SIGTERM");
    return true;
}
/**
 * Check if a command is currently running.
 */
export function isCommandRunning(requestId) {
    return runningCommands.has(requestId);
}
/**
 * Get the count of running commands for a specific project.
 */
export function getRunningCommandCount(projectId) {
    let count = 0;
    for (const cmd of runningCommands.values()) {
        if (cmd.projectId === projectId) {
            count++;
        }
    }
    return count;
}
/**
 * Kill all running commands (used during shutdown).
 */
export function killAllCommands() {
    for (const [requestId, cmd] of runningCommands) {
        logger.info(`Killing command ${requestId} during shutdown`);
        cmd.subprocess.kill("SIGTERM");
    }
    runningCommands.clear();
}
//# sourceMappingURL=command-runner.js.map