Skip to content

Commit a077de0

Browse files
wan9chicodex
andcommitted
feat(cache): add explicit auto output tracking
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent c219c54 commit a077de0

22 files changed

Lines changed: 454 additions & 131 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- **Added** Runner-aware tools can now call `ignoreInput(path)` to exclude non-semantic reads from auto-inferred task cache inputs.
44
- **Added** Runner-aware tools can now call `ignoreOutput(path)` to exclude non-semantic writes from read/write overlap checks.
5+
- **Added** Cached tasks can opt into automatic output restoration with `output: [{ auto: true }]`.
56
- **Changed** Tracked environment values in task cache fingerprints are now stored only as SHA-256 digests, and env-related cache miss details report names without values.
67
- **Added** Runner-aware `getEnvs` match sets can now participate in task cache fingerprints, so changing, adding, or removing a matching env var invalidates the cache ([#450](https://github.com/voidzero-dev/vite-task/pull/450)).
78
- **Added** Runner-aware `getEnvs` calls now return env values served by the runner for matching env glob patterns ([#449](https://github.com/voidzero-dev/vite-task/pull/449)).

crates/vite_task/src/session/execute/cache_update.rs

Lines changed: 96 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ use crate::{
2828
/// value is only ever `Some` when tracking happened (see [`observe_fspy`]).
2929
struct TrackingOutcome {
3030
path_reads: HashMap<RelativePathBuf, PathRead>,
31+
/// Auto-output writes after output exclusions are applied. Empty when
32+
/// `output_config.includes_auto` is false.
33+
path_writes: FxHashSet<RelativePathBuf>,
3134
/// First path that was both read and written during execution, if any.
3235
/// A non-empty value means caching this task is unsound.
3336
read_write_overlap: Option<RelativePathBuf>,
@@ -58,7 +61,6 @@ pub(super) async fn update_cache(
5861
) -> (CacheUpdateStatus, Option<ExecutionError>) {
5962
let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state;
6063
let fspy = tracking.fspy.as_ref();
61-
let input_negative_globs = fspy.map(|t| t.input_negative_globs.as_slice());
6264

6365
if let Some(reports) = reports
6466
&& reports.cache_disabled
@@ -89,7 +91,8 @@ pub(super) async fn update_cache(
8991

9092
let fspy_outcome = observe_fspy(
9193
outcome,
92-
input_negative_globs,
94+
metadata,
95+
fspy,
9396
&ignored_input_rels,
9497
&ignored_output_rels,
9598
workspace_root,
@@ -150,7 +153,12 @@ pub(super) async fn update_cache(
150153
}
151154
};
152155

153-
let output_archive = match collect_and_archive_outputs(metadata, workspace_root, cache_dir) {
156+
let output_archive = match collect_and_archive_outputs(
157+
metadata,
158+
fspy_outcome.as_ref(),
159+
workspace_root,
160+
cache_dir,
161+
) {
154162
Ok(archive) => archive,
155163
Err(err) => {
156164
return (
@@ -177,12 +185,18 @@ pub(super) async fn update_cache(
177185
}
178186

179187
/// Summarize the run's fspy observations. `Some` iff tracking was both
180-
/// requested (`input_negative_globs.is_some()`) and compiled in (`cfg(fspy)`). On a
188+
/// requested (`tracking.fspy.is_some()`) and compiled in (`cfg(fspy)`). On a
181189
/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`]
182190
/// short-circuits to `FspyUnsupported` when tracking was needed.
191+
///
192+
/// `path_reads` is gated on `input_config.includes_auto`, filtered by
193+
/// user-configured input negatives, and by tool-reported `ignoreInput` paths.
194+
/// `path_writes` is filtered by user-configured output negatives and
195+
/// tool-reported `ignoreOutput` paths before read-write overlap detection.
183196
fn observe_fspy(
184197
outcome: &ChildOutcome,
185-
input_negative_globs: Option<&[wax::Glob<'static>]>,
198+
metadata: &CacheMetadata,
199+
fspy: Option<&super::FspyTracking>,
186200
ignored_input_rels: &FxHashSet<RelativePathBuf>,
187201
ignored_output_rels: &FxHashSet<RelativePathBuf>,
188202
workspace_root: &AbsolutePath,
@@ -191,31 +205,56 @@ fn observe_fspy(
191205
{
192206
use super::tracked_accesses::TrackedPathAccesses;
193207

194-
outcome.path_accesses.as_ref().zip(input_negative_globs).map(|(raw, negatives)| {
195-
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negatives);
196-
let path_reads: HashMap<RelativePathBuf, PathRead> = tracked
197-
.path_reads
198-
.into_iter()
199-
.filter(|(path, _)| !is_ignored(path, ignored_input_rels))
200-
.collect();
201-
let path_writes: FxHashSet<RelativePathBuf> = tracked
202-
.path_writes
203-
.into_iter()
204-
.filter(|path| !is_ignored(path, ignored_output_rels))
205-
.collect();
206-
let read_write_overlap = path_reads.keys().find(|p| path_writes.contains(*p)).cloned();
207-
TrackingOutcome { path_reads, read_write_overlap }
208+
outcome.path_accesses.as_ref().map(|raw| {
209+
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root);
210+
let filtered_path_reads: HashMap<RelativePathBuf, PathRead> =
211+
// fspy can be attached for auto-output-only tasks. In that
212+
// mode reads must not become inferred inputs.
213+
if metadata.input_config.includes_auto
214+
&& let Some(fspy) = fspy
215+
{
216+
tracked
217+
.path_reads
218+
.iter()
219+
.filter(|(path, _)| {
220+
!matches_any_glob(path, &fspy.input_negative_globs)
221+
&& !is_ignored(path, ignored_input_rels)
222+
})
223+
.map(|(path, read)| (path.clone(), *read))
224+
.collect()
225+
} else {
226+
HashMap::default()
227+
};
228+
let filtered_path_writes: FxHashSet<RelativePathBuf> =
229+
// fspy can also be attached for auto-input-only tasks. In that
230+
// mode writes must not become auto outputs or overlap candidates.
231+
if metadata.output_config.includes_auto
232+
&& let Some(fspy) = fspy
233+
{
234+
tracked
235+
.path_writes
236+
.iter()
237+
.filter(|path| {
238+
!matches_any_glob(path, &fspy.output_negative_globs)
239+
&& !is_ignored(path, ignored_output_rels)
240+
})
241+
.cloned()
242+
.collect()
243+
} else {
244+
FxHashSet::default()
245+
};
246+
let read_write_overlap =
247+
filtered_path_reads.keys().find(|p| filtered_path_writes.contains(*p)).cloned();
248+
TrackingOutcome {
249+
path_reads: filtered_path_reads,
250+
path_writes: filtered_path_writes,
251+
read_write_overlap,
252+
}
208253
})
209254
}
210255
#[cfg(not(fspy))]
211256
{
212-
let _ = (
213-
outcome,
214-
input_negative_globs,
215-
ignored_input_rels,
216-
ignored_output_rels,
217-
workspace_root,
218-
);
257+
let _ = (outcome, metadata, fspy, ignored_input_rels, ignored_output_rels, workspace_root);
219258
None
220259
}
221260
}
@@ -256,6 +295,12 @@ fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet<RelativePathBuf>) -> b
256295
ignored.contains(path) || ignored.iter().any(|ig| path.strip_prefix(ig).is_some())
257296
}
258297

298+
fn matches_any_glob(path: &RelativePathBuf, globs: &[wax::Glob<'static>]) -> bool {
299+
use wax::Program as _;
300+
301+
globs.iter().any(|glob| glob.is_match(path.as_str()))
302+
}
303+
259304
/// Select tool-reported env records to embed in the post-run fingerprint.
260305
/// Names that the user already declared as fingerprinted are skipped because
261306
/// their values are already in the spawn fingerprint.
@@ -309,36 +354,49 @@ fn collect_tracked_env_globs(reports: &Reports) -> anyhow::Result<TrackedEnvGlob
309354
Ok(tracked_env_globs)
310355
}
311356

312-
/// Collect output files matching the configured globs and create a tar.zst
313-
/// archive in the cache directory.
357+
/// Collect output files and create a tar.zst archive in the cache directory.
358+
///
359+
/// Output files are determined by:
360+
/// - fspy-tracked writes (already empty when `output_config.includes_auto` is false)
361+
/// - Positive output globs (always, if configured)
362+
/// - Negative output globs and tool-reported `ignoreOutput` paths filter
363+
/// fspy-tracked writes before this function receives them
314364
///
315-
/// Returns `Some(archive_filename)` if files were archived, `None` if the
316-
/// output config has no positive globs or no files matched.
365+
/// Returns `Some(archive_filename)` if files were archived, `None` if no output files.
317366
fn collect_and_archive_outputs(
318367
cache_metadata: &CacheMetadata,
368+
tracking: Option<&TrackingOutcome>,
319369
workspace_root: &AbsolutePath,
320370
cache_dir: &AbsolutePath,
321371
) -> anyhow::Result<Option<Str>> {
322372
let output_config = &cache_metadata.output_config;
323373

324-
if output_config.positive_globs.is_empty() {
325-
return Ok(None);
374+
let mut output_files: FxHashSet<RelativePathBuf> = FxHashSet::default();
375+
376+
if let Some(t) = tracking {
377+
output_files.extend(t.path_writes.iter().cloned());
326378
}
327379

328-
let output_files = glob::collect_glob_paths(
329-
workspace_root,
330-
&output_config.positive_globs,
331-
&output_config.negative_globs,
332-
)?;
380+
if !output_config.positive_globs.is_empty() {
381+
let glob_paths = glob::collect_glob_paths(
382+
workspace_root,
383+
&output_config.positive_globs,
384+
&output_config.negative_globs,
385+
)?;
386+
output_files.extend(glob_paths);
387+
}
333388

334389
if output_files.is_empty() {
335390
return Ok(None);
336391
}
337392

393+
let mut sorted_files: Vec<RelativePathBuf> = output_files.into_iter().collect();
394+
sorted_files.sort();
395+
338396
let archive_name: Str = vite_str::format!("{}.tar.zst", uuid::Uuid::new_v4());
339397
let archive_path = cache_dir.join(archive_name.as_str());
340398

341-
archive::create_output_archive(workspace_root, &output_files, &archive_path)?;
399+
archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?;
342400

343401
Ok(Some(archive_name))
344402
}

crates/vite_task/src/session/execute/mod.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ struct CacheState<'a> {
8989
/// always present (possibly empty) once we reach the cache-update phase.
9090
std_outputs: Vec<StdOutput>,
9191
/// Runner-aware tracking for cached tasks: an IPC server is always
92-
/// available, and fspy path tracing is attached only when auto input
93-
/// inference needs it. Parts are borrowed in place during the wait/join;
94-
/// the struct is never moved out.
92+
/// available, and fspy path tracing is attached only when auto input or
93+
/// output inference needs it. Parts are borrowed in place during the
94+
/// wait/join; the struct is never moved out.
9595
tracking: Tracking,
9696
}
9797

@@ -100,9 +100,10 @@ struct CacheState<'a> {
100100
type IpcDriver = LocalBoxFuture<'static, Result<Recorder, vite_task_server::Error>>;
101101

102102
/// fspy path-tracking state, present only when a cached task needs automatic
103-
/// input inference.
103+
/// input or output inference.
104104
struct FspyTracking {
105105
input_negative_globs: Vec<wax::Glob<'static>>,
106+
output_negative_globs: Vec<wax::Glob<'static>>,
106107
}
107108

108109
/// Per-task runner-aware tracking: IPC server handle plus optional fspy state.
@@ -164,17 +165,24 @@ impl<'a> ExecutionMode<'a> {
164165
});
165166
};
166167

167-
let fspy = if metadata.input_config.includes_auto {
168-
// Resolve input negative globs for fspy path filtering (already
168+
let fspy = if metadata.input_config.includes_auto || metadata.output_config.includes_auto {
169+
// Resolve negative globs for fspy path filtering (already
169170
// workspace-root-relative).
170-
let negatives = metadata
171+
let input_negative_globs = metadata
171172
.input_config
172173
.negative_globs
173174
.iter()
174175
.map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned()))
175176
.collect::<anyhow::Result<Vec<_>>>()
176177
.map_err(ExecutionError::PostRunFingerprint)?;
177-
Some(FspyTracking { input_negative_globs: negatives })
178+
let output_negative_globs = metadata
179+
.output_config
180+
.negative_globs
181+
.iter()
182+
.map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned()))
183+
.collect::<anyhow::Result<Vec<_>>>()
184+
.map_err(ExecutionError::PostRunFingerprint)?;
185+
Some(FspyTracking { input_negative_globs, output_negative_globs })
178186
} else {
179187
None
180188
};

crates/vite_task/src/session/execute/tracked_accesses.rs

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
//! Normalize raw fspy path accesses into workspace-relative, filtered form.
2+
//!
3+
//! User-configured negative globs are NOT applied here. They are applied later,
4+
//! separately for reads (input config) and writes (output config), since those
5+
//! two configs are independent.
26
#![cfg(fspy)]
37

48
use std::collections::hash_map::Entry;
@@ -21,22 +25,19 @@ pub struct TrackedPathAccesses {
2125
}
2226

2327
impl TrackedPathAccesses {
24-
/// Build from fspy's raw iterable by stripping the workspace prefix,
25-
/// normalizing `..` components, and filtering against the negative globs.
26-
pub fn from_raw(
27-
raw: &PathAccessIterable,
28-
workspace_root: &AbsolutePath,
29-
resolved_negatives: &[wax::Glob<'static>],
30-
) -> Self {
28+
/// Build from fspy's raw iterable by stripping the workspace prefix and
29+
/// normalizing `..` components. `.git/*` paths are skipped. User-configured
30+
/// negatives are applied by the caller (see module docs).
31+
pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self {
3132
let mut accesses = Self::default();
3233
for access in raw.iter() {
33-
// Strip workspace root, clean `..` components, and filter in one pass.
34+
// Strip workspace root and clean `..` components in one pass.
3435
// fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`.
3536
let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| {
3637
let Ok(stripped_path) = strip_result else {
3738
return None;
3839
};
39-
normalize_tracked_workspace_path(stripped_path, resolved_negatives)
40+
normalize_tracked_workspace_path(stripped_path)
4041
});
4142

4243
let Some(relative_path) = relative_path else {
@@ -71,10 +72,7 @@ impl TrackedPathAccesses {
7172
clippy::disallowed_types,
7273
reason = "fspy strip_path_prefix exposes std::path::Path; convert to RelativePathBuf immediately"
7374
)]
74-
fn normalize_tracked_workspace_path(
75-
stripped_path: &std::path::Path,
76-
resolved_negatives: &[wax::Glob<'static>],
77-
) -> Option<RelativePathBuf> {
75+
fn normalize_tracked_workspace_path(stripped_path: &std::path::Path) -> Option<RelativePathBuf> {
7876
// On Windows, paths are possible to be still absolute after stripping the workspace root.
7977
// For example: c:\workspace\subdir\c:\workspace\subdir
8078
// Just ignore those accesses.
@@ -90,12 +88,6 @@ fn normalize_tracked_workspace_path(
9088
return None;
9189
}
9290

93-
if !resolved_negatives.is_empty()
94-
&& resolved_negatives.iter().any(|neg| wax::Program::is_match(neg, relative.as_str()))
95-
{
96-
return None;
97-
}
98-
9991
Some(relative)
10092
}
10193

@@ -111,8 +103,7 @@ mod tests {
111103
clippy::disallowed_types,
112104
reason = "normalize_tracked_workspace_path requires std::path::Path for fspy strip_path_prefix output"
113105
)]
114-
let relative_path =
115-
normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar"), &[]);
106+
let relative_path = normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar"));
116107
assert!(relative_path.is_none());
117108
}
118109
}
Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
{
2-
"name": "@test/normal-pkg",
3-
"scripts": {
4-
"task": "vtt print hello"
5-
}
2+
"name": "@test/normal-pkg"
63
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tasks": {
3+
"task": {
4+
"command": "vtt print hello",
5+
"output": [{ "auto": true }]
6+
}
7+
}
8+
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/rw-pkg/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"name": "@test/rw-pkg",
3-
"scripts": {
4-
"task": "vtt replace-file-content src/data.txt i !"
5-
},
63
"dependencies": {
74
"@test/touch-pkg": "workspace:*"
85
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tasks": {
3+
"task": {
4+
"command": "vtt replace-file-content src/data.txt i !",
5+
"output": [{ "auto": true }]
6+
}
7+
}
8+
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/touch-pkg/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"name": "@test/touch-pkg",
3-
"scripts": {
4-
"task": "vtt touch-file src/data.txt"
5-
},
63
"dependencies": {
74
"@test/normal-pkg": "workspace:*"
85
}

0 commit comments

Comments
 (0)