Skip to content

Commit 87d8c32

Browse files
branchseerclaude
andcommitted
feat(ipc): add fetchEnvs(glob) with match-set fingerprinting
Extends the runner-aware-tools IPC with a glob-pattern env query alongside the existing single-name `getEnv`. The pattern plus the match-set observed at execution time enter the post-run fingerprint, so any add / remove / change to a matching env invalidates the cache, while non-matching envs are ignored. - `Request::GetEnvs` + `GetEnvsResponse` carrying a sorted `BTreeMap<NativeStr, NativeStr>`; `NativeStr` now derives `Ord`. - `Recorder::get_envs` resolves the glob via `vite_glob`, snapshots the match-set, and records `{pattern, tracked, matches}`. - `fetchEnvs(pattern, { tracked })` in the JS wrapper always round-trips (the client can't know in advance which names match, so `process.env` short-circuit doesn't apply) and writes the returned values into `process.env` for names that aren't set. - `PostRunFingerprint.tracked_env_globs` + `EnvGlobDiff` (added / removed / changed), validated at lookup by re-enumerating `vars_os()` against the stored pattern and diffing the match-set; names already declared under the task's `env` config are dropped from the stored set to avoid double-counting. - `patches/vite.patch`: drop `fetchEnv("NODE_ENV")` in `resolveConfig`, call `fetchEnvs(`${prefix}*`, { tracked: true })` per configured `envPrefix` inside `loadEnv` — `envPrefix`-matching envs drive `import.meta.env.*` substitution, so the runner now tracks whatever vite actually reads from its own config. - E2E: new `fetch_envs_tracks_glob_match_set` fixture covers the five diff scenarios (unchanged / changed / added / removed / non-matching noise); `vite_build_cache`'s NODE_ENV case becomes `VITE_MODE`-driven to exercise the new glob path through a real `vite build`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee5f16c commit 87d8c32

28 files changed

Lines changed: 768 additions & 113 deletions

File tree

Cargo.lock

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

crates/native_str/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use wincode::{
3737
/// **Not portable across platforms.** The binary representation is platform-specific.
3838
/// Deserializing a `NativeStr` serialized on a different platform leads to unspecified
3939
/// behavior (garbage data), but is not unsafe. Designed for same-platform IPC only.
40-
#[derive(TransparentWrapper, PartialEq, Eq)]
40+
#[derive(TransparentWrapper, PartialEq, Eq, PartialOrd, Ord)]
4141
#[repr(transparent)]
4242
pub struct NativeStr {
4343
// On unix, this is the raw bytes of the OsStr.

crates/vite_task/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ tracing = { workspace = true }
3737
twox-hash = { workspace = true }
3838
materialized_artifact = { workspace = true }
3939
uuid = { workspace = true, features = ["v4"] }
40+
vite_glob = { workspace = true }
4041
vite_path = { workspace = true }
4142
vite_select = { workspace = true }
4243
vite_str = { workspace = true }

crates/vite_task/src/session/cache/display.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option<Str> {
184184
"○ cache miss: tracked env '{name}' changed, executing"
185185
));
186186
}
187+
FingerprintMismatch::TrackedEnvGlobChanged { pattern, .. } => {
188+
return Some(vite_str::format!(
189+
"○ cache miss: tracked env glob '{pattern}' changed, executing"
190+
));
191+
}
187192
};
188193
Some(vite_str::format!("○ cache miss: {reason}, executing"))
189194
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ pub enum FingerprintMismatch {
164164
old: Option<Str>,
165165
new: Option<Str>,
166166
},
167+
/// A tool-tracked env glob's match-set changed between runs.
168+
TrackedEnvGlobChanged {
169+
pattern: Str,
170+
diff: crate::session::execute::fingerprint::EnvGlobDiff,
171+
},
167172
}
168173

