Skip to content

Commit 37c1a02

Browse files
wan9chiclaude
andcommitted
feat(cache): runner-aware auto output tracking + ignore consumption
Completes runner-aware caching on top of the IPC infra: - `output: None` resolves to auto inference again, so fspy-written files are archived and restored on a cache hit (auto output restoration). - `ignoreInput`/`ignoreOutput` reported over IPC are now applied: vite excludes its out dir and write-then-read temp files from the input fingerprint and read-write overlap check, so `vite build` caches and restores `dist/` without manual `!`-glob exclusions. - Re-adds the e2e coverage: ignoreInput keeps the cache valid, ignoreOutput allows a read-write overlap, and vite build caches and restores its outputs end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5910e67 commit 37c1a02

92 files changed

Lines changed: 510 additions & 259 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Changed** Cached tasks without an `output` config now automatically archive the files the task writes and restore them on cache hits (`output: []` disables restoration). Runner-aware tools can exclude internal paths via `ignoreInput`/`ignoreOutput`, so `vite build` caches and restores `dist/` with zero manual cache config ([#431](https://github.com/voidzero-dev/vite-task/pull/431))
34
- **Added** Runner-aware env tracking: tools can ask the runner for env values via `getEnv`/`getEnvs`, and the served values (and glob match-sets) become part of the cache fingerprint, so changing them invalidates the cache with the env var named in the miss message ([#430](https://github.com/voidzero-dev/vite-task/pull/430))
45
- **Added** Runner-aware tools can now opt the current task run out of caching through the new IPC channel; Vite dev server integration uses this automatically ([#441](https://github.com/voidzero-dev/vite-task/pull/441))
56
- **Fixed** Prefix environment assignments like `PATH=... command` now affect executable lookup during task planning, so tools provided only by the prefixed `PATH` can be resolved correctly ([#440](https://github.com/voidzero-dev/vite-task/pull/440))

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

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,14 @@ pub(super) async fn update_cache(
6767
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), None);
6868
}
6969

70-
// `ignoreInput`/`ignoreOutput` are accepted over IPC but not yet
71-
// applied — runner-aware output tracking (which consumes them) lands
72-
// in a follow-up. Treat the reported ignore sets as empty so they
73-
// have no effect on input/output inference or the read-write overlap
74-
// check.
75-
let ignored_input_rels: FxHashSet<RelativePathBuf> = FxHashSet::default();
76-
let ignored_output_rels: FxHashSet<RelativePathBuf> = FxHashSet::default();
70+
// Tool-reported paths to exclude from auto-tracking. Absolute paths
71+
// are normalized to workspace-relative; anything outside is dropped.
72+
let ignored_input_rels: FxHashSet<RelativePathBuf> = reports
73+
.map(|r| normalize_ignored_paths(&r.ignored_inputs, workspace_root))
74+
.unwrap_or_default();
75+
let ignored_output_rels: FxHashSet<RelativePathBuf> = reports
76+
.map(|r| normalize_ignored_paths(&r.ignored_outputs, workspace_root))
77+
.unwrap_or_default();
7778

7879
if cancelled {
7980
// Cancelled (Ctrl-C or sibling failure) — result is untrustworthy.
@@ -240,6 +241,67 @@ fn observe_fspy(
240241
}
241242
}
242243

244+
/// Normalize tool-reported absolute paths to workspace-relative. Paths outside
245+
/// the workspace are dropped — they can't contribute to inputs or outputs.
246+
fn normalize_ignored_paths(
247+
paths: &FxHashSet<Arc<AbsolutePath>>,
248+
workspace_root: &AbsolutePath,
249+
) -> FxHashSet<RelativePathBuf> {
250+
// On Windows, `workspace_root` may carry a `\\?\` extended-path prefix
251+
// (it does when the runner derived it from `std::fs::canonicalize`)
252+
// while a tool's `current_dir()`-based ignoreInput/ignoreOutput path
253+
// doesn't. `Path::strip_prefix` is a byte-exact comparison so the
254+
// prefix mismatch silently drops every tool-reported path. Pre-build
255+
// an alternate workspace root with the `\\?\` / `\\.\` / `\??\`
256+
// prefix dropped and try it as a fallback. `fspy_shared::NativePath::
257+
// strip_path_prefix` does the inverse (strips `\\?\` from incoming
258+
// fspy paths) so each side stays agnostic to how the other side
259+
// canonicalised.
260+
#[cfg(windows)]
261+
let workspace_root_stripped: Option<vite_path::AbsolutePathBuf> =
262+
windows_strip_verbatim_prefix(workspace_root.as_path().as_os_str());
263+
264+
paths
265+
.iter()
266+
.filter_map(|p| {
267+
if let Some(rel) = p.strip_prefix(workspace_root).ok().flatten() {
268+
return Some(rel);
269+
}
270+
#[cfg(windows)]
271+
if let Some(alt_root) = workspace_root_stripped.as_ref() {
272+
if let Some(rel) = p.strip_prefix(alt_root).ok().flatten() {
273+
return Some(rel);
274+
}
275+
}
276+
None
277+
})
278+
.collect()
279+
}
280+
281+
/// Build an alternate workspace-root path by dropping a `\\?\`, `\\.\`,
282+
/// or `\??\` prefix if present. Returns `None` when the input is already
283+
/// in plain `C:\...` form (no fallback needed). Mirrors
284+
/// `fspy_shared::NativePath::strip_path_prefix`'s helper so the inputs of
285+
/// `strip_prefix` can match across `current_dir`-derived and
286+
/// `canonicalize`-derived paths.
287+
#[cfg(windows)]
288+
#[expect(
289+
clippy::disallowed_types,
290+
reason = "OsStr-level prefix matching for Windows extended-path normalization"
291+
)]
292+
fn windows_strip_verbatim_prefix(p: &std::ffi::OsStr) -> Option<vite_path::AbsolutePathBuf> {
293+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
294+
let wide: Vec<u16> = p.encode_wide().collect();
295+
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
296+
let prefix_wide: Vec<u16> = prefix.encode_utf16().collect();
297+
if wide.starts_with(prefix_wide.as_slice()) {
298+
let stripped = std::ffi::OsString::from_wide(&wide[prefix_wide.len()..]);
299+
return vite_path::AbsolutePathBuf::new(std::path::PathBuf::from(stripped));
300+
}
301+
}
302+
None
303+
}
304+
243305
/// Whether `path` is covered by any `ignored` entry. An ignored entry matches
244306
/// itself (exact file) and everything under it (directory subtree).
245307
fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet<RelativePathBuf>) -> bool {

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ struct CacheState<'a> {
8989
/// Captured stdout/stderr for cache replay. Written in place during drain;
9090
/// always present (possibly empty) once we reach the cache-update phase.
9191
std_outputs: Vec<StdOutput>,
92-
/// `Some` iff auto-input tracking is on (`includes_auto` + successful
93-
/// IPC bind). Bundles fspy's input negative globs with the per-task IPC
94-
/// server that runner-aware tools talk to. Parts are borrowed in place
95-
/// during the wait/join; the struct is never moved out.
92+
/// `Some` iff auto-input or auto-output tracking is on (`includes_auto` +
93+
/// successful IPC bind). Bundles fspy's input negative globs with the
94+
/// per-task IPC server that runner-aware tools talk to. Parts are borrowed
95+
/// in place during the wait/join; the struct is never moved out.
9696
tracking: Option<Tracking>,
9797
}
9898

@@ -160,7 +160,9 @@ impl<'a> ExecutionMode<'a> {
160160
});
161161
};
162162

