Skip to content

Commit 8a334a7

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

10 files changed

Lines changed: 182 additions & 56 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: 80 additions & 22 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+
/// All paths the task wrote to. Consumed by `collect_and_archive_outputs`
32+
/// when `output_config.includes_auto` is set.
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>,
@@ -89,6 +92,7 @@ pub(super) async fn update_cache(
8992

9093
let fspy_outcome = observe_fspy(
9194
outcome,
95+
metadata,
9296
input_negative_globs,
9397
&ignored_input_rels,
9498
&ignored_output_rels,
@@ -150,7 +154,13 @@ pub(super) async fn update_cache(
150154
}
151155
};
152156

153-
let output_archive = match collect_and_archive_outputs(metadata, workspace_root, cache_dir) {
157+
let output_archive = match collect_and_archive_outputs(
158+
metadata,
159+
fspy_outcome.as_ref(),
160+
&ignored_output_rels,
161+
workspace_root,
162+
cache_dir,
163+
) {
154164
Ok(archive) => archive,
155165
Err(err) => {
156166
return (
@@ -177,38 +187,58 @@ pub(super) async fn update_cache(
177187
}
178188

179189
/// 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
190+
/// requested (`tracking.fspy.is_some()`) and compiled in (`cfg(fspy)`). On a
181191
/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`]
182192
/// short-circuits to `FspyUnsupported` when tracking was needed.
193+
///
194+
/// `path_reads` is gated on `input_config.includes_auto`, filtered by
195+
/// user-configured input negatives, and by tool-reported `ignoreInput` paths.
196+
/// `path_writes` is NOT filtered here — output negatives and `ignoreOutput`
197+
/// are applied later inside `collect_and_archive_outputs`.
183198
fn observe_fspy(
184199
outcome: &ChildOutcome,
200+
metadata: &CacheMetadata,
185201
input_negative_globs: Option<&[wax::Glob<'static>]>,
186202
ignored_input_rels: &FxHashSet<RelativePathBuf>,
187203
ignored_output_rels: &FxHashSet<RelativePathBuf>,
188204
workspace_root: &AbsolutePath,
189205
) -> Option<TrackingOutcome> {
190206
#[cfg(fspy)]
191207
{
208+
use wax::Program as _;
209+
192210
use super::tracked_accesses::TrackedPathAccesses;
193211

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();
212+
outcome.path_accesses.as_ref().map(|raw| {
213+
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root);
214+
let path_reads: HashMap<RelativePathBuf, PathRead> =
215+
if metadata.input_config.includes_auto
216+
&& let Some(negatives) = input_negative_globs
217+
{
218+
tracked
219+
.path_reads
220+
.iter()
221+
.filter(|(path, _)| {
222+
!negatives.iter().any(|neg| neg.is_match(path.as_str()))
223+
&& !is_ignored(path, ignored_input_rels)
224+
})
225+
.map(|(path, read)| (path.clone(), *read))
226+
.collect()
227+
} else {
228+
HashMap::default()
229+
};
201230
let read_write_overlap = path_reads
202231
.keys()
203232
.find(|p| tracked.path_writes.contains(*p) && !is_ignored(p, ignored_output_rels))
204233
.cloned();
205-
TrackingOutcome { path_reads, read_write_overlap }
234+
TrackingOutcome { path_reads, path_writes: tracked.path_writes, read_write_overlap }
206235
})
207236
}
208237
#[cfg(not(fspy))]
209238
{
210239
let _ = (
211240
outcome,
241+
metadata,
212242
input_negative_globs,
213243
ignored_input_rels,
214244
ignored_output_rels,
@@ -307,36 +337,64 @@ fn collect_tracked_env_globs(reports: &Reports) -> anyhow::Result<TrackedEnvGlob
307337
Ok(tracked_env_globs)
308338
}
309339

310-
/// Collect output files matching the configured globs and create a tar.zst
311-
/// archive in the cache directory.
340+
/// Collect output files and create a tar.zst archive in the cache directory.
341+
///
342+
/// Output files are determined by:
343+
/// - fspy-tracked writes (when `output_config.includes_auto` is true)
344+
/// - Positive output globs (always, if configured)
345+
/// - Filtered by negative output globs
346+
/// - Filtered by tool-reported `ignoreOutput` paths (auto writes only)
312347
///
313-
/// Returns `Some(archive_filename)` if files were archived, `None` if the
314-
/// output config has no positive globs or no files matched.
348+
/// Returns `Some(archive_filename)` if files were archived, `None` if no output files.
315349
fn collect_and_archive_outputs(
316350
cache_metadata: &CacheMetadata,
351+
tracking: Option<&TrackingOutcome>,
352+
ignored_output_rels: &FxHashSet<RelativePathBuf>,
317353
workspace_root: &AbsolutePath,
318354
cache_dir: &AbsolutePath,
319355
) -> anyhow::Result<Option<Str>> {
356+
use wax::Program as _;
357+
320358
let output_config = &cache_metadata.output_config;
321359

322-
if output_config.positive_globs.is_empty() {
323-
return Ok(None);
360+
let mut output_files: FxHashSet<RelativePathBuf> = FxHashSet::default();
361+
362+
if output_config.includes_auto
363+
&& let Some(t) = tracking
364+
{
365+
output_files
366+
.extend(t.path_writes.iter().filter(|p| !is_ignored(p, ignored_output_rels)).cloned());
324367
}
325368

326-
let output_files = glob::collect_glob_paths(
327-
workspace_root,
328-
&output_config.positive_globs,
329-
&output_config.negative_globs,
330-
)?;
369+
if !output_config.positive_globs.is_empty() {
370+
let glob_paths = glob::collect_glob_paths(
371+
workspace_root,
372+
&output_config.positive_globs,
373+
&output_config.negative_globs,
374+
)?;
375+
output_files.extend(glob_paths);
376+
}
377+
378+
if output_config.includes_auto && !output_config.negative_globs.is_empty() {
379+
let negatives: Vec<wax::Glob<'static>> = output_config
380+
.negative_globs
381+
.iter()
382+
.map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned()))
383+
.collect::<anyhow::Result<_>>()?;
384+
output_files.retain(|path| !negatives.iter().any(|neg| neg.is_match(path.as_str())));
385+
}
331386

332387
if output_files.is_empty() {
333388
return Ok(None);
334389
}
335390

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

339-
archive::create_output_archive(workspace_root, &output_files, &archive_path)?;
397+
archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?;
340398

341399
Ok(Some(archive_name))
342400
}

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

Lines changed: 5 additions & 5 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,7 +100,7 @@ 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>>,
106106
}
@@ -164,7 +164,7 @@ impl<'a> ExecutionMode<'a> {
164164
});
165165
};
166166

167-
let fspy = if metadata.input_config.includes_auto {
167+
let fspy = if metadata.input_config.includes_auto || metadata.output_config.includes_auto {
168168
// Resolve input negative globs for fspy path filtering (already
169169
// workspace-root-relative).
170170
let negatives = metadata

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
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ steps = [
5858
], comment = "restored from the explicit output archive" },
5959
]
6060

61+
[[e2e]]
62+
name = "explicit_auto_output_restores_written_files"
63+
comment = """
64+
Exercises `output: [{ auto: true }]` with explicit inputs disabled. The runner attaches fspy for output tracking, archives the written file, and restores it on cache hit.
65+
"""
66+
ignore = true
67+
steps = [
68+
{ argv = [
69+
"vt",
70+
"run",
71+
"auto-output-explicit",
72+
], comment = "first run writes dist/auto.txt and archives fspy-tracked outputs" },
73+
{ argv = [
74+
"vtt",
75+
"rm",
76+
"dist/auto.txt",
77+
], comment = "remove the output so restoration is observable" },
78+
{ argv = [
79+
"vt",
80+
"run",
81+
"auto-output-explicit",
82+
], comment = "cache hit: fspy-tracked output is restored" },
83+
{ argv = [
84+
"vtt",
85+
"print-file",
86+
"dist/auto.txt",
87+
], comment = "restored from the auto output archive" },
88+
]
89+
6190
[[e2e]]
6291
name = "disable_cache_forces_reexecution"
6392
comment = """
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# explicit_auto_output_restores_written_files
2+
3+
Exercises `output: [{ auto: true }]` with explicit inputs disabled. The runner attaches fspy for output tracking, archives the written file, and restores it on cache hit.
4+
5+
## `vt run auto-output-explicit`
6+
7+
first run writes dist/auto.txt and archives fspy-tracked outputs
8+
9+
```
10+
$ vtt write-file dist/auto.txt ok
11+
```
12+
13+
## `vtt rm dist/auto.txt`
14+
15+
remove the output so restoration is observable
16+
17+
```
18+
```
19+
20+
## `vt run auto-output-explicit`
21+
22+
cache hit: fspy-tracked output is restored
23+
24+
```
25+
$ vtt write-file dist/auto.txt ok ◉ cache hit, replaying
26+
27+
---
28+
vt run: cache hit.
29+
```
30+
31+
## `vtt print-file dist/auto.txt`
32+
33+
restored from the auto output archive
34+
35+
```
36+
ok
37+
```

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
"output": ["dist/**"],
1010
"cache": true
1111
},
12+
"auto-output-explicit": {
13+
"command": "vtt write-file dist/auto.txt ok",
14+
"input": [],
15+
"output": [{ "auto": true }],
16+
"cache": true
17+
},
1218
"disable-cache": {
1319
"command": "node scripts/disable_cache.mjs",
1420
"cache": true

crates/vite_task_graph/run-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ input?: Array<string | GlobWithBase | AutoInput>,
6262
* - Omitted or `[]` (empty): no output archiving (default)
6363
* - Glob patterns (e.g. `"dist/**"`) select specific output files, relative to the package directory
6464
* - `{pattern: "...", base: "workspace" | "package"}` specifies a glob with an explicit base directory
65+
* - `{auto: true}` enables automatic output tracking
6566
* - Negative patterns (e.g. `"!dist/cache/**"`) exclude matched files
6667
*/
67-
output?: Array<string | GlobWithBase>, } | {
68+
output?: Array<string | GlobWithBase | AutoInput>, } | {
6869
/**
6970
* Whether to cache the task
7071
*/

0 commit comments

Comments
 (0)