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