Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod evict;
4pub mod expectations;
5mod glob;
6mod hash;
7mod ignored;
8mod inputs;
9mod paths;
10pub mod profile;
11mod record;
12pub mod recorded;
13mod remote;
14mod store;
15pub mod ultracache;
16pub mod warm;
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::PathBuf;
20use std::sync::OnceLock;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use anyhow::Result;
24use qk_config::Workspace;
25use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
26use qk_taskgraph::{Task, TaskGraph};
27use serde_json::Value;
28
29pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
30pub use glob::Pattern;
31pub use hash::{
32    DependencyGlob, Selects, SourceIgnore, dependency_globs, keyed_definition, source_files,
33    task_configuration,
34};
35pub use inputs::{
36    InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
37    manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
38    without_resolution,
39};
40/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
41pub use paths::expand as expand_project_path;
42pub use paths::git as workspace_git;
43pub use paths::{
44    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
45    worktree_state,
46};
47
48/// The nx.json settings of the workspace's remote store: its `s3`, and its
49/// `storeServer` or `qk:storeServer`. Nx never reads `qk:storeServer`, so a
50/// workspace can name a store server for qk while Nx's own cache plugin keeps
51/// `s3`.
52fn remote_settings(workspace: &Workspace) -> (Option<&Value>, Option<&Value>) {
53    let extra = &workspace.config.extra;
54    match extra.get("qk:storeServer") {
55        Some(server) => (None, Some(server)),
56        None => (extra.get("s3"), extra.get("storeServer")),
57    }
58}
59
60/// The cache for one run; its workspace snapshot is taken on first use.
61pub struct Cache {
62    pub root: PathBuf,
63    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
64    remote: Option<std::sync::Arc<remote::Remote>>,
65    /// Whether results and warm state may be restored or published.
66    reuse: bool,
67    /// The tasks whose outputs this run left exactly as they were.
68    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
69    /// Warm state being saved in the background.
70    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
71    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
72    /// Every target's declared outputs, which recordings' listings leave out.
73    workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
74}
75
76/// Where a task's result came from.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum CacheStatus {
80    LocalHit,
81    RemoteHit,
82    Miss,
83    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
84    Uncached,
85}
86
87/// One actual keyed execution, independent of reusable cache entries.
88pub struct Execution {
89    pub log: qk_executor::RecordedLog,
90    pub inputs_unchanged: bool,
91}
92
93pub struct TaskResult {
94    pub outcome: Outcome,
95    pub cache: CacheStatus,
96    /// The task's key and what it was computed from, when it has one.
97    pub key: Option<(String, Value)>,
98    /// What warm state did, for targets that keep it.
99    pub warm: Option<warm::WarmReport>,
100    pub execution: Option<Execution>,
101    /// How a task keyed by its recordings was keyed: see [`recorded`].
102    pub recorded: Option<Recorded>,
103    /// For a task with `qk:ultracache` mode `"record"`, what its recordings
104    /// would have done.
105    pub shadow: Option<Shadow>,
106}
107
108/// What the recordings of a task keyed by its declared inputs would have done.
109#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase", tag = "kind")]
111pub enum Shadow {
112    /// One of its path sets' keys had a result, here or in the remote store.
113    WouldHit { key: String },
114    /// None of its path sets' keys had a result.
115    WouldMiss,
116    /// It has no path set yet.
117    Unrecorded,
118}
119
120/// A lookup by a task's recordings in mode `"record"`, started before its
121/// lookup by its declared key so that the two wait on the remote store
122/// together rather than one after the other.
123enum Shadowing {
124    /// Answered without the store: a recording's result is held here, or
125    /// there is no store to ask.
126    Settled(Shadow),
127    /// Waiting for the path sets the store holds, with the keys of the path
128    /// sets held here, none of which has a result here.
129    Fetching {
130        keys: Vec<String>,
131        fetch: std::thread::JoinHandle<Result<Option<Vec<u8>>>>,
132    },
133}
134
135/// The key of one of a task's path sets, or none, said, when the path set
136/// cannot key it.
137fn recording_key(
138    weak: &str,
139    set: &recorded::PathSet,
140    snapshot: &hash::Snapshot,
141    workspace: &Workspace,
142    unlisted: &recorded::Unlisted,
143    task: &Task,
144) -> Option<String> {
145    recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
146        .inspect_err(|error| {
147            qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
148        })
149        .ok()
150}
151
152/// What a lookup by a task's recordings found.
153enum Found {
154    /// A result under this key in the local cache.
155    Local(String),
156    /// A result under this key in the remote store.
157    Remote(String),
158    Nothing,
159}
160
161/// How a task with `qk:ultracache` was keyed.
162#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163#[serde(rename_all = "camelCase", tag = "kind")]
164pub enum Recorded {
165    /// One of its path sets' keys had a result, restored under that key.
166    Hit { key: String },
167    /// This run recorded it, and stored its result under its new key.
168    Saved { key: String },
169    /// None of its path sets' keys had a result, and this run did not record.
170    Missed,
171    /// It has no path set yet, and this run did not record one: it was keyed
172    /// by its declared inputs.
173    Unrecorded,
174    /// None of its path sets' keys had a result, and its declared key, which
175    /// covers what each of them read, did: restored under that key.
176    Declared { key: String },
177    /// Recording it failed, with why.
178    Failed { reason: String },
179}
180
181impl TaskResult {
182    pub fn uncached(outcome: Outcome) -> Self {
183        Self {
184            outcome,
185            cache: CacheStatus::Uncached,
186            key: None,
187            warm: None,
188            execution: None,
189            recorded: None,
190            shadow: None,
191        }
192    }
193}
194
195impl Cache {
196    pub fn new(root: PathBuf) -> Self {
197        Self {
198            root,
199            snapshot: OnceLock::new(),
200            remote: None,
201            reuse: true,
202            kept: Default::default(),
203            saves: Default::default(),
204            cleanup: Default::default(),
205            workspace_outputs: OnceLock::new(),
206        }
207    }
208
209    /// Records verified executions without configuring or using result stores.
210    pub fn for_observation(workspace: &Workspace) -> Self {
211        Self {
212            reuse: false,
213            ..Self::new(paths::cache_location(workspace))
214        }
215    }
216
217    /// The workspace's cache, with the remote store its nx.json `s3`,
218    /// `storeServer` or `qk:storeServer` key configures, if any. Credentials
219    /// and modes are read from `environment`, which includes the workspace's
220    /// dotenv files. A remote store that cannot be used is reported and left
221    /// out; the local cache still works.
222    pub fn for_workspace(
223        workspace: &Workspace,
224        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
225    ) -> Self {
226        let root = paths::cache_location(workspace);
227        let (s3, server) = remote_settings(workspace);
228        let remote = match remote::configure(s3, server, environment) {
229            Ok(remote) => remote.map(std::sync::Arc::new),
230            Err(error) => {
231                qk_executor::status!("qk: remote cache disabled: {error:#}");
232                None
233            }
234        };
235        if let Some(remote) = &remote {
236            remote.read_index(&root);
237        }
238        Self {
239            remote,
240            ..Self::new(root)
241        }
242    }
243
244    /// Saves what later runs reuse and waits for background saves, uploads
245    /// and retired-output cleanup, reporting failures.
246    pub fn finish(&self, workspace: &Workspace) {
247        if !self.reuse {
248            return;
249        }
250        if let Some(Ok(snapshot)) = self.snapshot.get() {
251            snapshot.save_digests(&workspace.root);
252        }
253        // Before the uploads, which a save may add to.
254        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
255            let _ = save.join();
256        }
257        if let Some(remote) = &self.remote {
258            let failures = remote.finish();
259            for failure in &failures {
260                qk_executor::status!("qk: remote cache upload failed for {failure}");
261            }
262        }
263        let _profile = profile::span("cache", "restore_cleanup_wait");
264        self.cleanup.lock().unwrap().take();
265    }
266
267    fn snapshot(
268        &self,
269        workspace: &Workspace,
270        graph: &TaskGraph,
271    ) -> std::result::Result<&hash::Snapshot, String> {
272        self.snapshot
273            .get_or_init(|| {
274                hash::Snapshot::new(workspace, graph, &self.root)
275                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
276            })
277            .as_ref()
278            .map_err(Clone::clone)
279    }
280
281    pub fn run(
282        &self,
283        workspace: &Workspace,
284        graph: &TaskGraph,
285        task: &Task,
286        prepared: &PreparedTask,
287        cancelled: &AtomicBool,
288    ) -> Result<TaskResult> {
289        let result = self.run_task(workspace, graph, task, prepared, cancelled);
290        if let Some(Ok(snapshot)) = self.snapshot.get() {
291            snapshot.outputs_written(workspace, task);
292        }
293        result
294    }
295
296    fn run_task(
297        &self,
298        workspace: &Workspace,
299        graph: &TaskGraph,
300        task: &Task,
301        prepared: &PreparedTask,
302        cancelled: &AtomicBool,
303    ) -> Result<TaskResult> {
304        let cacheable = task.definition.cache == Some(true);
305        // Held until the task is done, whether it ran or came from the cache.
306        let _kept = self
307            .reuse
308            .then(|| warm::keep_across(workspace, graph, task));
309        let entries = if self.reuse {
310            warm::config(workspace, task).unwrap_or_else(|error| {
311                qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
312                Vec::new()
313            })
314        } else {
315            Vec::new()
316        };
317        // Names an entry in messages: the group it shares, if any.
318        let entry_label = |warm: &warm::Warm| match warm.group() {
319            Some(group) => format!(" (group {group})"),
320            None => String::new(),
321        };
322        // The warm variables point tools at their state; they are not inputs,
323        // so the key is computed from the task without them.
324        let mut running = prepared.clone();
325        for warm in &entries {
326            for (name, value) in &warm.env {
327                running.execution.insert(name.into(), value.into());
328            }
329        }
330        // What each entry restored, and which groups were on disk already.
331        let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
332            let (mut restored, mut present) = (Vec::new(), Vec::new());
333            for warm in &entries {
334                match self
335                    .initialize()
336                    .and_then(|()| self.restore_warm(workspace, task, warm, running))
337                {
338                    Ok((entry, groups)) => {
339                        if !entry.groups.is_empty() {
340                            restored.push(entry);
341                        }
342                        present.extend(groups);
343                    }
344                    Err(error) => qk_executor::status!(
345                        "qk: {}: warm state{} not restored ({error:#})",
346                        task.id,
347                        entry_label(warm)
348                    ),
349                }
350            }
351            (restored, present)
352        };
353        let after = |outcome: Outcome,
354                     (restored, present): (Vec<warm::Restored>, Vec<String>),
355                     running: &PreparedTask|
356         -> Option<warm::WarmReport> {
357            if entries.is_empty() {
358                return None;
359            }
360            let mut save_ms = None;
361            let mut background = false;
362            for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
363                let started = std::time::Instant::now();
364                let saved = if warm.background {
365                    self.save_warm_in_background(workspace, task, warm, running)
366                } else {
367                    self.save_warm(workspace, task, warm, running)
368                };
369                match saved {
370                    Ok(()) if warm.background => background = true,
371                    Ok(()) => {
372                        *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
373                    }
374                    Err(error) => qk_executor::status!(
375                        "qk: {}: warm state{} not saved ({error:#})",
376                        task.id,
377                        entry_label(warm)
378                    ),
379                }
380            }
381            Some(warm::WarmReport {
382                restored,
383                present,
384                save_ms,
385                background,
386            })
387        };
388        // Reports why this task runs uncached.
389        let bypass = |reason: String| {
390            if cacheable {
391                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
392            }
393            let _profile = profile::span(&task.id, "execute");
394            execute(prepared, cancelled).map(TaskResult::uncached)
395        };
396        let snapshot = match self.snapshot(workspace, graph) {
397            Ok(snapshot) => snapshot,
398            Err(reason) => return bypass(reason),
399        };
400        let dependency_profile = profile::span(&task.id, "dependency_inputs");
401        let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
402        {
403            Ok(dependencies) => dependencies,
404            Err(error) => return bypass(format!("{error:#}")),
405        };
406        drop(dependency_profile);
407        let input_profile = profile::span(&task.id, "inputs");
408        let (key, inputs) = match hash::inputs(
409            snapshot,
410            workspace,
411            task,
412            prepared,
413            &dependencies,
414            cancelled,
415        )
416        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
417        {
418            Ok(key) => key,
419            Err(error) => return bypass(format!("{error:#}")),
420        };
421        drop(input_profile);
422        let keyed = Some((key.clone(), inputs));
423        // As in Nx, the task sees its hash.
424        running
425            .execution
426            .insert("NX_TASK_HASH".into(), key.clone().into());
427        let running = &running;
428        let outputs = match paths::Outputs::new(workspace, task) {
429            Ok(outputs) => outputs,
430            Err(error) => return bypass(format!("{error:#}")),
431        };
432        if !cacheable {
433            let restored = before(running);
434            let executing = profile::span(&task.id, "execute");
435            let outcome = execute(running, cancelled)?;
436            drop(executing);
437            let warm_report = after(outcome, restored, running);
438            return Ok(TaskResult {
439                outcome,
440                cache: CacheStatus::Uncached,
441                key: keyed,
442                warm: warm_report,
443                execution: None,
444                recorded: None,
445                shadow: None,
446            });
447        }
448        if !self.reuse {
449            let capture = Capture::new(None, prepared.display.clone()).retain_log();
450            let executing = profile::span(&task.id, "execute");
451            let outcome = execute_captured(running, cancelled, Some(&capture))?;
452            drop(executing);
453            let inputs_unchanged =
454                self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
455            return Ok(TaskResult {
456                outcome,
457                cache: CacheStatus::Uncached,
458                key: keyed,
459                warm: None,
460                execution: capture.take_log().map(|log| Execution {
461                    log,
462                    inputs_unchanged,
463                }),
464                recorded: None,
465                shadow: None,
466            });
467        }
468        if let Err(error) = self.initialize() {
469            return bypass(format!("cache unavailable: {error}"));
470        }
471        let (_lock, waited) = match self.lock(&key, cancelled) {
472            Ok(Some(lock)) => lock,
473            Ok(None) => {
474                return Ok(TaskResult::uncached(Outcome::Cancelled));
475            }
476            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
477        };
478        // Inputs may have changed while another worktree held this key's lock.
479        if waited
480            && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
481                .and_then(|dependencies| {
482                    hash::fingerprint(
483                        snapshot,
484                        workspace,
485                        task,
486                        prepared,
487                        &dependencies,
488                        cancelled,
489                    )
490                })
491                .ok()
492                .as_ref()
493                != Some(&key)
494        {
495            return bypass("inputs changed while waiting for another run".into());
496        }
497        // Keyed by its recordings, a task's path sets stand in for its
498        // declared file inputs: see `recorded`.
499        let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
500            qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
501            None
502        });
503        let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
504        let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
505            .chain(warm_outputs.as_ref())
506            .collect();
507        // What its recordings' listings leave out, by the workspace's
508        // configuration rather than what this run schedules, so that the
509        // same recording keys the same in any run.
510        let others = settings.as_ref().map(|_| recorded::Others {
511            outputs: self
512                .workspace_outputs
513                .get_or_init(|| workspace_outputs(workspace)),
514            upstream: upstream(graph, task),
515        });
516        let ignore: Vec<Pattern> = settings
517            .as_ref()
518            .map(|settings| {
519                settings
520                    .ignore_reads
521                    .iter()
522                    .filter_map(|glob| Pattern::new(glob, false).ok())
523                    .collect()
524            })
525            .unwrap_or_default();
526        let unlisted = recorded::Unlisted {
527            own: own.clone(),
528            others,
529            ignore: &ignore,
530        };
531        let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
532        let mut recordings = settings.and_then(|settings| {
533            let weak = hash::weak_key(
534                snapshot,
535                workspace,
536                graph,
537                task,
538                prepared,
539                inputs,
540                &settings.ignore_reads,
541                cancelled,
542            );
543            match weak {
544                Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
545                Err(error) => {
546                    qk_executor::status!(
547                        "qk: {}: not keyed by its recordings ({error:#})",
548                        task.id
549                    );
550                    None
551                }
552            }
553        });
554        let mut shadowing = None;
555        if let Some((sets, weak, settings)) = &mut recordings {
556            if settings.mode == ultracache::Mode::Record {
557                let _lookup = profile::span(&task.id, "recorded_lookup");
558                shadowing =
559                    Some(self.start_shadow(workspace, task, snapshot, &unlisted, sets, weak));
560            } else {
561                let recorded_profile = profile::span(&task.id, "recorded_restore");
562                let found = self.by_recordings(
563                    workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
564                    cancelled,
565                );
566                drop(recorded_profile);
567                if let found @ (Found::Local(_) | Found::Remote(_)) = found {
568                    let (strong, remote) = match found {
569                        Found::Local(strong) => {
570                            if let Some(remote) = &self.remote {
571                                remote.held(&strong);
572                            }
573                            (strong, None)
574                        }
575                        Found::Remote(strong) => (strong, Some(())),
576                        Found::Nothing => unreachable!(),
577                    };
578                    recorded::touch(&self.root, weak);
579                    qk_executor::report::event(qk_executor::report::Event::Cache {
580                        id: task.id.clone(),
581                        cached: if remote.is_some() {
582                            qk_executor::report::Cached::RemoteHit
583                        } else {
584                            qk_executor::report::Cached::Hit
585                        },
586                    });
587                    return Ok(TaskResult {
588                        outcome: Outcome::Success,
589                        cache: if remote.is_some() {
590                            CacheStatus::RemoteHit
591                        } else {
592                            CacheStatus::LocalHit
593                        },
594                        key: keyed,
595                        warm: None,
596                        execution: None,
597                        recorded: Some(Recorded::Hit { key: strong }),
598                        shadow: None,
599                    });
600                }
601            }
602        }
603        // What its recordings would have done, once the store has said.
604        let mut settle = |recordings: &mut Option<(Vec<recorded::PathSet>, String, _)>| {
605            let shadowing = shadowing.take()?;
606            let (sets, weak, _) = recordings.as_mut()?;
607            let _wait = profile::span(&task.id, "recorded_wait");
608            Some(self.settle_shadow(shadowing, workspace, task, snapshot, &unlisted, sets, weak))
609        };
610        // A task with path sets is keyed by them alone, unless its declared
611        // inputs, as they are now, cover what each of them read: otherwise
612        // its declared inputs are what a recording found wanting. In mode
613        // "record" it is keyed by its declared inputs all the same.
614        let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
615            settings.mode == ultracache::Mode::Record
616                || sets.is_empty()
617                || hash::coverage(snapshot, workspace, graph, task, prepared, cancelled).is_ok_and(
618                    |coverage| {
619                        let outputs = self
620                            .workspace_outputs
621                            .get_or_init(|| workspace_outputs(workspace));
622                        let output = |path: &str| outputs.matches(path);
623                        sets.iter()
624                            .all(|set| coverage.covers(snapshot, &workspace.root, set, &output))
625                    },
626                )
627        });
628        // How its recordings keyed it, unless it runs and records.
629        let unrecorded = recordings
630            .as_ref()
631            .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
632            .map(|(sets, _, _)| {
633                if sets.is_empty() {
634                    Recorded::Unrecorded
635                } else {
636                    Recorded::Declared { key: key.clone() }
637                }
638            });
639        // An entry fetched ahead of this task is local, but it came from the
640        // remote store, and is reported as such.
641        let prefetched = declared
642            && self.remote.as_ref().is_some_and(|remote| {
643                remote.resolved(&key);
644                remote.prefetched(&key)
645            });
646        let local_profile = profile::span(&task.id, "local_restore");
647        let restored = if declared {
648            self.restore(
649                &workspace.root,
650                &task.id,
651                &task.dependencies,
652                &key,
653                &outputs,
654                &prepared.display,
655                if prefetched {
656                    qk_executor::Shown::RemoteCache
657                } else {
658                    qk_executor::Shown::LocalCache
659                },
660            )
661        } else {
662            Ok(false)
663        };
664        match restored {
665            Ok(true) => {
666                let claimed = match &self.remote {
667                    Some(remote) if prefetched && remote.claim(&key) => true,
668                    Some(remote) => {
669                        remote.held(&key);
670                        false
671                    }
672                    None => false,
673                };
674                qk_executor::report::event(qk_executor::report::Event::Cache {
675                    id: task.id.clone(),
676                    cached: if claimed {
677                        qk_executor::report::Cached::RemoteHit
678                    } else {
679                        qk_executor::report::Cached::Hit
680                    },
681                });
682                return Ok(TaskResult {
683                    outcome: Outcome::Success,
684                    cache: if claimed {
685                        CacheStatus::RemoteHit
686                    } else {
687                        CacheStatus::LocalHit
688                    },
689                    key: keyed,
690                    warm: None,
691                    execution: None,
692                    recorded: unrecorded,
693                    shadow: settle(&mut recordings),
694                });
695            }
696            Ok(false) => {}
697            Err(error) => {
698                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
699            }
700        }
701        drop(local_profile);
702        if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
703            let fetch_profile = profile::span(&task.id, "remote_fetch");
704            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
705            drop(fetch_profile);
706            let fetched = fetched.and_then(|found| {
707                if !found {
708                    return Ok(false);
709                }
710                self.restore(
711                    &workspace.root,
712                    &task.id,
713                    &task.dependencies,
714                    &key,
715                    &outputs,
716                    &prepared.display,
717                    qk_executor::Shown::RemoteCache,
718                )
719            });
720            match fetched {
721                Ok(true) => {
722                    qk_executor::report::event(qk_executor::report::Event::Cache {
723                        id: task.id.clone(),
724                        cached: qk_executor::report::Cached::RemoteHit,
725                    });
726                    return Ok(TaskResult {
727                        outcome: Outcome::Success,
728                        cache: CacheStatus::RemoteHit,
729                        key: keyed,
730                        warm: None,
731                        execution: None,
732                        recorded: unrecorded,
733                        shadow: settle(&mut recordings),
734                    });
735                }
736                Ok(false) => {}
737                Err(error) => {
738                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
739                }
740            }
741        }
742        // Settled before it runs: recording it merges the path sets fetched.
743        let shadow = settle(&mut recordings);
744        qk_executor::report::event(qk_executor::report::Event::Cache {
745            id: task.id.clone(),
746            cached: qk_executor::report::Cached::Miss,
747        });
748        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
749            Ok(log) => log,
750            Err(error) => return bypass(format!("cache log unavailable: {error}")),
751        };
752        let capture =
753            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
754        let restored = before(running);
755        let listed = match (&recordings, &prepared.recording) {
756            (Some(_), Some(_)) => {
757                let project_root = workspace
758                    .projects
759                    .get(&task.project)
760                    .map_or("", |project| project.root.as_str());
761                recorded::Before::take(
762                    &workspace.root,
763                    unlisted.directories(project_root),
764                    &unlisted,
765                )
766            }
767            _ => recorded::Before::default(),
768        };
769        let started = recorded::now(&self.root.join("tmp"));
770        let executing = profile::span(&task.id, "execute");
771        let outcome = execute_captured(running, cancelled, Some(&capture))?;
772        drop(executing);
773        let warm_report = after(outcome, restored, running);
774        let inputs_unchanged =
775            self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
776        let execution = capture.take_log().map(|log| Execution {
777            log,
778            inputs_unchanged,
779        });
780        let mut recorded = unrecorded.map(|unrecorded| match &recordings {
781            Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
782            _ => unrecorded,
783        });
784        let path_set = match (&recordings, &prepared.recording) {
785            (Some((_, _, settings)), Some(recording)) => Some(
786                self.path_set(workspace, recording, prepared, settings, &own)
787                    .and_then(|set| {
788                        // Keyed by nothing, a recording would match any workspace.
789                        if set.is_empty() {
790                            return Err("its trace named no path in the workspace".to_owned());
791                        }
792                        match set
793                            .changed_since(&workspace.root, started, &unlisted, &listed)
794                            .map(|changed| changed.to_string())
795                        {
796                            Some(reason) => Err(reason),
797                            None => Ok(set),
798                        }
799                    }),
800            ),
801            _ => None,
802        };
803        if let Some(Err(reason)) = &path_set {
804            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
805            recorded = Some(Recorded::Failed {
806                reason: reason.clone(),
807            });
808        }
809        if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
810            let publishing = profile::span(&task.id, "publish");
811            let published = self.publish(&workspace.root, &key, &outputs, log.path());
812            drop(publishing);
813            match published {
814                Ok(()) => {
815                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
816                    if let Some(remote) = &self.remote {
817                        remote.upload(&self.root, &key);
818                    }
819                }
820                Err(error) => {
821                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
822                }
823            }
824            // Its result is kept under the key of what it read, too.
825            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
826                let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
827                    .and_then(|strong| {
828                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
829                        recorded::save(&self.root, weak, set)?;
830                        // Shared, the sets are merged with those the store
831                        // held when this task looked it up.
832                        if let Some(remote) = &self.remote {
833                            remote.upload(&self.root, &strong);
834                            if let Some(record) = recorded::record(&self.root, weak) {
835                                remote.upload_path_sets(&self.root, weak, record);
836                            }
837                        }
838                        Ok(strong)
839                    });
840                recorded = Some(match saved {
841                    Ok(key) => Recorded::Saved { key },
842                    Err(error) => {
843                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
844                        Recorded::Failed {
845                            reason: format!("{error:#}"),
846                        }
847                    }
848                });
849            }
850        }
851        Ok(TaskResult {
852            outcome,
853            cache: CacheStatus::Miss,
854            key: keyed,
855            warm: warm_report,
856            execution,
857            recorded,
858            shadow,
859        })
860    }
861
862    /// Looks a task up by its recordings and restores what it finds: by each
863    /// path set's key in the local cache, then, with none there, among the
864    /// path sets and results the remote store holds, adding its path sets to
865    /// `sets`.
866    #[allow(clippy::too_many_arguments)]
867    fn by_recordings(
868        &self,
869        workspace: &Workspace,
870        task: &Task,
871        prepared: &PreparedTask,
872        snapshot: &hash::Snapshot,
873        outputs: &paths::Outputs,
874        unlisted: &recorded::Unlisted,
875        sets: &mut Vec<recorded::PathSet>,
876        weak: &str,
877        declared: &str,
878        cancelled: &AtomicBool,
879    ) -> Found {
880        let strong =
881            |set: &recorded::PathSet| recording_key(weak, set, snapshot, workspace, unlisted, task);
882        let restored = |strong: &str, shown| {
883            self.restore(
884                &workspace.root,
885                &task.id,
886                &task.dependencies,
887                strong,
888                outputs,
889                &prepared.display,
890                shown,
891            )
892            .unwrap_or_else(|error| {
893                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
894                false
895            })
896        };
897        let local = |strong: &str| restored(strong, qk_executor::Shown::LocalCache);
898        let mut keys = Vec::new();
899        for set in sets.iter() {
900            if let Some(strong) = strong(set) {
901                if local(&strong) {
902                    return Found::Local(strong);
903                }
904                keys.push(strong);
905            }
906        }
907        let Some(remote) = &self.remote else {
908            return Found::Nothing;
909        };
910        // Without path sets here, a result here under the declared key is
911        // the task's, and asking the store for path sets would only delay
912        // it, run after run, and have them shadow it.
913        if sets.is_empty()
914            && self
915                .root
916                .join("entries")
917                .join(format!("{declared}.json"))
918                .is_file()
919        {
920            return Found::Nothing;
921        }
922        // Path sets other machines recorded, new here.
923        let fetched = remote
924            .fetch_path_sets(&self.root, weak, &task.id)
925            .and_then(|record| match record {
926                Some(record) => recorded::merge(&self.root, weak, &record),
927                None => Ok(Vec::new()),
928            });
929        match fetched {
930            Ok(added) => {
931                for set in added {
932                    if let Some(strong) = strong(&set) {
933                        if local(&strong) {
934                            sets.push(set);
935                            return Found::Local(strong);
936                        }
937                        keys.push(strong);
938                    }
939                    sets.push(set);
940                }
941            }
942            Err(error) => {
943                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
944            }
945        }
946        for strong in keys {
947            let found = remote
948                .fetch(&self.root, &strong, &task.id, cancelled)
949                .unwrap_or_else(|error| {
950                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id);
951                    false
952                })
953                && restored(&strong, qk_executor::Shown::RemoteCache);
954            if found {
955                return Found::Remote(strong);
956            }
957        }
958        Found::Nothing
959    }
960
961    /// Starts looking a task in mode `"record"` up by its recordings, without
962    /// restoring anything: by each path set's key in the local cache, and with
963    /// none there, by fetching the path sets the remote store holds on a
964    /// thread of its own, for [`Self::settle_shadow`] to finish.
965    fn start_shadow(
966        &self,
967        workspace: &Workspace,
968        task: &Task,
969        snapshot: &hash::Snapshot,
970        unlisted: &recorded::Unlisted,
971        sets: &[recorded::PathSet],
972        weak: &str,
973    ) -> Shadowing {
974        let mut keys = Vec::new();
975        for set in sets {
976            if let Some(strong) = recording_key(weak, set, snapshot, workspace, unlisted, task) {
977                if self.holds_entry(&strong) {
978                    return Shadowing::Settled(Shadow::WouldHit { key: strong });
979                }
980                keys.push(strong);
981            }
982        }
983        let Some(remote) = &self.remote else {
984            return Shadowing::Settled(if sets.is_empty() {
985                Shadow::Unrecorded
986            } else {
987                Shadow::WouldMiss
988            });
989        };
990        let (remote, root, weak, id) = (
991            std::sync::Arc::clone(remote),
992            self.root.clone(),
993            weak.to_owned(),
994            task.id.clone(),
995        );
996        Shadowing::Fetching {
997            keys,
998            fetch: std::thread::spawn(move || remote.fetch_path_sets(&root, &weak, &id)),
999        }
1000    }
1001
1002    /// What a task's recordings would have done: [`Self::start_shadow`]'s
1003    /// lookup, finished with the path sets the store held, which are added
1004    /// to `sets`, and the results it holds.
1005    #[allow(clippy::too_many_arguments)]
1006    fn settle_shadow(
1007        &self,
1008        shadowing: Shadowing,
1009        workspace: &Workspace,
1010        task: &Task,
1011        snapshot: &hash::Snapshot,
1012        unlisted: &recorded::Unlisted,
1013        sets: &mut Vec<recorded::PathSet>,
1014        weak: &str,
1015    ) -> Shadow {
1016        let (mut keys, fetch) = match shadowing {
1017            Shadowing::Settled(shadow) => return shadow,
1018            Shadowing::Fetching { keys, fetch } => (keys, fetch),
1019        };
1020        let fetched = fetch
1021            .join()
1022            .unwrap_or_else(|_| Err(anyhow::anyhow!("fetching them panicked")))
1023            .and_then(|record| match record {
1024                Some(record) => recorded::merge(&self.root, weak, &record),
1025                None => Ok(Vec::new()),
1026            });
1027        match fetched {
1028            Ok(added) => {
1029                for set in added {
1030                    if let Some(strong) =
1031                        recording_key(weak, &set, snapshot, workspace, unlisted, task)
1032                    {
1033                        if self.holds_entry(&strong) {
1034                            sets.push(set);
1035                            return Shadow::WouldHit { key: strong };
1036                        }
1037                        keys.push(strong);
1038                    }
1039                    sets.push(set);
1040                }
1041            }
1042            Err(error) => {
1043                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1044            }
1045        }
1046        let remote = self
1047            .remote
1048            .as_ref()
1049            .expect("only a remote store is fetched from");
1050        keys.into_iter()
1051            .find(|strong| remote.holds(strong).unwrap_or(false))
1052            .map_or(
1053                if sets.is_empty() {
1054                    Shadow::Unrecorded
1055                } else {
1056                    Shadow::WouldMiss
1057                },
1058                |key| Shadow::WouldHit { key },
1059            )
1060    }
1061
1062    /// Whether the local cache holds a result under `key`.
1063    fn holds_entry(&self, key: &str) -> bool {
1064        self.root
1065            .join("entries")
1066            .join(format!("{key}.json"))
1067            .is_file()
1068    }
1069
1070    /// The path set of a task's recording, or why there is none.
1071    fn path_set(
1072        &self,
1073        workspace: &Workspace,
1074        recording: &qk_executor::Recording,
1075        prepared: &PreparedTask,
1076        settings: &ultracache::Settings,
1077        own: &[&paths::Outputs],
1078    ) -> std::result::Result<recorded::PathSet, String> {
1079        let failed = |error: anyhow::Error| format!("{error:#}");
1080        let canonical = workspace
1081            .root
1082            .canonicalize()
1083            .map_err(|e| failed(e.into()))?;
1084        // Only what a path set can hold is kept and counted: a build reads
1085        // far more in node_modules and outside the workspace than in it.
1086        let keep = |path: &std::path::Path| {
1087            [&workspace.root, &canonical].iter().any(|root| {
1088                path.strip_prefix(root).is_ok_and(|relative| {
1089                    !relative.components().any(|component| {
1090                        matches!(
1091                            component.as_os_str().to_str(),
1092                            Some("node_modules" | ".git")
1093                        )
1094                    })
1095                })
1096            })
1097        };
1098        let limits = qk_input_analysis::strace::Limits {
1099            records: 1_000_000,
1100            bytes: 256 * 1024 * 1024,
1101            accesses: 500_000,
1102            keep: Some(&keep),
1103        };
1104        recording.settle().map_err(failed)?;
1105        let (accesses, diagnostics) =
1106            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
1107                .map_err(failed)?;
1108        // A partial trace would key the task by part of what it read.
1109        if let Some(diagnostic) = diagnostics.first() {
1110            return Err(diagnostic.clone());
1111        }
1112        let ignore = settings
1113            .ignore_reads
1114            .iter()
1115            .map(|glob| Pattern::new(glob, false))
1116            .collect::<Result<Vec<_>>>()
1117            .map_err(failed)?;
1118        // qk's own directories, where they lie inside the workspace.
1119        let state = [self.root.clone(), worktree_state(&workspace.root)]
1120            .iter()
1121            .filter_map(|path| {
1122                [&workspace.root, &canonical]
1123                    .iter()
1124                    .find_map(|root| path.strip_prefix(root).ok())
1125                    .and_then(|relative| relative.to_str())
1126                    .map(|relative| relative.replace('\\', "/"))
1127            })
1128            .collect();
1129        let exclusions = recorded::Exclusions {
1130            roots: [&workspace.root, &canonical],
1131            outputs: own,
1132            ignore,
1133            state,
1134        };
1135        Ok(recorded::PathSet::from_accesses(
1136            &accesses,
1137            &exclusions,
1138            &canonical,
1139        ))
1140    }
1141
1142    /// Rechecks declared inputs for successful and failed executions alike.
1143    #[allow(clippy::too_many_arguments)]
1144    fn inputs_match(
1145        &self,
1146        snapshot: &hash::Snapshot,
1147        workspace: &Workspace,
1148        graph: &TaskGraph,
1149        task: &Task,
1150        prepared: &PreparedTask,
1151        before: &str,
1152        cancelled: &AtomicBool,
1153    ) -> bool {
1154        if cancelled.load(Ordering::SeqCst) {
1155            return false;
1156        }
1157        let _profile = profile::span(&task.id, "inputs_recheck");
1158        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1159            .and_then(|dependencies| {
1160                hash::fingerprint(
1161                    snapshot,
1162                    workspace,
1163                    task,
1164                    prepared,
1165                    &dependencies,
1166                    cancelled,
1167                )
1168            });
1169        if after.as_deref().ok() != Some(before) {
1170            if task.definition.cache == Some(true) {
1171                if self.reuse {
1172                    qk_executor::status!(
1173                        "qk: {}: not caching because inputs changed during execution",
1174                        task.id
1175                    );
1176                } else {
1177                    qk_executor::status!(
1178                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
1179                        task.id
1180                    );
1181                }
1182            }
1183            return false;
1184        }
1185        true
1186    }
1187}
1188
1189/// A task's key, and the inputs it is computed from, or why it has none.
1190pub type Keyed = std::result::Result<(String, Value), String>;
1191
1192/// The key each task in `graph` would be cached under if it ran now, and what
1193/// it is computed from, without running a task. Runtime inputs are executed,
1194/// and the dependency outputs a task reads are taken as they are on disk.
1195pub fn keys(
1196    workspace: &Workspace,
1197    graph: &TaskGraph,
1198    prepared: &BTreeMap<String, PreparedTask>,
1199    cancelled: &AtomicBool,
1200) -> Result<BTreeMap<String, Keyed>> {
1201    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1202    Ok(graph
1203        .tasks
1204        .values()
1205        .map(|task| {
1206            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1207            (task.id.clone(), keyed)
1208        })
1209        .collect())
1210}
1211
1212fn key_now(
1213    snapshot: &hash::Snapshot,
1214    workspace: &Workspace,
1215    graph: &TaskGraph,
1216    task: &Task,
1217    prepared: &BTreeMap<String, PreparedTask>,
1218    cancelled: &AtomicBool,
1219) -> Keyed {
1220    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1221    let dependencies =
1222        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1223    let inputs = hash::inputs(
1224        snapshot,
1225        workspace,
1226        task,
1227        &prepared[&task.id],
1228        &dependencies,
1229        cancelled,
1230    )
1231    .map_err(failed)?;
1232    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1233}
1234
1235/// Runs the private detached-upload protocol used by the qk executable.
1236#[doc(hidden)]
1237pub fn run_upload_worker() -> anyhow::Result<()> {
1238    remote::background::run()
1239}
1240
1241/// The declared outputs of every target in the workspace, as their targets
1242/// declare them and as each of their configurations does with its options,
1243/// which can move an output; one whose outputs cannot be read is left out.
1244fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1245    let mut outputs = Vec::new();
1246    for (name, project) in &workspace.projects {
1247        for (target, definition) in &project.targets {
1248            let configured = definition
1249                .configurations
1250                .iter()
1251                .map(|(configuration, options)| {
1252                    let mut definition = definition.clone();
1253                    definition.options.extend(options.clone());
1254                    (Some(configuration.clone()), definition)
1255                });
1256            for (configuration, definition) in
1257                std::iter::once((None, definition.clone())).chain(configured)
1258            {
1259                let task = Task {
1260                    id: format!("{name}:{target}"),
1261                    project: name.clone(),
1262                    target: target.clone(),
1263                    configuration: configuration.clone(),
1264                    args: Vec::new(),
1265                    definition,
1266                    dependencies: Default::default(),
1267                    edges: Default::default(),
1268                };
1269                if let Some(declared) = paths::Outputs::new(workspace, &task)
1270                    .ok()
1271                    .filter(paths::Outputs::declared)
1272                {
1273                    outputs.push(((name.clone(), target.clone(), configuration), declared));
1274                }
1275            }
1276        }
1277    }
1278    let roots = workspace
1279        .projects
1280        .iter()
1281        .map(|(name, project)| (name.clone(), project.root.clone()))
1282        .collect();
1283    recorded::WorkspaceOutputs::new(outputs, &roots)
1284}
1285
1286/// The targets of every task `task` depends on, each with the configuration
1287/// it runs with. Its own target is not among them: its outputs are left out
1288/// of its listings as other targets' are.
1289fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1290    let of = |task: &Task| {
1291        (
1292            task.project.clone(),
1293            task.target.clone(),
1294            task.configuration.clone(),
1295        )
1296    };
1297    let mut targets = BTreeSet::new();
1298    let mut seen = BTreeSet::new();
1299    let mut pending: Vec<&String> = task.dependencies.iter().collect();
1300    while let Some(id) = pending.pop() {
1301        if !seen.insert(id) {
1302            continue;
1303        }
1304        if let Some(dependency) = graph.tasks.get(id) {
1305            targets.insert(of(dependency));
1306            pending.extend(&dependency.dependencies);
1307        }
1308    }
1309    targets
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315
1316    #[test]
1317    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1318        let workspace = tempfile::tempdir().unwrap();
1319        let root = workspace.path();
1320        let cache = Cache::new(root.join("cache"));
1321        cache.initialize().unwrap();
1322        let mut sources = Vec::new();
1323        for index in 0..130 {
1324            let source = root.join(format!("source-{index}"));
1325            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1326            sources.push(source);
1327        }
1328        let digest = cache.put_blob(&sources[0]).unwrap();
1329        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1330        std::fs::remove_file(&sources[31]).unwrap();
1331        let results = cache.put_blobs(&sources);
1332        assert_eq!(results.len(), sources.len());
1333        for (index, result) in results.into_iter().enumerate() {
1334            if index == 31 {
1335                assert_eq!(
1336                    result
1337                        .unwrap_err()
1338                        .downcast_ref::<std::io::Error>()
1339                        .unwrap()
1340                        .kind(),
1341                    std::io::ErrorKind::NotFound
1342                );
1343                continue;
1344            }
1345            let digest = result.unwrap();
1346            let held = cache.root.join("blobs").join(&digest);
1347            assert_eq!(
1348                std::fs::read(&held).unwrap(),
1349                format!("content-{}", index % 65).as_bytes()
1350            );
1351            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1352        }
1353    }
1354
1355    /// Content the store holds already is reused, not copied over again.
1356    #[cfg(unix)]
1357    #[test]
1358    fn a_blob_the_store_holds_is_not_written_again() {
1359        use std::os::unix::fs::MetadataExt;
1360        let workspace = tempfile::tempdir().unwrap();
1361        let root = workspace.path();
1362        let cache = Cache::new(root.join(".qk/cache"));
1363        cache.initialize().unwrap();
1364        std::fs::write(root.join("first"), "same").unwrap();
1365        std::fs::write(root.join("second"), "same").unwrap();
1366        std::fs::write(root.join("other"), "different").unwrap();
1367        let first = cache.put_blob(&root.join("first")).unwrap();
1368        let blob = cache.root.join("blobs").join(&first);
1369        let stored = std::fs::metadata(&blob).unwrap().ino();
1370        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1371        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1372        // A stored blob whose content no longer matches is stored anew.
1373        std::fs::write(&blob, "corrupt").unwrap();
1374        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1375        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1376        // So is one that cannot be read.
1377        {
1378            use std::os::unix::fs::PermissionsExt;
1379            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1380        }
1381        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1382        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1383        let other = cache.put_blob(&root.join("other")).unwrap();
1384        assert_ne!(other, first);
1385        assert_eq!(
1386            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1387            b"different"
1388        );
1389    }
1390
1391    /// Complete-directory restore preserves contents, links, modes and
1392    /// timestamps, and the subsequent hit keeps restored files in place.
1393    #[test]
1394    fn complete_directory_restores_preserve_outputs() {
1395        let workspace = tempfile::tempdir().unwrap();
1396        let root = workspace.path();
1397        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1398        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1399        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1400        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1401        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1402        #[cfg(unix)]
1403        {
1404            use std::os::unix::fs::PermissionsExt;
1405            let script = root.join("dist/run.sh");
1406            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1407            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1408            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1409            std::fs::set_permissions(
1410                root.join("dist/nested"),
1411                std::fs::Permissions::from_mode(0o500),
1412            )
1413            .unwrap();
1414            std::fs::set_permissions(
1415                root.join("bundle/readonly"),
1416                std::fs::Permissions::from_mode(0o444),
1417            )
1418            .unwrap();
1419        }
1420        let log = root.join("log");
1421        std::fs::write(&log, "").unwrap();
1422
1423        let cache = Cache::new(root.join(".qk/cache"));
1424        cache.initialize().unwrap();
1425        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1426        let key = "0".repeat(64);
1427        cache.publish(root, &key, &outputs, &log).unwrap();
1428
1429        #[cfg(unix)]
1430        {
1431            use std::os::unix::fs::PermissionsExt;
1432            // The manifest retains the restrictive mode; cleanup needs write
1433            // permission on the old directory to remove its files.
1434            std::fs::set_permissions(
1435                root.join("dist/nested"),
1436                std::fs::Permissions::from_mode(0o755),
1437            )
1438            .unwrap();
1439        }
1440        std::fs::remove_dir_all(root.join("dist")).unwrap();
1441        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1442        let before = std::time::SystemTime::now();
1443        let restored = cache
1444            .restore(
1445                root,
1446                "app:build",
1447                &Default::default(),
1448                &key,
1449                &outputs,
1450                &qk_executor::Display::Stream,
1451                qk_executor::Shown::LocalCache,
1452            )
1453            .unwrap();
1454        assert!(restored);
1455        assert!(root.join("dist/nested/empty").is_dir());
1456        assert!(root.join("bundle/empty").is_dir());
1457        assert_eq!(
1458            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1459            "readonly"
1460        );
1461        #[cfg(unix)]
1462        {
1463            assert_eq!(
1464                std::fs::read_link(root.join("dist/link")).unwrap(),
1465                std::path::Path::new("nested/a.txt")
1466            );
1467            assert_eq!(
1468                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1469                0o755
1470            );
1471            assert_eq!(
1472                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1473                0o500
1474            );
1475            assert_eq!(
1476                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1477                0o444
1478            );
1479        }
1480        let path = root.join("dist/nested/a.txt");
1481        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1482        assert!(modified >= before);
1483        let kept = cache
1484            .restore(
1485                root,
1486                "app:build",
1487                &Default::default(),
1488                &key,
1489                &outputs,
1490                &qk_executor::Display::Hidden,
1491                qk_executor::Shown::LocalCache,
1492            )
1493            .unwrap();
1494        assert!(kept);
1495        assert_eq!(
1496            std::fs::metadata(&path).unwrap().modified().unwrap(),
1497            modified
1498        );
1499        #[cfg(unix)]
1500        {
1501            use std::os::unix::fs::PermissionsExt;
1502            std::fs::set_permissions(
1503                root.join("dist/nested"),
1504                std::fs::Permissions::from_mode(0o755),
1505            )
1506            .unwrap();
1507        }
1508    }
1509
1510    #[test]
1511    fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1512        let temp = tempfile::tempdir().unwrap();
1513        let root = temp.path();
1514        std::fs::write(root.join("nx.json"), "{}").unwrap();
1515        std::fs::create_dir_all(root.join("app")).unwrap();
1516        std::fs::create_dir_all(root.join("lib")).unwrap();
1517        // A project at the workspace root, with an output in a folder of its
1518        // own and one inside another project.
1519        std::fs::write(
1520            root.join("project.json"),
1521            r#"{"name":"workspace","targets":{"cache":{"command":"true",
1522                "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1523        )
1524        .unwrap();
1525        std::fs::write(
1526            root.join("app/project.json"),
1527            r#"{"name":"app","root":"./app","targets":{
1528                "build":{"command":"true","outputs":["{options.outputPath}"],
1529                  "options":{"outputPath":"app/dist"},
1530                  "configurations":{"production":{"outputPath":"app/dist-production"}}},
1531                "lint":{"command":"true"},
1532                "cover":{"command":"true","outputs":["{projectRoot}/.coverage/unit"]},
1533                "cover-browser":{"command":"true","outputs":["{projectRoot}/.coverage/browser"]},
1534                "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1535                "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1536                  "configurations":{"production":{}}}}}"#,
1537        )
1538        .unwrap();
1539        std::fs::write(
1540            root.join("lib/project.json"),
1541            r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1542                "options":{"outputPath":"lib/dist"},
1543                "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1544        )
1545        .unwrap();
1546        let workspace = Workspace::load(root).unwrap();
1547        let graph = qk_taskgraph::TaskGraph::build(
1548            &workspace,
1549            &[
1550                qk_taskgraph::Request::parse("app:lint").unwrap(),
1551                qk_taskgraph::Request::parse("app:cover").unwrap(),
1552                qk_taskgraph::Request::parse("app:test").unwrap(),
1553                qk_taskgraph::Request::parse("app:test:production").unwrap(),
1554            ],
1555        )
1556        .unwrap();
1557        let outputs = workspace_outputs(&workspace);
1558        let others = |task: &str| recorded::Others {
1559            outputs: &outputs,
1560            upstream: upstream(&graph, &graph.tasks[task]),
1561        };
1562        let lint = others("app:lint");
1563        assert!(lint.matches("app/dist/main.js"));
1564        assert!(lint.matches("app/dist-production/main.js"));
1565        assert!(lint.matches("lib/dist"));
1566        assert!(!lint.matches("app/src/main.ts"));
1567        // The directories holding an output count as it, not its project's.
1568        assert!(lint.matches("app/target"));
1569        assert!(lint.matches("app/target/wasm"));
1570        assert!(!lint.matches("app"));
1571        // A root project's folders are top-level, but never another project.
1572        assert!(lint.matches(".cache"));
1573        assert!(lint.matches("app/extra"));
1574        assert!(!lint.matches("lib"));
1575        // Before the task runs, the directories those folders come and go
1576        // in are listed, another project's root among them.
1577        let unlisted = recorded::Unlisted {
1578            own: Vec::new(),
1579            others: Some(lint),
1580            ignore: &[],
1581        };
1582        let directories = unlisted.directories("lib");
1583        for directory in [".", ".cache", "app", "app/extra"] {
1584            assert!(directories.contains(directory), "{directory}");
1585        }
1586        // A dependency's output is the test's input, and only that of the
1587        // configuration it depends on.
1588        assert!(!others("app:test").matches("lib/dist"));
1589        assert!(others("app:test").matches("lib/dist-production"));
1590        // A folder holding the task's own output and another target's is
1591        // left out, as both are.
1592        assert!(others("app:cover").matches("app/.coverage"));
1593        let production = others("app:test:production");
1594        assert!(!production.matches("lib/dist-production"));
1595        assert!(production.matches("lib/dist"));
1596    }
1597}