/**
 * Pipeline orchestrator — sequences steps and short-circuits on failure.
 *
 * This replaces the 1,672-line executePipeline() in pipeline-action.ts
 * with declarative step sequencing. Each step is run through the
 * StepRunner which provides uniform timing, precondition checking,
 * error handling, and logging.
 *
 * When a jobId is present in the config (API-triggered evaluations),
 * the orchestrator emits progress updates to the Content Lake after
 * each step completes. This enables the GET /v1/jobs/:jobId polling
 * endpoint to show real-time progress.
 */
import { ARTIFACT_EXPORT_PHASE_ID, assoc, } from "../_vendor/ailf-core/index.js";
import { runStep } from "./step-runner.js";
// ---------------------------------------------------------------------------
// Job progress reporter
// ---------------------------------------------------------------------------
/**
 * Report job progress to the Content Lake via the report store's
 * underlying Sanity client. Best-effort — failures are logged and
 * never block the pipeline.
 */
async function reportJobProgress(ctx, stepName, completedSteps, totalSteps, status, errorInfo, jobUpdates) {
    const jobId = ctx.config.jobId;
    if (!jobId)
        return;
    // Accumulate update for artifact capture
    jobUpdates?.push({
        jobId,
        stepName,
        completedSteps,
        totalSteps,
        status,
        errorInfo,
        timestamp: new Date().toISOString(),
    });
    // Use the report store's write capability to patch the job document.
    // The report store exposes a Sanity client — we access it through
    // a best-effort PATCH via the same client infrastructure.
    try {
        // Dynamic import to avoid circular deps — the job store is a
        // lightweight module that only needs a Sanity token.
        const { JobStore } = await import("../job-store.js");
        const store = new JobStore({
            token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
                process.env.SANITY_API_TOKEN ??
                undefined,
        });
        const update = {
            status,
            progress: {
                currentStep: stepName,
                completedSteps,
                totalSteps,
            },
        };
        if (status === "running" && completedSteps === 1) {
            update.startedAt = new Date().toISOString();
        }
        if (errorInfo) {
            update.error = errorInfo;
            update.completedAt = new Date().toISOString();
        }
        await store.updateJob(jobId, update);
    }
    catch {
        // Best effort — progress reporting should never block the pipeline
        ctx.logger.warn(`Failed to report job progress for step "${stepName}" — continuing`);
    }
}
/**
 * Patch the parent ailf.evalRequest doc when the underlying ailf.job
 * reaches a terminal state (completed | failed).
 *
 * Thin wrapper over `JobStore.patchEvalRequestForJob` that handles client
 * construction (env-driven token) and the logger callback. Best-effort:
 * never throws, logs warnings on lookup miss or write failure.
 *
 * Closes the S1-B gap from the 2026-05-24 new-eval audit — until this
 * runs, evalRequest docs stay `status: "dispatched"` indefinitely and
 * the dashboard can't surface completion or errors to users.
 */
