Skip to content

Commit aad8d08

Browse files
wan9chicodex
andcommitted
feat(cache): honor runner ignored inputs
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 0a4c11e commit aad8d08

14 files changed

Lines changed: 331 additions & 21 deletions

File tree

CHANGELOG.md

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

3+
- **Added** Runner-aware tools can now call `ignoreInput(path)` to exclude non-semantic reads from auto-inferred task cache inputs.
34
- **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.
45
- **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)).
56
- **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)).

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
44
use std::{collections::BTreeMap, sync::Arc, time::Duration};
55

6+
use rustc_hash::FxHashSet;
67
use vite_path::{AbsolutePath, RelativePathBuf};
78
use vite_str::Str;
89
use vite_task_plan::cache_metadata::{CacheMetadata, EnvValueHash};
@@ -67,6 +68,12 @@ pub(super) async fn update_cache(
6768
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), None);
6869
}
6970

71+
// Tool-reported paths to exclude from auto input tracking. Absolute paths
72+
// are normalized to workspace-relative; anything outside is dropped.
73+
let ignored_input_rels: FxHashSet<RelativePathBuf> = reports
74+
.map(|r| normalize_ignored_paths(&r.ignored_inputs, workspace_root))
75+
.unwrap_or_default();
76+
7077
if cancelled {
7178
// Cancelled (Ctrl-C or sibling failure) — result is untrustworthy.
7279
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None);
@@ -77,7 +84,8 @@ pub(super) async fn update_cache(
7784
return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None);
7885
}
7986

80-
let fspy_outcome = observe_fspy(outcome, input_negative_globs, workspace_root);
87+
let fspy_outcome =
88+
observe_fspy(outcome, input_negative_globs, &ignored_input_rels, workspace_root);
8189

