/** * KHAELOR * File: src/cli/replay.ts * Description: /replay driver — re-run a session's user turns with another model, optionally sandboxed in a worktree (v2 design §2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ResolvedConfig } from "../config/index.js"; import { SessionLog, buildUsageTotals, extractUserTurns, summarizeSessionRun, writeSessionMeta, } from "../session/index.js"; import type { SessionRunSummary } from "../session/index.js"; import { createWorktree, removeWorktree } from "../tasks/index.js"; import type { ExecFn } from "../tasks/index.js"; import { assembleEngine, openSessionContext } from "./engine.js"; import type { FileLogger } from "./logger.js"; import { projectHash } from "./sessions.js"; export interface ReplayOptions { config: ResolvedConfig; cwd: string; sourceSessionId: string; sessionsDir: string; logger: FileLogger; /** Replay inside a throwaway worktree so tool calls have no side effects on the working copy (v2 §2/§6). */ sandbox?: { exec: ExecFn }; onProgress?: (message: string) => void; } export interface ReplayResult { newSessionId: string; turnsReplayed: number; summary: SessionRunSummary; } /** * Replay = re-run the *user* turns sequentially in a fresh session. Tool * calls are re-executed, not replayed from the log — combine with sandbox * to avoid side effects. The result is /sdiff-ready (v2 §2). */ export async function replaySession(options: ReplayOptions): Promise { const hash = projectHash(options.cwd); const source = await SessionLog.open({ projectHash: hash, sessionId: options.sourceSessionId, sessionsDir: options.sessionsDir, }); const turns = extractUserTurns(source.replayedEvents); if (turns.length === 0) { throw new Error(`Session ${options.sourceSessionId} has no user turns to replay.`); } let runCwd = options.cwd; let worktree: Awaited> | null = null; if (options.sandbox !== undefined) { worktree = await createWorktree( options.sandbox.exec, options.cwd, `replay-${Date.now().toString(36)}`, ); runCwd = worktree.path; } try { const context = await openSessionContext({ cwd: runCwd, sessionsDir: options.sessionsDir, logger: options.logger, }); const engine = await assembleEngine({ config: options.config, cwd: runCwd, context, logger: options.logger, }); try { await writeSessionMeta(options.sessionsDir, context.hash, context.sessionId, { parent: null, forkPoint: null, replayOf: options.sourceSessionId, createdAt: Date.now(), }); await engine.captureBaseline(); let replayed = 0; for (const turn of turns) { replayed += 1; options.onProgress?.(`replaying turn ${replayed}/${turns.length}`); engine.session.publishDurable({ type: "user.message-created", payload: { text: turn, mentions: [] }, }); const outcome = await engine.runTurn(); if (outcome.kind === "failed") break; // honest stop — the summary shows it } const events = engine.session.events(); // Touch usage so the totals are computed once even if the caller ignores them. buildUsageTotals(events); return { newSessionId: context.sessionId, turnsReplayed: replayed, summary: summarizeSessionRun(context.sessionId, events), }; } finally { await engine.shutdown(); } } finally { if (worktree !== null && options.sandbox !== undefined) { await removeWorktree(options.sandbox.exec, options.cwd, worktree, { deleteBranch: true }); } } }