"use strict";
// Workflow runner v1.
//
// Workflows are declarative tool-call sequences stored under
// `.harness/workflows/<name>.{yaml,json}`. The runner executes one step at a
// time, runs against the live tool registry + permission engine, and supports
// dry-run, pause, resume, and cancel.
//
// Run state is persisted to `.harness/workflows/runs/<runId>.json` on every
// state transition (start, pause, resume, cancel, per-step settle, terminal
// status). Writes go through `withFileLock` + `atomicWriteFile` so two
// concurrent transitions on the same run cannot lose each other. On
// `restoreRuns()` any run found in `running` or `pending` status is demoted
// to `failed` with a recovery note — tool side effects are not idempotent so
// we never auto-resume an in-flight run.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.WorkflowRegistry = void 0;
const crypto = __importStar(require("crypto"));
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const logger_1 = require("../core/logger");
const atomicFile_1 = require("../persistence/atomicFile");
const tracing_1 = require("../core/tracing");
class WorkflowRegistry {
    workflowsDir;
    runs = new Map();
    signals = new Map();
    // Tracks in-flight `persistRun` promises kicked off by the synchronous
    // mutator methods (`startRun`, `pause`, `resume`, `cancel`). Tests and the
    // server's shutdown path call `flush()` to drain these before teardown so a
    // late rename does not race a directory removal.
    pendingPersists = new Set();
    constructor(workflowsDir) {
        this.workflowsDir = workflowsDir;
    }
    // Wait for every persist-write currently in-flight to settle. Always
    // resolves — individual write failures are handled inside `persistRun`.
    async flush() {
        if (this.pendingPersists.size === 0)
            return;
        await Promise.allSettled(Array.from(this.pendingPersists));
    }
    trackPersist(run) {
        const promise = this.persistRun(run).finally(() => {
            this.pendingPersists.delete(promise);
        });
        this.pendingPersists.add(promise);
    }
    runsDir() {
        return path.join(this.workflowsDir, 'runs');
    }
    runFilePath(id) {
        return path.join(this.runsDir(), `${id}.json`);
    }
    // Persistence is best-effort: a write failure must not crash the run. The
    // in-memory state remains authoritative for the rest of the current
    // process; the next successful write will catch the persisted snapshot up.
    async persistRun(run) {
        try {
            await fs.mkdir(this.runsDir(), { recursive: true });
            const filePath = this.runFilePath(run.id);
            await (0, atomicFile_1.withFileLock)(filePath, async () => {
                // Snapshot inside the lock so a concurrent in-memory mutation cannot
                // produce a torn JSON document on disk.
                const snapshot = JSON.stringify(run, null, 2);
                await (0, atomicFile_1.atomicWriteFile)(filePath, snapshot, { encoding: 'utf-8' });
            });
        }
        catch (error) {
            logger_1.logger.warn('Workflow', 'Failed to persist workflow run', {
                runId: run.id, error: error instanceof Error ? error.message : String(error),
            });
        }
    }
    // Load every persisted run into memory. Runs found in `running` (the server
    // exited mid-step) or `pending` (server exited before execute() began) are
    // demoted to `failed` and re-persisted so the next restore is idempotent.
    // Returns counts for the startup log.
    async restoreRuns() {
        let entries = [];
        try {
            entries = await fs.readdir(this.runsDir());
        }
        catch {
            return { restored: 0, demoted: 0 };
        }
        let restored = 0;
        let demoted = 0;
        for (const name of entries) {
            if (!name.endsWith('.json'))
                continue;
            const filePath = path.join(this.runsDir(), name);
            try {
                const raw = await fs.readFile(filePath, 'utf-8');
                const parsed = JSON.parse(raw);
                if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !Array.isArray(parsed.steps)) {
                    logger_1.logger.warn('Workflow', 'Skipping malformed run file', { file: name });
                    continue;
                }
                let wasDemoted = false;
                if (parsed.status === 'running' || parsed.status === 'pending') {
                    const recoveryNote = 'Server exited while workflow run was in progress; not auto-resumed.';
                    for (const step of parsed.steps) {
                        if (step.status === 'running') {
                            step.status = 'failed';
                            step.error = step.error ?? recoveryNote;
                            step.finishedAt = step.finishedAt ?? new Date().toISOString();
                        }
                    }
                    parsed.status = 'failed';
                    parsed.finishedAt = parsed.finishedAt ?? new Date().toISOString();
                    demoted += 1;
                    wasDemoted = true;
                }
                this.runs.set(parsed.id, parsed);
                // Restored runs are inert — no executor is bound to their signal. A
                // future resume() call would currently fail because the signal entry
                // is only honoured by an in-process execute(). That is correct for
                // v1: restored runs are visible for inspection only.
                this.signals.set(parsed.id, { paused: parsed.status === 'paused', cancelled: false });
                restored += 1;
                if (wasDemoted) {
                    // Re-persist the demoted state so the next restore is idempotent.
                    await this.persistRun(parsed);
                }
            }
            catch (error) {
                logger_1.logger.warn('Workflow', 'Failed to restore workflow run', {
                    file: name, error: error instanceof Error ? error.message : String(error),
                });
            }
        }
        return { restored, demoted };
    }
    async list() {
        let entries = [];
        try {
            entries = await fs.readdir(this.workflowsDir);
        }
        catch {
            return [];
        }
        const defs = [];
        for (const name of entries) {
            if (!/\.(ya?ml|json)$/i.test(name))
                continue;
            try {
                const def = await this.loadFile(path.join(this.workflowsDir, name));
                if (def)
                    defs.push(def);
            }
            catch (error) {
                logger_1.logger.warn('Workflow', 'Failed to load workflow', { name, error: error instanceof Error ? error.message : String(error) });
            }
        }
        return defs.sort((a, b) => a.name.localeCompare(b.name));
    }
    async load(name) {
        for (const ext of ['.yaml', '.yml', '.json']) {
            const filePath = path.join(this.workflowsDir, name + ext);
            try {
                const def = await this.loadFile(filePath);
                if (def)
                    return def;
            }
            catch { /* try next extension */ }
        }
        return null;
    }
    async loadFile(filePath) {
        const raw = await fs.readFile(filePath, 'utf-8');
        const parsed = filePath.endsWith('.json') ? JSON.parse(raw) : parseSimpleYaml(raw);
        return normalizeDefinition(parsed, filePath);
    }
    startRun(definition, options = {}) {
        const id = `run-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
        const variables = { ...(definition.variables ?? {}), ...(options.variables ?? {}) };
        const run = {
            id,
            workflowName: definition.name,
            status: 'pending',
            dryRun: Boolean(options.dryRun),
            startedAt: new Date().toISOString(),
            variables,
            steps: definition.steps.map((step) => ({ step, status: 'pending' })),
            currentStepIndex: 0,
        };
        this.runs.set(id, run);
        this.signals.set(id, { paused: false, cancelled: false });
        // Fire-and-forget tracked persist: `flush()` will await it on shutdown.
        // persistRun is internally try/catch so the failure mode is a warn-log,
        // never an unhandled rejection. Awaiting here would block the synchronous
        // startRun contract callers rely on.
        this.trackPersist(run);
        return run;
    }
    getRun(id) {
        return this.runs.get(id);
    }
    listRuns() {
        return Array.from(this.runs.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
    }
    pause(id, reason) {
        const signal = this.signals.get(id);
        const run = this.runs.get(id);
        if (!signal || !run)
            return false;
        // Allow pre-pausing a pending run so the caller can choose when to begin
        // executing it. Re-pausing an already paused run is a no-op success.
        if (run.status !== 'running' && run.status !== 'pending' && run.status !== 'paused')
            return false;
        signal.paused = true;
        run.pauseReason = reason;
        if (run.status === 'pending')
            run.status = 'paused';
        this.trackPersist(run);
        return true;
    }
    resume(id) {
        const signal = this.signals.get(id);
        const run = this.runs.get(id);
        if (!signal || !run)
            return false;
        if (run.status !== 'paused')
            return false;
        signal.paused = false;
        run.pauseReason = undefined;
        this.trackPersist(run);
        return true;
    }
    cancel(id, reason) {
        const signal = this.signals.get(id);
        const run = this.runs.get(id);
        if (!signal || !run)
            return false;
        if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled')
            return false;
        signal.cancelled = true;
        signal.paused = false;
        run.cancelReason = reason;
        this.trackPersist(run);
        return true;
    }
    async execute(runId, deps) {
        const run = this.runs.get(runId);
        const signal = this.signals.get(runId);
        if (!run || !signal)
            throw new Error(`Unknown workflow run: ${runId}`);
        if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled')
            return run;
        if (signal.paused) {
            run.status = 'paused';
            tracing_1.runtimeTracer.recordEvent('workflow.run_paused', { runId });
            await this.persistRun(run);
            return run;
        }
        run.status = 'running';
        tracing_1.runtimeTracer.recordEvent('workflow.run_started', { runId, workflow: run.workflowName, dryRun: run.dryRun });
        await this.persistRun(run);
        while (run.currentStepIndex < run.steps.length) {
            if (signal.cancelled) {
                run.status = 'cancelled';
                run.finishedAt = new Date().toISOString();
                tracing_1.runtimeTracer.recordEvent('workflow.run_cancelled', { runId, reason: run.cancelReason });
                await this.persistRun(run);
                return run;
            }
            if (signal.paused) {
                run.status = 'paused';
                tracing_1.runtimeTracer.recordEvent('workflow.run_paused', { runId });
                await this.persistRun(run);
                return run;
            }
            const stepExecution = run.steps[run.currentStepIndex];
            try {
                await this.runStep(run, stepExecution, deps);
            }
            catch (error) {
                // Preserve a more specific status (e.g. 'denied') if runStep already set
                // it; otherwise fall back to 'failed'.
                if (stepExecution.status === 'pending' || stepExecution.status === 'running') {
                    stepExecution.status = 'failed';
                }
                if (!stepExecution.error)
                    stepExecution.error = error instanceof Error ? error.message : String(error);
                if (!stepExecution.finishedAt)
                    stepExecution.finishedAt = new Date().toISOString();
                if (!stepExecution.step.continueOnError) {
                    run.status = 'failed';
                    run.finishedAt = new Date().toISOString();
                    tracing_1.runtimeTracer.recordEvent('workflow.run_failed', { runId, step: stepExecution.step.id, error: stepExecution.error });
                    await this.persistRun(run);
                    return run;
                }
            }
            run.currentStepIndex++;
            // Persist after each step settles so a crash mid-loop doesn't lose the
            // record of which step ran last. The next-step write would catch the
            // index up otherwise, but the run-level summary should stay current.
            await this.persistRun(run);
        }
        run.status = 'completed';
        run.finishedAt = new Date().toISOString();
        tracing_1.runtimeTracer.recordEvent('workflow.run_completed', { runId, steps: run.steps.length });
        await this.persistRun(run);
        return run;
    }
    async runStep(run, execution, deps) {
        execution.status = 'running';
        execution.startedAt = new Date().toISOString();
        const resolvedInput = substituteVariables(execution.step.input ?? {}, run.variables);
        execution.resolvedInput = resolvedInput;
        const tool = deps.tools.find((candidate) => candidate.name === execution.step.tool);
        if (!tool) {
            execution.status = 'failed';
            execution.error = `Unknown tool: ${execution.step.tool}`;
            execution.finishedAt = new Date().toISOString();
            throw new Error(execution.error);
        }
        const permission = deps.permissions.evaluate({ name: tool.name, input: resolvedInput });
        execution.permissionReason = permission.reason;
        if (permission.decision === 'deny') {
            execution.status = 'denied';
            execution.error = permission.reason;
            execution.finishedAt = new Date().toISOString();
            tracing_1.runtimeTracer.recordEvent('workflow.step_denied', { runId: run.id, step: execution.step.id, reason: permission.reason });
            if (!execution.step.continueOnError)
                throw new Error(permission.reason ?? 'Tool denied by permission engine');
            return;
        }
        if (run.dryRun) {
            execution.dryRun = true;
            execution.result = { success: true, output: `[dry-run] would call ${tool.name}` };
            execution.status = 'completed';
            execution.finishedAt = new Date().toISOString();
            tracing_1.runtimeTracer.recordEvent('workflow.step_dry_run', { runId: run.id, step: execution.step.id, tool: tool.name });
            return;
        }
        if (permission.decision === 'ask') {
            // Workflow runner does not interactively prompt — treat 'ask' as denied
            // to keep batch execution deterministic.
            execution.status = 'denied';
            execution.error = 'Permission engine requested approval; workflow runner cannot prompt.';
            execution.finishedAt = new Date().toISOString();
            if (!execution.step.continueOnError)
                throw new Error(execution.error);
            return;
        }
        try {
            execution.result = await tool.execute(resolvedInput);
            execution.status = execution.result.success ? 'completed' : 'failed';
            if (!execution.result.success && !execution.step.continueOnError) {
                execution.error = execution.result.error || execution.result.output;
                throw new Error(execution.error);
            }
        }
        finally {
            execution.finishedAt = new Date().toISOString();
        }
    }
}
exports.WorkflowRegistry = WorkflowRegistry;
function normalizeDefinition(parsed, filePath) {
    if (!parsed || typeof parsed !== 'object')
        return null;
    const name = typeof parsed.name === 'string' && parsed.name.trim()
        ? parsed.name.trim()
        : path.basename(filePath).replace(/\.(ya?ml|json)$/i, '');
    const stepsRaw = Array.isArray(parsed.steps) ? parsed.steps : [];
    const steps = [];
    for (let i = 0; i < stepsRaw.length; i++) {
        const raw = stepsRaw[i];
        if (!raw || typeof raw !== 'object')
            continue;
        const tool = typeof raw.tool === 'string' ? raw.tool : '';
        if (!tool)
            continue;
        steps.push({
            id: typeof raw.id === 'string' && raw.id ? raw.id : `step-${i + 1}`,
            tool,
            input: typeof raw.input === 'object' && raw.input !== null ? raw.input : undefined,
            description: typeof raw.description === 'string' ? raw.description : undefined,
            continueOnError: Boolean(raw.continue_on_error ?? raw.continueOnError),
        });
    }
    if (steps.length === 0)
        return null;
    const riskRaw = typeof parsed.risk_level === 'string' ? parsed.risk_level.toLowerCase() : (typeof parsed.riskLevel === 'string' ? parsed.riskLevel.toLowerCase() : '');
    const riskLevel = riskRaw === 'low' || riskRaw === 'medium' || riskRaw === 'high' ? riskRaw : undefined;
    return {
        name,
        description: typeof parsed.description === 'string' ? parsed.description : undefined,
        riskLevel,
        variables: typeof parsed.variables === 'object' && parsed.variables !== null ? parsed.variables : undefined,
        steps,
        filePath,
    };
}
function substituteVariables(input, variables) {
    return JSON.parse(JSON.stringify(input, (_, value) => {
        if (typeof value !== 'string')
            return value;
        return value.replace(/\$\{variables\.([a-zA-Z0-9_]+)\}/g, (_match, key) => {
            const replacement = variables[key];
            return replacement === undefined || replacement === null ? '' : String(replacement);
        });
    }));
}
// Minimal YAML subset parser sufficient for workflow definitions:
//   key: value
//   key:
//     - item
//     - item
//   key:
//     subkey: value
function parseSimpleYaml(content) {
    const lines = content.replace(/\r\n/g, '\n').split('\n');
    const root = {};
    const stack = [{ indent: -1, container: root }];
    for (let i = 0; i < lines.length; i++) {
        const rawLine = lines[i];
        if (!rawLine.trim() || rawLine.trim().startsWith('#'))
            continue;
        const indent = rawLine.length - rawLine.trimStart().length;
        while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
            stack.pop();
        const top = stack[stack.length - 1];
        const line = rawLine.trim();
        if (line.startsWith('- ')) {
            if (!Array.isArray(top.container))
                continue;
            const value = line.slice(2).trim();
            if (value.includes(':')) {
                const item = {};
                top.container.push(item);
                const [k, v] = splitYamlKv(value);
                if (k)
                    item[k] = parseScalar(v);
                stack.push({ indent, container: item });
            }
            else {
                top.container.push(parseScalar(value));
            }
            continue;
        }
        const [key, value] = splitYamlKv(line);
        if (!key)
            continue;
        if (Array.isArray(top.container))
            continue;
        if (value === '') {
            // peek ahead for list vs object
            let j = i + 1;
            while (j < lines.length && !lines[j].trim())
                j++;
            const next = j < lines.length ? lines[j] : '';
            const nextIndent = next.length - next.trimStart().length;
            const isList = next.trim().startsWith('- ') && nextIndent > indent;
            const container = isList ? [] : {};
            top.container[key] = container;
            stack.push({ indent, container });
        }
        else {
            top.container[key] = parseScalar(value);
        }
    }
    return root;
}
function splitYamlKv(line) {
    const idx = line.indexOf(':');
    if (idx === -1)
        return ['', ''];
    return [line.slice(0, idx).trim(), line.slice(idx + 1).trim()];
}
function parseScalar(value) {
    if (value === '')
        return '';
    if (value === 'true')
        return true;
    if (value === 'false')
        return false;
    if (value === 'null' || value === '~')
        return null;
    if (/^-?\d+$/.test(value))
        return Number(value);
    if (/^-?\d*\.\d+$/.test(value))
        return Number(value);
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
        return value.slice(1, -1);
    }
    return value;
}
//# sourceMappingURL=workflowRegistry.js.map