/** * S7: Replay thread - Replay command handler * * Responsibilities: * - Load artifact from --artifact path * - Validate artifact schema version * - Check CLI version compatibility * - Re-run the failing route/contract with the same seed * - Handle source code changes since artifact (warn but attempt) * - Handle missing/corrupted artifacts * - Handle route no longer existing * - Fast startup (must feel instant) * - Exit 0 if replay reproduces same failure, 1 if different, 2 on error * * Architecture: * - Dependency injection: all dependencies passed explicitly * - No optional imports — everything is required or injected * - Inline comments for documentation * - Reuses verify runner for actual replay execution */ import type { CliContext } from '../../core/context.js' import { loadConfig } from '../../core/config-loader.js' import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js' import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js' import type { CommandResult, Artifact, FailureRecord } from '../../core/types.js' import { runVerify } from '../verify/runner.js' import { loadArtifact, type ArtifactLoadResult } from './loader.js' import { renderJson } from '../../renderers/json.js' import type { OutputContext } from '../../renderers/shared.js' import { executeHttp } from '../../../infrastructure/http-executor.js' import { parse } from '../../../formula/parser.js' import { evaluateAsync } from '../../../formula/evaluator.js' import { createOperationResolver } from '../../../formula/runtime.js' import type { EvalContext, RouteContract } from '../../../types.js' import { resolve } from 'node:path' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ReplayOptions { artifact: string config?: string cwd?: string format?: 'human' | 'json' | 'ndjson' | 'json-summary' | 'ndjson-summary' quiet?: boolean verbose?: boolean route?: string } export interface ReplayResult { exitCode: number message?: string warnings?: string[] reproduced: boolean originalFailure?: FailureRecord newFailure?: FailureRecord } // --------------------------------------------------------------------------- // Human output formatting // --------------------------------------------------------------------------- /** * Format human-readable output for replay results. */ function formatHumanOutput(result: ReplayResult, artifact: Artifact): string { const lines: string[] = [] const sourceDriftDetected = (result.warnings || []).some(w => w.includes('Source code has changed since artifact was created') || w.includes('modified since artifact was created') || w.includes('Artifact cwd no longer exists') ) if (result.reproduced) { lines.push('Replay reproduced the original failure.') lines.push('') lines.push('Original failure') lines.push(` Route: ${result.originalFailure?.route}`) lines.push(` Contract: ${result.originalFailure?.contract}`) lines.push(` Expected: ${result.originalFailure?.expected}`) lines.push(` Observed: ${result.originalFailure?.observed}`) lines.push(` Seed: ${artifact.seed}`) } else if (result.newFailure) { lines.push('Replay produced a different result.') lines.push('') lines.push('Original failure') lines.push(` Route: ${result.originalFailure?.route}`) lines.push(` Contract: ${result.originalFailure?.contract}`) lines.push('') lines.push('New result') lines.push(` Route: ${result.newFailure.route}`) lines.push(` Contract: ${result.newFailure.contract}`) lines.push(` Expected: ${result.newFailure.expected}`) lines.push(` Observed: ${result.newFailure.observed}`) lines.push(` Seed: ${artifact.seed}`) } else { lines.push('Replay passed — failure no longer reproduces.') lines.push('') lines.push('Original failure') lines.push(` Route: ${result.originalFailure?.route}`) lines.push(` Contract: ${result.originalFailure?.contract}`) lines.push(` Seed: ${artifact.seed}`) } // Add trust labeling and stabilization guidance when replay does not exactly match. if (!result.reproduced) { lines.push('') lines.push('Replay confidence') if (sourceDriftDetected) { lines.push(' Degraded: source drift detected since artifact creation; exact reproduction is not guaranteed.') } else { lines.push(' Degraded: same-seed replay diverged without source drift; likely runtime/data nondeterminism.') } lines.push('') lines.push('Stabilization guidance:') lines.push(' 1. Ensure the app database/state is reset to a known baseline') lines.push(' 2. Run with --seed for explicit control') lines.push(' 3. Freeze time/randomness in app code and isolate external dependencies') lines.push(' 4. Disable chaos/stateful gates in profile if not needed for this failure') } if (result.warnings && result.warnings.length > 0) { lines.push('') lines.push('Warnings') for (const warning of result.warnings) { lines.push(` ⚠ ${warning}`) } } return lines.join('\n') } // --------------------------------------------------------------------------- // Direct contract execution (bypasses route discovery) // --------------------------------------------------------------------------- /** * Execute a contract directly against a Fastify instance without route discovery. * Used by replay when the app doesn't have APOPHIS plugin pre-registered. */ async function executeContractDirect( fastify: any, route: string, contract: string, seed: number, ): Promise<{ success: boolean; observed?: string }> { // Parse route into method and path const parts = route.split(' ') const method = parts[0] || 'GET' const path = parts.slice(1).join(' ') // Check if route exists using hasRoute const hasRoute = typeof fastify.hasRoute === 'function' && fastify.hasRoute({ url: path, method }) if (!hasRoute) { return { success: false, observed: `Route "${route}" no longer exists` } } // Build a minimal route contract const routeContract: RouteContract = { method: method as RouteContract['method'], path, category: 'observer', schema: {}, requires: [], ensures: [contract], invariants: [], regexPatterns: {}, validateRuntime: false, } // Build request const headers: Record = {} // Execute request try { const ctx = await executeHttp(fastify, routeContract, { method, url: path, headers, query: {}, }) // Build eval context const evalCtx: EvalContext = { ...ctx, operationResolver: createOperationResolver(fastify, headers, ctx), } // Parse and evaluate contract const parsed = parse(contract) const result = await evaluateAsync(parsed.ast, evalCtx) if (!result.success || !result.value) { return { success: false, observed: result.success ? String(result.value) : result.error, } } return { success: true } } catch (error) { return { success: false, observed: error instanceof Error ? error.message : String(error), } } } // --------------------------------------------------------------------------- // Replay execution // --------------------------------------------------------------------------- /** * Run the replay by re-executing verify with the same seed and route filter. * * Flow: * 1. Load the Fastify app from artifact.cwd * 2. Run verify with the artifact's seed and route filter * 3. Compare results to the original failure * 4. Return whether the failure was reproduced */ async function executeReplay( artifact: Artifact, failure: FailureRecord, artifactPath: string, ctx: CliContext, options?: { sourceChanged?: boolean }, ): Promise { const workingDir = artifact.cwd const warnings: string[] = [] // Load the Fastify app let fastify: unknown try { const { loadApp } = await import('../../core/app-loader.js') const loaded = await loadApp(workingDir) fastify = loaded.fastify if (fastify && typeof (fastify as any).ready === 'function') { // Only register APOPHIS plugin if not already registered // The fixture apps already register it, so re-registering throws const hasApophis = (fastify as any).apophis !== undefined const canRegister = typeof (fastify as any).register === 'function' if (!hasApophis && canRegister) { const { apophisPlugin } = await import('../../../plugin/index.js') if (typeof apophisPlugin === 'function') { await (fastify as any).register(apophisPlugin, { runtime: 'off' }) } } await (fastify as any).ready() } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) return { exitCode: USAGE_ERROR, message: `Cannot load Fastify app from ${workingDir}/app.js: ${errorMessage}`, warnings, reproduced: false, originalFailure: failure, } } // Try to run verify first (works if app has APOPHIS plugin) let runResult = await runVerify({ fastify: fastify as any, seed: artifact.seed || 42, routeFilters: [failure.route], }) // If no routes matched, or route found but no contracts (plugin not registered before routes), // try direct contract execution if (runResult.noRoutesMatched || runResult.noContractsFound) { const directResult = await executeContractDirect( fastify as any, failure.route, failure.contract, artifact.seed || 42, ) if (!directResult.success) { // Check if it's a route-not-found error if (directResult.observed?.includes('no longer exists')) { return { exitCode: USAGE_ERROR, message: `Route "${failure.route}" no longer exists in the application.\n` + `The source code has drifted since the artifact was created.`, warnings: [...warnings, `Route "${failure.route}" no longer exists`], reproduced: false, originalFailure: failure, } } // Same failure reproduced via direct execution return { exitCode: BEHAVIORAL_FAILURE, message: formatHumanOutput({ exitCode: BEHAVIORAL_FAILURE, reproduced: true, originalFailure: failure, warnings, }, artifact), warnings, reproduced: true, originalFailure: failure, } } // Direct execution passed — failure no longer reproduces return { exitCode: SUCCESS, message: formatHumanOutput({ exitCode: SUCCESS, reproduced: false, originalFailure: failure, warnings, }, artifact), warnings, reproduced: false, originalFailure: failure, } } // Check if the same failure was reproduced const reproducedFailure = runResult.failures.find(f => f.route === failure.route && f.contract === failure.contract ) if (reproducedFailure) { // Same failure reproduced return { exitCode: BEHAVIORAL_FAILURE, message: formatHumanOutput({ exitCode: BEHAVIORAL_FAILURE, reproduced: true, originalFailure: failure, warnings, }, artifact), warnings, reproduced: true, originalFailure: failure, } } // Check if there are different failures if (runResult.failures.length > 0) { const newFailure = runResult.failures[0] if (!newFailure) { return { exitCode: SUCCESS, message: formatHumanOutput({ exitCode: SUCCESS, reproduced: false, originalFailure: failure, warnings, }, artifact), warnings, reproduced: false, originalFailure: failure, } } return { exitCode: BEHAVIORAL_FAILURE, message: formatHumanOutput({ exitCode: BEHAVIORAL_FAILURE, reproduced: false, originalFailure: failure, newFailure: { route: newFailure.route, contract: newFailure.contract, expected: newFailure.expected, observed: newFailure.observed, seed: artifact.seed || 42, replayCommand: `apophis replay --artifact ${artifactPath}`, }, warnings, }, artifact), warnings, reproduced: false, originalFailure: failure, newFailure: { route: newFailure.route, contract: newFailure.contract, expected: newFailure.expected, observed: newFailure.observed, seed: artifact.seed || 42, replayCommand: `apophis replay --artifact ${artifactPath}`, }, } } // No failures — the bug was fixed if (!options?.sourceChanged) { warnings.push('Replay diverged with same seed and no source drift detected. Likely runtime/data nondeterminism.') } return { exitCode: SUCCESS, message: formatHumanOutput({ exitCode: SUCCESS, reproduced: false, originalFailure: failure, warnings, }, artifact), warnings, reproduced: false, originalFailure: failure, } } // --------------------------------------------------------------------------- // Main command handler // --------------------------------------------------------------------------- /** * Main replay command handler. * * Flow: * 1. Load and validate artifact * 2. Check CLI version compatibility * 3. Detect source code changes (warn but continue) * 4. Load Fastify app and re-run verify with same seed * 5. Compare results to original failure * 6. Return appropriate exit code * * Exit codes: * - 0: Replay passed (failure no longer reproduces) * - 1: Same failure reproduced OR different failure found * - 2: Error (missing artifact, corrupted, route no longer exists, etc.) */ export async function replayCommand( options: ReplayOptions, ctx: CliContext, ): Promise { const { artifact: artifactPath, config: configPath, cwd } = options const workingDir = cwd || ctx.cwd const resolvedArtifactPath = resolve(workingDir, artifactPath) try { // 1. Load and validate artifact const loadResult = loadArtifact({ artifactPath, cwd: workingDir, routeFilter: options.route, }) if (!loadResult.success) { return { exitCode: USAGE_ERROR, message: loadResult.message, warnings: loadResult.warnings, } } const artifact = loadResult.artifact! const failure = loadResult.failure! const warnings = [...loadResult.warnings] // 2. Execute replay const replayResult = await executeReplay(artifact, failure, resolvedArtifactPath, ctx, { sourceChanged: loadResult.sourceChanged, }) // Merge warnings if (replayResult.warnings) { warnings.push(...replayResult.warnings) } return { exitCode: replayResult.exitCode as import('../../core/types.js').ExitCode, message: replayResult.message, warnings: warnings.length > 0 ? warnings : undefined, } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { exitCode: INTERNAL_ERROR, message: `Internal error in replay command: ${message}`, } } } // --------------------------------------------------------------------------- // CLI adapter // --------------------------------------------------------------------------- /** * Adapter that bridges the CLI framework (cac) to the replay command handler. * This function signature matches what the CLI core expects. */ export async function handleReplay( args: string[], ctx: CliContext, ): Promise { const options: ReplayOptions = { artifact: '', config: ctx.options.config || undefined, cwd: ctx.cwd, format: ctx.options.format as ReplayOptions['format'], quiet: ctx.options.quiet, verbose: ctx.options.verbose, } // Parse command-specific flags from args (passed by CLI dispatcher) const artifactIdx = args.indexOf('--artifact') if (artifactIdx !== -1 && args[artifactIdx + 1]) { options.artifact = args[artifactIdx + 1]! } const routeIdx = args.indexOf('--route') if (routeIdx !== -1 && args[routeIdx + 1]) { options.route = args[routeIdx + 1]! } if (!options.artifact) { const format = options.format || ctx.options.format || 'human' if (format === 'json') { console.log(renderJson({ exitCode: USAGE_ERROR, error: 'Error: --artifact is required', })) } else if (format === 'ndjson') { process.stdout.write(JSON.stringify({ type: 'run.completed', command: 'replay', exitCode: USAGE_ERROR, error: 'Error: --artifact is required', }) + '\n') } else { console.error('Error: --artifact is required') } return USAGE_ERROR } const result = await replayCommand(options, ctx) // Output result based on format if (!ctx.options.quiet && result.message) { const format = options.format || ctx.options.format || 'human' if (format === 'json') { console.log(renderJson({ exitCode: result.exitCode, message: result.message, warnings: result.warnings, })) } else if (format === 'ndjson') { process.stdout.write(JSON.stringify({ type: 'run.completed', command: 'replay', exitCode: result.exitCode, message: result.message, warnings: result.warnings, }) + '\n') } else { console.log(result.message) } } // Print warnings in human mode only const format = options.format || ctx.options.format || 'human' if (format === 'human' && result.warnings && result.warnings.length > 0 && !ctx.options.quiet) { for (const warning of result.warnings) { console.warn(`Warning: ${warning}`) } } return result.exitCode }