169174
impl Display for FingerprintMismatch {
@@ -193,6 +198,30 @@ impl Display for FingerprintMismatch {
193198
None => write!(f, "(unset)"),
194199
}
195200
}
201+
Self::TrackedEnvGlobChanged { pattern, diff } => {
202+
write!(f, "tracked env glob {:?}:", pattern.as_str())?;
203+
let mut first = true;
204+
for (name, value) in &diff.added {
205+
write!(f, "{} +{}={:?}", if first { "" } else { "," }, name, value.as_str())?;
206+
first = false;
207+
}
208+
for (name, value) in &diff.removed {
209+
write!(f, "{} -{}={:?}", if first { "" } else { "," }, name, value.as_str())?;
210+
first = false;
211+
}
212+
for (name, (old, new)) in &diff.changed {
213+
write!(
214+
f,
215+
"{} {}: {:?} → {:?}",
216+
if first { "" } else { "," },
217+
name,
218+
old.as_str(),
219+
new.as_str()
220+
)?;
221+
first = false;
222+
}
223+
Ok(())
224+
}
196225
}
197226
}
198227
}
@@ -292,6 +321,10 @@ impl ExecutionCache {
292321
old,
293322
new,
294323
} => FingerprintMismatch::TrackedEnvChanged { name, old, new },
324+
crate::session::execute::fingerprint::PostRunMismatch::TrackedEnvGlobChanged {
325+
pattern,
326+
diff,
327+
} => FingerprintMismatch::TrackedEnvGlobChanged { pattern, diff },
295328
};
296329
return Ok(Err(CacheMiss::FingerprintMismatch(fingerprint_mismatch)));
297330
}

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

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,52 @@ pub struct PostRunFingerprint {
2727
/// Only populated when `input_config.includes_auto` is true.
2828
pub inferred_inputs: HashMap<RelativePathBuf, PathFingerprint>,
2929

30-
/// Env vars observed via runner-aware IPC with `tracked: true`. Key is the
31-
/// env name; value is the env value at execution time (or `None` if unset).
32-
/// Validated at cache lookup by comparing against the current parent env.
30+
/// Env vars observed via runner-aware IPC `getEnv` with `tracked: true`.
31+
/// Key is the env name; value is the env value at execution time (or
32+
/// `None` if unset). Validated at cache lookup by comparing against the
33+
/// current parent env.
3334
pub tracked_envs: BTreeMap<Str, Option<Str>>,
35+
36+
/// Glob-pattern env queries (`getEnvs`) made with `tracked: true`.
37+
/// Outer key is the glob pattern, inner map is the match-set at
38+
/// execution time (name → value). Validated at cache lookup by
39+
/// re-matching against the current parent env and comparing the
40+
/// resulting set.
41+
pub tracked_env_globs: BTreeMap<Str, BTreeMap<Str, Str>>,
3442
}
3543

3644
/// A mismatch between the stored post-run fingerprint and the current state.
45+
#[expect(
46+
clippy::enum_variant_names,
47+
reason = "all three variants describe different kinds of post-run changes; \
48+
dropping the `Changed` suffix on any one of them would be misleading"
49+
)]
3750
#[derive(Debug, Clone)]
3851
pub enum PostRunMismatch {
3952
/// An inferred input file or directory changed.
4053
InputChanged { kind: InputChangeKind, path: RelativePathBuf },
4154
/// A tool-tracked env var changed value (or was added/removed).
4255
TrackedEnvChanged { name: Str, old: Option<Str>, new: Option<Str> },
56+
/// A tool-tracked env glob's match-set changed between runs. The glob
57+
/// still matches the same pattern, but added/removed/mutated entries.
58+
TrackedEnvGlobChanged { pattern: Str, diff: EnvGlobDiff },
59+
}
60+
61+
/// Per-pattern diff between stored and current match-set. Each map is a
62+
/// subset of the symmetric difference keyed by env name. `changed` holds
63+
/// names present in both with differing values; `added` / `removed` are
64+
/// exclusive to one side.
65+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66+
pub struct EnvGlobDiff {
67+
pub added: BTreeMap<Str, Str>,
68+
pub removed: BTreeMap<Str, Str>,
69+
pub changed: BTreeMap<Str, (Str, Str)>,
70+
}
71+
72+
impl EnvGlobDiff {
73+
fn is_empty(&self) -> bool {
74+
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
75+
}
4376
}
4477

