From b0276d8ae57f784da14db70d118a3319047bf34b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Sun, 9 Aug 2026 11:04:19 +0800 Subject: [PATCH] refactor(cache): restructure cache lookup and update around the task's fs story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the filesystem side of a task run one seam: TaskFs::pre_run (before the task executes: validate io config, snapshot the listed inputs, and report the first input changed since a previous run's fingerprints) and TaskFs::post_run (after: judge the traced accesses and conclude InputModified or Cacheable with the run's fingerprints and outputs). The seam makes the read-write-overlap policy and its filters drivable with synthetic traces on temp directories — the new unit tests in task_fs/task_run.rs do exactly that. Lookup flips to fetch-first so pre_run can compare against the stored fingerprints, and — since that makes the entry fetch and the old-key miss classification adjacent — both now run in one deferred read transaction under a single lock hold (fetch_entry), closing the window where a concurrent run could break the classifier's the-key-just-missed assumption. The cache module shrinks to storage primitives; FspyTracking dissolves into TaskFs. The task_fs module tree mirrors a standalone-crate layout on purpose: the follow-up moves it out of the engine wholesale. Co-authored-by: Claude Fable 5 --- Cargo.lock | 1 + crates/vt/Cargo.toml | 1 + crates/vt/docs/task-cache.md | 8 +- crates/vt/src/lib.rs | 1 - crates/vt/src/session/cache/mod.rs | 190 ++-- crates/vt/src/session/execute/cache_update.rs | 297 ++---- crates/vt/src/session/execute/mod.rs | 161 ++-- crates/vt/src/session/execute/post_run.rs | 2 +- crates/vt/src/session/execute/spawn.rs | 4 +- .../execute/task_fs}/collections.rs | 0 .../execute/{ => task_fs}/fingerprint.rs | 2 +- .../src/session/execute/{ => task_fs}/glob.rs | 0 .../src/session/execute/{ => task_fs}/hash.rs | 0 crates/vt/src/session/execute/task_fs/mod.rs | 22 + .../src/session/execute/task_fs/task_run.rs | 860 ++++++++++++++++++ .../execute/{ => task_fs}/tracked_accesses.rs | 15 +- 16 files changed, 1141 insertions(+), 423 deletions(-) rename crates/vt/src/{ => session/execute/task_fs}/collections.rs (100%) rename crates/vt/src/session/execute/{ => task_fs}/fingerprint.rs (99%) rename crates/vt/src/session/execute/{ => task_fs}/glob.rs (100%) rename crates/vt/src/session/execute/{ => task_fs}/hash.rs (100%) create mode 100644 crates/vt/src/session/execute/task_fs/mod.rs create mode 100644 crates/vt/src/session/execute/task_fs/task_run.rs rename crates/vt/src/session/execute/{ => task_fs}/tracked_accesses.rs (91%) diff --git a/Cargo.lock b/Cargo.lock index 00ee74ab3..b5fc5484b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4149,6 +4149,7 @@ dependencies = [ "ctrlc", "derive_more", "fspy", + "fspy_shared", "futures-util", "materialized_artifact", "materialized_artifact_build", diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index 1a59fc291..f12d53bfa 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -19,6 +19,7 @@ wincode = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] } ctrlc = { workspace = true } derive_more = { workspace = true, features = ["debug", "from"] } +fspy_shared = { workspace = true } futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } diff --git a/crates/vt/docs/task-cache.md b/crates/vt/docs/task-cache.md index 64050cd62..c26838859 100644 --- a/crates/vt/docs/task-cache.md +++ b/crates/vt/docs/task-cache.md @@ -573,7 +573,13 @@ crates/vt/src/session/ │ └── display.rs # Cache status display formatting ├── execute/ │ ├── mod.rs # execute_spawn, SpawnOutcome -│ ├── fingerprint.rs # InputFingerprints, PathFingerprint, InputChange +│ ├── task_fs/ # The run's filesystem story +│ │ ├── task_run.rs # TaskFs (pre_run/post_run), Conclusion +│ │ ├── fingerprint.rs # InputFingerprints, PathFingerprint, InputChange +│ │ ├── tracked_accesses.rs # fspy access normalization +│ │ ├── glob.rs # Glob walking + input hashing +│ │ └── hash.rs # Content hashing +│ ├── cache_update.rs # Post-run cache update decision │ ├── post_run.rs # TrackedEnvFingerprints (tracked env validation) │ └── spawn.rs # spawn_with_tracking, fspy integration └── reporter/ diff --git a/crates/vt/src/lib.rs b/crates/vt/src/lib.rs index 7a59dfd3b..b5a9e8258 100644 --- a/crates/vt/src/lib.rs +++ b/crates/vt/src/lib.rs @@ -1,5 +1,4 @@ mod cli; -mod collections; mod napi_client; pub mod session; diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 91954ec4e..597fbfd37 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -3,7 +3,7 @@ pub mod archive; pub mod display; -use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; +use std::{fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; // Re-export display functions for convenience pub use display::format_cache_status_inline; @@ -25,11 +25,11 @@ use wincode::{ io::{Reader, Writer}, }; -pub use super::execute::fingerprint::InputChangeKind; +pub use super::execute::task_fs::InputChangeKind; use super::execute::{ - fingerprint::{InputChange, InputFingerprints}, pipe::StdOutput, post_run::{PostRunMismatch, TrackedEnvFingerprints, TrackedEnvQuery}, + task_fs::{InputChange, InputFingerprints}, }; const TASK_CACHE_PREALLOCATION_SIZE_LIMIT: usize = 256 * 1024 * 1024; @@ -314,73 +314,81 @@ impl ExecutionCache { Ok(()) } - /// Try to hit cache by looking up the cache entry key and validating inputs. - /// Returns `Ok(Ok(cache_value))` on cache hit, `Ok(Err(cache_miss))` on miss. + /// Fetch the stored entry for this task's exact cache key, or the reason + /// the key missed: the task ran before under a different key (command/env, + /// input config, or output config changed — checked in that priority + /// order), or it never ran at all. The old entry's value is never reused, + /// only its key is compared. + /// + /// Whether a fetched entry is still valid is the caller's question. + #[expect( + clippy::significant_drop_tightening, + reason = "lock guard cannot be dropped earlier because the transaction borrows the connection" + )] #[tracing::instrument(level = "debug", skip_all)] - pub async fn try_hit( + pub(crate) async fn fetch_entry( &self, cache_metadata: &CacheMetadata, - globbed_inputs: &BTreeMap, - workspace_root: &AbsolutePath, ) -> anyhow::Result> { - let spawn_fingerprint = &cache_metadata.spawn_fingerprint; - let execution_cache_key = &cache_metadata.execution_cache_key; - let cache_key = CacheEntryKey::from_metadata(cache_metadata); - // Try to find the cache entry by key (spawn fingerprint + input config) - if let Some(cache_value) = self.get_by_cache_key(&cache_key).await? { - // Validate the stored input fingerprints against the filesystem: - // the listed-inputs snapshot against the fresh one, then each - // discovered input against the disk. - if let Some(change) = - cache_value.input_fingerprints.find_change(globbed_inputs, workspace_root)? - { - return Ok(Err(CacheMiss::FingerprintMismatch(change.into()))); - } - - // Validate the tracked env state against the current env context. - if let Some(mismatch) = cache_value - .tracked_env_fingerprints - .validate_envs(&cache_metadata.unfiltered_envs)? - { - return Ok(Err(CacheMiss::FingerprintMismatch(mismatch.into()))); - } - // Associate the execution key to the cache entry key if not already, - // so that next time we can find it and report what changed - self.upsert_task_fingerprint(execution_cache_key, &cache_key).await?; - return Ok(Ok(cache_value)); - } - - // No cache found with the current cache entry key, - // check if execution key maps to a different cache entry key - if let Some(old_cache_key) = - self.get_cache_key_by_execution_key(execution_cache_key).await? - { - // Destructure to ensure we handle all fields when new ones are added. - // `get_by_cache_key` above returned None for the *current* cache key, - // so at least one field on `old_cache_key` must differ from the - // current metadata — checked in priority order (spawn → input → output). - let CacheEntryKey { - spawn_fingerprint: old_spawn_fingerprint, - input_config: old_input_config, - output_config: old_output_config, - } = old_cache_key; - let mismatch = if old_spawn_fingerprint != *spawn_fingerprint { - FingerprintMismatch::SpawnFingerprint { - old: old_spawn_fingerprint, - new: spawn_fingerprint.clone(), - } - } else if old_input_config != cache_metadata.input_config { - FingerprintMismatch::InputConfig + let (entry, old_cache_key) = { + let mut conn = self.conn.lock().await; + // Both reads run in one deferred read transaction, so they see a + // single database snapshot: the miss classification below may rely + // on the entry fetch having missed even while a concurrent run of + // the same task writes the cache. + let tx = conn.transaction()?; + let entry: Option = get_value(&tx, "cache_entries", &cache_key)?; + let old_cache_key: Option = if entry.is_some() { + None } else { - debug_assert_ne!(old_output_config, cache_metadata.output_config); - FingerprintMismatch::OutputConfig + get_value(&tx, "task_fingerprints", &cache_metadata.execution_cache_key)? }; - return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); + // Read-only: dropping `tx` (a rollback) is equivalent to a commit. + (entry, old_cache_key) + }; + + if let Some(entry) = entry { + return Ok(Ok(entry)); } - Ok(Err(CacheMiss::NotFound)) + let Some(old_cache_key) = old_cache_key else { + return Ok(Err(CacheMiss::NotFound)); + }; + + // Destructure to ensure we handle all fields when new ones are added. + // The current cache key found no entry in the same snapshot, so at + // least one field on `old_cache_key` must differ from the current + // metadata. + let CacheEntryKey { + spawn_fingerprint: old_spawn_fingerprint, + input_config: old_input_config, + output_config: old_output_config, + } = old_cache_key; + let spawn_fingerprint = &cache_metadata.spawn_fingerprint; + let mismatch = if old_spawn_fingerprint != *spawn_fingerprint { + FingerprintMismatch::SpawnFingerprint { + old: old_spawn_fingerprint, + new: spawn_fingerprint.clone(), + } + } else if old_input_config != cache_metadata.input_config { + FingerprintMismatch::InputConfig + } else { + debug_assert_ne!(old_output_config, cache_metadata.output_config); + FingerprintMismatch::OutputConfig + }; + Ok(Err(CacheMiss::FingerprintMismatch(mismatch))) + } + + /// Associate the task's execution key with the entry key that served a + /// hit, so a later key-level miss can report what changed. + pub(crate) async fn record_hit(&self, cache_metadata: &CacheMetadata) -> anyhow::Result<()> { + self.upsert_task_fingerprint( + &cache_metadata.execution_cache_key, + &CacheEntryKey::from_metadata(cache_metadata), + ) + .await } /// Update cache after successful execution. @@ -417,52 +425,34 @@ impl ExecutionCache { } } +/// Fetch and deserialize one value by key from `table` on an already-held +/// connection (or transaction), so callers control how many reads share a +/// snapshot. +fn get_value(conn: &Connection, table: &str, key: &K) -> anyhow::Result> +where + K: SchemaWrite, + V: SchemaReadOwned, +{ + let key_blob = serialize_cache(key)?; + #[expect(clippy::disallowed_macros, reason = "SQL query string for rusqlite requires String")] + let mut select_stmt = conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; + let value_blob: Option> = + select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()?; + let Some(value_blob) = value_blob else { + return Ok(None); + }; + let value: V = deserialize_cache(&value_blob)?; + Ok(Some(value)) +} + // Basic database operations impl ExecutionCache { - #[expect( - clippy::significant_drop_tightening, - reason = "lock guard cannot be dropped earlier because prepared statement borrows connection" - )] - async fn get_key_by_value< - K: SchemaWrite, - V: SchemaReadOwned, - >( - &self, - table: &str, - key: &K, - ) -> anyhow::Result> { - let key_blob = serialize_cache(key)?; - let value_blob = { - let conn = self.conn.lock().await; - #[expect( - clippy::disallowed_macros, - reason = "SQL query string for rusqlite requires String" - )] - let mut select_stmt = - conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; - let value_blob: Option> = - select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()?; - value_blob - }; - let Some(value_blob) = value_blob else { - return Ok(None); - }; - let value: V = deserialize_cache(&value_blob)?; - Ok(Some(value)) - } - async fn get_by_cache_key( &self, cache_key: &CacheEntryKey, ) -> anyhow::Result> { - self.get_key_by_value("cache_entries", cache_key).await - } - - async fn get_cache_key_by_execution_key( - &self, - execution_cache_key: &ExecutionCacheKey, - ) -> anyhow::Result> { - self.get_key_by_value("task_fingerprints", execution_cache_key).await + let conn = self.conn.lock().await; + get_value(&conn, "cache_entries", cache_key) } #[expect( diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 88089cfc4..24026c752 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -1,5 +1,5 @@ //! Post-run cache update: decide whether a finished spawn may be cached and, -//! if so, store its fingerprint, captured output, and output archive. +//! if so, store its fingerprints, captured output, and output archive. use std::{collections::BTreeMap, sync::Arc, time::Duration}; @@ -11,32 +11,15 @@ use vt_str::Str; use super::{ CacheState, - fingerprint::{InputFingerprints, PathRead, fingerprint_discovered}, - glob, post_run::{TrackedEnvFingerprints, TrackedEnvQuery}, spawn::ChildOutcome, + task_fs::{Conclusion, PostRunError}, }; -use crate::{ - collections::HashMap, - session::{ - cache::{CacheEntryValue, ExecutionCache, archive}, - event::{CacheErrorKind, CacheNotUpdatedReason, CacheUpdateStatus, ExecutionError}, - }, +use crate::session::{ + cache::{CacheEntryValue, ExecutionCache, archive}, + event::{CacheErrorKind, CacheNotUpdatedReason, CacheUpdateStatus, ExecutionError}, }; -/// Post-execution summary of what fspy observed for a single task. Fields are -/// cfg-agnostic so the decision logic below doesn't need `cfg(fspy)` — the -/// value is only ever `Some` when tracking happened (see [`observe_fspy`]). -struct TrackingOutcome { - path_reads: HashMap, - /// Auto-output writes after output exclusions are applied. Empty when - /// `output_config.includes_auto` is false. - path_writes: FxHashSet, - /// First path that was both read and written during execution, if any. - /// A non-empty value means caching this task is unsound. - read_write_overlap: Option, -} - type TrackedEnvValues = BTreeMap>; type TrackedEnvQueryValues = BTreeMap>; @@ -60,8 +43,7 @@ pub(super) async fn update_cache( duration: Duration, cancelled: bool, ) -> (CacheUpdateStatus, Option) { - let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state; - let fspy = tracking.fspy.as_ref(); + let CacheState { metadata, task_fs, std_outputs, tracking } = state; if let Some(reports) = reports && reports.cache_disabled @@ -71,15 +53,6 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), None); } - // Tool-reported paths to exclude from auto input tracking. Absolute paths - // are normalized to workspace-relative; anything outside is dropped. - let ignored_input_rels: FxHashSet = reports - .map(|r| normalize_ignored_paths(&r.ignored_inputs, workspace_root)) - .unwrap_or_default(); - let ignored_output_rels: FxHashSet = reports - .map(|r| normalize_ignored_paths(&r.ignored_outputs, workspace_root)) - .unwrap_or_default(); - if cancelled { // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy. return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None); @@ -90,56 +63,57 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } - let fspy_outcome = observe_fspy( - outcome, - metadata, - fspy, - &ignored_input_rels, - &ignored_output_rels, - workspace_root, - ); - - if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { - // fspy-inferred read-write overlap: the task wrote to a file it also - // read, so the prerun input hashes are stale and caching is unsound. - // (We only check fspy-inferred reads, not globbed_inputs. A task that - // writes to a glob-matched file without reading it produces perpetual - // cache misses but not a correctness bug.) - return ( - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { - path: path.clone(), - }), - None, - ); - } - - if fspy_outcome.is_none() && fspy.is_some() { - // Task requested fspy auto-inference but this binary was built without - // `cfg(fspy)`. Task ran, but we can't compute a valid cache entry - // without tracked path accesses. + // Task requested fspy auto-inference but no trace exists (this binary was + // built without `cfg(fspy)`). Task ran, but we can't compute a valid cache + // entry without tracked path accesses. + #[cfg(fspy)] + let has_trace = outcome.path_accesses.is_some(); + #[cfg(not(fspy))] + let has_trace = false; + if tracking.fspy && !has_trace { return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None); } - // Collect tool-reported tracked envs for the post-run fingerprint. Env - // names that the user already declared are skipped because their values - // are already part of the spawn fingerprint. - let (tracked_envs, tracked_env_queries) = match collect_tracked_reports(reports, metadata) { - Ok(tracked_reports) => tracked_reports, - Err(err) => { + let conclusion = { + #[cfg(fspy)] + let accesses = outcome.path_accesses.as_ref().map(fspy::PathAccessIterable::iter); + #[cfg(not(fspy))] + let accesses: Option>> = None; + + let empty_ignored = FxHashSet::default(); + let reported_ignored_inputs = reports.map_or(&empty_ignored, |r| &r.ignored_inputs); + let reported_ignored_outputs = reports.map_or(&empty_ignored, |r| &r.ignored_outputs); + task_fs.post_run(accesses, reported_ignored_inputs, reported_ignored_outputs) + }; + let (input_fingerprints, outputs) = match conclusion { + Ok(Conclusion::InputModified { path }) => { + // The task wrote a file it also read, so the pre-run input hashes + // are stale and caching is unsound. + return ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path }), + None, + ); + } + Ok(Conclusion::Cacheable { input_fingerprints, outputs }) => (input_fingerprints, outputs), + Err(PostRunError::InputFingerprints(err)) => { return ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), Some(ExecutionError::PostRunFingerprint(err)), ); } + Err(PostRunError::Outputs(err)) => { + return ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::Cache { kind: CacheErrorKind::Update, source: err }), + ); + } }; - // Fingerprint the discovered inputs (traced reads not already in the - // globbed snapshot: the overlap check above guarantees no input - // modification, so the prerun hash is the correct post-exec hash). - let empty_path_reads = HashMap::default(); - let path_reads = fspy_outcome.as_ref().map_or(&empty_path_reads, |o| &o.path_reads); - let discovered = match fingerprint_discovered(path_reads, workspace_root, &globbed_inputs) { - Ok(discovered) => discovered, + // Collect tool-reported tracked envs for the cache entry. Env names that + // the user already declared are skipped because their values are already + // part of the spawn fingerprint. + let (tracked_envs, tracked_env_queries) = match collect_tracked_reports(reports, metadata) { + Ok(tracked_reports) => tracked_reports, Err(err) => { return ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), @@ -148,12 +122,7 @@ pub(super) async fn update_cache( } }; - let output_archive = match collect_and_archive_outputs( - metadata, - fspy_outcome.as_ref(), - workspace_root, - cache_dir, - ) { + let output_archive = match archive_outputs(&outputs, workspace_root, cache_dir) { Ok(archive) => archive, Err(err) => { return ( @@ -164,7 +133,7 @@ pub(super) async fn update_cache( }; let new_cache_value = CacheEntryValue { - input_fingerprints: InputFingerprints::new(globbed_inputs, discovered), + input_fingerprints, tracked_env_fingerprints: TrackedEnvFingerprints { tracked_envs, tracked_env_queries }, std_outputs: std_outputs.into(), duration, @@ -179,81 +148,6 @@ pub(super) async fn update_cache( } } -/// Summarize the run's fspy observations. `Some` iff tracking was both -/// requested (`tracking.fspy.is_some()`) and compiled in (`cfg(fspy)`). On a -/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`] -/// short-circuits to `FspyUnsupported` when tracking was needed. -/// -/// `path_reads` is gated on `input_config.includes_auto`, filtered by -/// user-configured input negatives, and by tool-reported `ignoreInput` paths. -/// `path_writes` is filtered by user-configured output negatives and -/// tool-reported `ignoreOutput` paths before read-write overlap detection. -fn observe_fspy( - outcome: &ChildOutcome, - metadata: &CacheMetadata, - fspy: Option<&super::FspyTracking<'_>>, - ignored_input_rels: &FxHashSet, - ignored_output_rels: &FxHashSet, - workspace_root: &AbsolutePath, -) -> Option { - #[cfg(fspy)] - { - use super::tracked_accesses::TrackedPathAccesses; - - outcome.path_accesses.as_ref().map(|raw| { - let tracked = TrackedPathAccesses::from_raw(raw, workspace_root); - let filtered_path_reads: HashMap = - // fspy can be attached for auto-output-only tasks. In that - // mode reads must not become inferred inputs. - if metadata.input_config.includes_auto - && let Some(fspy) = fspy - { - tracked - .path_reads - .iter() - .filter(|(path, _)| { - !fspy.input_negative_globs.is_match(path.as_str()) - && !is_ignored(path, ignored_input_rels) - }) - .map(|(path, read)| (path.clone(), *read)) - .collect() - } else { - HashMap::default() - }; - let filtered_path_writes: FxHashSet = - // fspy can also be attached for auto-input-only tasks. In that - // mode writes must not become auto outputs or overlap candidates. - if metadata.output_config.includes_auto - && let Some(fspy) = fspy - { - tracked - .path_writes - .iter() - .filter(|path| { - !fspy.output_negative_globs.is_match(path.as_str()) - && !is_ignored(path, ignored_output_rels) - }) - .cloned() - .collect() - } else { - FxHashSet::default() - }; - let read_write_overlap = - filtered_path_reads.keys().find(|p| filtered_path_writes.contains(*p)).cloned(); - TrackingOutcome { - path_reads: filtered_path_reads, - path_writes: filtered_path_writes, - read_write_overlap, - } - }) - } - #[cfg(not(fspy))] - { - let _ = (outcome, metadata, fspy, ignored_input_rels, ignored_output_rels, workspace_root); - None - } -} - fn collect_tracked_reports( reports: Option<&Reports>, metadata: &CacheMetadata, @@ -268,29 +162,7 @@ fn collect_tracked_reports( .map(Option::unwrap_or_default) } -/// Normalize tool-reported absolute paths to cleaned workspace-relative paths. -/// Paths outside the workspace are dropped — they can't contribute to inputs -/// or outputs. -fn normalize_ignored_paths( - paths: &FxHashSet>, - workspace_root: &AbsolutePath, -) -> FxHashSet { - paths - .iter() - .filter_map(|p| p.strip_prefix(workspace_root).ok().flatten()?.clean().ok()) - .collect() -} - -/// Whether `path` is covered by any `ignored` entry. An ignored entry matches -/// itself (exact file) and everything under it (directory subtree). -fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet) -> bool { - if ignored.is_empty() { - return false; - } - ignored.contains(path) || ignored.iter().any(|ig| path.strip_prefix(ig).is_some()) -} - -/// Select tool-reported env records to embed in the post-run fingerprint. +/// Select tool-reported env records to embed in the cache entry. /// Names that the user already declared as fingerprinted are skipped because /// their values are already in the spawn fingerprint. fn collect_tracked_envs( @@ -321,8 +193,8 @@ fn collect_tracked_envs( Ok(tracked_envs) } -/// Select tool-reported bulk env query records to embed in the post-run -/// fingerprint. The full match-set is stored as value hashes. +/// Select tool-reported bulk env query records to embed in the cache entry. +/// The full match-set is stored as value hashes. fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result { let mut tracked_env_queries = BTreeMap::new(); @@ -351,74 +223,23 @@ fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result, +/// Returns `Some(archive_filename)` if files were archived, `None` if the run +/// produced no output files. +fn archive_outputs( + outputs: &[RelativePathBuf], workspace_root: &AbsolutePath, cache_dir: &AbsolutePath, ) -> anyhow::Result> { - let output_config = &cache_metadata.output_config; - - let mut output_files: FxHashSet = FxHashSet::default(); - - if let Some(t) = tracking { - output_files.extend(t.path_writes.iter().cloned()); - } - - if !output_config.positive_globs.is_empty() { - let glob_paths = glob::collect_glob_paths( - workspace_root, - &output_config.positive_globs, - &output_config.negative_globs, - )?; - output_files.extend(glob_paths); - } - - if output_files.is_empty() { + if outputs.is_empty() { return Ok(None); } - let mut sorted_files: Vec = output_files.into_iter().collect(); - sorted_files.sort(); - let archive_name: Str = vt_str::format!("{}.tar.zst", uuid::Uuid::new_v4()); let archive_path = cache_dir.join(archive_name.as_str()); - archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?; + archive::create_output_archive(workspace_root, outputs, &archive_path)?; Ok(Some(archive_name)) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use rustc_hash::FxHashSet; - use vt_path::{AbsolutePath, RelativePathBuf}; - - use super::normalize_ignored_paths; - - #[test] - fn normalize_ignored_paths_cleans_relative_components() { - let workspace_root = - AbsolutePath::new(if cfg!(windows) { r"C:\repo" } else { "/repo" }).unwrap(); - let ignored = - workspace_root.join(if cfg!(windows) { r"pkg\..\cache" } else { "pkg/../cache" }); - let mut ignored_paths = FxHashSet::default(); - ignored_paths.insert(Arc::::from(ignored)); - - let normalized = normalize_ignored_paths(&ignored_paths, workspace_root); - - let expected = RelativePathBuf::new("cache").unwrap(); - assert!(normalized.contains(&expected)); - } -} diff --git a/crates/vt/src/session/execute/mod.rs b/crates/vt/src/session/execute/mod.rs index 0a9dc8986..d3599c197 100644 --- a/crates/vt/src/session/execute/mod.rs +++ b/crates/vt/src/session/execute/mod.rs @@ -1,18 +1,13 @@ mod cache_update; -pub mod fingerprint; -pub mod glob; -mod hash; pub mod pipe; pub mod post_run; mod scheduler; pub mod spawn; -#[cfg(fspy)] -pub mod tracked_accesses; +pub mod task_fs; #[cfg(windows)] mod win_job; use std::{ - collections::BTreeMap, ffi::{OsStr, OsString}, sync::Arc, time::Instant, @@ -20,16 +15,15 @@ use std::{ use futures_util::future::LocalBoxFuture; use tokio_util::sync::CancellationToken; -use vt_glob::path::PathGlobSet; use vt_ipc_shared::NODE_CLIENT_PATH_ENV_NAME; -use vt_path::{AbsolutePath, RelativePathBuf}; +use vt_path::AbsolutePath; use vt_plan::{SpawnExecution, cache_metadata::CacheMetadata}; use vt_server::{Recorder, Reports, ServerHandle, StopAccepting, serve}; use self::{ - glob::compute_globbed_inputs, pipe::{PipeSinks, StdOutput, pipe_stdio}, spawn::{ChildHandle, ChildOutcome, SpawnStdio, spawn}, + task_fs::TaskFs, }; use super::{ cache::{CacheEntryValue, CacheMiss, ExecutionCache, archive}, @@ -64,6 +58,10 @@ pub enum SpawnOutcome { /// `includes_auto`, which only lives on cache metadata). /// - Cached execution always owns [`PipeWriters`] (piped stdio is forced so /// that output can be captured for replay). +#[expect( + clippy::large_enum_variant, + reason = "one short-lived value per task execution; boxing the cached state would only add indirection" +)] enum ExecutionMode<'a> { Cached { /// Borrowed by [`PipeSinks`] during drain; dropped at end of function. @@ -86,7 +84,9 @@ enum ExecutionMode<'a> { /// a borrow inside [`PipeSinks::capture`]. struct CacheState<'a> { metadata: &'a CacheMetadata, - globbed_inputs: BTreeMap, + /// The run's filesystem story, begun at cache lookup; concluded by the + /// cache-update phase after the child exits. + task_fs: TaskFs<'a>, /// Captured stdout/stderr for cache replay. Written in place during drain; /// always present (possibly empty) once we reach the cache-update phase. std_outputs: Vec, @@ -94,24 +94,19 @@ struct CacheState<'a> { /// available, and fspy path tracing is attached only when auto input or /// output inference needs it. Parts are borrowed in place during the /// wait/join; the struct is never moved out. - tracking: Tracking<'a>, + tracking: Tracking, } /// The IPC server's driver future: resolves with the recorded reports after /// [`StopAccepting::signal`] fires and all in-flight clients drain. type IpcDriver = LocalBoxFuture<'static, Result>; -/// fspy path-tracking state, present only when a cached task needs automatic -/// input or output inference. -struct FspyTracking<'a> { - input_negative_globs: PathGlobSet<'a>, - output_negative_globs: PathGlobSet<'a>, -} - -/// Per-task runner-aware tracking: IPC server handle plus optional fspy state. -/// Lifetime-tied to a single `execute_spawn` call. -struct Tracking<'a> { - fspy: Option>, +/// Per-task runner-aware tracking: IPC server handle plus whether fspy path +/// tracing is attached. Tied to a single `execute_spawn` call. +struct Tracking { + /// fspy path tracing is attached iff a cached task needs automatic input + /// or output inference (`includes_auto` on either side). + fspy: bool, ipc_envs: Vec<(&'static OsStr, OsString)>, ipc_server_fut: IpcDriver, stop_accepting: StopAccepting, @@ -156,28 +151,19 @@ impl<'a> ExecutionMode<'a> { /// `cache_metadata.is_some_and(_)`) at every downstream use site. /// ───────────────────────────────────────────────────────────────────── fn build( - cache_metadata: Option<&'a CacheMetadata>, + cached: Option<(&'a CacheMetadata, TaskFs<'a>)>, stdio_config: StdioConfig, - globbed_inputs: BTreeMap, ) -> Result { - let Some(metadata) = cache_metadata else { + let Some((metadata, task_fs)) = cached else { return Ok(Self::Uncached { pipe_writers: (stdio_config.suggestion == StdioSuggestion::Piped) .then_some(stdio_config.writers), }); }; - let fspy = if metadata.input_config.includes_auto || metadata.output_config.includes_auto { - // Resolve negative globs for fspy path filtering (already - // workspace-root-relative). - let input_negative_globs = PathGlobSet::new(&metadata.input_config.negative_globs) - .map_err(|err| ExecutionError::PostRunFingerprint(err.into()))?; - let output_negative_globs = PathGlobSet::new(&metadata.output_config.negative_globs) - .map_err(|err| ExecutionError::PostRunFingerprint(err.into()))?; - Some(FspyTracking { input_negative_globs, output_negative_globs }) - } else { - None - }; + // fspy path tracing is attached iff auto input or output inference + // needs it. + let fspy = metadata.input_config.includes_auto || metadata.output_config.includes_auto; // Bind runner IPC for every cached task. The merged cache-control API // (`disableCache`) must work even when a task uses explicit inputs and @@ -190,7 +176,7 @@ impl<'a> ExecutionMode<'a> { Ok(Self::Cached { pipe_writers: stdio_config.writers, - state: CacheState { metadata, globbed_inputs, std_outputs: Vec::new(), tracking }, + state: CacheState { metadata, task_fs, std_outputs: Vec::new(), tracking }, }) } @@ -216,7 +202,7 @@ impl<'a> ExecutionMode<'a> { /// whether fspy tracking is on. const fn spawn_config(&self) -> (SpawnStdio, bool) { match self { - Self::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.fspy.is_some()), + Self::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.fspy), Self::Uncached { pipe_writers: Some(_) } => (SpawnStdio::Piped, false), Self::Uncached { pipe_writers: None } => (SpawnStdio::Inherited, false), } @@ -383,8 +369,9 @@ async fn run( // 2. Report execution start with the looked-up cache status (`start()` // runs exactly once on every arm) and either replay the hit — no need - // to execute the command — or carry the globbed inputs into the run. - let (stdio_config, globbed_inputs) = match lookup { + // to execute the command — or carry the run's filesystem story into + // the cache-update phase. + let (stdio_config, cached) = match lookup { CacheLookup::Hit(cached) => { let mut stdio_config = reporter.start(CacheStatus::Hit { replayed_duration: cached.duration }); @@ -396,18 +383,16 @@ async fn run( program_name, )); } - CacheLookup::Miss { miss, globbed_inputs } => { - (reporter.start(CacheStatus::Miss(miss)), globbed_inputs) + CacheLookup::Miss { miss, metadata, task_fs } => { + (reporter.start(CacheStatus::Miss(miss)), Some((metadata, task_fs))) + } + CacheLookup::Disabled => { + (reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)), None) } - CacheLookup::Disabled => ( - reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)), - BTreeMap::new(), - ), }; // 4. Fold the cache/fspy/stdio decisions into the typed mode. - let mut mode = ExecutionMode::build(cache_metadata, stdio_config, globbed_inputs) - .map_err(Report::failed)?; + let mut mode = ExecutionMode::build(cached, stdio_config).map_err(Report::failed)?; // Measure end-to-end duration here — spawn() doesn't track time. let start = Instant::now(); @@ -499,45 +484,77 @@ async fn run( /// Outcome of the cache-lookup phase. Each variant carries exactly what that /// outcome provides: a hit owns the cached entry to replay, a miss keeps the -/// reason plus the globbed inputs (reused by the cache-update phase after the -/// run), and disabled has neither. -enum CacheLookup { +/// reason plus the begun run (concluded by the cache-update phase after the +/// child exits), and disabled has neither. +enum CacheLookup<'a> { /// Cache hit — the cached entry to replay. Hit(CacheEntryValue), /// Cache miss — the detailed reason (`NotFound` or `FingerprintMismatch`). - Miss { miss: CacheMiss, globbed_inputs: BTreeMap }, + Miss { miss: CacheMiss, metadata: &'a CacheMetadata, task_fs: TaskFs<'a> }, /// Caching is disabled for this task (no cache metadata). Disabled, } -/// Phase 1: compute the globbed inputs and try to hit the cache. -async fn lookup_cache( - cache_metadata: Option<&CacheMetadata>, +/// A cache-lookup failure, reported as an infrastructure error. +const fn lookup_error(source: anyhow::Error) -> Report { + Report::failed(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source }) +} + +/// Phase 1: fetch the stored entry, begin the run's filesystem story, and +/// decide hit or miss. +/// +/// The fetch comes first — the entry's key does not depend on filesystem +/// state, and `pre_run` wants the stored fingerprints to compare against. +/// A key-level miss arrives already classified (atomically with the fetch). +/// The checks then run in order: input changes (filesystem), tracked envs. +async fn lookup_cache<'a>( + cache_metadata: Option<&'a CacheMetadata>, cache: &ExecutionCache, - workspace_root: &Arc, -) -> Result { - let Some(cache_metadata) = cache_metadata else { + workspace_root: &'a Arc, +) -> Result, Report> { + let Some(metadata) = cache_metadata else { return Ok(CacheLookup::Disabled); }; - // Compute globbed inputs from positive globs at execution time. - // Globs are already workspace-root-relative (resolved at task graph stage). - let globbed_inputs = compute_globbed_inputs( + let entry = cache.fetch_entry(metadata).await.map_err(lookup_error)?; + + let (task_fs, change) = TaskFs::pre_run( workspace_root, - &cache_metadata.input_config.positive_globs, - &cache_metadata.input_config.negative_globs, + &metadata.input_config, + &metadata.output_config, + entry.as_ref().ok().map(|entry| &entry.input_fingerprints), ) - .map_err(|err| { - Report::failed(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err }) - })?; + .map_err(lookup_error)?; - match cache.try_hit(cache_metadata, &globbed_inputs, workspace_root).await { - Ok(Ok(cached)) => Ok(CacheLookup::Hit(cached)), - Ok(Err(miss)) => Ok(CacheLookup::Miss { miss, globbed_inputs }), - Err(err) => { - Err(Report::failed(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err })) - } + let cached = match entry { + Ok(cached) => cached, + Err(miss) => return Ok(CacheLookup::Miss { miss, metadata, task_fs }), + }; + + if let Some(change) = change { + return Ok(CacheLookup::Miss { + miss: CacheMiss::FingerprintMismatch(change.into()), + metadata, + task_fs, + }); } + + if let Some(mismatch) = cached + .tracked_env_fingerprints + .validate_envs(&metadata.unfiltered_envs) + .map_err(lookup_error)? + { + return Ok(CacheLookup::Miss { + miss: CacheMiss::FingerprintMismatch(mismatch.into()), + metadata, + task_fs, + }); + } + + // Remember which entry key served this task so a later key-level miss can + // report what changed. + cache.record_hit(metadata).await.map_err(lookup_error)?; + Ok(CacheLookup::Hit(cached)) } /// Phase 3 (cache hit): replay the captured stdout/stderr and restore the diff --git a/crates/vt/src/session/execute/post_run.rs b/crates/vt/src/session/execute/post_run.rs index f504f0391..bdc6c1fec 100644 --- a/crates/vt/src/session/execute/post_run.rs +++ b/crates/vt/src/session/execute/post_run.rs @@ -1,7 +1,7 @@ //! Post-run environment fingerprinting: env values and bulk env queries //! observed by runner-aware tools during execution, validated again at cache //! lookup. The filesystem half of post-run fingerprinting lives in -//! [`super::fingerprint`]. +//! [`super::task_fs`]. use std::{collections::BTreeMap, ffi::OsStr, sync::Arc}; diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index adff8aac9..b8adff88e 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -2,8 +2,8 @@ //! //! [`spawn`] does one thing: hand back the child's stdio pipes plus a //! cancellation-aware `wait` future. Draining the pipes is [`super::pipe`]'s -//! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s (only -//! compiled when `cfg(fspy)` is on). +//! job; the raw path accesses are judged by [`super::task_fs`] during the +//! cache update. use std::{ffi::OsStr, io, process::Stdio}; diff --git a/crates/vt/src/collections.rs b/crates/vt/src/session/execute/task_fs/collections.rs similarity index 100% rename from crates/vt/src/collections.rs rename to crates/vt/src/session/execute/task_fs/collections.rs diff --git a/crates/vt/src/session/execute/fingerprint.rs b/crates/vt/src/session/execute/task_fs/fingerprint.rs similarity index 99% rename from crates/vt/src/session/execute/fingerprint.rs rename to crates/vt/src/session/execute/task_fs/fingerprint.rs index 721f5ab81..a00dae8f7 100644 --- a/crates/vt/src/session/execute/fingerprint.rs +++ b/crates/vt/src/session/execute/task_fs/fingerprint.rs @@ -13,7 +13,7 @@ use vt_path::{AbsolutePath, RelativePathBuf}; use vt_str::Str; use wincode::{SchemaRead, SchemaWrite}; -use crate::collections::HashMap; +use super::collections::HashMap; /// Path read access info #[derive(Debug, Clone, Copy)] diff --git a/crates/vt/src/session/execute/glob.rs b/crates/vt/src/session/execute/task_fs/glob.rs similarity index 100% rename from crates/vt/src/session/execute/glob.rs rename to crates/vt/src/session/execute/task_fs/glob.rs diff --git a/crates/vt/src/session/execute/hash.rs b/crates/vt/src/session/execute/task_fs/hash.rs similarity index 100% rename from crates/vt/src/session/execute/hash.rs rename to crates/vt/src/session/execute/task_fs/hash.rs diff --git a/crates/vt/src/session/execute/task_fs/mod.rs b/crates/vt/src/session/execute/task_fs/mod.rs new file mode 100644 index 000000000..5da6dfb20 --- /dev/null +++ b/crates/vt/src/session/execute/task_fs/mod.rs @@ -0,0 +1,22 @@ +//! Filesystem fingerprinting for task caching. +//! +//! One run of a task, from the filesystem's point of view: +//! +//! - [`TaskFs::pre_run`] — before the task executes: capture the state of its +//! configured inputs and detect what changed since a previous run. +//! - [`TaskFs::post_run`] — after it finished: judge the traced file accesses +//! and produce everything the cache should remember ([`Conclusion`]). +//! +//! [`InputFingerprints`] is the opaque record that links the two: a run's +//! `post_run` produces it, the caller stores it, and a later run's `pre_run` +//! checks the filesystem against it. + +mod collections; +mod fingerprint; +mod glob; +mod hash; +mod task_run; +mod tracked_accesses; + +pub use fingerprint::{InputChange, InputChangeKind, InputFingerprints}; +pub use task_run::{Conclusion, PostRunError, TaskFs}; diff --git a/crates/vt/src/session/execute/task_fs/task_run.rs b/crates/vt/src/session/execute/task_fs/task_run.rs new file mode 100644 index 000000000..33e7200ab --- /dev/null +++ b/crates/vt/src/session/execute/task_fs/task_run.rs @@ -0,0 +1,860 @@ +//! One run of a task, from the filesystem's point of view. + +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +use fspy_shared::ipc::PathAccess; +use rustc_hash::FxHashSet; +use vt_glob::path::PathGlobSet; +use vt_graph::config::ResolvedGlobConfig; +use vt_path::{AbsolutePath, RelativePathBuf}; +use vt_str::Str; + +use super::{ + collections::HashMap, + fingerprint::{self, InputChange, InputFingerprints, PathRead}, + glob, + tracked_accesses::TrackedPathAccesses, +}; + +/// One run of a task, from the filesystem's point of view. +/// +/// Two stages: [`pre_run`](Self::pre_run) before the task executes, +/// [`post_run`](Self::post_run) after it finished. The final reuse decision +/// belongs to the caller — a run with no input change may still be +/// invalidated by checks outside this crate. +pub struct TaskFs<'a> { + workspace_root: &'a AbsolutePath, + input: &'a ResolvedGlobConfig, + output: &'a ResolvedGlobConfig, + /// Present iff either side has auto tracking — the same condition under + /// which a trace is attached, so filters exist whenever a side counts. + auto_filters: Option>, + /// Content hashes of the listed inputs, captured before the run. + snapshot: BTreeMap, +} + +/// Compiled negative globs for filtering traced accesses, one per side. +struct AutoFilters<'a> { + input_negative_globs: PathGlobSet<'a>, + output_negative_globs: PathGlobSet<'a>, +} + +/// How the run ended, from the filesystem's point of view. +#[derive(Debug)] +pub enum Conclusion { + /// The task wrote a path it also read: the inputs it started from no + /// longer exist as such, so caching this run would be unsound. + InputModified { path: RelativePathBuf }, + /// Caching is sound; this is what the cache should remember. + Cacheable { + /// Fingerprints of everything the run read. + input_fingerprints: InputFingerprints, + /// The files the run produced, sorted. + outputs: Vec, + }, +} + +/// Which half of [`TaskFs::post_run`] failed. +#[derive(Debug, thiserror::Error)] +pub enum PostRunError { + /// The run's input fingerprints could not be computed. + #[error(transparent)] + InputFingerprints(anyhow::Error), + /// The run's outputs could not be collected. + #[error(transparent)] + Outputs(anyhow::Error), +} + +impl<'a> TaskFs<'a> { + /// Before the task runs: validate the task's io configuration, capture + /// the current state of its listed inputs, and — when a previous run's + /// fingerprints are given — report the first input that changed since + /// that run. + /// + /// A returned change of `None` means no input changed on the filesystem + /// (trivially so when `previous` is `None`); whether the previous result + /// can be reused may involve further checks by the caller. + /// + /// # Errors + /// + /// Fails when the io configuration is invalid or the inputs' state cannot + /// be read — in both cases before the task runs. + #[tracing::instrument(level = "debug", skip_all)] + pub fn pre_run( + workspace_root: &'a AbsolutePath, + input: &'a ResolvedGlobConfig, + output: &'a ResolvedGlobConfig, + previous: Option<&InputFingerprints>, + ) -> anyhow::Result<(Self, Option)> { + // Negative globs are only ever matched against traced accesses, so + // they are compiled exactly when a trace will exist (either side has + // auto). Tasks without auto never pay for — or fail on — this. + let auto_filters = if input.includes_auto || output.includes_auto { + Some(AutoFilters { + input_negative_globs: PathGlobSet::new(&input.negative_globs)?, + output_negative_globs: PathGlobSet::new(&output.negative_globs)?, + }) + } else { + None + }; + + let snapshot = glob::compute_globbed_inputs( + workspace_root, + &input.positive_globs, + &input.negative_globs, + )?; + + let task_fs = Self { workspace_root, input, output, auto_filters, snapshot }; + let change = previous + .map(|previous| previous.find_change(&task_fs.snapshot, workspace_root)) + .transpose()? + .flatten(); + Ok((task_fs, change)) + } + + /// After the task ran: judge the traced file accesses and produce what + /// the cache should remember. + /// + /// Traced reads and writes each count only for a side whose configuration + /// has auto tracking, and are filtered by that side's negative globs and + /// the tool-reported ignore paths. `accesses: None` means the run was not + /// traced; the configured outputs are still collected. + /// + /// # Errors + /// + /// Fails when the run's input fingerprints or outputs cannot be collected + /// from the filesystem; the [`PostRunError`] variant says which half. + #[tracing::instrument(level = "debug", skip_all)] + pub fn post_run<'r>( + self, + accesses: Option>>, + reported_ignored_inputs: &FxHashSet>, + reported_ignored_outputs: &FxHashSet>, + ) -> Result { + let tracked = accesses + .map(|raw| TrackedPathAccesses::from_raw(raw, self.workspace_root)) + .unwrap_or_default(); + + // A trace can be attached for auto-output-only tasks; in that mode + // reads must not become discovered inputs. Symmetrically, writes must + // not become outputs (or overlap candidates) for auto-input-only + // tasks. Each side is therefore gated on its own config. + let path_reads: HashMap = if self.input.includes_auto + && let Some(filters) = &self.auto_filters + { + let ignored = normalize_ignored_paths(reported_ignored_inputs, self.workspace_root); + tracked + .path_reads + .iter() + .filter(|(path, _)| { + !filters.input_negative_globs.is_match(path.as_str()) + && !is_ignored(path, &ignored) + }) + .map(|(path, read)| (path.clone(), *read)) + .collect() + } else { + HashMap::default() + }; + let path_writes: FxHashSet = if self.output.includes_auto + && let Some(filters) = &self.auto_filters + { + let ignored = normalize_ignored_paths(reported_ignored_outputs, self.workspace_root); + tracked + .path_writes + .iter() + .filter(|path| { + !filters.output_negative_globs.is_match(path.as_str()) + && !is_ignored(path, &ignored) + }) + .cloned() + .collect() + } else { + FxHashSet::default() + }; + + // The verdict, checked before any fingerprinting so a doomed run does + // no filesystem work: a path both read and written means the pre-run + // snapshot is stale and caching would be unsound. Exact-path equality + // only. (Only traced reads are checked, not the snapshot: a task that + // writes a listed input it never reads causes perpetual cache misses, + // which is wasteful but not a correctness bug.) + if let Some(path) = path_reads.keys().find(|path| path_writes.contains(*path)).cloned() { + return Ok(Conclusion::InputModified { path }); + } + + let discovered = + fingerprint::fingerprint_discovered(&path_reads, self.workspace_root, &self.snapshot) + .map_err(PostRunError::InputFingerprints)?; + + let outputs = collect_outputs( + path_writes, + self.workspace_root, + &self.output.positive_globs, + &self.output.negative_globs, + ) + .map_err(PostRunError::Outputs)?; + + Ok(Conclusion::Cacheable { + input_fingerprints: InputFingerprints::new(self.snapshot, discovered), + outputs, + }) + } +} + +/// Files the run produced: filtered traced writes ∪ configured output-glob +/// matches, sorted. +fn collect_outputs( + writes: FxHashSet, + root: &AbsolutePath, + positive_globs: &BTreeSet, + negative_globs: &BTreeSet, +) -> anyhow::Result> { + let mut files = writes; + if !positive_globs.is_empty() { + files.extend(glob::collect_glob_paths(root, positive_globs, negative_globs)?); + } + let mut sorted: Vec = files.into_iter().collect(); + sorted.sort(); + Ok(sorted) +} + +/// Normalize tool-reported absolute paths to cleaned workspace-relative paths. +/// Paths outside the workspace are dropped — they can't contribute to inputs +/// or outputs. +fn normalize_ignored_paths( + paths: &FxHashSet>, + workspace_root: &AbsolutePath, +) -> FxHashSet { + paths + .iter() + .filter_map(|p| p.strip_prefix(workspace_root).ok().flatten()?.clean().ok()) + .collect() +} + +/// Whether `path` is covered by any `ignored` entry. An ignored entry matches +/// itself (exact file) and everything under it (directory subtree). +fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet) -> bool { + if ignored.is_empty() { + return false; + } + ignored.contains(path) || ignored.iter().any(|ig| path.strip_prefix(ig).is_some()) +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsStr, fs}; + + use fspy_shared::ipc::AccessMode; + use tempfile::TempDir; + use vt_path::AbsolutePathBuf; + + use super::{super::fingerprint::InputChangeKind, *}; + + fn workspace() -> (TempDir, AbsolutePathBuf) { + let tmp = TempDir::new().unwrap(); + let root = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + (tmp, root) + } + + fn config(auto: bool, positive: &[&str], negative: &[&str]) -> ResolvedGlobConfig { + ResolvedGlobConfig { + includes_auto: auto, + positive_globs: positive.iter().map(|s| (*s).into()).collect(), + negative_globs: negative.iter().map(|s| (*s).into()).collect(), + } + } + + fn rel(s: &str) -> RelativePathBuf { + RelativePathBuf::new(s).unwrap() + } + + fn no_ignored() -> FxHashSet> { + FxHashSet::default() + } + + /// Owns the backing storage for synthetic traced accesses, so tests can + /// drive `post_run` without spawning a traced process. + #[derive(Default)] + struct Trace { + #[cfg(unix)] + entries: Vec<(AccessMode, std::ffi::OsString)>, + #[cfg(windows)] + entries: Vec<(AccessMode, Vec)>, + } + + impl Trace { + fn push(&mut self, mode: AccessMode, path: impl AsRef) { + #[cfg(unix)] + self.entries.push((mode, path.as_ref().to_os_string())); + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt as _; + self.entries.push((mode, path.as_ref().encode_wide().collect())); + } + } + + fn accesses(&self) -> impl Iterator> { + self.entries.iter().map(|(mode, path)| { + #[cfg(unix)] + let path: &fspy_shared::ipc::NativePath = path.into(); + #[cfg(windows)] + let path = fspy_shared::ipc::NativePath::from_wide(path); + PathAccess { mode: *mode, path } + }) + } + } + + /// `pre_run` with no previous run; asserts the trivially-empty change. + fn first_run<'a>( + root: &'a AbsolutePath, + input: &'a ResolvedGlobConfig, + output: &'a ResolvedGlobConfig, + ) -> TaskFs<'a> { + let (task_fs, change) = TaskFs::pre_run(root, input, output, None).unwrap(); + assert!(change.is_none(), "a first run has nothing to differ from"); + task_fs + } + + fn cacheable(conclusion: Conclusion) -> (InputFingerprints, Vec) { + match conclusion { + Conclusion::Cacheable { input_fingerprints, outputs } => (input_fingerprints, outputs), + Conclusion::InputModified { path } => panic!("unexpected InputModified at {path}"), + } + } + + /// Run the whole pipeline once: trace → conclude, returning the stored + /// fingerprints for a later run to validate against. + fn conclude( + root: &AbsolutePath, + input: &ResolvedGlobConfig, + output: &ResolvedGlobConfig, + trace: &Trace, + ) -> (InputFingerprints, Vec) { + let task_fs = first_run(root, input, output); + cacheable(task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap()) + } + + /// What changed since the run that stored `previous`? + fn change_since( + root: &AbsolutePath, + input: &ResolvedGlobConfig, + output: &ResolvedGlobConfig, + previous: &InputFingerprints, + ) -> Option { + let (_task_fs, change) = TaskFs::pre_run(root, input, output, Some(previous)).unwrap(); + change + } + + // ── the verdict ───────────────────────────────────────────────────────── + + #[test] + fn read_write_same_path_is_input_modified() { + let (_tmp, root) = workspace(); + fs::write(root.join("file.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("file.txt").as_path()); + trace.push(AccessMode::WRITE, root.join("file.txt").as_path()); + + let conclusion = + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(); + assert!( + matches!(conclusion, Conclusion::InputModified { path } if path == rel("file.txt")) + ); + } + + #[test] + fn single_read_write_access_is_input_modified() { + let (_tmp, root) = workspace(); + fs::write(root.join("file.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + // One O_RDWR-style access carrying both bits. + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("file.txt").as_path()); + + let conclusion = + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(); + assert!( + matches!(conclusion, Conclusion::InputModified { path } if path == rel("file.txt")) + ); + } + + #[test] + fn reading_dir_and_writing_child_is_not_overlap() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("sub")).unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("sub").as_path()); + trace.push(AccessMode::WRITE, root.join("sub/out.txt").as_path()); + + let (_fingerprints, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert_eq!(outputs, vec![rel("sub/out.txt")]); + } + + #[test] + fn input_negative_glob_dissolves_overlap() { + let (_tmp, root) = workspace(); + fs::write(root.join("build.log"), "x").unwrap(); + let input = config(true, &[], &["**/*.log"]); + let output = config(true, &[], &[]); + + let task_fs = first_run(&root, &input, &output); + let mut trace = Trace::default(); + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("build.log").as_path()); + + // The read is excluded by the input negative, so no overlap remains; + // the write still counts as an output. + let (_fingerprints, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert_eq!(outputs, vec![rel("build.log")]); + } + + #[test] + fn output_negative_glob_dissolves_overlap() { + let (_tmp, root) = workspace(); + fs::write(root.join("build.log"), "x").unwrap(); + let input = config(true, &[], &[]); + let output = config(true, &[], &["**/*.log"]); + + let task_fs = first_run(&root, &input, &output); + let mut trace = Trace::default(); + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("build.log").as_path()); + + let (_fingerprints, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert!(outputs.is_empty(), "the write is excluded by the output negative"); + } + + #[test] + fn reported_ignored_input_dissolves_overlap() { + let (_tmp, root) = workspace(); + fs::write(root.join("state.json"), "x").unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("state.json").as_path()); + + let mut ignored = FxHashSet::default(); + ignored.insert(Arc::::from(root.join("state.json"))); + + let (_fingerprints, outputs) = + cacheable(task_fs.post_run(Some(trace.accesses()), &ignored, &no_ignored()).unwrap()); + assert_eq!(outputs, vec![rel("state.json")]); + } + + #[test] + fn reported_ignored_input_covers_subtree() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("gen")).unwrap(); + fs::write(root.join("gen/state.json"), "x").unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("gen/state.json").as_path()); + + let mut ignored = FxHashSet::default(); + ignored.insert(Arc::::from(root.join("gen"))); + + let conclusion = task_fs.post_run(Some(trace.accesses()), &ignored, &no_ignored()).unwrap(); + assert!(matches!(conclusion, Conclusion::Cacheable { .. })); + } + + // ── per-side gating ───────────────────────────────────────────────────── + + #[test] + fn input_auto_only_writes_dont_count() { + let (_tmp, root) = workspace(); + fs::write(root.join("file.txt"), "x").unwrap(); + let input = config(true, &[], &[]); + let output = config(false, &[], &[]); + + let task_fs = first_run(&root, &input, &output); + let mut trace = Trace::default(); + trace.push(AccessMode::READ | AccessMode::WRITE, root.join("file.txt").as_path()); + + // Writes don't count for a non-auto output side: no overlap verdict, + // and no traced outputs. + let (_fingerprints, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert!(outputs.is_empty()); + } + + #[test] + fn output_auto_only_reads_dont_count() { + let (_tmp, root) = workspace(); + fs::write(root.join("read.txt"), "x").unwrap(); + let input = config(false, &[], &[]); + let output = config(true, &[], &[]); + + let task_fs = first_run(&root, &input, &output); + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("read.txt").as_path()); + trace.push(AccessMode::WRITE, root.join("written.txt").as_path()); + + let (previous, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert_eq!(outputs, vec![rel("written.txt")]); + + // The read was never fingerprinted, so changing it goes unnoticed. + fs::write(root.join("read.txt"), "changed").unwrap(); + assert!(change_since(&root, &input, &output, &previous).is_none()); + } + + #[test] + fn untraced_run_still_collects_configured_outputs() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("out")).unwrap(); + fs::write(root.join("out/a.js"), "x").unwrap(); + let input = config(false, &[], &[]); + let output = config(false, &["out/**"], &[]); + + let task_fs = first_run(&root, &input, &output); + let conclusion = task_fs + .post_run(None::>>, &no_ignored(), &no_ignored()) + .unwrap(); + let (_fingerprints, outputs) = cacheable(conclusion); + assert_eq!(outputs, vec![rel("out/a.js")]); + } + + #[test] + fn outputs_union_deduplicates_and_sorts() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("out")).unwrap(); + fs::write(root.join("out/a.js"), "a").unwrap(); + fs::write(root.join("out/b.js"), "b").unwrap(); + let input = config(false, &[], &[]); + let output = config(true, &["out/**"], &[]); + + let task_fs = first_run(&root, &input, &output); + let mut trace = Trace::default(); + // b.js is both traced and glob-matched; zzz.txt only traced. + trace.push(AccessMode::WRITE, root.join("zzz.txt").as_path()); + trace.push(AccessMode::WRITE, root.join("out/b.js").as_path()); + + let (_fingerprints, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert_eq!(outputs, vec![rel("out/a.js"), rel("out/b.js"), rel("zzz.txt")]); + } + + // ── trace normalization ───────────────────────────────────────────────── + + #[test] + fn accesses_outside_the_workspace_are_dropped() { + let (_tmp, root) = workspace(); + let (_other_tmp, other) = workspace(); + fs::write(other.join("outside.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let task_fs = first_run(&root, &io, &io); + let mut trace = Trace::default(); + trace.push(AccessMode::READ, other.join("outside.txt").as_path()); + trace.push(AccessMode::WRITE, other.join("outside.txt").as_path()); + + // Neither the read-write overlap nor the write registers. + let (previous, outputs) = cacheable( + task_fs.post_run(Some(trace.accesses()), &no_ignored(), &no_ignored()).unwrap(), + ); + assert!(outputs.is_empty()); + + // The outside read was never fingerprinted either. + fs::remove_file(other.join("outside.txt")).unwrap(); + assert!(change_since(&root, &io, &io, &previous).is_none()); + } + + #[test] + fn git_accesses_are_skipped() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join(".git")).unwrap(); + fs::write(root.join(".git/index"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join(".git/index").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::remove_file(root.join(".git/index")).unwrap(); + assert!(change_since(&root, &io, &io, &previous).is_none()); + } + + #[test] + fn parent_components_are_cleaned() { + let (tmp, root) = workspace(); + fs::create_dir(root.join("pkg")).unwrap(); + fs::write(root.join("data.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, tmp.path().join("pkg/../data.txt")); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::write(root.join("data.txt"), "changed").unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::ContentModified, path }) if path == rel("data.txt") + )); + } + + // ── discovered inputs, fingerprinted and re-checked ───────────────────── + + #[test] + fn discovered_content_change_is_reported() { + let (_tmp, root) = workspace(); + fs::write(root.join("file.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("file.txt").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + assert!(change_since(&root, &io, &io, &previous).is_none(), "unchanged file"); + + fs::write(root.join("file.txt"), "changed").unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::ContentModified, path }) if path == rel("file.txt") + )); + } + + #[test] + fn discovered_removal_is_reported() { + let (_tmp, root) = workspace(); + fs::write(root.join("file.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("file.txt").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::remove_file(root.join("file.txt")).unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Removed, path }) if path == rel("file.txt") + )); + } + + #[test] + fn discovered_missing_path_that_appears_is_reported_as_added() { + let (_tmp, root) = workspace(); + let io = config(true, &[], &[]); + + // The task probed a path that didn't exist. + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("config.local").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::write(root.join("config.local"), "now exists").unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Added, path }) if path == rel("config.local") + )); + } + + #[test] + fn discovered_file_replaced_by_dir_is_reported() { + let (_tmp, root) = workspace(); + fs::write(root.join("thing"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("thing").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::remove_file(root.join("thing")).unwrap(); + fs::create_dir(root.join("thing")).unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!( + matches!(change, Some(InputChange { kind: InputChangeKind::Added, path }) if path == rel("thing")) + ); + } + + #[test] + fn dir_opened_without_listing_ignores_new_entries() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("dir")).unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ, root.join("dir").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::write(root.join("dir/new.txt"), "x").unwrap(); + assert!(change_since(&root, &io, &io, &previous).is_none()); + } + + #[test] + fn listed_dir_reports_added_and_removed_entries() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("dir")).unwrap(); + fs::write(root.join("dir/old.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ_DIR, root.join("dir").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + fs::write(root.join("dir/new.txt"), "x").unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Added, path }) if path == rel("dir/new.txt") + )); + + fs::remove_file(root.join("dir/new.txt")).unwrap(); + fs::remove_file(root.join("dir/old.txt")).unwrap(); + let change = change_since(&root, &io, &io, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Removed, path }) if path == rel("dir/old.txt") + )); + } + + #[test] + fn listed_dir_does_not_see_ds_store_or_dist() { + let (_tmp, root) = workspace(); + fs::create_dir(root.join("dir")).unwrap(); + fs::write(root.join("dir/keep.txt"), "x").unwrap(); + let io = config(true, &[], &[]); + + let mut trace = Trace::default(); + trace.push(AccessMode::READ_DIR, root.join("dir").as_path()); + let (previous, _outputs) = conclude(&root, &io, &io, &trace); + + // Neither `.DS_Store` nor a `dist` entry (any casing) is visible to + // directory fingerprints. + fs::write(root.join("dir/.DS_Store"), "junk").unwrap(); + fs::create_dir(root.join("dir/DIST")).unwrap(); + assert!(change_since(&root, &io, &io, &previous).is_none()); + } + + // ── listed inputs, snapshotted and diffed ─────────────────────────────── + + /// A listed-inputs-only task: no auto tracking on either side. + fn listed_only() -> (ResolvedGlobConfig, ResolvedGlobConfig) { + (config(false, &["src/**"], &[]), config(false, &[], &[])) + } + + fn listed_workspace() -> (TempDir, AbsolutePathBuf) { + let (tmp, root) = workspace(); + fs::create_dir(root.join("src")).unwrap(); + fs::write(root.join("src/a.txt"), "a").unwrap(); + fs::write(root.join("src/b.txt"), "b").unwrap(); + (tmp, root) + } + + fn conclude_untraced( + root: &AbsolutePath, + input: &ResolvedGlobConfig, + output: &ResolvedGlobConfig, + ) -> InputFingerprints { + let task_fs = first_run(root, input, output); + let conclusion = task_fs + .post_run(None::>>, &no_ignored(), &no_ignored()) + .unwrap(); + cacheable(conclusion).0 + } + + #[test] + fn unchanged_listed_inputs_report_nothing() { + let (_tmp, root) = listed_workspace(); + let (input, output) = listed_only(); + let previous = conclude_untraced(&root, &input, &output); + + assert!(change_since(&root, &input, &output, &previous).is_none()); + } + + #[test] + fn modified_listed_input_is_reported() { + let (_tmp, root) = listed_workspace(); + let (input, output) = listed_only(); + let previous = conclude_untraced(&root, &input, &output); + + fs::write(root.join("src/b.txt"), "changed").unwrap(); + let change = change_since(&root, &input, &output, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::ContentModified, path }) if path == rel("src/b.txt") + )); + } + + #[test] + fn removed_listed_input_is_reported() { + let (_tmp, root) = listed_workspace(); + let (input, output) = listed_only(); + let previous = conclude_untraced(&root, &input, &output); + + fs::remove_file(root.join("src/a.txt")).unwrap(); + let change = change_since(&root, &input, &output, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Removed, path }) if path == rel("src/a.txt") + )); + } + + #[test] + fn added_listed_input_is_reported() { + let (_tmp, root) = listed_workspace(); + let (input, output) = listed_only(); + let previous = conclude_untraced(&root, &input, &output); + + fs::write(root.join("src/c.txt"), "c").unwrap(); + let change = change_since(&root, &input, &output, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Added, path }) if path == rel("src/c.txt") + )); + } + + #[test] + fn first_listed_change_in_path_order_wins() { + let (_tmp, root) = listed_workspace(); + let (input, output) = listed_only(); + let previous = conclude_untraced(&root, &input, &output); + + // Both changed; `src/a.txt` sorts first, so its removal is the answer. + fs::remove_file(root.join("src/a.txt")).unwrap(); + fs::write(root.join("src/b.txt"), "changed").unwrap(); + let change = change_since(&root, &input, &output, &previous); + assert!(matches!( + change, + Some(InputChange { kind: InputChangeKind::Removed, path }) if path == rel("src/a.txt") + )); + } + + #[test] + fn normalize_ignored_paths_cleans_relative_components() { + let workspace_root = + AbsolutePath::new(if cfg!(windows) { r"C:\repo" } else { "/repo" }).unwrap(); + let ignored = + workspace_root.join(if cfg!(windows) { r"pkg\..\cache" } else { "pkg/../cache" }); + let mut ignored_paths = FxHashSet::default(); + ignored_paths.insert(Arc::::from(ignored)); + + let normalized = normalize_ignored_paths(&ignored_paths, workspace_root); + + let expected = RelativePathBuf::new("cache").unwrap(); + assert!(normalized.contains(&expected)); + } +} diff --git a/crates/vt/src/session/execute/tracked_accesses.rs b/crates/vt/src/session/execute/task_fs/tracked_accesses.rs similarity index 91% rename from crates/vt/src/session/execute/tracked_accesses.rs rename to crates/vt/src/session/execute/task_fs/tracked_accesses.rs index 08de0262b..66d8bd8a6 100644 --- a/crates/vt/src/session/execute/tracked_accesses.rs +++ b/crates/vt/src/session/execute/task_fs/tracked_accesses.rs @@ -3,16 +3,14 @@ //! User-configured negative globs are NOT applied here. They are applied later, //! separately for reads (input config) and writes (output config), since those //! two configs are independent. -#![cfg(fspy)] use std::collections::hash_map::Entry; -use fspy::{AccessMode, PathAccessIterable}; +use fspy_shared::ipc::{AccessMode, PathAccess}; use rustc_hash::FxHashSet; use vt_path::{AbsolutePath, RelativePathBuf}; -use super::fingerprint::PathRead; -use crate::collections::HashMap; +use super::{collections::HashMap, fingerprint::PathRead}; /// Tracked file accesses from fspy, normalized to workspace-relative paths. #[derive(Default, Debug)] @@ -25,12 +23,15 @@ pub struct TrackedPathAccesses { } impl TrackedPathAccesses { - /// Build from fspy's raw iterable by stripping the workspace prefix and + /// Build from raw accesses by stripping the workspace prefix and /// normalizing `..` components. `.git/*` paths are skipped. User-configured /// negatives are applied by the caller (see module docs). - pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self { + pub fn from_raw<'a>( + raw: impl IntoIterator>, + workspace_root: &AbsolutePath, + ) -> Self { let mut accesses = Self::default(); - for access in raw.iter() { + for access in raw { // Strip workspace root and clean `..` components in one pass. // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`. let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| {