diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d77818..31b3800f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Added** `replayLogs: false` for cached tasks that should restore outputs and report cache hits without replaying cached stdout/stderr. - **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)). - **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)). - **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)). diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index f0c68fad..f0bdb1aa 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -149,9 +149,13 @@ fn format_env_changed_inline(names: &[&Str]) -> Str { /// Note: Returns plain text without styling. The reporter applies colors. pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { match cache_status { - CacheStatus::Hit { .. } => { - // Show "cache hit" indicator when replaying from cache - Some(Str::from("◉ cache hit, replaying")) + CacheStatus::Hit { logs_replayed, .. } => { + if *logs_replayed { + // Show "cache hit" indicator when replaying from cache + Some(Str::from("◉ cache hit, replaying")) + } else { + Some(Str::from("◉ cache hit")) + } } CacheStatus::Miss(CacheMiss::NotFound) => { // No inline message for "not found" case - just show command diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index a04af70a..96039e85 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -111,7 +111,7 @@ pub enum CacheUpdateStatus { pub enum CacheStatus { Disabled(CacheDisabledReason), Miss(CacheMiss), - Hit { replayed_duration: Duration }, + Hit { replayed_duration: Duration, logs_replayed: bool }, } /// Convert `ExitStatus` to an i32 exit code. diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index a3ed5924..8aa1ac98 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -385,14 +385,18 @@ async fn run( // to execute the command — or carry the globbed inputs into the run. let (stdio_config, globbed_inputs) = match lookup { CacheLookup::Hit(cached) => { - let mut stdio_config = - reporter.start(CacheStatus::Hit { replayed_duration: cached.duration }); + let replay_logs = cache_metadata.map_or(true, |metadata| metadata.replay_logs); + let mut stdio_config = reporter.start(CacheStatus::Hit { + replayed_duration: cached.duration, + logs_replayed: replay_logs, + }); return Ok(replay_cache_hit( &mut stdio_config, &cached, workspace_root, cache_dir, program_name, + replay_logs, )); } CacheLookup::Miss { miss, globbed_inputs } => { @@ -547,14 +551,17 @@ fn replay_cache_hit( workspace_root: &Arc, cache_dir: &AbsolutePath, program_name: &str, + replay_logs: bool, ) -> Report { - for output in cached.std_outputs.iter() { - let writer: &mut dyn std::io::Write = match output.kind { - pipe::OutputKind::StdOut => &mut stdio_config.writers.stdout_writer, - pipe::OutputKind::StdErr => &mut stdio_config.writers.stderr_writer, - }; - let _ = writer.write_all(&output.content); - let _ = writer.flush(); + if replay_logs { + for output in cached.std_outputs.iter() { + let writer: &mut dyn std::io::Write = match output.kind { + pipe::OutputKind::StdOut => &mut stdio_config.writers.stdout_writer, + pipe::OutputKind::StdErr => &mut stdio_config.writers.stderr_writer, + }; + let _ = writer.write_all(&output.content); + let _ = writer.flush(); + } } // Restore output files from the cached archive. Failure here means the diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 83bd2a93..48403dc1 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -63,8 +63,12 @@ pub struct TaskSummary { /// making invalid combinations unrepresentable. #[derive(Serialize, Deserialize)] pub enum TaskResult { - /// Cache hit — output was replayed from cache. Always successful. - CacheHit { saved_duration_ms: u64 }, + /// Cache hit. Always successful. + CacheHit { + saved_duration_ms: u64, + #[serde(default = "default_logs_replayed")] + logs_replayed: bool, + }, /// In-process execution (built-in command like echo). Always successful. InProcess, @@ -92,6 +96,10 @@ pub enum SpawnedCacheStatus { Disabled, } +const fn default_logs_replayed() -> bool { + true +} + /// Outcome of a spawned process. #[derive(Serialize, Deserialize)] pub enum SpawnOutcome { @@ -191,7 +199,7 @@ impl SummaryStats { for task in tasks { match &task.result { - TaskResult::CacheHit { saved_duration_ms } => { + TaskResult::CacheHit { saved_duration_ms, .. } => { stats.cache_hits += 1; stats.total_saved += Duration::from_millis(*saved_duration_ms); } @@ -333,9 +341,10 @@ impl TaskResult { ); match cache_status { - CacheStatus::Hit { replayed_duration } => { - Self::CacheHit { saved_duration_ms: duration_to_ms(*replayed_duration) } - } + CacheStatus::Hit { replayed_duration, logs_replayed } => Self::CacheHit { + saved_duration_ms: duration_to_ms(*replayed_duration), + logs_replayed: *logs_replayed, + }, CacheStatus::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess, CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned { cache_status: SpawnedCacheStatus::Disabled, @@ -553,10 +562,14 @@ impl TaskResult { } match self { - Self::CacheHit { saved_duration_ms } => { + Self::CacheHit { saved_duration_ms, logs_replayed } => { let d = Duration::from_millis(*saved_duration_ms); let formatted_duration = format_summary_duration(d); - vite_str::format!("→ Cache hit - output replayed - {formatted_duration} saved") + if *logs_replayed { + vite_str::format!("→ Cache hit - output replayed - {formatted_duration} saved") + } else { + vite_str::format!("→ Cache hit - logs skipped - {formatted_duration} saved") + } } Self::InProcess => Str::from("→ Cache disabled for built-in command"), Self::Spawned { cache_status, .. } => match cache_status { diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 43ccd08d..6c19e51e 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -95,6 +95,7 @@ impl vite_task::CommandHandler for CommandHandler { program, args: args.into_iter().filter(|a| a.as_str() != "--").collect(), cache_config: UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: None, diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/package.json new file mode 100644 index 00000000..25d4e08c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/package.json @@ -0,0 +1,3 @@ +{ + "name": "replay-logs-test" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots.toml new file mode 100644 index 00000000..22aa37b2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots.toml @@ -0,0 +1,28 @@ +[[e2e]] +name = "cache_hit_skips_log_replay_but_restores_outputs" +comment = """ +`replayLogs: false` should keep cache hits and output restoration, while suppressing cached stdout/stderr replay. +""" +steps = [ + { argv = [ + "vt", + "run", + "build", + ], comment = "first run — cache miss, prints logs and writes dist/output.txt" }, + { argv = [ + "vtt", + "rm", + "-rf", + "dist", + ], comment = "delete dist/ to prove cache-hit output restoration still happens" }, + { argv = [ + "vt", + "run", + "build", + ], comment = "second run — cache hit, skips cached log replay" }, + { argv = [ + "vtt", + "print-file", + "dist/output.txt", + ], comment = "output file restored from cache" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots/cache_hit_skips_log_replay_but_restores_outputs.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots/cache_hit_skips_log_replay_but_restores_outputs.md new file mode 100644 index 00000000..b345b74a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/snapshots/cache_hit_skips_log_replay_but_restores_outputs.md @@ -0,0 +1,38 @@ +# cache_hit_skips_log_replay_but_restores_outputs + +`replayLogs: false` should keep cache hits and output restoration, while suppressing cached stdout/stderr replay. + +## `vt run build` + +first run — cache miss, prints logs and writes dist/output.txt + +``` +$ vtt print fresh logs; vtt write-file dist/output.txt artifact +fresh logs +``` + +## `vtt rm -rf dist` + +delete dist/ to prove cache-hit output restoration still happens + +``` +``` + +## `vt run build` + +second run — cache hit, skips cached log replay + +``` +$ vtt print fresh logs; vtt write-file dist/output.txt artifact ◉ cache hit + +--- +vt run: cache hit. +``` + +## `vtt print-file dist/output.txt` + +output file restored from cache + +``` +artifact +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/vite-task.json new file mode 100644 index 00000000..4050fd7b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/replay_logs/vite-task.json @@ -0,0 +1,11 @@ +{ + "tasks": { + "build": { + "command": "vtt print fresh logs; vtt write-file dist/output.txt artifact", + "cache": true, + "replayLogs": false, + "input": [], + "output": ["dist/**"] + } + } +} diff --git a/crates/vite_task_graph/run-config.ts b/crates/vite_task_graph/run-config.ts index cd780fce..c8e39c04 100644 --- a/crates/vite_task_graph/run-config.ts +++ b/crates/vite_task_graph/run-config.ts @@ -50,6 +50,10 @@ dependsOn?: Array, } & ({ * Whether to cache the task */ cache?: true, +/** + * Whether to replay cached stdout/stderr on cache hits. + */ +replayLogs?: boolean, /** * Environment variable names to be fingerprinted and passed to the task. */ diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 61358ec5..c5c5ba0a 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -66,6 +66,7 @@ impl ResolvedTaskOptions { let cache_config = match user_options.cache_config { UserCacheConfig::Disabled { cache: MustBe!(false) } => None, UserCacheConfig::Enabled { cache: _, enabled_cache_config } => { + let replay_logs = enabled_cache_config.replay_logs.unwrap_or(true); let mut untracked_env: FxHashSet = enabled_cache_config.untracked_env.unwrap_or_default().into_iter().collect(); untracked_env.extend(DEFAULT_UNTRACKED_ENV.iter().copied().map(Str::from)); @@ -83,6 +84,7 @@ impl ResolvedTaskOptions { )?; Some(CacheConfig { + replay_logs, env_config: EnvConfig { fingerprinted_envs: enabled_cache_config .env @@ -105,11 +107,17 @@ impl ResolvedTaskOptions { reason = "env_config, input_config, output_config are distinct config categories, not a naming smell" )] pub struct CacheConfig { + #[serde(skip_serializing_if = "is_true")] + pub replay_logs: bool, pub env_config: EnvConfig, pub input_config: ResolvedGlobConfig, pub output_config: ResolvedGlobConfig, } +const fn is_true(value: &bool) -> bool { + *value +} + /// Resolved input configuration for cache fingerprinting. /// /// This is the normalized form after parsing user config. diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 487ccec2..795e84fb 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -192,6 +192,9 @@ impl UserCacheConfig { #[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields))] #[serde(rename_all = "camelCase")] pub struct EnabledCacheConfig { + /// Whether to replay cached stdout/stderr on cache hits. + pub replay_logs: Option, + /// Environment variable names to be fingerprinted and passed to the task. pub env: Option>, @@ -260,6 +263,7 @@ impl Default for UserTaskOptions { cache_config: UserCacheConfig::Enabled { cache: None, enabled_cache_config: EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: None, @@ -702,6 +706,7 @@ mod tests { UserCacheConfig::Enabled { cache: Some(MustBe!(true)), enabled_cache_config: EnabledCacheConfig { + replay_logs: None, env: Some(std::iter::once("NODE_ENV".into()).collect()), untracked_env: Some(std::iter::once("FOO".into()).collect()), input: None, @@ -711,6 +716,27 @@ mod tests { ); } + #[test] + fn test_replay_logs_can_be_disabled() { + let user_config_json = json!({ + "cache": true, + "replayLogs": false, + }); + assert_eq!( + serde_json::from_value::(user_config_json).unwrap(), + UserCacheConfig::Enabled { + cache: Some(MustBe!(true)), + enabled_cache_config: EnabledCacheConfig { + replay_logs: Some(false), + env: None, + untracked_env: None, + input: None, + output: None, + } + }, + ); + } + #[test] fn test_input_empty_array() { let user_config_json = json!({ diff --git a/crates/vite_task_plan/src/cache_metadata.rs b/crates/vite_task_plan/src/cache_metadata.rs index 9c2c5489..b321fb4a 100644 --- a/crates/vite_task_plan/src/cache_metadata.rs +++ b/crates/vite_task_plan/src/cache_metadata.rs @@ -55,6 +55,10 @@ pub struct CacheMetadata { /// Used at execution time to determine what output files to archive. pub output_config: ResolvedGlobConfig, + /// Whether cached stdout/stderr should be replayed on cache hits. + #[serde(skip_serializing_if = "is_true")] + pub replay_logs: bool, + /// The unfiltered env context for runner-aware APIs. This is the planning /// context's envs before spawn-env filtering, including command prefix envs /// from this command and enclosing nested `vp run` expansions. @@ -66,6 +70,10 @@ pub struct CacheMetadata { pub unfiltered_envs: Arc, Arc>>, } +const fn is_true(value: &bool) -> bool { + *value +} + /// Fingerprint for spawn execution that affects caching. /// /// # Environment Variable Impact on Cache diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index b9455434..ae5c4fc3 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -490,7 +490,7 @@ fn resolve_synthetic_cache_config( Ok(match synthetic_cache_config { UserCacheConfig::Disabled { .. } => Option::None, UserCacheConfig::Enabled { enabled_cache_config, .. } => { - let EnabledCacheConfig { env, untracked_env, input, output } = + let EnabledCacheConfig { replay_logs: _, env, untracked_env, input, output } = enabled_cache_config; parent_config.env_config.fingerprinted_envs.extend(env.unwrap_or_default()); parent_config @@ -675,6 +675,7 @@ fn plan_spawn_execution( execution_cache_key, input_config: cache_config.input_config.clone(), output_config: cache_config.output_config.clone(), + replay_logs: cache_config.replay_logs, unfiltered_envs: Arc::clone(envs), }); } @@ -936,6 +937,7 @@ mod tests { fn parent_config(includes_auto: bool, positive_globs: &[&str]) -> CacheConfig { CacheConfig { + replay_logs: true, env_config: EnvConfig { fingerprinted_envs: FxHashSet::default(), untracked_env: FxHashSet::default(), @@ -964,6 +966,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: None, @@ -986,6 +989,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![UserInputEntry::Glob("config/**".into())]), @@ -1008,6 +1012,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![UserInputEntry::Glob("config/**".into())]), @@ -1033,6 +1038,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![ @@ -1064,6 +1070,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![UserInputEntry::Glob("config/**".into())]), @@ -1088,6 +1095,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Disabled, UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![UserInputEntry::Glob("config/**".into())]), @@ -1121,6 +1129,7 @@ mod tests { let result = resolve_synthetic_cache_config( ParentCacheConfig::Inherited(parent), UserCacheConfig::with_config(EnabledCacheConfig { + replay_logs: None, env: None, untracked_env: None, input: Some(vec![UserInputEntry::Glob("!dist/**".into())]),