4578
/// Fingerprint for a single path (file or directory)
@@ -79,12 +112,14 @@ impl PostRunFingerprint {
79112
/// * `base_dir` - Workspace root for resolving relative paths
80113
/// * `globbed_inputs` - Prerun glob fingerprint; paths here are skipped
81114
/// * `tracked_envs` - Tool-requested env vars (name → value), validated on lookup
115+
/// * `tracked_env_globs` - Tool-requested env globs (pattern → matches), validated on lookup
82116
#[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")]
83117
pub fn create(
84118
inferred_path_reads: &HashMap<RelativePathBuf, PathRead>,
85119
base_dir: &AbsolutePath,
86120
globbed_inputs: &BTreeMap<RelativePathBuf, u64>,
87121
tracked_envs: BTreeMap<Str, Option<Str>>,
122+
tracked_env_globs: BTreeMap<Str, BTreeMap<Str, Str>>,
88123
) -> anyhow::Result<Self> {
89124
let inferred_inputs = inferred_path_reads
90125
.par_iter()
@@ -96,7 +131,7 @@ impl PostRunFingerprint {
96131
})
97132
.collect::<anyhow::Result<HashMap<_, _>>>()?;
98133

99-
Ok(Self { inferred_inputs, tracked_envs })
134+
Ok(Self { inferred_inputs, tracked_envs, tracked_env_globs })
100135
}
101136

102137
/// Validates the fingerprint against current filesystem and env state.
@@ -148,10 +183,65 @@ impl PostRunFingerprint {
148183
}));
149184
}
150185
}
186+
187+
// Validate tracked env globs: re-enumerate parent env for each
188+
// pattern and diff against the stored match-set.
189+
for (pattern, stored_matches) in &self.tracked_env_globs {
190+
let current_matches = match_env_glob(pattern.as_str())?;
191+
let diff = diff_env_glob(stored_matches, &current_matches);
192+
if !diff.is_empty() {
193+
return Ok(Some(PostRunMismatch::TrackedEnvGlobChanged {
194+
pattern: pattern.clone(),
195+
diff,
196+
}));
197+
}
198+
}
199+
151200
Ok(None)
152201
}
153202
}
154203