async function patchEvalRequestForJob(ctx, jobId, patch) {
    try {
        const { JobStore } = await import("../job-store.js");
        const store = new JobStore({
            token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
                process.env.SANITY_API_TOKEN ??
                undefined,
        });
        const patched = await store.patchEvalRequestForJob(jobId, patch);
        if (!patched) {
            ctx.logger.debug(`No ailf.evalRequest patched for jobId ${jobId} — lookup miss or write failure`);
        }
    }
    catch (err) {
        ctx.logger.warn(`Failed to patch ailf.evalRequest for jobId ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
    }
}
// ---------------------------------------------------------------------------
// Artifact capture
// ---------------------------------------------------------------------------
/**
 * Capture a snapshot of the pipeline config, final state, and step results.
 * Strips secrets (API keys, tokens) from the config.
 */
async function capturePipelineContext(ctx, state, results) {
    const sanitized = Object.fromEntries(Object.entries(ctx.config).filter(([k]) => !/token|secret|key/i.test(k)));
    // W0050 — migrated from ctx.collector.capture("pipeline", "pipeline-context", …)
    // to the registry-driven emit() path. The writer handles redaction,
    // --capture-exclude gating, and local+GCS fanout internally.
    //
    // Awaited (not fire-and-forget) so the write is observable by the
    // orchestrator's caller — a fire-and-forget let the emit fall through
    // to runtime teardown in tests with aggressive afterEach cleanup.
    // `emit` is non-blocking internally (P5): failures return null + warn,
    // never throw, so awaiting can't surface a rejected promise either.
    try {
        await ctx.artifactWriter.emit("pipelineContext", assoc(ctx), {
            config: sanitized,
            state: {
                reportId: state.reportId,
                evalFingerprint: state.evalFingerprint,
                belowCritical: state.belowCritical,
                remoteCacheHits: state.remoteCacheHits
                    ? [...state.remoteCacheHits]
                    : undefined,
                releaseAutoScope: state.releaseAutoScope,
                testSummary: state.testSummary,
            },
            steps: Object.entries(results).map(([name, result]) => ({
                name,
                status: result.status,
                durationMs: result.status !== "skipped" ? result.durationMs : undefined,
            })),
        });
    }
    catch (err) {
        ctx.logger.debug(`pipelineContext emit rejected: ${err instanceof Error ? err.message : String(err)}`);
    }
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
/**
 * Run a sequence of pipeline steps, short-circuiting on required step failure.
 *
 * Optional steps (step.optional === true) may fail without stopping
 * the pipeline. Required step failures cause an immediate abort.
 *
 * When ctx.config.jobId is set, emits progress updates to the Content
 * Lake after each step completes.
 */
export async function orchestratePipeline(ctx, steps) {
    const results = {};
    const state = {};
    const validation = { issues: [], valid: true };
    const pipelineStart = Date.now();
    const hasJob = !!ctx.config.jobId;
    const jobUpdates = [];
    // DOC-2064 — tracks whether the pre-finalize pipelineContext emit fired so
    // the post-loop fallback can skip redundant writes. A second emit to the
    // same GCS path produces a 412 Precondition Failed from the signed-URL
    // writer (which enforces no-overwrite), logging spurious warnings on every
    // successful run.
    let pipelineContextEmitted = false;
    ctx.logger.section("ai-literacy-framework — Evaluation Pipeline");
    ctx.logger.debug(`Pipeline starting with ${steps.length} steps`, {
        steps: steps.map((s) => s.name),
        mode: ctx.config.mode,
        source: ctx.config.source,
        noCache: ctx.config.noCache,
        jobId: ctx.config.jobId,
    });
    // Report initial running status
    if (hasJob) {
        await reportJobProgress(ctx, steps[0]?.name ?? "init", 0, steps.length, "running", undefined, jobUpdates);
    }
    // W0053 — artifact export phase. Opens the first time a non-`run-eval`
    // step starts, signalling the user that promptfoo's progress bar is done
    // and the (previously silent) GCS export/upload window is now active.
    // Closed in a finally after the step loop, regardless of pipeline outcome.
    const exportPhase = createExportPhaseGate(ctx);
    for (let i = 0; i < steps.length; i++) {
        const step = steps[i];
        ctx.logger.debug(`Starting step ${i + 1}/${steps.length}: ${step.name}`);
        ctx.logger.section(step.name);
        exportPhase.maybeOpen(step.name);
        // DOC-2064 — emit pipelineContext BEFORE finalize-run so the artifact
        // ref registers with the accumulator and lands in RunManifest.artifacts,
        // which PublishReportStep then snapshots into Report.artifactManifest.
        // The previous post-loop emit ran after publish and was invisible to
        // Content Lake readers. The failure-path capture below still fires on
        // pre-finalize aborts so aborted runs retain the on-disk artifact.
        if (step.name === "finalize-run") {
            await capturePipelineContext(ctx, state, results);
            pipelineContextEmitted = true;
        }
        // Report current step progress
        if (hasJob) {
            await reportJobProgress(ctx, step.name, i, steps.length, "running", undefined, jobUpdates);
        }
        const result = await runStep(step, ctx, state);
        results[step.name] = result;
        // Collect validation issues from the validate step
        if (step.name === "validate" && result.status === "failed") {
            validation.valid = false;
        }
        // Fail fast on required step failure
        if (result.status === "failed" && !step.optional) {
            ctx.logger.error(`Pipeline aborted: ${step.name} failed`);
            const failedError = result.status === "failed" ? result.error : `${step.name} failed`;
            // Report failure to job store
            if (hasJob) {
                await reportJobProgress(ctx, step.name, i + 1, steps.length, "failed", {
                    message: failedError,
                    step: step.name,
                }, jobUpdates);
                await patchEvalRequestForJob(ctx, ctx.config.jobId, {
                    status: "failed",
                    completedAt: new Date().toISOString(),
                    error: `${step.name}: ${failedError}`,
                });
            }
            // Capture pipeline context before exiting. `job-updates` was an
            // observability-only capture not tied to a registered artifact type;
            // dropped in W0050. Use the JobStore path for job telemetry. Skip
            // when the pre-finalize emit already fired to avoid a 412 overwrite
            // warning (DOC-2064).
            if (!pipelineContextEmitted) {
                await capturePipelineContext(ctx, state, results);
            }
            exportPhase.close();
            return {
                belowCritical: state.belowCritical,
                durationMs: Date.now() - pipelineStart,
                failureReason: {
                    type: "step-failed",
                    step: step.name,
                    message: failedError,
                },
                promptfooUrls: state.promptfooUrls,
                steps: results,
                success: false,
                validation,
            };
        }
        // Report step completion
        if (hasJob) {
            await reportJobProgress(ctx, step.name, i + 1, steps.length, "running", undefined, jobUpdates);
        }
    }
    const durationMs = Date.now() - pipelineStart;
    ctx.logger.section("Pipeline Complete");
    ctx.logger.info(`All steps completed in ${durationMs}ms`);
    ctx.logger.debug("Pipeline state at completion", {
        belowCritical: state.belowCritical,
        reportId: state.reportId,
        remoteCacheHits: state.remoteCacheHits
            ? [...state.remoteCacheHits]
            : undefined,
    });
    // Report completion to job store (with reportId from state if available)
    if (hasJob) {
        try {
            const { JobStore } = await import("../job-store.js");
            const store = new JobStore({
                token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
                    process.env.SANITY_API_TOKEN ??
                    undefined,
            });
            // DOC-2121 RC-3 — surface the first configured-but-failed optional
            // step on the job document so external --remote consumers can detect
            // partial-completion outcomes. The pipeline still ran end to end
            // (P5 / local-first) and `success: true` is preserved; the `error`
            // field is the wire signal that a configured optional step failed.
            const firstOptionalFailure = getFirstOptionalFailure(steps, results);
            const completedAt = new Date().toISOString();
            await store.updateJob(ctx.config.jobId, {
                status: "completed",
                completedAt,
                progress: {
                    currentStep: "complete",
                    completedSteps: steps.length,
                    totalSteps: steps.length,
                },
                ...(state.reportId ? { reportId: state.reportId } : {}),
                ...(firstOptionalFailure ? { error: firstOptionalFailure } : {}),
            });
            await patchEvalRequestForJob(ctx, ctx.config.jobId, {
                status: "completed",
                completedAt,
                ...(state.reportId ? { reportId: state.reportId } : {}),
                ...(firstOptionalFailure
                    ? {
                        error: `${firstOptionalFailure.step}: ${firstOptionalFailure.message}`,
                    }
                    : {}),
            });
        }
        catch {
            ctx.logger.warn("Failed to report job completion — continuing");
        }
    }
    // DOC-2064 — post-loop fallback. Only fires when the pre-finalize emit
    // inside the step loop didn't run — typically because the pipeline has no
    // finalize-run step (test harnesses, air-gapped runs). Skipping this when
    // the pre-finalize emit already fired avoids a 412 Precondition Failed
    // from the signed-URL writer, which refuses to overwrite the existing
    // path. The tradeoff is that pipelineContext captures pipeline state as
    // of finalize-run, not post-publish — reportId is absent. Acceptable
    // because runId is the primary join key and reportId is trivially
    // looked up from Content Lake via runId.
    if (!pipelineContextEmitted) {
        await capturePipelineContext(ctx, state, results);
    }
    exportPhase.close();
    return {
        belowCritical: state.belowCritical,
        durationMs,
        promptfooUrls: state.promptfooUrls,
        // Phase 6 / DIAG-06 — thread reportId from state so the post-run hook in
        // pipeline-action.ts can target the published Content Lake document.
        reportId: state.reportId,
        steps: results,
        success: true,
        testSummary: state.testSummary,
        validation,
    };
}
// ---------------------------------------------------------------------------
// Optional-step failure surfacing (DOC-2121 RC-3)
// ---------------------------------------------------------------------------
/**
 * Find the first optional pipeline step that returned `status: "failed"`
 * in step-array order. Returns the diagnostic shape the API job document
 * already accepts (`{ message, step }`), or null when no optional step
 * failed.
 *
 * Required-step failures don't reach this code path — the orchestrator
 * aborts before completion when a required step fails. This helper is
 * the bridge between "step ran and failed" and the wire signal that
 * external `--remote` consumers use to distinguish a clean completion
 * from a degraded one.
 *
 * @see docs/design-docs/optional-step-failure-surfacing.md
 */
export function getFirstOptionalFailure(steps, results) {
    for (const step of steps) {
        if (step.optional !== true)
            continue;
        const result = results[step.name];
        if (result?.status === "failed") {
            return {
                message: result.error ?? `${step.name} failed`,
                step: step.name,
            };
        }
    }
    return null;
}
// ---------------------------------------------------------------------------
// Artifact export phase gate (W0053)
// ---------------------------------------------------------------------------
/**
 * Returns a lazy gate that opens the `artifact-export` progress phase on the
 * first step after `run-eval` and closes it on pipeline completion. The gate
 * tolerates repeated opens / closes — each is a no-op after the first.
 *
 * The phase is keyed on step names rather than timestamps so the header lands
 * exactly when the user sees promptfoo's `Evaluating` bar hit 100% and the
 * next pipeline step takes over. `run-eval` produces artifacts too, but its
 * own progress is owned by promptfoo; opening the phase before run-eval would
 * produce a duplicate progress channel for the same window.
 */
function createExportPhaseGate(ctx) {
    let opened = false;
    let closed = false;
    let startedAt = 0;
    // Step names that run BEFORE the artifact-heavy post-eval section. The
    // phase opens on the first step whose name is not in this set — typically
    // `calculate-scores` once promptfoo has handed back control.
    const preExportSteps = new Set([
        "validate",
        "mirror-repo-tasks",
        "fetch-docs",
        "generate-configs",
        "grader-consistency",
    ]);
    const { label, detail } = describeExportPhase(ctx);
    return {
        maybeOpen(stepName) {
            if (opened)
                return;
            if (stepName.startsWith("run-eval"))
                return;
            if (preExportSteps.has(stepName))
                return;
            opened = true;
            startedAt = Date.now();
            ctx.progress.phaseStart({
                phaseId: ARTIFACT_EXPORT_PHASE_ID,
                label,
                detail,
                startedAt,
            });
        },
        close() {
            if (!opened || closed)
                return;
            closed = true;
            // Cumulative counts live inside the reporter adapter (it accumulates
            // each phaseProgress event). The orchestrator does not track the
            // running total — it only knows when the phase is over. Adapters that
            // render a final summary use their own state; NoOp / JSON adapters
            // ignore the event.
            ctx.progress.phaseComplete({
                phaseId: ARTIFACT_EXPORT_PHASE_ID,
                itemsCompleted: 0,
                bytesCompleted: 0,
                durationMs: Date.now() - startedAt,
            });
        },
    };
}
/**
 * Build the user-facing phase label by peeking at the wired writer chain.
 * `AccumulatingArtifactWriter` wraps a `FanoutArtifactWriter([local, remote])`
 * when remote credentials are present, or a bare `LocalFilesystemArtifactWriter`
 * otherwise — naming the destination in the label keeps every progress line
 * self-describing.
 */
function describeExportPhase(ctx) {
    const writer = ctx.artifactWriter;
    const inner = writer.inner?.constructor.name ?? writer.constructor.name;
    if (inner === "FanoutArtifactWriter") {
        return { label: "Exporting run artifacts", detail: "local + GCS" };
    }
    if (inner === "GcsArtifactWriter") {
        return { label: "Exporting run artifacts", detail: "GCS" };
    }
    if (inner === "ApiGatewayArtifactWriter") {
        return { label: "Exporting run artifacts", detail: "API gateway" };
    }
    if (inner === "NoOpArtifactWriter") {
        return { label: "Finalizing run" };
    }
    return { label: "Exporting run artifacts", detail: "local" };
}