8290
if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome {
8391
// fspy-inferred read-write overlap: the task wrote to a file it also
@@ -167,6 +175,7 @@ pub(super) async fn update_cache(
167175
fn observe_fspy(
168176
outcome: &ChildOutcome,
169177
input_negative_globs: Option<&[wax::Glob<'static>]>,
178+
ignored_input_rels: &FxHashSet<RelativePathBuf>,
170179
workspace_root: &AbsolutePath,
171180
) -> Option<TrackingOutcome> {
172181
#[cfg(fspy)]
@@ -175,14 +184,19 @@ fn observe_fspy(
175184

176185
outcome.path_accesses.as_ref().zip(input_negative_globs).map(|(raw, negatives)| {
177186
let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negatives);
187+
let path_reads: HashMap<RelativePathBuf, PathRead> = tracked
188+
.path_reads
189+
.into_iter()
190+
.filter(|(path, _)| !is_ignored(path, ignored_input_rels))
191+
.collect();
178192
let read_write_overlap =
179-
tracked.path_reads.keys().find(|p| tracked.path_writes.contains(*p)).cloned();
180-
TrackingOutcome { path_reads: tracked.path_reads, read_write_overlap }
193+
path_reads.keys().find(|p| tracked.path_writes.contains(*p)).cloned();
194+
TrackingOutcome { path_reads, read_write_overlap }
181195
})
182196
}
183197
#[cfg(not(fspy))]
184198
{
185-
let _ = (outcome, input_negative_globs, workspace_root);
199+
let _ = (outcome, input_negative_globs, ignored_input_rels, workspace_root);
186200
None
187201
}
188202
}
@@ -201,6 +215,52 @@ fn collect_tracked_reports(
201215
.map(Option::unwrap_or_default)
202216
}
203217

218+
/// Normalize tool-reported absolute paths to cleaned workspace-relative paths.
219+
/// Paths outside the workspace are dropped — they can't contribute to inputs.
220+
fn normalize_ignored_paths(
221+
paths: &FxHashSet<Arc<AbsolutePath>>,
222+
workspace_root: &AbsolutePath,
223+
) -> FxHashSet<RelativePathBuf> {
224+
paths
225+
.iter()
226+
.filter_map(|p| p.strip_path_prefix(workspace_root).ok().flatten()?.clean().ok())
227+
.collect()
228+
}
229+
230+
/// Whether `path` is covered by any `ignored` entry. An ignored entry matches
231+
/// itself (exact file) and everything under it (directory subtree).
232+
fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet<RelativePathBuf>) -> bool {
233+
if ignored.is_empty() {
234+
return false;
235+
}
236+
ignored.contains(path) || ignored.iter().any(|ig| path.strip_prefix(ig).is_some())
237+
}
238+
239+
#[cfg(test)]
240+
mod tests {
241+
use std::sync::Arc;
242+
243+
use rustc_hash::FxHashSet;
244+
use vite_path::{AbsolutePath, RelativePathBuf};
245+
246+
use super::normalize_ignored_paths;
247+
248+
#[test]
249+
fn normalize_ignored_paths_cleans_relative_components() {
250+
let workspace_root =
251+
AbsolutePath::new(if cfg!(windows) { r"C:\repo" } else { "/repo" }).unwrap();
252+
let ignored =
253+
workspace_root.join(if cfg!(windows) { r"pkg\..\cache" } else { "pkg/../cache" });
254+
let mut ignored_paths = FxHashSet::default();
255+
ignored_paths.insert(Arc::<AbsolutePath>::from(ignored));
256+
257+
let normalized = normalize_ignored_paths(&ignored_paths, &workspace_root);
258+
259+
let expected = RelativePathBuf::new("cache").unwrap();
260+
assert!(normalized.contains(&expected));
261+
}
262+
}
263+
204264
/// Select tool-reported env records to embed in the post-run fingerprint.
205265
/// Names that the user already declared as fingerprinted are skipped because
206266
/// their values are already in the spawn fingerprint.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { ignoreInput } from '@voidzero-dev/vite-task-client';
3+
4+
mkdirSync('cache_like', { recursive: true });
5+
mkdirSync('dist', { recursive: true });
6+
7+
ignoreInput('cache_like');
8+
9+
const value = readFileSync('cache_like/input.txt', 'utf8').trim();
10+
writeFileSync('dist/out.txt', value + '\n');

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,34 @@
1+
[[e2e]]
2+
name = "ignore_input_keeps_cache_valid"
3+
comment = """
4+
Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. The runner treats `cache_like/` as non-input, so mutations under it between runs do not invalidate the cache.
5+
"""
6+
ignore = true
7+
steps = [
8+
{ argv = [
9+
"vtt",
10+
"write-file",
11+
"cache_like/input.txt",
12+
"before",
13+
], comment = "seed the file the task will read and ignore" },
14+
{ argv = [
15+
"vt",
16+
"run",
17+
"ignore-input",
18+
], comment = "populate the cache" },
19+
{ argv = [
20+
"vtt",
21+
"write-file",
22+
"cache_like/input.txt",
23+
"after",
24+
], comment = "mutate the ignored input — would invalidate if tracked" },
25+
{ argv = [
26+
"vt",
27+
"run",
28+
"ignore-input",
29+
], comment = "cache hit: cache_like/ was ignored via ignoreInput" },
30+
]
31+
132
[[e2e]]
233
name = "disable_cache_forces_reexecution"
334
comment = """
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# ignore_input_keeps_cache_valid
2+
3+
Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. The runner treats `cache_like/` as non-input, so mutations under it between runs do not invalidate the cache.
4+
5+
## `vtt write-file cache_like/input.txt before`
6+
7+
seed the file the task will read and ignore
8+
9+
```
10+
```
11+
12+
## `vt run ignore-input`
13+
14+
populate the cache
15+
16+
```
17+
$ node scripts/ignore_input.mjs
18+
```
19+
20+
## `vtt write-file cache_like/input.txt after`
21+
22+
mutate the ignored input — would invalidate if tracked
23+
24+
```
25+
```
26+
27+
## `vt run ignore-input`
28+
29+
cache hit: cache_like/ was ignored via ignoreInput
30+
31+
```
32+
$ node scripts/ignore_input.mjs ◉ cache hit, replaying
33+
34+
---
35+
vt run: cache hit.
36+
```

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"tasks": {
3+
"ignore-input": {
4+
"command": "node scripts/ignore_input.mjs",
5+
"cache": true
6+
},
37
"disable-cache": {
48
"command": "node scripts/disable_cache.mjs",
59
"cache": true

crates/vite_task_client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ rust-version.workspace = true
99
[dependencies]
1010
native_str = { workspace = true }
1111
rustc-hash = { workspace = true }
12+
vite_path = { workspace = true }
1213
vite_task_ipc_shared = { workspace = true }
1314
wincode = { workspace = true, features = ["derive"] }
1415

crates/vite_task_client/src/lib.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77

88
use native_str::NativeStr;
99
use rustc_hash::FxHashMap;
10+
use vite_path::{self, AbsolutePath};
1011
use vite_task_ipc_shared::{GetEnvResponse, GetEnvsResponse, IPC_ENV_NAME, Request};
1112
use wincode::{SchemaRead, config::DefaultConfig};
1213

@@ -46,13 +47,28 @@ impl Client {
4647
Self { stream: RefCell::new(stream), scratch: RefCell::new(Vec::new()) }
4748
}
4849

50+
/// `path` can be a file or a directory; for a directory, all files inside
51+
/// it are ignored. Relative paths are resolved against the current working
52+
/// directory before being sent to the runner.
53+
///
4954
/// Fire-and-forget: the call returns once the request is flushed to the
5055
/// kernel pipe buffer; the runner processes it during its drain phase
5156
/// after this process exits. See the `Request` type in the IPC protocol
5257
/// crate for why this is safe.
5358
///
5459
/// # Errors
5560
///
61+
/// Returns an error if the request fails to send, or if a relative `path`
62+
/// cannot be resolved against the current working directory.
63+
pub fn ignore_input(&self, path: &OsStr) -> io::Result<()> {
64+
let ns = resolve_path(path)?;
65+
self.send(&Request::IgnoreInput(&ns))
66+
}
67+
68+
/// Fire-and-forget — see [`Self::ignore_input`].
69+
///
70+
/// # Errors
71+
///
5672
/// Returns an error if the request fails to send.
5773
pub fn disable_cache(&self) -> io::Result<()> {
5874
self.send(&Request::DisableCache)
@@ -129,6 +145,44 @@ impl Client {
129145
}
130146
}
131147

148+
#[expect(
149+
clippy::disallowed_types,
150+
reason = "std::path::PathBuf is needed to round-trip through std::fs::canonicalize on Windows below"
151+
)]
152+
fn resolve_path(path: &OsStr) -> io::Result<Box<NativeStr>> {
153+
let absolute: std::path::PathBuf = if let Some(abs) = AbsolutePath::new(path) {
154+
abs.as_path().to_path_buf()
155+
} else {
156+
let mut buf = vite_path::current_dir()?;
157+
buf.push(path);
158+
buf.as_path().to_path_buf()
159+
};
160+
161+
// On Windows, canonicalize so the path uses the exact on-disk casing and
162+
// resolves substitute drives / junctions the same way fspy-reported paths
163+
// do. Strip the `\\?\` namespace prefix for local drive paths because
164+
// fspy strips it on the runner side too; keep `\\?\UNC\` unchanged.
165+
#[cfg(windows)]
166+
let absolute = std::fs::canonicalize(&absolute).map_or(absolute, |canonical| {
167+
use std::{
168+
ffi::OsString,
169+
os::windows::ffi::{OsStrExt, OsStringExt},
170+
};
171+
let wide: Vec<u16> = canonical.as_os_str().encode_wide().collect();
172+
let unc_prefix: Vec<u16> = r"\\?\UNC\".encode_utf16().collect();
173+
let nt_prefix: Vec<u16> = r"\\?\".encode_utf16().collect();
174+
if wide.starts_with(&unc_prefix) {
175+
canonical
176+
} else if let Some(rest) = wide.strip_prefix(nt_prefix.as_slice()) {
177+
std::path::PathBuf::from(OsString::from_wide(rest))
178+
} else {
179+
canonical
180+
}
181+
});
182+
183+
Ok(Box::<NativeStr>::from(absolute.as_os_str()))
184+
}
185+
132186
#[cfg(unix)]
133187
fn connect(name: &OsStr) -> io::Result<Stream> {
134188
std::os::unix::net::UnixStream::connect(name)

crates/vite_task_client_napi/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ pub struct RunnerClient {
7474

7575
#[napi]
7676
impl RunnerClient {
77-
/// No-op for now: the runner cannot apply ignore reports yet. Becomes a
78-
/// real request once auto output tracking can consume them.
7977
#[napi]
80-
pub fn ignore_input(&self, _path: String) -> Result<()> {
81-
Ok(())
78+
pub fn ignore_input(&self, path: String) -> Result<()> {
79+
self.client
80+
.ignore_input(OsStr::new(&path))
81+
.map_err(|err| err_string(vite_str::format!("{err}")))
8282
}
8383

8484
/// No-op for now — see [`Self::ignore_input`].

0 commit comments

Comments
 (0)