204+
/// Build the current match-set for `pattern` by enumerating
205+
/// `std::env::vars_os()` and keeping UTF-8 names whose representation matches
206+
/// the glob. Mirrors the server-side match (see
207+
/// `vite_task_server::Recorder::get_envs`).
208+
fn match_env_glob(pattern: &str) -> anyhow::Result<BTreeMap<Str, Str>> {
209+
let set = vite_glob::GlobPatternSet::new(std::iter::once(pattern))?;
210+
Ok(std::env::vars_os()
211+
.filter_map(|(name, value)| {
212+
let name_str = name.to_str()?.to_owned();
213+
let value_str = value.to_str()?.to_owned();
214+
if set.is_match(&name_str) {
215+
Some((Str::from(name_str.as_str()), Str::from(value_str.as_str())))
216+
} else {
217+
None
218+
}
219+
})
220+
.collect())
221+
}
222+
223+
/// Compute the diff of two match-sets for the same glob pattern.
224+
fn diff_env_glob(stored: &BTreeMap<Str, Str>, current: &BTreeMap<Str, Str>) -> EnvGlobDiff {
225+
let mut diff = EnvGlobDiff::default();
226+
for (name, stored_value) in stored {
227+
match current.get(name) {
228+
None => {
229+
diff.removed.insert(name.clone(), stored_value.clone());
230+
}
231+
Some(current_value) if current_value != stored_value => {
232+
diff.changed.insert(name.clone(), (stored_value.clone(), current_value.clone()));
233+
}
234+
Some(_) => {}
235+
}
236+
}
237+
for (name, current_value) in current {
238+
if !stored.contains_key(name) {
239+
diff.added.insert(name.clone(), current_value.clone());
240+
}
241+
}
242+
diff
243+
}
244+
155245
/// Determine the kind of change between two differing path fingerprints.
156246
/// Caller guarantees `stored != current`.
157247
///

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,10 @@ pub async fn execute_spawn(
765765
.as_ref()
766766
.map(|r| collect_tracked_envs(r, metadata))
767767
.unwrap_or_default();
768+
let tracked_env_globs = reports
769+
.as_ref()
770+
.map(|r| collect_tracked_env_globs(r, metadata))
771+
.unwrap_or_default();
768772

769773
// Execution succeeded — attempt to create fingerprint and update cache.
770774
// Paths already in globbed_inputs are skipped: Rule 1 (above) guarantees
@@ -774,6 +778,7 @@ pub async fn execute_spawn(
774778
cache_base_path,
775779
&globbed_inputs,
776780
tracked_envs,
781+
tracked_env_globs,
777782
) {
778783
Ok(post_run_fingerprint) => {
779784
// Collect output files and create archive. Tool-reported
@@ -885,6 +890,38 @@ fn collect_tracked_envs(reports: &Reports, metadata: &CacheMetadata) -> BTreeMap
885890
.collect()
886891
}
887892

893+
/// Select tool-reported env-glob records to embed in the post-run
894+
/// fingerprint. Only `tracked: true` records are included, and within each
895+
/// matched set, names already covered by the user's declared `env` (via the
896+
/// spawn fingerprint) are skipped — they're redundant and could cause
897+
/// spurious cache misses if the user later changes their `env` config.
898+
fn collect_tracked_env_globs(
899+
reports: &Reports,
900+
metadata: &CacheMetadata,
901+
) -> BTreeMap<Str, BTreeMap<Str, Str>> {
902+
let fingerprinted = &metadata.spawn_fingerprint.env_fingerprints().fingerprinted_envs;
903+
reports
904+
.env_glob_records
905+
.iter()
906+
.filter(|(_, record)| record.tracked)
907+
.map(|(pattern, record)| {
908+
let filtered: BTreeMap<Str, Str> = record
909+
.matches
910+
.iter()
911+
.filter_map(|(name, value)| {
912+
let name_str = name.to_str()?;
913+
if fingerprinted.contains_key(name_str) {
914+
return None;
915+
}
916+
let value_str = value.to_str()?;
917+
Some((Str::from(name_str), Str::from(value_str)))
918+
})
919+
.collect();
920+
(Str::from(pattern.as_ref()), filtered)
921+
})
922+
.collect()
923+
}
924+
888925
/// Collect output files and create a tar.zst archive in the cache directory.
889926
///
890927
/// Output files are determined by:

crates/vite_task/src/session/reporter/summary.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ pub enum SavedCacheMissReason {
132132
InputChanged { kind: InputChangeKind, path: Str },
133133
/// A runner-aware tool reported a tracked env var that changed between runs.
134134
TrackedEnvChanged { name: Str, old: Option<Str>, new: Option<Str> },
135+
/// A runner-aware tool reported a tracked env glob whose match-set changed
136+
/// between runs.
137+
TrackedEnvGlobChanged { pattern: Str, diff: crate::session::execute::fingerprint::EnvGlobDiff },
135138
}
136139

137140
/// An execution error, serializable for persistence.
@@ -280,6 +283,9 @@ impl SavedCacheMissReason {
280283
new: new.clone(),
281284
}
282285
}
286+
FingerprintMismatch::TrackedEnvGlobChanged { pattern, diff } => {
287+
Self::TrackedEnvGlobChanged { pattern: pattern.clone(), diff: diff.clone() }
288+
}
283289
},
284290
}
285291
}
@@ -554,6 +560,9 @@ impl TaskResult {
554560
SavedCacheMissReason::TrackedEnvChanged { name, .. } => {
555561
vite_str::format!("→ Cache miss: tracked env '{name}' changed")
556562
}
563+
SavedCacheMissReason::TrackedEnvGlobChanged { pattern, .. } => {
564+
vite_str::format!("→ Cache miss: tracked env glob '{pattern}' changed")
565+
}
557566
},
558567
},
559568
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { fetchEnvs } from '@voidzero-dev/vite-task-client';
2+
import { writeFileSync, mkdirSync } from 'node:fs';
3+
4+
// fetchEnvs asks the runner for every env matching the glob. The glob (plus
5+
// its match-set) becomes part of the post-run fingerprint, so adding,
6+
// removing, or changing any matching env invalidates the cache on the next
7+
// run. The non-matching UNRELATED envs set by some test steps must not
8+
// contribute.
9+
const matches = fetchEnvs('PROBE_*', { tracked: true });
10+
11+
mkdirSync('dist', { recursive: true });
12+
const sorted = Object.entries(matches).sort(([a], [b]) => a.localeCompare(b));
13+
const body = sorted.map(([k, v]) => `${k}=${v}`).join('\n');
14+
writeFileSync('dist/out.txt', body + '\n');

0 commit comments

Comments
 (0)