Skip to main content

artifactize_core/
cache.rs

1//! Fingerprints, reuse keys, execution ownership, reuse, and end-of-review rechecks.
2
3mod content;
4
5use std::{
6    collections::{BTreeMap, BTreeSet},
7    path::Path,
8    sync::Arc,
9};
10
11use futures_util::{StreamExt, stream::FuturesUnordered};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use sha2::{Digest, Sha256};
15use tokio::sync::Semaphore;
16use tokio_util::sync::CancellationToken;
17
18pub use crate::store::history::{Entry, list, remove, show};
19pub(crate) use content::{HASH_BUFFER_BYTES, ignore_patterns};
20
21use crate::{
22    config::{ArtifactName, Eval, EvalDeclaration, Fingerprint, Profile, RepoConfig},
23    process, runtime, scope,
24    store::{Execution, Request},
25    workspace,
26};
27
28/// Keep retained per-file provenance small; the content digest remains complete
29/// even when the optional explain-changes manifest is too large to store.
30const MANIFEST_BYTES: usize = 64 * 1024;
31/// Keep change explanations readable in CLI output; the complete path list remains in Changes.
32const MAX_SUMMARY_PATHS: usize = 10;
33/// Fingerprint scripts receive version 1's Artifact identity stdin envelope. Its
34/// explicit format version lets owner scripts distinguish future envelopes, not DB schemas.
35const FINGERPRINT_INPUT_VERSION: u32 = 1;
36/// Keep per-file change explanations compact with 8 digest bytes (16 hex characters).
37/// This diagnostic prefix is not identity: artifactsum/reuse keys retain full SHA-256.
38const MANIFEST_DIGEST_PREFIX_BYTES: usize = 8;
39/// Domain-separated kind-aware key format; changes invalidate persisted cache keys.
40const KIND_KEY_FORMAT_PREFIX: &[u8] = b"artifactize-kind-key-v1\n";
41/// Domain-separate and version the reuse-key hash input, including its terminating newline.
42/// Changing these bytes invalidates existing reuse keys; this is not a config/session/DB version.
43const REUSE_KEY_FORMAT_PREFIX: &str = "artifactize-key-v2\n";
44
45/// Hash the evaluation criteria: what is asked and how the answer is judged, never how the eval
46/// is executed. Execution options (backend, model, reasoning, limits, the profile variant),
47/// the eval's id and title, and tool views stay out.
48pub fn eval_definition_hash(eval: &EvalDeclaration) -> crate::types::DefinitionHash {
49    let kind = match &eval.profile() {
50        Profile::Agent { .. } => "agent",
51        Profile::Human {} => "human",
52        Profile::Runtime { .. } => "runtime",
53    };
54    let mut criteria = json!({
55        "kind": kind,
56        "payload": eval.payload(),
57        "passSchema": eval.pass_schema(),
58        "failSchema": eval.fail_schema(),
59    });
60    if let Profile::Runtime { command, args, .. } = &eval.profile() {
61        criteria["command"] = json!(command);
62        criteria["args"] = json!(args);
63    }
64    criteria.sort_all_objects();
65    content::hex(&Sha256::digest(
66        serde_json::to_vec(&criteria).expect("evaluation criteria is JSON"),
67    ))
68    .parse()
69    .expect("a SHA-256 digest in hex is a definition hash")
70}
71
72/// A prepared fingerprint; artifactsum also carries its manifest.
73#[derive(Debug, Clone)]
74pub struct PreparedFingerprint {
75    pub value: crate::types::Fingerprint,
76    pub manifest: Option<Manifest>,
77}
78
79/// What artifactsum covered, saved with executions to explain later changes.
80/// A file map that would exceed 64 KiB is omitted; the fingerprint still covers it.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Manifest {
83    /// SHA-256 over every input path and file digest: the fingerprint without its prefix.
84    pub inputs: crate::types::Sha256Digest,
85    /// Owner-relative path to the first 16 hex digits of its SHA-256.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub files: Option<BTreeMap<String, crate::types::DigestPrefix>>,
88}
89
90/// An eval's reuse key: `hash(evaluation criteria, sorted (name, fingerprint) of the Artifacts it
91/// depends on)`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Key {
94    pub value: crate::types::ReuseKey,
95    pub eval_def_hash: crate::types::DefinitionHash,
96    /// Each Artifact the eval depends on, its target included, with its fingerprint.
97    pub fingerprints: BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
98    pub artifact_kinds: BTreeMap<crate::types::ArtifactName, crate::config::ArtifactKind>,
99}
100
101/// Why an eval has no reuse key: an Artifact it depends on declares `fingerprint: false`.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Unkeyed {
104    /// The eval's own target.
105    Target,
106    /// A child or referenced Artifact.
107    Dependency(ArtifactName),
108}
109
110/// Why the current key differs from an earlier cached result for the same eval and Eval
111/// definition.
112#[derive(Debug, Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct Changes {
115    pub since_run_id: crate::types::RunId,
116    /// The target's own files: `path` changed, `+path` added, `-path` removed; absent when
117    /// not comparable.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub files: Option<Vec<String>>,
120    /// Dependency Artifacts: `name` changed, `+name` added, `-name` removed; absent when none
121    /// changed or not comparable.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub dependencies: Option<Vec<String>>,
124    pub summary: String,
125}
126
127/// The Artifacts an eval depends on: its target, the target's child Artifacts, and
128/// the Artifacts the eval names in its instruction or runtime args. Connections further away
129/// count only through how the developer defines fingerprints.
130pub fn dependencies<'a>(config: &'a RepoConfig, eval: &'a Eval) -> BTreeSet<&'a ArtifactName> {
131    let target = &config.artifacts[&eval.target];
132    std::iter::once(&eval.target)
133        .chain(target.children.values())
134        .chain(&eval.deps)
135        .collect()
136}
137
138/// Restrict fingerprint preparation to the required Eval key inputs.
139pub(crate) fn fingerprint_targets<'a>(
140    config: &'a RepoConfig,
141    required: &BTreeSet<&'a ArtifactName>,
142) -> BTreeSet<&'a ArtifactName> {
143    config
144        .evals
145        .iter()
146        .filter(|eval| required.contains(&eval.target))
147        .flat_map(|eval| dependencies(config, eval))
148        .collect()
149}
150
151/// The reuse key of an Eval definition hash over these fingerprints.
152pub fn key(
153    eval_def_hash: &crate::types::DefinitionHash,
154    fingerprints: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
155) -> crate::types::ReuseKey {
156    let mut digest = Sha256::new();
157    digest.update(format!("{REUSE_KEY_FORMAT_PREFIX}eval {eval_def_hash}\n"));
158    // Names are identifiers and fingerprints never contain spaces or line breaks.
159    for (name, fingerprint) in fingerprints {
160        digest.update(format!("artifact {name} {fingerprint}\n"));
161    }
162    content::hex(&digest.finalize())
163        .parse()
164        .expect("SHA-256 is a reuse key")
165}
166
167/// Scope kinds distinguish file/folder observation without coupling reuse to repo paths.
168pub fn key_with_kinds(
169    eval_def_hash: &crate::types::DefinitionHash,
170    fingerprints: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
171    kinds: &BTreeMap<crate::types::ArtifactName, crate::config::ArtifactKind>,
172) -> crate::types::ReuseKey {
173    if kinds.is_empty() {
174        return key(eval_def_hash, fingerprints);
175    }
176    let mut digest = Sha256::new();
177    digest.update(KIND_KEY_FORMAT_PREFIX);
178    digest.update(key(eval_def_hash, fingerprints).as_bytes());
179    digest.update(serde_json::to_vec(kinds).expect("Artifact kinds are JSON"));
180    content::hex(&digest.finalize())
181        .parse()
182        .expect("SHA-256 is a reuse key")
183}
184
185/// The eval's reuse key from prepared fingerprints. Without a fingerprint on every Artifact
186/// it depends on, the eval has no key, so it is never reused or published.
187pub fn eval_key(
188    config: &RepoConfig,
189    eval: &Eval,
190    fingerprints: &BTreeMap<&ArtifactName, PreparedFingerprint>,
191) -> Result<Key, Unkeyed> {
192    if !fingerprints.contains_key(&eval.target) {
193        return Err(Unkeyed::Target);
194    }
195    let mut values: BTreeMap<ArtifactName, crate::types::Fingerprint> = BTreeMap::new();
196    for id in dependencies(config, eval) {
197        let fingerprint = fingerprints
198            .get(id)
199            .ok_or_else(|| Unkeyed::Dependency(id.clone()))?;
200        values.insert(id.clone(), fingerprint.value.clone());
201    }
202    let eval_def_hash = eval_definition_hash(&eval.declaration);
203    let artifact_kinds = values
204        .keys()
205        .map(|id| (id.clone(), config.artifacts[id].kind))
206        .collect();
207    Ok(Key {
208        value: key_with_kinds(&eval_def_hash, &values, &artifact_kinds),
209        eval_def_hash,
210        fingerprints: values,
211        artifact_kinds,
212    })
213}
214
215/// Keys of every eval whose dependency Artifacts were all prepared, by eval id.
216pub fn eval_keys<'a>(
217    config: &'a RepoConfig,
218    fingerprints: &BTreeMap<&ArtifactName, PreparedFingerprint>,
219) -> BTreeMap<&'a crate::types::EvalId, Key> {
220    config
221        .evals
222        .iter()
223        .filter_map(|eval| Some((&eval.id, eval_key(config, eval, fingerprints).ok()?)))
224        .collect()
225}
226
227/// How many fingerprints a process computes at once: one bound shared by a Run's
228/// preparation and its end-of-review rechecks, or by `status`.
229#[derive(Debug, Clone)]
230pub struct Parallelism {
231    slots: Arc<Semaphore>,
232    limit: usize,
233}
234
235impl Parallelism {
236    /// At most `limit` fingerprints at a time; at least one.
237    pub fn new(limit: usize) -> Self {
238        let limit = limit.max(1);
239        Self {
240            slots: Arc::new(Semaphore::new(limit)),
241            limit,
242        }
243    }
244
245    /// The default bound: the CPUs available to this process.
246    pub fn available() -> usize {
247        std::thread::available_parallelism().map_or(1, usize::from)
248    }
249
250    pub fn limit(&self) -> usize {
251        self.limit
252    }
253}
254
255/// Every file visible to a review must still be its declared regular target.
256pub(crate) fn validate_file_inputs(config: &RepoConfig, eval: &Eval) -> Result<(), String> {
257    let scope = scope::eval_scope(config, eval).map_err(|error| error.to_string())?;
258    for artifact in scope.artifacts.values() {
259        scope::validate_file_target(&config.root, artifact).map_err(|error| error.to_string())?;
260    }
261    Ok(())
262}
263
264/// Prepare only the selected dependency closure, once per Artifact, computing up to the
265/// parallelism's limit of fingerprints at a time. The result does not depend on completion
266/// order: a failure reports the first failing Artifact in input order, and fingerprints after
267/// it are cancelled because they cannot change which failure that is.
268pub async fn prepare<'a>(
269    config: &RepoConfig,
270    artifacts: impl IntoIterator<Item = &'a ArtifactName>,
271    output_root: &Path,
272    parallelism: &Parallelism,
273    cancellation: CancellationToken,
274) -> Result<BTreeMap<&'a ArtifactName, PreparedFingerprint>, String> {
275    let artifacts: Vec<_> = artifacts.into_iter().collect();
276    for id in &artifacts {
277        scope::validate_file_target(&config.root, &config.artifacts[*id])
278            .map_err(|error| error.to_string())?;
279    }
280    let mut seen = BTreeSet::new();
281    let ids: Vec<&'a ArtifactName> = artifacts
282        .into_iter()
283        .filter(|id| config.artifacts[*id].fingerprint.is_some() && seen.insert(*id))
284        .collect();
285    let tokens: Vec<_> = ids.iter().map(|_| cancellation.child_token()).collect();
286    let mut running: FuturesUnordered<_> = ids
287        .iter()
288        .zip(&tokens)
289        .enumerate()
290        .map(|(index, (id, token))| async move {
291            let result = match parallelism.slots.acquire().await {
292                // A slot can be waited for long; a cancelled fingerprint never starts.
293                Ok(_slot) if !token.is_cancelled() => {
294                    compute(config, id, output_root, token.clone()).await
295                }
296                _ => Err(process::Error::Cancelled.to_string()),
297            };
298            (index, result)
299        })
300        .collect();
301    let mut prepared = BTreeMap::new();
302    let mut failure: Option<(usize, String)> = None;
303    while let Some((index, result)) = running.next().await {
304        match result {
305            Ok(fingerprint) => {
306                prepared.insert(ids[index], fingerprint);
307            }
308            Err(error) if failure.as_ref().is_none_or(|(first, _)| index < *first) => {
309                for token in &tokens[index + 1..] {
310                    token.cancel();
311                }
312                failure = Some((index, error));
313            }
314            Err(_) => {}
315        }
316    }
317    match failure {
318        Some((_, error)) => Err(error),
319        None => Ok(prepared),
320    }
321}
322
323/// Give a request another execution's result. A request that waited for this very
324/// execution (a live owner it joined, or the Human wait it followed) joined it; any other
325/// found a completed record.
326pub fn reuse(request: &mut Request, execution: &Execution, completed_at: crate::types::Timestamp) {
327    request.joined = request.execution_id.as_ref() == Some(&execution.id);
328    request.state = match execution.state.clone() {
329        crate::store::ExecutionState::Completed { result, .. } => {
330            crate::store::RequestState::completed(result, completed_at)
331        }
332        crate::store::ExecutionState::Failed { error, code, .. } => {
333            crate::store::RequestState::failed(error, code, completed_at)
334        }
335        state => state.into(),
336    };
337    request.execution_id = Some(execution.id.clone());
338    request.profile = execution.profile.clone();
339    request.options = execution.options.clone();
340    request.provenance = Some(execution.provenance.clone());
341    request.usage = None;
342    request.reused_usage = execution.usage.clone();
343    request.producer = execution.producer.clone();
344    request.session = execution
345        .producer
346        .as_ref()
347        .and_then(|producer| producer.session.clone());
348    request.reviewer = execution.reviewer.clone();
349    request.origin = execution.origin.clone();
350    request.queue = None;
351    request.blocked_reason = None;
352}
353
354/// Recompute the eval's key after a review: every Artifact it depends on is fingerprinted
355/// again, so a change to any of them during the review is detected.
356pub async fn recheck(
357    config: &RepoConfig,
358    eval: &Eval,
359    output_root: &Path,
360    parallelism: &Parallelism,
361    cancellation: CancellationToken,
362) -> Result<Option<crate::types::ReuseKey>, String> {
363    validate_file_inputs(config, eval)?;
364    let fingerprints = prepare(
365        config,
366        dependencies(config, eval),
367        output_root,
368        parallelism,
369        cancellation,
370    )
371    .await?;
372    validate_file_inputs(config, eval)?;
373    Ok(eval_key(config, eval, &fingerprints)
374        .ok()
375        .map(|key| key.value))
376}
377
378async fn compute(
379    config: &RepoConfig,
380    id: &ArtifactName,
381    output_root: &Path,
382    cancellation: CancellationToken,
383) -> Result<PreparedFingerprint, String> {
384    scope::validate_file_target(&config.root, &config.artifacts[id])
385        .map_err(|error| error.to_string())?;
386    let result = match &config.artifacts[id].fingerprint {
387        Some(Fingerprint::Graph) => Ok(PreparedFingerprint {
388            value: crate::graph_structure::read(&config.root)?.fingerprint(),
389            manifest: None,
390        }),
391        Some(Fingerprint::Artifactsum { files, ignore }) => {
392            content(config, id, files, ignore, &cancellation)
393                .await
394                .map_err(|error| format!("Artifactsum for Artifact {id} failed: {error}"))
395        }
396        Some(Fingerprint::Script { .. }) => Ok(PreparedFingerprint {
397            value: script(config, id, output_root, cancellation)
398                .await
399                .map_err(|error| format!("Fingerprint script for Artifact {id} failed: {error}"))?,
400            manifest: None,
401        }),
402        None => Err(format!("Artifact {id} declares fingerprint: false.")),
403    }?;
404    scope::validate_file_target(&config.root, &config.artifacts[id])
405        .map_err(|error| error.to_string())?;
406    Ok(result)
407}
408
409/// The built-in hash of the Artifact's own input files, nothing else.
410async fn content(
411    config: &RepoConfig,
412    id: &ArtifactName,
413    inputs: &[crate::config::LogicalPath],
414    ignore: &[String],
415    cancellation: &CancellationToken,
416) -> Result<PreparedFingerprint, String> {
417    let files = content::files(config, id, inputs, ignore, cancellation).await?;
418    let mut manifest = Manifest {
419        inputs: files.digest.clone(),
420        files: Some(
421            files
422                .files
423                .iter()
424                .map(|(path, file)| {
425                    (
426                        path.to_string(),
427                        content::hex(&file.as_bytes()[..MANIFEST_DIGEST_PREFIX_BYTES])
428                            .parse()
429                            .expect("eight-byte SHA-256 prefix"),
430                    )
431                })
432                .collect(),
433        ),
434    };
435    if serde_json::to_vec(&manifest)
436        .expect("manifest is JSON")
437        .len()
438        > MANIFEST_BYTES
439    {
440        manifest.files = None;
441    }
442    Ok(PreparedFingerprint {
443        value: format!("artifactsum:{}", files.digest)
444            .parse()
445            .expect("artifactsum digest is a fingerprint"),
446        manifest: Some(manifest),
447    })
448}
449
450/// Explain a changed key against an earlier cached execution of the same Eval definition:
451/// which of the target's files changed, and which dependency Artifacts' fingerprints changed.
452pub fn changes(
453    previous: &Execution,
454    target: &ArtifactName,
455    current: &Key,
456    manifest: Option<&Manifest>,
457) -> Changes {
458    let mut parts = Vec::new();
459    let mut files = None;
460    if previous.fingerprints.get(target) != current.fingerprints.get(target) {
461        match previous.manifest.as_ref().zip(manifest) {
462            Some((old, new)) if old.inputs == new.inputs => files = Some(Vec::new()),
463            Some((old, new)) => {
464                files = old
465                    .files
466                    .as_ref()
467                    .zip(new.files.as_ref())
468                    .map(|(old, new)| diff(old, new));
469                match &files {
470                    Some(files) if !files.is_empty() => {
471                        let shown = files
472                            .iter()
473                            .take(MAX_SUMMARY_PATHS)
474                            .cloned()
475                            .collect::<Vec<_>>()
476                            .join(", ");
477                        parts.push(match files.len() {
478                            0..=MAX_SUMMARY_PATHS => format!("changed: {shown}"),
479                            total => {
480                                format!("changed: {shown} and {} more", total - MAX_SUMMARY_PATHS)
481                            }
482                        });
483                    }
484                    _ => parts.push("inputs changed".to_owned()),
485                }
486            }
487            None => parts.push("fingerprint changed".to_owned()),
488        }
489    }
490    let dependencies = (!previous.fingerprints.is_empty())
491        .then(|| {
492            let others = |map: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>| {
493                map.iter()
494                    .filter(|(name, _)| *name != target)
495                    .map(|(name, value)| (name.clone(), value.clone()))
496                    .collect::<BTreeMap<_, _>>()
497            };
498            diff(
499                &others(&previous.fingerprints),
500                &others(&current.fingerprints),
501            )
502        })
503        .filter(|dependencies| !dependencies.is_empty());
504    for dependency in dependencies.iter().flatten() {
505        parts.push(if let Some(id) = dependency.strip_prefix('+') {
506            format!("dependency {id} added")
507        } else if let Some(id) = dependency.strip_prefix('-') {
508            format!("dependency {id} removed")
509        } else {
510            format!("dependency {dependency} changed")
511        });
512    }
513    if parts.is_empty() {
514        parts.push("fingerprint changed".to_owned());
515    }
516    Changes {
517        since_run_id: previous.provenance.run_id.clone(),
518        files,
519        dependencies,
520        summary: parts.join("; "),
521    }
522}
523
524fn diff<K: Ord + std::fmt::Display, T: PartialEq>(
525    old: &BTreeMap<K, T>,
526    new: &BTreeMap<K, T>,
527) -> Vec<String> {
528    old.keys()
529        .chain(new.keys())
530        .collect::<BTreeSet<_>>()
531        .into_iter()
532        .filter_map(|key| match (old.get(key), new.get(key)) {
533            (Some(old), Some(new)) if old != new => Some(key.to_string()),
534            (None, Some(_)) => Some(format!("+{key}")),
535            (Some(_), None) => Some(format!("-{key}")),
536            _ => None,
537        })
538        .collect()
539}
540
541async fn script(
542    config: &RepoConfig,
543    artifact_id: &ArtifactName,
544    output_root: &Path,
545    cancellation: CancellationToken,
546) -> Result<crate::types::Fingerprint, String> {
547    if cancellation.is_cancelled() {
548        return Err(process::Error::Cancelled.to_string());
549    }
550    let artifact = &config.artifacts[artifact_id];
551    let Some(Fingerprint::Script {
552        command,
553        args,
554        files,
555        timeout_ms,
556    }) = &artifact.fingerprint
557    else {
558        unreachable!("fingerprint script")
559    };
560    let cwd = scope::scoped_path(&config.root, artifact.folder()).map_err(|e| e.to_string())?;
561    for input in files {
562        scope::scoped_path(&cwd, Path::new(input)).map_err(|e| e.to_string())?;
563    }
564    let scope = scope::argv_scope(config, artifact_id, args).map_err(|e| e.to_string())?;
565    let program = scope::executable(&config.root, &scope, artifact_id, command)
566        .map_err(|error| format!("Fingerprint executable could not be spawned: {error}"))?;
567    let args = scope::resolve_argv(config, &scope, artifact_id, args).map_err(|e| e.to_string())?;
568    let input = json!({"version":FINGERPRINT_INPUT_VERSION,"artifactId":artifact_id});
569    let output_root =
570        workspace::prepare_directory(output_root, &config.root).map_err(|e| e.to_string())?;
571    let disposable = tempfile::Builder::new()
572        .prefix("fingerprint-")
573        .tempdir_in(output_root)
574        .map_err(|e| e.to_string())?;
575    let result = async {
576        let mut command = runtime::Command::prepare(
577            program,
578            args.iter().map(Into::into).collect(),
579            &config.root,
580            disposable.path(),
581            *timeout_ms,
582        )
583        .map_err(|e| e.to_string())?;
584        command.cwd = cwd;
585        let output = command
586            .output(input.to_string().into_bytes(), cancellation.clone())
587            .await
588            .map_err(|e| e.to_string())?;
589        if !output.status.success() {
590            return Err(format!(
591                "Fingerprint command exited with {}.",
592                crate::platform::exit_description(&output.status)
593            ));
594        }
595        if output.truncated {
596            return Err("Fingerprint command output exceeded its limit.".into());
597        }
598        validate_output(&output.stdout)
599    }
600    .await;
601    let cleaned = disposable.close();
602    if cancellation.is_cancelled() {
603        return Err(process::Error::Cancelled.to_string());
604    }
605    let value = result?;
606    cleaned.map_err(|_| "Fingerprint validation failed.".to_owned())?;
607    Ok(value)
608}
609
610fn validate_output(stdout: &[u8]) -> Result<crate::types::Fingerprint, String> {
611    let (value, endings) = crate::platform::fingerprint_line_ending(stdout);
612    if !(1..=crate::types::MAX_FINGERPRINT_BYTES).contains(&value.len())
613        || !value
614            .iter()
615            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
616    {
617        return Err(format!(
618            "stdout must contain 1–128 characters from [A-Za-z0-9._:-], with at most one trailing {endings}."
619        ));
620    }
621    String::from_utf8(value.to_vec())
622        .map_err(|error| error.to_string())?
623        .parse()
624}
625
626#[cfg(test)]
627mod tests;