//! Turn-boundary fan-out for the workspace. //! //! [`WorkspaceHandle::on_turn_boundary`] is the single internal entry point for //! turn/prompt boundaries. //! //! A rewind checkpoint is keyed by `prompt_index` and bundles per-domain state //! (filesystem [`RewindPoint`], optional hunk delta, optional git HEAD/index); //! restore reverts all enabled domains together. use crate::handle::WorkspaceHandle; use crate::session::WorkspaceSession; use crate::session::file_state::{FileRewindResponse, RewindPoint, rewind_files}; use crate::session::git; use kigi_hunk_tracker::{HunkId, HunkTrackerSnapshot, HunkTurnDelta}; use kigi_tool_protocol::turn_hook::TurnHookOutcome; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; /// A turn/prompt boundary routed through [`WorkspaceHandle::on_turn_boundary`]. /// /// `prompt_index` selects the origin and keeps the two effect sets disjoint: /// - `None` — a turn hook (`on_before_turn`/`on_after_turn`). /// - `Some(idx)` — a rewind RPC arm (`begin_prompt`/`end_prompt`). pub(crate) enum TurnBoundary { /// Turn start. Start { prompt_index: Option, turn_number: u64, }, /// Turn end. End { prompt_index: Option, turn_number: u64, duration_ms: u64, outcome: TurnHookOutcome, }, } impl TurnBoundary { pub(crate) fn turn_start(turn_number: u64) -> Self { Self::Start { prompt_index: None, turn_number, } } /// Turn-hook end (from `on_after_turn`): activity bookkeeping (+ optional /// rewind finalize under `workspace_rewind_all_outcomes`). pub(crate) fn turn_end(turn_number: u64, duration_ms: u64, outcome: TurnHookOutcome) -> Self { Self::End { prompt_index: None, turn_number, duration_ms, outcome, } } /// Rewind begin (from the `begin_prompt` RPC arm): FS-rewind only. /// /// The RPC carries no turn metadata; `turn ≈ prompt`, so `turn_number` /// mirrors `prompt_index` and the dispatcher ignores it on this path. pub(crate) fn rewind_begin(prompt_index: usize) -> Self { Self::Start { prompt_index: Some(prompt_index), turn_number: prompt_index as u64, } } /// Rewind finalize (from the `end_prompt` RPC arm): FS-rewind only. /// /// As with [`Self::rewind_begin`], the turn-hook fields are inert on this /// path and are never read by the dispatcher. pub(crate) fn rewind_finalize(prompt_index: usize) -> Self { Self::End { prompt_index: Some(prompt_index), turn_number: prompt_index as u64, duration_ms: 0, outcome: TurnHookOutcome::Completed, } } } /// A per-prompt rewind checkpoint: the FS rewind point bundled with the /// incremental hunk-tracker delta for the same `prompt_index`. Assembled on /// demand by [`WorkspaceSession::get_checkpoint`]; serialized to disk by the /// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore). /// /// Optional domain fields use `#[serde(default)]` so the schema stays additive: /// a blob written before a later field existed still deserializes (field `None`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RewindCheckpoint { /// The prompt this checkpoint belongs to. pub prompt_index: usize, /// Filesystem before/after snapshots for the prompt. pub fs: RewindPoint, /// Incremental hunk delta. `None` when capture was off or the turn touched /// no tracked files. #[serde(default)] pub hunks: Option, } /// Resolve `workspace_rewind_hunks` from `KIGI_WORKSPACE_REWIND_HUNKS` (default off). pub(crate) fn rewind_hunks_enabled() -> bool { kigi_config::env_bool("KIGI_WORKSPACE_REWIND_HUNKS").unwrap_or(false) } /// Resolve `workspace_rewind_durable` from `KIGI_WORKSPACE_REWIND_DURABLE` /// (default off). Off ⇒ the legacy in-memory-only path with no disk I/O. pub(crate) fn rewind_durable_enabled() -> bool { kigi_config::env_bool("KIGI_WORKSPACE_REWIND_DURABLE").unwrap_or(false) } impl WorkspaceSession { /// Capture the hunk delta for `prompt_index` into the in-memory store. /// Last-write-wins, so repeated finalizes (e.g. `ForceContinue`) are /// idempotent. Empty deltas are skipped to avoid a stale `turn_index` entry. /// Returns `true` when a delta was stored, so callers count a hunk capture. pub(crate) async fn capture_hunk_delta(&self, prompt_index: usize) -> bool { let Some(delta) = self.hunk_tracker.snapshot_turn_delta(prompt_index).await else { return false; }; if delta.file_states.is_empty() { return false; } self.hunk_checkpoints .lock() .await .insert(prompt_index, delta); true } /// Record the metrics shared by both finalize paths: FS capture + finalize /// counters and FS capture duration, plus the git-domain capture when git /// state was recorded at begin. Git is captured at begin (outcome unknown), /// so it's *counted* here where `outcome` is known. Hunk is recorded by the /// caller on whichever finalize path captured it. async fn record_finalize_metrics( &self, prompt_index: usize, outcome: TurnHookOutcome, fs_capture_seconds: f64, ) { crate::handle::record_fs_finalize(outcome, fs_capture_seconds); if git::git_rewind_enabled() && self.git_checkpoints().contains(prompt_index).await { crate::handle::record_rewind_capture(crate::handle::RewindDomain::Git, outcome); } } /// Re-seed the hunk tracker to the **start** of `target_prompt_index` by /// composing stored deltas for prompts `< target` (ascending; last write per /// path wins) and dropping deltas `>= target`, mirroring the FS rewind. /// /// No-op when the store is empty, or holds only deltas `>= target` with /// `target > 0` (flag enabled mid-session; start-of-target can't be rebuilt, /// so uncaptured live hunks aren't wiped). `target == 0` re-seeds to empty. /// Composed `turn_index` ids are pruned to those surviving in the snapshots. pub(crate) async fn restore_hunk_checkpoints(&self, target_prompt_index: usize) { let mut store = self.hunk_checkpoints.lock().await; if store.is_empty() { return; } let mut prompts: Vec = store .keys() .copied() .filter(|&idx| idx < target_prompt_index) .collect(); if prompts.is_empty() && target_prompt_index > 0 { return; } prompts.sort_unstable(); let mut file_states: HashMap = HashMap::new(); let mut turn_index: HashMap> = HashMap::new(); for idx in prompts { let delta = &store[&idx]; for (path, snap) in &delta.file_states { file_states.insert(path.clone(), snap.clone()); } turn_index.insert(idx, delta.hunk_ids.clone()); } let present: HashSet = file_states .values() .flat_map(|state| state.hunks.iter().map(|h| h.id.clone())) .collect(); turn_index.retain(|_, ids| { ids.retain(|id| present.contains(id)); !ids.is_empty() }); let session_stats = self.hunk_tracker.get_session_summary().await.stats; self.hunk_tracker.restore_state(HunkTrackerSnapshot { file_states, turn_index, session_stats, }); store.retain(|&idx, _| idx < target_prompt_index); } /// Assemble the [`RewindCheckpoint`] for `prompt_index` from the live FS /// rewind point and stored hunk delta. `None` when no FS rewind point exists. pub async fn get_checkpoint(&self, prompt_index: usize) -> Option { let fs = self .file_state_tracker .get_rewind_point(prompt_index) .await?; let hunks = self .hunk_checkpoints .lock() .await .get(&prompt_index) .cloned(); Some(RewindCheckpoint { prompt_index, fs, hunks, }) } /// Assemble the checkpoint for `prompt_index` and write it through the durable /// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore). /// Last-write-wins (idempotent across repeated finalizes); no-op when no /// checkpoint exists. Mirror only — restore stays in-process. pub(crate) async fn persist_checkpoint(&self, prompt_index: usize) { let Some(checkpoint) = self.get_checkpoint(prompt_index).await else { return; }; self.checkpoint_store.persist(checkpoint).await; } /// Drop persisted checkpoints `>= target_prompt_index` (cache + disk), /// mirroring the FS/hunk truncation so the on-disk mirror stays consistent. pub(crate) async fn truncate_checkpoint_store(&self, target_prompt_index: usize) { self.checkpoint_store .truncate_from(target_prompt_index) .await; } } impl WorkspaceHandle { /// Single fan-out for turn/prompt boundaries. /// /// Keyed on `prompt_index`: turn hooks (`None`) drive activity; rewind RPC /// arms (`Some`) drive rewind capture (FS, plus git/hunks when their flags /// are on). `workspace_rewind_all_outcomes` also finalizes the open FS /// checkpoint on non-`Completed` turn-ends (gap #2). pub(crate) async fn on_turn_boundary(&self, session_id: &str, boundary: TurnBoundary) { match boundary { TurnBoundary::Start { prompt_index: Some(idx), .. } => { if let Some(session) = self.session(session_id) { session.file_state_tracker().begin_prompt(idx).await; if git::git_rewind_enabled() { let started = std::time::Instant::now(); if let Some(state) = git::capture_git_state(session.cwd()).await { session.git_checkpoints().record(idx, state).await; crate::handle::observe_rewind_capture_duration( crate::handle::RewindDomain::Git, started.elapsed().as_secs_f64(), ); } } } } TurnBoundary::Start { prompt_index: None, turn_number, } => { self.shared .activity_tracker .turn_started(session_id, turn_number); } TurnBoundary::End { prompt_index: Some(idx), outcome, .. } => { if let Some(session) = self.session(session_id) { let tracker = session.file_state_tracker(); let finalized = tracker.current_prompt_index().await == Some(idx); let fs_started = std::time::Instant::now(); tracker.end_prompt(session.async_fs(), idx).await; let fs_elapsed = fs_started.elapsed().as_secs_f64(); let mut hunk_captured = false; let mut hunk_capture_secs = 0.0_f64; if rewind_hunks_enabled() { let hunk_started = std::time::Instant::now(); hunk_captured = session.capture_hunk_delta(idx).await; hunk_capture_secs = hunk_started.elapsed().as_secs_f64(); } if rewind_durable_enabled() { session.persist_checkpoint(idx).await; } if finalized { session .record_finalize_metrics(idx, outcome, fs_elapsed) .await; if hunk_captured { crate::handle::observe_rewind_capture_duration( crate::handle::RewindDomain::Hunk, hunk_capture_secs, ); crate::handle::record_rewind_capture( crate::handle::RewindDomain::Hunk, outcome, ); } } } } TurnBoundary::End { prompt_index: None, turn_number, duration_ms, outcome, } => { self.shared .activity_tracker .turn_completed(session_id, turn_number, duration_ms); if self.shared.workspace_rewind_all_outcomes && outcome != TurnHookOutcome::Completed && let Some(session) = self.session(session_id) { let tracker = session.file_state_tracker(); if let Some(idx) = tracker.current_prompt_index().await { let fs_started = std::time::Instant::now(); tracker.end_prompt(session.async_fs(), idx).await; let fs_elapsed = fs_started.elapsed().as_secs_f64(); let mut hunk_captured = false; let mut hunk_capture_secs = 0.0_f64; if rewind_hunks_enabled() { let hunk_started = std::time::Instant::now(); hunk_captured = session.capture_hunk_delta(idx).await; hunk_capture_secs = hunk_started.elapsed().as_secs_f64(); } if rewind_durable_enabled() { session.persist_checkpoint(idx).await; } session .record_finalize_metrics(idx, outcome, fs_elapsed) .await; if hunk_captured { crate::handle::observe_rewind_capture_duration( crate::handle::RewindDomain::Hunk, hunk_capture_secs, ); crate::handle::record_rewind_capture( crate::handle::RewindDomain::Hunk, outcome, ); } crate::handle::record_non_completed_finalize_canary(outcome); } } } } } /// User-initiated restore (`workspace.rewind_to` RPC): restore every enabled /// domain to before `target_prompt_index`. Ordering matters: git's soft /// restore (stash + `reset --soft` + unstage, behind `workspace_rewind_git`) /// runs first so its stash-or-abort guard sees live state; then the FS is /// reverted; then — only on FS success — git paths are re-staged against the /// reverted tree and git/hunk checkpoints `>= target` are dropped. A failed /// `rewind_files` keeps all domains for retry. Missing session yields a failed /// response, not a panic. pub(crate) async fn rewind_to( &self, session_id: &str, target_prompt_index: usize, ) -> FileRewindResponse { let Some(session) = self.session(session_id) else { return FileRewindResponse { success: false, target_prompt_index, reverted_files: Vec::new(), clean_files: Vec::new(), conflicts: Vec::new(), error: Some(format!("workspace session not found: {session_id}")), }; }; let git_restore = self .restore_git_checkpoint(session_id, target_prompt_index) .await; if let Some((git_outcome, _)) = git_restore.as_ref() { if !git_outcome.restored { crate::handle::record_rewind_restore(crate::handle::RewindDomain::Git, false); tracing::warn!( session_id, target_prompt_index, reason = ? git_outcome .aborted_reason, stash_ref = ? git_outcome.stash_ref, "rewind_to: git domain not restored; filesystem still reverted (partial rewind)" ); } else if let Some(stash_ref) = &git_outcome.stash_ref { tracing::info!( session_id, stash_ref = % stash_ref, "rewind_to: git domain restored; pre-rewind changes saved to a stash" ); } } let response = rewind_files( session.file_state_tracker(), session.async_fs(), target_prompt_index, ) .await; crate::handle::record_rewind_restore(crate::handle::RewindDomain::Fs, response.success); if response.success { if let Some((git_outcome, git_state)) = git_restore.as_ref() && git_outcome.restored { let restaged = git::restage_git_paths(session.cwd(), git_state, session_id).await; crate::handle::record_rewind_restore( crate::handle::RewindDomain::Git, git_outcome.index_reset && restaged, ); session .git_checkpoints() .truncate_from(target_prompt_index) .await; } if git::git_rewind_enabled() && git_restore.is_none() { tracing::warn!( session_id, target_prompt_index, "rewind_to: git domain not rewound (no git checkpoint at or before \ target); HEAD left untouched and may not match the reverted tree \ (partial rewind); git checkpoints retained to stay coherent with HEAD" ); } if rewind_hunks_enabled() { session.restore_hunk_checkpoints(target_prompt_index).await; crate::handle::record_rewind_restore(crate::handle::RewindDomain::Hunk, true); } } if response.success && rewind_durable_enabled() { session.truncate_checkpoint_store(target_prompt_index).await; } response } /// Soft-restore the git domain at `target_prompt_index` (phase 1 only: stash + /// `reset --soft` + unstage; turn-local commits preserved). Returns the /// [`GitRestoreOutcome`](git::GitRestoreOutcome) and captured /// [`GitStateRef`](git::GitStateRef) so the caller can, after the FS revert /// succeeds, re-stage paths and drop checkpoints `>= target` — deferred so a /// failed FS revert retains them for retry. /// /// Checkpoint selection prefers the state captured exactly at the target, but /// falls back to the nearest earlier checkpoint (greatest index `<= target`) /// when none was captured at the target itself — e.g. git-rewind was enabled /// mid-session so the target predates capture, or capture was skipped for that /// prompt. This lands HEAD on the closest known-good git state instead of /// leaving it post-turn. `None` when disabled, no session, or no git state /// captured at or before the target (caller then degrades explicitly). pub(crate) async fn restore_git_checkpoint( &self, session_id: &str, target_prompt_index: usize, ) -> Option<(git::GitRestoreOutcome, git::GitStateRef)> { if !git::git_rewind_enabled() { return None; } let session = self.session(session_id)?; let store = session.git_checkpoints(); let (checkpoint_index, state) = match store.get_at_or_before(target_prompt_index).await { Some(found) => found, None => { tracing::debug!( session_id, target_prompt_index, "restore_git_checkpoint: no git state captured at or before target; skipping" ); return None; } }; if checkpoint_index != target_prompt_index { tracing::info!( session_id, target_prompt_index, checkpoint_index, "restore_git_checkpoint: no checkpoint at target; soft-restoring to the \ nearest earlier git checkpoint (closest known-good HEAD)" ); } let outcome = git::soft_restore_git_state(session.cwd(), &state, session_id).await; Some((outcome, state)) } } #[cfg(test)] mod tests { use super::*; use crate::handle::tests::{make_handle, make_handle_with_rewind_all_outcomes}; #[tokio::test] async fn start_with_prompt_index_begins_and_end_finalizes_fs_rewind() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); handle .on_turn_boundary("main", TurnBoundary::rewind_begin(3)) .await; let tracker = session.file_state_tracker(); assert_eq!(tracker.current_prompt_index().await, Some(3)); assert!( tracker.get_rewind_point(3).await.is_some(), "begin should create a rewind point for the prompt" ); tracker .add_before_snapshot_for_prompt(3, &cwd.join("a.txt"), &cwd, Some("v1".to_owned())) .await; handle .on_turn_boundary("main", TurnBoundary::rewind_finalize(3)) .await; assert_eq!( tracker.current_prompt_index().await, None, "finalize (end_prompt) clears the current prompt index" ); let point = tracker .get_rewind_point(3) .await .expect("rewind point still present after finalize"); assert!( !point.after_snapshots.is_empty(), "finalize should capture after-snapshots for touched files" ); } #[tokio::test] async fn turn_hook_start_does_not_touch_fs_rewind() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); handle .on_turn_boundary("main", TurnBoundary::turn_start(7)) .await; let tracker = session.file_state_tracker(); assert!( tracker.get_rewind_points().await.is_empty(), "turn-hook boundary must not create rewind points" ); assert_eq!(tracker.current_prompt_index().await, None); } /// Open a checkpoint and record one touched file so finalize has an after-snapshot. async fn open_checkpoint_with_touched_file(handle: &WorkspaceHandle, prompt_index: usize) { let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); handle .on_turn_boundary("main", TurnBoundary::rewind_begin(prompt_index)) .await; session .file_state_tracker() .add_before_snapshot_for_prompt( prompt_index, &cwd.join("a.txt"), &cwd, Some("v1".to_owned()), ) .await; } #[tokio::test] async fn non_completed_turn_end_finalizes_open_fs_rewind_when_flag_on() { let handle = make_handle_with_rewind_all_outcomes(true); open_checkpoint_with_touched_file(&handle, 5).await; let session = handle.session("main").expect("main session exists"); let tracker = session.file_state_tracker(); assert_eq!(tracker.current_prompt_index().await, Some(5)); handle .on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error)) .await; assert_eq!( tracker.current_prompt_index().await, None, "non-Completed turn-end should finalize (clear) the open prompt" ); let point = tracker .get_rewind_point(5) .await .expect("rewind point present"); assert!( !point.after_snapshots.is_empty(), "finalize should capture after-snapshots for touched files" ); } #[tokio::test] async fn non_completed_turn_end_does_not_finalize_when_flag_off() { let handle = make_handle(); open_checkpoint_with_touched_file(&handle, 5).await; let session = handle.session("main").expect("main session exists"); let tracker = session.file_state_tracker(); handle .on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error)) .await; assert_eq!( tracker.current_prompt_index().await, Some(5), "with the flag off the turn-hook path must leave the checkpoint open" ); let point = tracker .get_rewind_point(5) .await .expect("rewind point present"); assert!( point.after_snapshots.is_empty(), "no after-snapshots should be captured when the flag is off" ); } #[tokio::test] async fn completed_turn_end_hook_does_not_finalize_even_when_flag_on() { let handle = make_handle_with_rewind_all_outcomes(true); open_checkpoint_with_touched_file(&handle, 5).await; let session = handle.session("main").expect("main session exists"); let tracker = session.file_state_tracker(); handle .on_turn_boundary( "main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Completed), ) .await; assert_eq!( tracker.current_prompt_index().await, Some(5), "Completed turn-ends are finalized by the RPC path, not the hook path" ); } /// The non-Completed canary advances when a non-`Completed` turn-end finalizes /// an open FS checkpoint (flag on). Counters are monotonic, so `after > before` is robust. #[tokio::test] async fn canary_counts_non_completed_finalize() { let label = crate::handle::rewind_outcome_label(TurnHookOutcome::Error); let before = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL .with_label_values(&[label]) .get(); let handle = make_handle_with_rewind_all_outcomes(true); open_checkpoint_with_touched_file(&handle, 11).await; handle .on_turn_boundary( "main", TurnBoundary::turn_end(11, 0, TurnHookOutcome::Error), ) .await; let after = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL .with_label_values(&[label]) .get(); assert!( after > before, "canary must advance on a non-Completed finalize (before={before}, after={after})" ); } /// A `Completed` turn-end never feeds the canary: the `completed` label must /// stay zero, guarding the `outcome != Completed` gate. #[tokio::test] async fn canary_never_labeled_completed() { let handle = make_handle_with_rewind_all_outcomes(true); open_checkpoint_with_touched_file(&handle, 12).await; handle .on_turn_boundary( "main", TurnBoundary::turn_end(12, 0, TurnHookOutcome::Completed), ) .await; assert_eq!( crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL .with_label_values(&[crate::handle::rewind_outcome_label( TurnHookOutcome::Completed )]) .get(), 0, "Completed turns must never increment the non-Completed finalize canary" ); } /// Capture two turns, rewind: earlier turn survives; target-and-later are /// dropped and truncated. #[tokio::test] async fn capture_then_restore_round_trips_turn_delta() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let hunks = session.hunk_tracker(); hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None); hunks.record_agent_write(cwd.join("b.rs"), "fn b() {}\n".to_owned(), 1, None); session.capture_hunk_delta(0).await; session.capture_hunk_delta(1).await; assert!(!hunks.get_turn_hunks(0).await.is_empty()); assert!(!hunks.get_turn_hunks(1).await.is_empty()); session.restore_hunk_checkpoints(1).await; assert!( hunks.get_turn_hunks(1).await.is_empty(), "rewind must drop the rewound turn's hunks" ); assert!( !hunks.get_turn_hunks(0).await.is_empty(), "rewind must keep turns before the target" ); let tracked = hunks.get_all_tracked_paths().await; assert!( tracked.contains(&cwd.join("a.rs")), "kept turn's file remains" ); assert!( !tracked.contains(&cwd.join("b.rs")), "rewound turn's file is gone" ); let store = session.hunk_checkpoints.lock().await; assert!(store.contains_key(&0), "kept delta remains in the store"); assert!(!store.contains_key(&1), "rewound delta is dropped"); } /// `get_checkpoint` assembles both domains: FS rewind point + stored hunk delta. #[tokio::test] async fn get_checkpoint_bundles_fs_point_and_hunk_delta() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let tracker = session.file_state_tracker(); tracker.begin_prompt(0).await; tracker .add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned())) .await; session.hunk_tracker().record_agent_write( cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None, ); session.capture_hunk_delta(0).await; let checkpoint = session.get_checkpoint(0).await.expect("checkpoint exists"); assert_eq!(checkpoint.prompt_index, 0); assert!( !checkpoint.fs.file_snapshots.is_empty(), "fs side carries the before-snapshot" ); let delta = checkpoint.hunks.expect("hunk delta captured"); assert!(delta.file_states.contains_key(&cwd.join("a.rs"))); assert!(session.get_checkpoint(5).await.is_none()); } /// Flag off (default): finalize never captures a hunk delta. #[tokio::test] async fn turn_end_with_flag_off_skips_hunk_capture() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); session.hunk_tracker().record_agent_write( cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None, ); assert!( !rewind_hunks_enabled(), "flag must default off for the legacy path" ); handle .on_turn_boundary("main", TurnBoundary::rewind_finalize(0)) .await; assert!( session.hunk_checkpoints.lock().await.is_empty(), "flag-off finalize must not store hunk deltas (legacy default)" ); } /// Mid-session enable with no deltas before a non-zero target: restore must /// not wipe live hunks. #[tokio::test] async fn restore_with_no_deltas_before_target_does_not_wipe() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let hunks = session.hunk_tracker(); hunks.record_agent_write(cwd.join("early.rs"), "fn e() {}\n".to_owned(), 1, None); hunks.record_agent_write(cwd.join("late.rs"), "fn l() {}\n".to_owned(), 5, None); session.capture_hunk_delta(5).await; session.restore_hunk_checkpoints(3).await; let tracked = hunks.get_all_tracked_paths().await; assert!( tracked.contains(&cwd.join("early.rs")), "uncaptured live hunks must not be wiped when nothing is captured before the target" ); assert!( session.hunk_checkpoints.lock().await.contains_key(&5), "the store is left untouched when restore cannot reconstruct" ); } /// Rewind to 0: re-seed to the empty start-of-session state. #[tokio::test] async fn restore_to_zero_clears_all_hunk_state() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let hunks = session.hunk_tracker(); hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None); session.capture_hunk_delta(0).await; assert!(!hunks.get_all_tracked_paths().await.is_empty()); session.restore_hunk_checkpoints(0).await; assert!( hunks.get_all_tracked_paths().await.is_empty(), "rewind to 0 reconstructs the empty start-of-session hunk state" ); assert!( session.hunk_checkpoints.lock().await.is_empty(), "rewind to 0 truncates the entire delta store" ); } /// `workspace_rewind_git` default (OFF): capture must not touch git. #[tokio::test] async fn git_capture_is_noop_when_flag_disabled_by_default() { assert!( !git::git_rewind_enabled(), "workspace_rewind_git must default OFF" ); let handle = make_handle(); let session = handle.session("main").expect("main session exists"); handle .on_turn_boundary("main", TurnBoundary::rewind_begin(2)) .await; assert!( session.git_checkpoints().get(2).await.is_none(), "git state must not be captured while workspace_rewind_git is disabled" ); assert!( session .file_state_tracker() .get_rewind_point(2) .await .is_some(), "FS-rewind capture must be unaffected by the git flag" ); } /// A fully-populated checkpoint (FS + hunk delta) round-trips through JSON: /// `Value` is stable across encode→decode→encode and both payloads survive. /// `Hunk.selected` is `#[serde(skip)]` (transient), so it decodes to default. #[tokio::test] async fn rewind_checkpoint_round_trips_through_json() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let tracker = session.file_state_tracker(); tracker.begin_prompt(0).await; tracker .add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned())) .await; tracker.end_prompt(session.async_fs(), 0).await; session.hunk_tracker().record_agent_write( cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None, ); session.capture_hunk_delta(0).await; let original = session.get_checkpoint(0).await.expect("checkpoint exists"); assert!( original.hunks.is_some(), "precondition: hunk delta captured, so serde covers all populated domains" ); let encoded = serde_json::to_value(&original).expect("serialize"); let decoded: RewindCheckpoint = serde_json::from_value(encoded.clone()).expect("deserialize"); let re_encoded = serde_json::to_value(&decoded).expect("re-serialize"); assert_eq!( encoded, re_encoded, "RewindCheckpoint must round-trip through JSON unchanged" ); assert_eq!(decoded.prompt_index, 0); assert_eq!( decoded.fs.file_snapshots.len(), 1, "fs before-snapshot survives the round-trip" ); let delta = decoded.hunks.expect("hunk delta survives round-trip"); assert_eq!(delta.prompt_index, 0); let snapshot = delta .file_states .get(&cwd.join("a.rs")) .expect("hunk delta's touched file survives"); let hunk = snapshot.hunks.first().expect("the file's hunk survives"); assert!( hunk.new_text.contains("fn a()"), "hunk new_text payload survives the round-trip, got {:?}", hunk.new_text ); assert!(!hunk.selected, "transient `selected` is not persisted"); } /// Optional domain fields are additive: a checkpoint round-trips with /// `hunks: None`, and JSON omitting the optional field still deserializes. #[tokio::test] async fn checkpoint_serde_tolerates_missing_optional_fields() { let cp = RewindCheckpoint { prompt_index: 2, fs: RewindPoint::new(2), hunks: None, }; let encoded = serde_json::to_value(&cp).expect("serialize"); let decoded: RewindCheckpoint = serde_json::from_value(encoded).expect("deserialize"); assert_eq!(decoded.prompt_index, 2); assert!(decoded.hunks.is_none()); let json = r#"{ "prompt_index": 2, "fs": { "prompt_index": 2, "created_at": "2024-01-01T00:00:00Z", "file_snapshots": {}, "after_snapshots": {} } }"#; let from_legacy: RewindCheckpoint = serde_json::from_str(json).expect("deserialize legacy"); assert_eq!(from_legacy.prompt_index, 2); assert!( from_legacy.hunks.is_none(), "a missing optional field defaults to None" ); } /// Durable flag off (default): finalize does no disk I/O — the on-disk store /// directory is never created. #[tokio::test] async fn turn_end_with_durable_flag_off_writes_nothing_to_disk() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); assert!( !rewind_durable_enabled(), "durable flag must default off for the legacy path" ); handle .on_turn_boundary("main", TurnBoundary::rewind_begin(0)) .await; session .file_state_tracker() .add_before_snapshot_for_prompt(0, &cwd.join("a.txt"), &cwd, Some("v".to_owned())) .await; handle .on_turn_boundary("main", TurnBoundary::rewind_finalize(0)) .await; let store_root = cwd.join(".kigi").join("rewind-checkpoints"); assert!( !store_root.exists(), "flag-off finalize must not touch disk (legacy default)" ); } /// `persist_checkpoint` writes the assembled checkpoint through the session's /// durable store (called directly to bypass the flag); retrievable with both domains. #[tokio::test] async fn persist_checkpoint_serializes_through_session_store() { let handle = make_handle(); let session = handle.session("main").expect("main session exists"); let cwd = session.cwd().to_path_buf(); let tracker = session.file_state_tracker(); tracker.begin_prompt(0).await; tracker .add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned())) .await; tracker.end_prompt(session.async_fs(), 0).await; session.hunk_tracker().record_agent_write( cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None, ); session.capture_hunk_delta(0).await; session.persist_checkpoint(0).await; let store_root = cwd.join(".kigi").join("rewind-checkpoints"); assert!(store_root.exists(), "persist creates the durable store dir"); let stored = session .checkpoint_store .get(0) .await .expect("checkpoint persisted"); assert_eq!(stored.prompt_index, 0); assert!( stored.hunks.is_some(), "hunk delta is bundled into the blob" ); assert_eq!(stored.fs.file_snapshots.len(), 1); } /// Full rewind lifecycle against a disabled (`noop()`) hunk tracker — the /// operating mode when the user sets `hunk_tracker_mode = off`. Capture /// must report "nothing stored" and restore must be a clean no-op, while /// the FS-rewind half keeps working (hunks simply absent from the blob). #[tokio::test] async fn rewind_lifecycle_is_clean_noop_with_disabled_hunk_tracker() { let handle = make_handle(); handle.drop_session("main", "main").expect("drop main"); let session = handle .create_session_with_tracker_and_viewer_ctx( "main", handle.root_cwd().unwrap(), kigi_hunk_tracker::HunkTrackerHandle::noop(), None, crate::capability::CapabilityMode::All, None, false, ) .expect("create session with noop tracker"); let cwd = session.cwd().to_path_buf(); let file = cwd.join("a.rs"); let tracker = session.file_state_tracker(); tracker.begin_prompt(0).await; tracker .add_before_snapshot_for_prompt(0, &file, &cwd, Some("v0\n".to_owned())) .await; session .async_fs() .write_file(&file, b"v1\n") .await .expect("write v1"); tracker.end_prompt(session.async_fs(), 0).await; session .hunk_tracker() .record_agent_write(file.clone(), "v1\n".to_owned(), 0, None); assert!( !session.capture_hunk_delta(0).await, "capture_hunk_delta must return false under a disabled hunk tracker" ); session.restore_hunk_checkpoints(0).await; session.persist_checkpoint(0).await; let stored = session .checkpoint_store .get(0) .await .expect("FS checkpoint persisted even with the tracker disabled"); assert_eq!(stored.prompt_index, 0); assert!( stored.hunks.is_none(), "no hunk delta is bundled when the tracker is disabled" ); assert_eq!(stored.fs.file_snapshots.len(), 1); let resp = handle.rewind_to("main", 0).await; assert!(resp.success, "FS rewind must succeed: {:?}", resp.error); assert!( resp.reverted_files.iter().any(|f| f.ends_with("a.rs")), "a.rs must be among the reverted files: {:?}", resp.reverted_files ); let restored = session .async_fs() .try_read_to_string(&file) .await .expect("read a.rs"); assert_eq!( restored.as_deref(), Some("v0\n"), "file content must revert to its pre-prompt state" ); } }