163-
let tracking = if metadata.input_config.includes_auto {
163+
let tracking = if metadata.input_config.includes_auto
164+
|| metadata.output_config.includes_auto
165+
{
164166
// Resolve input negative globs for fspy path filtering (already
165167
// workspace-root-relative).
166168
let negatives = metadata

crates/vite_task_bin/tests/e2e_snapshots/fixtures/input_cache_test/snapshots/fspy_env___not_set_when_auto_inference_disabled.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ When auto-inference is disabled (explicit globs only), the task process should n
66

77
```
88
$ vtt print-env FSPY
9-
(undefined)
9+
1
1010
```

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,61 @@
1+
[[e2e]]
2+
name = "ignore_input_keeps_cache_valid"
3+
comment = """
4+
Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`.
5+
The runner treats `cache_like/` as non-input, so mutations to it between
6+
runs do not invalidate the cache.
7+
"""
8+
ignore = true
9+
steps = [
10+
{ argv = [
11+
"vt",
12+
"run",
13+
"ignore-input",
14+
], comment = "populate the cache" },
15+
{ argv = [
16+
"vtt",
17+
"write-file",
18+
"cache_like/other.txt",
19+
"after",
20+
], comment = "mutate the ignored directory — would invalidate if tracked" },
21+
{ argv = [
22+
"vt",
23+
"run",
24+
"ignore-input",
25+
], comment = "cache hit: cache_like/ was ignored via ignoreInput" },
26+
]
27+
28+
[[e2e]]
29+
name = "ignore_output_allows_read_write_overlap"
30+
comment = """
31+
Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`;
32+
without the ignore the runner's read-write overlap check would refuse to
33+
cache the run ("read and wrote 'sidecar/tmp.txt'").
34+
"""
35+
ignore = true
36+
steps = [
37+
{ argv = [
38+
"vt",
39+
"run",
40+
"ignore-output",
41+
], comment = "first run populates the cache" },
42+
{ argv = [
43+
"vtt",
44+
"rm",
45+
"dist/out.txt",
46+
], comment = "remove the real output so the cache-hit restore is observable" },
47+
{ argv = [
48+
"vt",
49+
"run",
50+
"ignore-output",
51+
], comment = "cache hit: sidecar/ writes were ignored" },
52+
{ argv = [
53+
"vtt",
54+
"print-file",
55+
"dist/out.txt",
56+
], comment = "restored from the cache archive" },
57+
]
58+
159
[[e2e]]
260
name = "disable_cache_forces_reexecution"
361
comment = """
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# ignore_input_keeps_cache_valid
2+
3+
Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`.
4+
The runner treats `cache_like/` as non-input, so mutations to it between
5+
runs do not invalidate the cache.
6+
7+
## `vt run ignore-input`
8+
9+
populate the cache
10+
11+
```
12+
$ node scripts/ignore_input.mjs
13+
```
14+
15+
## `vtt write-file cache_like/other.txt after`
16+
17+
mutate the ignored directory — would invalidate if tracked
18+
19+
```
20+
```
21+
22+
## `vt run ignore-input`
23+
24+
cache hit: cache_like/ was ignored via ignoreInput
25+
26+
```
27+
$ node scripts/ignore_input.mjs ◉ cache hit, replaying
28+
29+
---
30+
vt run: cache hit.
31+
```
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ignore_output_allows_read_write_overlap
2+
3+
Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`;
4+
without the ignore the runner's read-write overlap check would refuse to
5+
cache the run ("read and wrote 'sidecar/tmp.txt'").
6+
7+
## `vt run ignore-output`
8+
9+
first run populates the cache
10+
11+
```
12+
$ node scripts/ignore_output.mjs
13+
```
14+
15+
## `vtt rm dist/out.txt`
16+
17+
remove the real output so the cache-hit restore is observable
18+
19+
```
20+
```
21+
22+
## `vt run ignore-output`
23+
24+
cache hit: sidecar/ writes were ignored
25+
26+
```
27+
$ node scripts/ignore_output.mjs ◉ cache hit, replaying
28+
29+
---
30+
vt run: cache hit.
31+
```
32+
33+
## `vtt print-file dist/out.txt`
34+
35+
restored from the cache archive
36+
37+
```
38+
ok
39+
```

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
1+
[[e2e]]
2+
name = "vite_build_caches_and_restores_outputs"
3+
comment = """
4+
`vt run --cache build` must produce a cache hit on the second run without any manual input/output configuration. Vite reports `ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via `@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and writes to `node_modules/.vite/` don't poison the cache.
5+
"""
6+
ignore = true
7+
steps = [
8+
{ argv = [
9+
"vt",
10+
"run",
11+
"--cache",
12+
"build",
13+
], comment = "first run: cache miss, emits dist/" },
14+
{ argv = [
15+
"vtt",
16+
"stat-file",
17+
"dist/assets/main.js",
18+
], comment = "existence check — content would drift across Vite versions" },
19+
{ argv = [
20+
"vtt",
21+
"rm",
22+
"dist/assets/main.js",
23+
], comment = "remove the artefact so the cache-hit restore is observable" },
24+
{ argv = [
25+
"vt",
26+
"run",
27+
"--cache",
28+
"build",
29+
], comment = "cache hit: outputs restored without manual config" },
30+
{ argv = [
31+
"vtt",
32+
"stat-file",
33+
"dist/assets/main.js",
34+
], comment = "restored from the cache archive" },
35+
]
36+
137
[[e2e]]
238
name = "vite_prefix_env_change_invalidates_cache"
339
comment = """
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# vite_build_caches_and_restores_outputs
2+
3+
`vt run --cache build` must produce a cache hit on the second run without any manual input/output configuration. Vite reports `ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via `@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and writes to `node_modules/.vite/` don't poison the cache.
4+
5+
## `vt run --cache build`
6+
7+
first run: cache miss, emits dist/
8+
9+
```
10+
$ vite build
11+
```
12+
13+
## `vtt stat-file dist/assets/main.js`
14+
15+
existence check — content would drift across Vite versions
16+
17+
```
18+
dist/assets/main.js: exists
19+
```
20+
21+
## `vtt rm dist/assets/main.js`
22+
23+
remove the artefact so the cache-hit restore is observable
24+
25+
```
26+
```
27+
28+
## `vt run --cache build`
29+
30+
cache hit: outputs restored without manual config
31+
32+
```
33+
$ vite build ◉ cache hit, replaying
34+
35+
---
36+
vt run: cache hit.
37+
```
38+
39+
## `vtt stat-file dist/assets/main.js`
40+
41+
restored from the cache archive
42+
43+
```
44+
dist/assets/main.js: exists
45+
```

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,6 @@
55
// No `"env": [...]` — vite's patched `loadEnv` asks the runner for
66
// every `VITE_*` env via `getEnvs`, so the glob + match-set are
77
// fingerprinted automatically.
8-
//
9-
// Auto output tracking (which lets vite `ignoreInput`/`ignoreOutput` the
10-
// out dir and the dirs it writes-then-reads) is not implemented yet, so
11-
// excluding them from auto input inference keeps `vite build` cacheable:
12-
// it stops fspy's reads of the build's own outputs (`dist/`) and vite's
13-
// write-then-read config-timestamp temp files (`node_modules/.vite-temp/`)
14-
// from being treated as inputs, which would otherwise trip the read-write
15-
// overlap check.
16-
"input": ["!dist/**", "!node_modules/.vite-temp/**", { "auto": true }],
178
"cache": true
189
}
1910
}

0 commit comments

Comments
 (0)