Skip to main content

artifactize_core/
config.rs

1//! Inert discovery and strict declarations.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8pub use crate::types::{ArtifactName, EvalId};
9mod identities;
10pub use identities::{
11    ChildPrefix, EndpointId, ExecutionPinName, HubEpoch, LogicalPath, ModelId, ProfileVariantName,
12    ReferenceName, ToolName, ToolOperationName,
13};
14use serde_json::{Map, Value};
15use thiserror::Error;
16
17mod discovery;
18mod location;
19mod mutex;
20pub use discovery::read_workspace_config;
21pub(crate) use discovery::read_workspace_declarations;
22use discovery::{logical_join, read_regular_text};
23mod site;
24pub use mutex::MutexTag;
25pub(crate) use site::read_artifact_scope;
26pub use site::{ScopeReadError, Site, read_sites};
27use site::{declaration_of, folder_of};
28mod plain;
29mod schemas;
30mod stored;
31mod tools;
32pub(crate) mod validation;
33pub use stored::{Field, StoredPayload, StoredProfile};
34pub use validation::MAX_TIMEOUT_MS;
35
36pub use tools::{
37    AgentTool, Builtin, BuiltinTool, CommandTool, HumanBuiltinTool, HumanCommandTool, HumanTool,
38    HumanToolKind, SessionCommand, ToolProtocol, ToolSession,
39};
40
41use location::Location;
42pub(crate) use validation::{artifact_name, identifier};
43use validation::{path, paths, positive_integer, present, script, tags, text, timeout};
44
45/// Format version for artifactize configuration documents.
46pub const CONFIG_FORMAT_VERSION: u32 = 1;
47pub const CONFIG_FILE: &str = "index.artf";
48/// Gitignore-style folder patterns, at the workspace root, that discovery skips.
49/// Bound named variants and declared input lists to keep discovery and preparation finite.
50const MAX_DECLARED_ITEMS: usize = 64;
51pub const IGNORE_FILE: &str = ".artfignore";
52
53#[derive(Debug, Error)]
54#[error("{}: {message}", crate::platform::path_text(.path))]
55pub struct ConfigError {
56    pub path: PathBuf,
57    pub message: String,
58}
59
60impl ConfigError {
61    pub(crate) fn new(path: impl Into<PathBuf>, message: impl ToString) -> Self {
62        Self {
63            path: path.into(),
64            message: message.to_string(),
65        }
66    }
67
68    /// Position cross-Artifact validation failures at their declaration's typed key.
69    pub(crate) fn declaration(
70        path: impl Into<PathBuf>,
71        keys: &[&str],
72        message: impl ToString,
73    ) -> Self {
74        let path = path.into();
75        let message = message.to_string();
76        let message = read_regular_text(&path).map_or(message.clone(), |source| {
77            location::error_at(&source, keys, &message)
78        });
79        Self { path, message }
80    }
81}
82
83/// A built-in provider: one artifactize reaches with a sign-in or an API key and no other setup.
84#[derive(
85    Debug,
86    Clone,
87    Copy,
88    PartialEq,
89    Eq,
90    PartialOrd,
91    Ord,
92    Hash,
93    Serialize,
94    Deserialize,
95    clap::ValueEnum,
96)]
97#[serde(rename_all = "lowercase")]
98pub enum Provider {
99    /// The ChatGPT/Codex account (artifactize login codex).
100    Codex,
101    /// The OpenAI API (OPENAI_API_KEY or a saved key).
102    Openai,
103    /// The Anthropic API (ANTHROPIC_API_KEY or a saved key).
104    Anthropic,
105    /// The Gemini API (GEMINI_API_KEY or a saved key).
106    Gemini,
107}
108
109impl Provider {
110    /// In the order Settings lists them.
111    pub const ALL: [Self; 4] = [Self::Codex, Self::Openai, Self::Anthropic, Self::Gemini];
112
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::Anthropic => "anthropic",
116            Self::Codex => "codex",
117            Self::Gemini => "gemini",
118            Self::Openai => "openai",
119        }
120    }
121
122    fn named(name: &str) -> Option<Self> {
123        Self::ALL
124            .into_iter()
125            .find(|provider| provider.as_str() == name)
126    }
127}
128
129impl std::fmt::Display for Provider {
130    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        formatter.write_str(self.as_str())
132    }
133}
134
135/// Backends that 0.5.0 removed; a declaration naming one fails with its replacements.
136const REMOVED_BACKENDS: [&str; 2] = ["chatgpt", "claude"];
137
138/// What an Agent profile's `backend` names: a built-in [`Provider`], or a custom provider
139/// set up on the machine that runs the eval. The project only declares the name.
140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
141#[serde(transparent)]
142pub struct Backend(String);
143
144impl Backend {
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148
149    /// The built-in provider this names, or `None` for a custom provider.
150    pub fn builtin(&self) -> Option<Provider> {
151        Provider::named(&self.0)
152    }
153}
154
155impl From<Provider> for Backend {
156    fn from(provider: Provider) -> Self {
157        Self(provider.as_str().to_owned())
158    }
159}
160
161impl std::str::FromStr for Backend {
162    type Err = String;
163    fn from_str(name: &str) -> Result<Self, Self::Err> {
164        if REMOVED_BACKENDS.contains(&name) {
165            return Err(format!(
166                r#"backend "{name}" was removed in 0.5.0; use "openai", "anthropic" or "gemini" with an API key, or "codex" with a ChatGPT/Codex sign-in"#
167            ));
168        }
169        identifier(name, "backend")?;
170        Ok(Self(name.to_owned()))
171    }
172}
173
174impl<'de> Deserialize<'de> for Backend {
175    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
176        String::deserialize(deserializer)?
177            .parse()
178            .map_err(serde::de::Error::custom)
179    }
180}
181
182impl std::fmt::Display for Backend {
183    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        formatter.write_str(&self.0)
185    }
186}
187
188/// Declared effort is exact: unsupported backend efforts fail rather than remap.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "lowercase")]
191pub enum Reasoning {
192    None,
193    Minimal,
194    Low,
195    Medium,
196    High,
197    Xhigh,
198    Max,
199}
200impl Reasoning {
201    pub fn as_str(self) -> &'static str {
202        match self {
203            Self::None => "none",
204            Self::Minimal => "minimal",
205            Self::Low => "low",
206            Self::Medium => "medium",
207            Self::High => "high",
208            Self::Xhigh => "xhigh",
209            Self::Max => "max",
210        }
211    }
212}
213impl std::fmt::Display for Reasoning {
214    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        formatter.write_str(self.as_str())
216    }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
221pub enum Profile {
222    Agent {
223        backend: Backend,
224        model: ModelId,
225        #[serde(default, deserialize_with = "present")]
226        reasoning: Option<Reasoning>,
227        #[serde(
228            rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
229            default,
230            deserialize_with = "timeout",
231            serialize_with = "validation::milliseconds::serialize"
232        )]
233        timeout_ms: Option<std::time::Duration>,
234        #[serde(
235            rename(serialize = "maxToolCalls", deserialize = "max_tool_calls"),
236            default,
237            deserialize_with = "positive_integer"
238        )]
239        max_tool_calls: Option<u64>,
240        #[serde(
241            rename(serialize = "maxTokens", deserialize = "max_tokens"),
242            default,
243            deserialize_with = "positive_integer"
244        )]
245        max_tokens: Option<u64>,
246    },
247    Human {},
248    Runtime {
249        #[serde(serialize_with = "crate::platform::serialize_program")]
250        command: String,
251        args: Vec<crate::scope::Argument>,
252        #[serde(
253            rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
254            default,
255            deserialize_with = "timeout",
256            serialize_with = "validation::milliseconds::serialize"
257        )]
258        timeout_ms: Option<std::time::Duration>,
259    },
260}
261
262impl Profile {
263    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
264        match self {
265            Self::Agent {
266                backend,
267                model,
268                reasoning,
269                ..
270            } => {
271                location
272                    .child("model")
273                    .check(text(model, "Agent profile model"))?;
274                // A custom provider's API is known only where it runs, so its effort is
275                // checked there.
276                if let (Some(reasoning), Some(provider)) = (reasoning, backend.builtin()) {
277                    location.child("reasoning").check(
278                        crate::llm::Api::from(provider).validate_reasoning(reasoning.as_str()),
279                    )?;
280                }
281                Ok(())
282            }
283            Self::Human {} => Ok(()),
284            Self::Runtime { command, args, .. } => location.check(script(command, args)),
285        }
286    }
287
288    pub fn kind(&self) -> ProfileKind {
289        match self {
290            Self::Agent { .. } => ProfileKind::Agent,
291            Self::Human {} => ProfileKind::Human,
292            Self::Runtime { .. } => ProfileKind::Runtime,
293        }
294    }
295}
296
297/// The kind of reviewer a profile names, without its execution options.
298#[derive(
299    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
300)]
301#[serde(rename_all = "lowercase")]
302pub enum ProfileKind {
303    /// Runtime evals: a command's exit code.
304    Runtime,
305    /// Agent evals: a model's review.
306    Agent,
307    /// Human evals: a person's sign-off.
308    Human,
309}
310
311impl ProfileKind {
312    pub fn name(self) -> &'static str {
313        match self {
314            Self::Runtime => "runtime",
315            Self::Agent => "agent",
316            Self::Human => "human",
317        }
318    }
319}
320
321/// The required instruction is typed at the declaration and saved-request boundaries;
322/// owner-defined context remains extensible JSON with the same object representation.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct EvalPayload {
325    pub instruction: String,
326    #[serde(flatten)]
327    pub extra: Map<String, Value>,
328}
329
330/// An Eval's id within its Artifact, such as `follows-style`: the key of its
331/// `[evals.<id>]` table, validated when the declaration is read.
332#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
333#[serde(transparent)]
334pub struct LocalEvalId(String);
335
336impl<'de> Deserialize<'de> for LocalEvalId {
337    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
338        String::deserialize(deserializer)?
339            .parse()
340            .map_err(serde::de::Error::custom)
341    }
342}
343
344impl std::str::FromStr for LocalEvalId {
345    type Err = String;
346    fn from_str(value: &str) -> Result<Self, Self::Err> {
347        identifier(value, "Eval id")?;
348        Ok(Self(value.to_owned()))
349    }
350}
351
352impl LocalEvalId {
353    pub fn as_str(&self) -> &str {
354        &self.0
355    }
356}
357
358impl PartialEq<&str> for LocalEvalId {
359    fn eq(&self, other: &&str) -> bool {
360        self.0 == *other
361    }
362}
363
364impl std::ops::Deref for LocalEvalId {
365    type Target = str;
366    fn deref(&self) -> &str {
367        &self.0
368    }
369}
370
371impl std::fmt::Display for LocalEvalId {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        self.0.fmt(f)
374    }
375}
376
377#[derive(Debug, Clone, Serialize)]
378#[serde(rename_all = "camelCase")]
379pub struct EvalDeclaration {
380    pub id: LocalEvalId,
381    pub title: String,
382    #[serde(skip_serializing_if = "Vec::is_empty")]
383    pub mutex: Vec<MutexTag>,
384    #[serde(flatten)]
385    body: EvalBody,
386}
387
388/// Every Eval has an instruction and a complete execution profile.
389#[derive(Debug, Clone, Serialize)]
390#[serde(rename_all = "camelCase")]
391struct EvalBody {
392    profile: Profile,
393    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
394    profile_variants: BTreeMap<ProfileVariantName, Profile>,
395    payload: EvalPayload,
396    /// Each shared with every equal verdict schema in the same read.
397    pass_schema: Option<std::sync::Arc<Map<String, Value>>>,
398    fail_schema: Option<std::sync::Arc<Map<String, Value>>>,
399}
400
401impl EvalDeclaration {
402    pub fn profile(&self) -> &Profile {
403        &self.body.profile
404    }
405    pub fn payload(&self) -> Option<&EvalPayload> {
406        Some(&self.body.payload)
407    }
408    pub fn profile_variants(&self) -> &BTreeMap<ProfileVariantName, Profile> {
409        &self.body.profile_variants
410    }
411    pub fn pass_schema(&self) -> Option<&Map<String, Value>> {
412        self.body.pass_schema.as_deref()
413    }
414    pub fn fail_schema(&self) -> Option<&Map<String, Value>> {
415        self.body.fail_schema.as_deref()
416    }
417    #[cfg(test)]
418    pub(crate) fn profile_mut(&mut self) -> &mut Profile {
419        &mut self.body.profile
420    }
421    #[cfg(test)]
422    pub(crate) fn set_pass_schema(&mut self, schema: Option<Map<String, Value>>) {
423        self.body.pass_schema = schema.map(std::sync::Arc::new);
424    }
425    #[cfg(test)]
426    pub(crate) fn set_fail_schema(&mut self, schema: Option<Map<String, Value>>) {
427        self.body.fail_schema = schema.map(std::sync::Arc::new);
428    }
429    pub(crate) fn select_profile(&mut self, name: &ProfileVariantName) -> Result<(), String> {
430        self.body.profile = self
431            .body
432            .profile_variants
433            .get(name)
434            .ok_or_else(|| format!("Unknown profile variant: {name}"))?
435            .clone();
436        Ok(())
437    }
438}
439
440#[derive(Deserialize)]
441#[serde(rename_all = "snake_case", deny_unknown_fields)]
442struct EvalFields {
443    title: String,
444    #[serde(default, deserialize_with = "present")]
445    mutex: Option<Vec<MutexTag>>,
446    profile: Profile,
447    #[serde(default, deserialize_with = "present")]
448    profile_variants: Option<BTreeMap<ProfileVariantName, Profile>>,
449    #[serde(default, deserialize_with = "present")]
450    payload: Option<EvalPayload>,
451    #[serde(default, deserialize_with = "present")]
452    pass_schema: Option<Map<String, Value>>,
453    #[serde(default, deserialize_with = "present")]
454    fail_schema: Option<Map<String, Value>>,
455}
456
457impl EvalDeclaration {
458    /// Check the fields of the `[evals.<id>]` table that every profile needs together.
459    fn new(id: LocalEvalId, fields: EvalFields) -> Result<Self, String> {
460        if fields.mutex.is_some()
461            && !matches!(
462                fields.profile.kind(),
463                ProfileKind::Runtime | ProfileKind::Agent
464            )
465        {
466            return Err("mutex is supported only on runtime and agent Evals.".into());
467        }
468        let body = EvalBody {
469            profile: fields.profile,
470            profile_variants: fields.profile_variants.unwrap_or_default(),
471            payload: fields
472                .payload
473                .ok_or("Eval payload is required for runtime, agent and human profiles.")?,
474            pass_schema: fields.pass_schema.map(std::sync::Arc::new),
475            fail_schema: fields.fail_schema.map(std::sync::Arc::new),
476        };
477        Ok(Self {
478            id,
479            title: fields.title,
480            mutex: fields.mutex.unwrap_or_default(),
481            body,
482        })
483    }
484}
485
486impl EvalDeclaration {
487    fn validate(
488        &self,
489        location: &Location<'_, '_>,
490        schemas: &mut schemas::Schemas,
491    ) -> Result<(), String> {
492        location
493            .child("title")
494            .check(text(&self.title, "Eval title"))?;
495        if let Some(payload) = self.payload() {
496            location
497                .child("payload")
498                .child("instruction")
499                .check(text(&payload.instruction, "Eval payload.instruction"))?;
500        }
501        location
502            .child("mutex")
503            .check(mutex::validate(&self.mutex))?;
504        self.profile().validate(&location.child("profile"))?;
505        for (key, schema) in [
506            ("pass_schema", &self.body.pass_schema),
507            ("fail_schema", &self.body.fail_schema),
508        ] {
509            if let Some(schema) = schema {
510                location
511                    .child(key)
512                    .check(schemas.check(schema, crate::agent::verdict::validate_schema))?;
513                // A person enters a Human result in a form: only shapes it can enter.
514                if self.profile().kind() == ProfileKind::Human {
515                    location
516                        .child(key)
517                        .check(crate::review::check_schema(schema))?;
518                }
519            }
520        }
521        if self.profile_variants().len() > MAX_DECLARED_ITEMS {
522            return Err(location
523                .child("profile_variants")
524                .error("profile_variants must contain at most 64 named profiles."));
525        }
526        for (name, profile) in self.profile_variants() {
527            let variant = location.child("profile_variants").child(name);
528            location
529                .child("profile_variants")
530                .key(name)
531                .check(identifier(name, "Profile variant name"))?;
532            profile.validate(&variant)?;
533            if std::mem::discriminant(profile) != std::mem::discriminant(self.profile()) {
534                return Err(
535                    variant.error("Profile variants must retain the declared reviewer kind.")
536                );
537            }
538        }
539        Ok(())
540    }
541}
542
543#[derive(Debug, Clone, Default, Serialize, Deserialize)]
544#[serde(
545    rename_all(serialize = "camelCase", deserialize = "snake_case"),
546    deny_unknown_fields
547)]
548pub struct Views {
549    #[serde(default)]
550    pub agent_sessions: BTreeMap<ToolOperationName, ToolSession>,
551    #[serde(default)]
552    pub agent_tools: BTreeMap<ToolOperationName, AgentTool>,
553    #[serde(default)]
554    pub human_tools: BTreeMap<ToolOperationName, HumanTool>,
555}
556
557impl Views {
558    fn validate(
559        &self,
560        location: &Location<'_, '_>,
561        schemas: &mut schemas::Schemas,
562    ) -> Result<(), String> {
563        for (name, session) in &self.agent_sessions {
564            let sessions = location.child("agent_sessions");
565            sessions
566                .key(name)
567                .check(identifier(name, "Tool session name"))?;
568            sessions
569                .child(name)
570                .child("start")
571                .check(session.start.validate())?;
572            sessions
573                .child(name)
574                .child("stop")
575                .check(session.stop.validate())?;
576        }
577        for (name, tool) in &self.agent_tools {
578            let tools = location.child("agent_tools");
579            if let AgentTool::Command(tool) = tool
580                && let Some(session) = &tool.session
581                && !self.agent_sessions.contains_key(session)
582            {
583                return Err(tools
584                    .child(name)
585                    .child("session")
586                    .error(format!("Unknown tool session: {session}")));
587            }
588            tools.key(name).check(identifier(name, "Agent tool name"))?;
589            tools.child(name).check(
590                tool.validate(schemas)
591                    .map_err(|e| format!("Tool {name}: {e}")),
592            )?;
593        }
594        for (name, tool) in &self.human_tools {
595            let tools = location.child("human_tools");
596            tools.key(name).check(identifier(name, "Human tool name"))?;
597            tools
598                .child(name)
599                .check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
600        }
601        Ok(())
602    }
603}
604
605/// The developer's definition of what an Artifact's reviews depend on: a script's output or
606/// artifactsum, the built-in hash of the Artifact's own files. Its form is the plain
607/// object; the script form keeps its `script` wrapper.
608#[derive(Debug, Clone, Serialize, Deserialize)]
609#[serde(try_from = "Map<String, Value>", into = "Value")]
610pub enum Fingerprint {
611    /// Assigned by discovery only; it cannot be declared.
612    Graph,
613    Script {
614        command: String,
615        args: Vec<crate::scope::Argument>,
616        /// Owner-relative paths that must exist on every call; never hashed.
617        files: Vec<LogicalPath>,
618        timeout_ms: Option<std::time::Duration>,
619    },
620    Artifactsum {
621        files: Vec<LogicalPath>,
622        ignore: crate::cache::IgnorePatterns,
623    },
624}
625
626#[derive(Deserialize)]
627#[serde(deny_unknown_fields)]
628struct ScriptForm {
629    script: ScriptFields,
630}
631
632#[derive(Deserialize)]
633#[serde(rename_all = "snake_case", deny_unknown_fields)]
634struct ScriptFields {
635    command: String,
636    args: Vec<crate::scope::Argument>,
637    #[serde(default)]
638    files: Vec<LogicalPath>,
639    #[serde(
640        default,
641        deserialize_with = "timeout",
642        serialize_with = "validation::milliseconds::serialize"
643    )]
644    timeout_ms: Option<std::time::Duration>,
645}
646
647#[derive(Deserialize)]
648#[serde(deny_unknown_fields)]
649struct ArtifactsumForm {
650    #[serde(default = "owner_root")]
651    files: Vec<LogicalPath>,
652    #[serde(default)]
653    ignore: crate::cache::IgnorePatterns,
654}
655
656fn owner_root() -> Vec<LogicalPath> {
657    vec![".".parse().expect("owner root logical path")]
658}
659
660impl Default for Fingerprint {
661    fn default() -> Self {
662        Self::Artifactsum {
663            files: owner_root(),
664            ignore: Default::default(),
665        }
666    }
667}
668
669fn default_fingerprint() -> Option<Fingerprint> {
670    Some(Fingerprint::default())
671}
672
673fn declared_fingerprint<'de, D: serde::Deserializer<'de>>(
674    deserializer: D,
675) -> Result<Option<Fingerprint>, D::Error> {
676    use serde::de::Error;
677    match Value::deserialize(deserializer)? {
678        Value::Bool(false) => Ok(None),
679        Value::Object(object) => Fingerprint::try_from(object)
680            .map(Some)
681            .map_err(D::Error::custom),
682        _ => Err(D::Error::custom(
683            "fingerprint must be false or an object declaring artifactsum (files, ignore) or a script.",
684        )),
685    }
686}
687
688impl TryFrom<Map<String, Value>> for Fingerprint {
689    type Error = serde_json::Error;
690
691    fn try_from(object: Map<String, Value>) -> Result<Self, Self::Error> {
692        Ok(if object.contains_key("script") {
693            let ScriptForm {
694                script:
695                    ScriptFields {
696                        command,
697                        args,
698                        files,
699                        timeout_ms,
700                    },
701            } = serde_json::from_value(Value::Object(object))?;
702            Self::Script {
703                command,
704                args,
705                files,
706                timeout_ms,
707            }
708        } else {
709            if object.contains_key("dependencies") {
710                return Err(serde::de::Error::custom(
711                    "fingerprint.dependencies was removed in 0.5: a fingerprint covers only its own Artifact, and an eval's reuse key adds the fingerprints of its children and referenced Artifacts. Remove the field.",
712                ));
713            }
714            let ArtifactsumForm { files, ignore } = serde_json::from_value(Value::Object(object))?;
715            Self::Artifactsum { files, ignore }
716        })
717    }
718}
719
720impl From<Fingerprint> for Value {
721    fn from(fingerprint: Fingerprint) -> Self {
722        match fingerprint {
723            Fingerprint::Graph => serde_json::json!({"graph": true}),
724            Fingerprint::Script {
725                command,
726                args,
727                files,
728                timeout_ms,
729            } => serde_json::json!({
730                "script": {
731                    "command": crate::platform::program_text(&command),
732                    "args": args,
733                    "files": files,
734                    "timeoutMs": timeout_ms.map(|value| value.as_millis()),
735                },
736            }),
737            Fingerprint::Artifactsum { files, ignore } => {
738                serde_json::json!({"files": files, "ignore": ignore})
739            }
740        }
741    }
742}
743
744impl Fingerprint {
745    pub fn kind(&self) -> crate::types::FingerprintKind {
746        match self {
747            Self::Graph => crate::types::FingerprintKind::Graph,
748            Self::Artifactsum { .. } => crate::types::FingerprintKind::Artifactsum,
749            Self::Script { .. } => crate::types::FingerprintKind::Script,
750        }
751    }
752
753    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
754        match self {
755            Self::Graph => Ok(()),
756            Self::Script {
757                command,
758                args,
759                files,
760                ..
761            } => {
762                location.child("script").check(script(command, args))?;
763                location
764                    .child("script")
765                    .child("files")
766                    .check(paths(files, "fingerprint.script.files"))
767            }
768            Self::Artifactsum { files, .. } => {
769                if files.is_empty()
770                    || files.len() > MAX_DECLARED_ITEMS
771                    || files.iter().collect::<BTreeSet<_>>().len() != files.len()
772                {
773                    return Err(location.child("files").error(
774                        "fingerprint.files must contain 1–64 unique owner-relative paths.",
775                    ));
776                }
777                for file in files.iter().filter(|file| *file != ".") {
778                    location.child("files").check(
779                        path(file).map_err(|message| format!("fingerprint.files: {message}")),
780                    )?;
781                }
782                Ok(())
783            }
784        }
785    }
786}
787
788#[derive(Debug, Clone, Deserialize)]
789#[serde(rename_all = "snake_case", deny_unknown_fields)]
790pub struct ArtifactDeclaration {
791    pub name: ArtifactName,
792    #[serde(default)]
793    pub tags: Vec<String>,
794    #[serde(default, deserialize_with = "declared_evals")]
795    pub evals: Vec<EvalDeclaration>,
796    #[serde(default)]
797    pub views: Views,
798    #[serde(default, deserialize_with = "present")]
799    pub basis: Option<bool>,
800    #[serde(
801        default = "default_fingerprint",
802        deserialize_with = "declared_fingerprint"
803    )]
804    pub fingerprint: Option<Fingerprint>,
805    /// An aspect's paths, relative to its declaration's folder.
806    #[serde(default, deserialize_with = "present")]
807    pub select: Option<Vec<LogicalPath>>,
808    /// Artifacts this one is made of besides those its folder holds.
809    #[serde(default, deserialize_with = "present")]
810    pub parts: Option<Vec<ArtifactName>>,
811}
812
813impl ArtifactDeclaration {
814    /// Keep each of its schemas once in `schemas`, shared with the equal ones already read.
815    fn share(&mut self, schemas: &mut schemas::Schemas) {
816        for tool in self.views.agent_tools.values_mut() {
817            if let AgentTool::Command(tool) = tool {
818                tool.input_schema = schemas.tool(&tool.input_schema);
819            }
820        }
821        for eval in &mut self.evals {
822            for schema in [&mut eval.body.pass_schema, &mut eval.body.fail_schema]
823                .into_iter()
824                .flatten()
825            {
826                *schema = schemas.verdict(schema);
827            }
828        }
829    }
830
831    fn validate(
832        &self,
833        location: &Location<'_, '_>,
834        schemas: &mut schemas::Schemas,
835    ) -> Result<(), String> {
836        location
837            .child("name")
838            .check(artifact_name(&self.name, "Artifact name"))?;
839        location.child("tags").check(tags(&self.tags))?;
840        for eval in &self.evals {
841            eval.validate(&location.child("evals").key(&eval.id), schemas)
842                .map_err(|message| format!("Eval {}: {message}", eval.id))?;
843        }
844        if self.basis == Some(true) && !self.evals.is_empty() {
845            return Err(location
846                .child("basis")
847                .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
848        }
849        self.views.validate(&location.child("views"), schemas)?;
850        if let Some(fingerprint) = &self.fingerprint {
851            fingerprint.validate(&location.child("fingerprint"))?;
852        }
853        if let Some(parts) = &self.parts {
854            let parts_location = location.child("parts");
855            if parts.is_empty()
856                || parts.len() > MAX_DECLARED_ITEMS
857                || parts.iter().collect::<BTreeSet<_>>().len() != parts.len()
858            {
859                return Err(parts_location.error("parts must name 1–64 unique Artifacts."));
860            }
861            if parts.contains(&self.name) {
862                return Err(
863                    parts_location.error(format!("{} cannot be a part of itself.", self.name))
864                );
865            }
866        }
867        if let Some(select) = &self.select {
868            let select_location = location.child("select");
869            if select.is_empty()
870                || select.len() > MAX_DECLARED_ITEMS
871                || select.iter().collect::<BTreeSet<_>>().len() != select.len()
872            {
873                return Err(select_location
874                    .error("select must contain 1–64 unique paths relative to the declaration."));
875            }
876            for selected in select {
877                select_location
878                    .check(path(selected).map_err(|message| format!("select: {message}")))?;
879                if selected.ends_with(".artf") {
880                    return Err(select_location.error(
881                        "select: Artifact declarations (*.artf) are never part of an aspect.",
882                    ));
883                }
884            }
885        }
886        Ok(())
887    }
888}
889
890/// Eval ids live in table keys, not fields; BTreeMap also fixes their discovery order.
891fn declared_evals<'de, D: serde::Deserializer<'de>>(
892    deserializer: D,
893) -> Result<Vec<EvalDeclaration>, D::Error> {
894    use serde::de::Error;
895    BTreeMap::<String, EvalFields>::deserialize(deserializer)?
896        .into_iter()
897        .map(|(id, fields)| {
898            let id = id.parse().map_err(D::Error::custom)?;
899            EvalDeclaration::new(id, fields).map_err(D::Error::custom)
900        })
901        .collect()
902}
903
904/// Validate an Artifact declaration without opening scripts or declared inputs.
905pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
906    parse_declaration_in(source, &mut schemas::Schemas::default())
907}
908
909/// [`parse_declaration`] as one of a read's declarations, its schemas shared through `schemas`.
910fn parse_declaration_in(
911    source: &str,
912    schemas: &mut schemas::Schemas,
913) -> Result<ArtifactDeclaration, String> {
914    let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
915    let span = table.span();
916    let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
917    let location = Location::root(source, &document);
918    for (key, value) in document.get_ref().as_table().unwrap() {
919        reject_non_json_values(value, key.get_ref().as_ref(), source)?;
920    }
921    if document.get_ref().get("family").is_some() {
922        return Err(location
923            .child("family")
924            .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
925    }
926    for key in ["stale_key", "stale"] {
927        if document.get_ref().get(key).is_some() {
928            return Err(location.child(key).error(format!(
929                "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
930            )));
931        }
932    }
933    if let Some(evals) = document
934        .get_ref()
935        .get("evals")
936        .and_then(|value| value.get_ref().as_table())
937    {
938        for (id, eval) in evals {
939            if eval.get_ref().get("gate").is_some() {
940                return Err(location
941                    .child("evals")
942                    .child(id.get_ref())
943                    .child("gate")
944                    .error(format!(
945                        "Eval {}: gate was removed in 0.16.0; remove it. A Human sign-off never holds up other evals.",
946                        id.get_ref()
947                    )));
948            }
949            if eval.get_ref().get("result_check").is_some() {
950                return Err(location
951                    .child("evals")
952                    .child(id.get_ref())
953                    .child("result_check")
954                    .error(format!(
955                        "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
956                        id.get_ref()
957                    )));
958            }
959        }
960    }
961    // TOML buffers enum/table-key deserialization. Check lexical fields at their
962    // own source positions before constructing validated identities, not in discovery.
963    validate_identity_locations(&location, &document)?;
964    // These sum types use JSON maps internally; pin field errors to their original spans.
965    if let Some(fingerprint) = document.get_ref().get("fingerprint")
966        && let Some(fields) = fingerprint.get_ref().as_table()
967    {
968        let fingerprint_location = location.child("fingerprint");
969        if fields.contains_key("script") {
970            fingerprint_location.deserialize::<ScriptForm>()?;
971        } else if !fields.contains_key("dependencies") {
972            fingerprint_location.deserialize::<ArtifactsumForm>()?;
973        }
974    }
975    if let Some(tools) = document
976        .get_ref()
977        .get("views")
978        .and_then(|views| views.get_ref().get("agent_tools"))
979        .and_then(|tools| tools.get_ref().as_table())
980    {
981        for (name, tool) in tools {
982            if tool.get_ref().as_table().is_some() {
983                let tool_location = location
984                    .child("views")
985                    .child("agent_tools")
986                    .child(name.get_ref());
987                if tool.get_ref().get("builtin").is_some() {
988                    tool_location.deserialize::<BuiltinTool>()?;
989                } else {
990                    tool_location.deserialize::<CommandTool>()?;
991                }
992            }
993        }
994    }
995    if let Some(tools) = document
996        .get_ref()
997        .get("views")
998        .and_then(|views| views.get_ref().get("human_tools"))
999        .and_then(|tools| tools.get_ref().as_table())
1000    {
1001        for (name, _) in tools {
1002            location
1003                .child("views")
1004                .child("human_tools")
1005                .child(name.get_ref())
1006                .deserialize::<HumanTool>()?;
1007        }
1008    }
1009    if let Some(sessions) = document
1010        .get_ref()
1011        .get("views")
1012        .and_then(|views| views.get_ref().get("agent_sessions"))
1013        .and_then(|sessions| sessions.get_ref().as_table())
1014    {
1015        for (name, _) in sessions {
1016            location
1017                .child("views")
1018                .child("agent_sessions")
1019                .child(name.get_ref())
1020                .deserialize::<ToolSession>()?;
1021        }
1022    }
1023    let mut declaration =
1024        ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
1025            .map_err(|mut error| {
1026                error.set_input(Some(source));
1027                error.to_string()
1028            })?;
1029    declaration.share(schemas);
1030    declaration.validate(&location, schemas)?;
1031    Ok(declaration)
1032}
1033
1034fn validate_identity_locations(
1035    location: &Location<'_, '_>,
1036    document: &toml::Spanned<toml::de::DeValue<'_>>,
1037) -> Result<(), String> {
1038    use toml::de::DeValue;
1039    if let Some(name) = document.get_ref().get("name")
1040        && let DeValue::String(name) = name.get_ref()
1041    {
1042        location
1043            .child("name")
1044            .check(artifact_name(name, "Artifact name"))?;
1045    }
1046    if let Some(evals) = document
1047        .get_ref()
1048        .get("evals")
1049        .and_then(|value| value.get_ref().as_table())
1050    {
1051        for (id, eval) in evals {
1052            let eval_location = location.child("evals").key(id.get_ref());
1053            eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1054            if let Some(model) = eval
1055                .get_ref()
1056                .get("profile")
1057                .and_then(|profile| profile.get_ref().get("model"))
1058                && let DeValue::String(model) = model.get_ref()
1059            {
1060                eval_location
1061                    .child("profile")
1062                    .child("model")
1063                    .check(text(model, "Agent profile model"))?;
1064            }
1065            if let Some(variants) = eval
1066                .get_ref()
1067                .get("profile_variants")
1068                .and_then(|value| value.get_ref().as_table())
1069            {
1070                for (name, profile) in variants {
1071                    let variant = eval_location.child("profile_variants").key(name.get_ref());
1072                    variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1073                    if let Some(model) = profile.get_ref().get("model")
1074                        && let DeValue::String(model) = model.get_ref()
1075                    {
1076                        variant
1077                            .child("model")
1078                            .check(text(model, "Agent profile model"))?;
1079                    }
1080                }
1081            }
1082        }
1083    }
1084    Ok(())
1085}
1086
1087/// Reject TOML values that would become lossy or implementation-specific owner JSON.
1088fn reject_non_json_values(
1089    value: &toml::Spanned<toml::de::DeValue<'_>>,
1090    path: &str,
1091    source: &str,
1092) -> Result<(), String> {
1093    use toml::de::DeValue;
1094    let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1095    match value.get_ref() {
1096        DeValue::Datetime(_) => Err(diagnostic(
1097            "TOML date/time values have no JSON equivalent; use a quoted string.",
1098        )),
1099        DeValue::Float(number)
1100            if matches!(
1101                number.as_str(),
1102                "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1103            ) =>
1104        {
1105            Err(diagnostic(
1106                "TOML non-finite floats have no JSON equivalent.",
1107            ))
1108        }
1109        DeValue::Array(items) => {
1110            for (index, item) in items.iter().enumerate() {
1111                reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1112            }
1113            Ok(())
1114        }
1115        DeValue::Table(table) => {
1116            for (key, value) in table {
1117                reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1118            }
1119            Ok(())
1120        }
1121        _ => Ok(()),
1122    }
1123}
1124
1125pub use artifactize_tools::scope::ArtifactKind;
1126
1127/// Nested folder paths with their Artifacts as tool-side IDs. Discovery validated both, so a
1128/// failure here is a bug.
1129fn tool_nested(
1130    nested: &BTreeMap<ChildPrefix, ArtifactName>,
1131) -> BTreeMap<artifactize_tools::scope::ChildPrefix, artifactize_tools::scope::ArtifactId> {
1132    nested
1133        .iter()
1134        .map(|(path, name)| {
1135            (
1136                artifactize_tools::scope::ChildPrefix::new(path.as_ref())
1137                    .expect("validated child prefix"),
1138                crate::scope::tool_id(name),
1139            )
1140        })
1141        .collect()
1142}
1143
1144#[derive(Debug, Clone, Serialize)]
1145#[serde(rename_all = "camelCase")]
1146pub struct Artifact {
1147    #[serde(with = "crate::platform::path_serde")]
1148    pub path: PathBuf,
1149    pub kind: ArtifactKind,
1150    /// Folder Artifacts declared in subfolders, by their path here. Their files are theirs,
1151    /// not this Artifact's; nesting is no relation between the two.
1152    pub nested: BTreeMap<ChildPrefix, ArtifactName>,
1153    /// An aspect's selected paths below its folder; empty for other kinds.
1154    #[serde(skip_serializing_if = "Vec::is_empty")]
1155    pub select: Vec<LogicalPath>,
1156    /// What it is made of: the Artifacts its folder holds and those its `parts` names.
1157    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
1158    pub parts: BTreeSet<ArtifactName>,
1159    pub name: ArtifactName,
1160    pub tags: Vec<String>,
1161    pub views: Views,
1162    pub basis: Option<bool>,
1163    pub fingerprint: Option<Fingerprint>,
1164}
1165
1166impl Artifact {
1167    pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1168        artifactize_tools::scope::Artifact {
1169            path: self.path.clone(),
1170            kind: self.kind,
1171            name: self.name.to_string(),
1172            nested: tool_nested(&self.nested),
1173            select: self.select.iter().map(|path| path.to_string()).collect(),
1174        }
1175    }
1176
1177    /// Runtime commands and relative declarations use the containing folder.
1178    pub fn folder(&self) -> &Path {
1179        folder_of(self.kind, &self.path)
1180    }
1181
1182    /// What `{name}` stands for: the folder or file, or an aspect's folder, since an
1183    /// aspect's path is only its declaration's label.
1184    pub fn referenced_path(&self) -> &Path {
1185        if self.kind == ArtifactKind::Aspect {
1186            self.folder()
1187        } else {
1188            &self.path
1189        }
1190    }
1191
1192    pub fn file_name(&self) -> Option<&str> {
1193        (self.kind == ArtifactKind::File)
1194            .then(|| self.path.file_name().and_then(|name| name.to_str()))
1195            .flatten()
1196    }
1197
1198    pub fn declaration_path(&self) -> PathBuf {
1199        declaration_of(self.kind, &self.path)
1200    }
1201
1202    /// Where it is declared.
1203    pub fn site(&self) -> Site {
1204        Site {
1205            kind: self.kind,
1206            path: self.path.clone(),
1207        }
1208    }
1209}
1210
1211#[derive(Debug, Serialize)]
1212pub struct Eval {
1213    /// Workspace-qualified id; declaration.id remains the owner's local id.
1214    pub id: EvalId,
1215    pub target: ArtifactName,
1216    pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1217    pub deps: Vec<ArtifactName>,
1218    pub declaration: EvalDeclaration,
1219    /// The `profileVariants` entry selected for this execution, if any: an execution option,
1220    /// recorded with results but never part of the saved declaration.
1221    #[serde(skip)]
1222    pub variant: Option<ProfileVariantName>,
1223}
1224
1225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1226pub enum ReviewRequirement {
1227    Basis,
1228    Unreviewed,
1229    Evals,
1230    /// No evals of its own: its parts' verdicts are its verdict.
1231    Parts,
1232}
1233
1234#[derive(Debug)]
1235pub struct RepoConfig {
1236    pub root: PathBuf,
1237    pub artifacts: BTreeMap<ArtifactName, Artifact>,
1238    pub evals: Vec<Eval>,
1239    pub relations: Vec<crate::scope::Relation>,
1240}
1241
1242impl RepoConfig {
1243    /// Every Artifact `name` is made of, directly or through its parts, each once.
1244    pub fn descendants<'a>(&'a self, name: &'a ArtifactName) -> Vec<&'a ArtifactName> {
1245        let mut seen = BTreeSet::new();
1246        let mut pending = vec![name];
1247        while let Some(next) = pending.pop() {
1248            for part in self.artifacts.get(next).into_iter().flat_map(|a| &a.parts) {
1249                if seen.insert(part) {
1250                    pending.push(part);
1251                }
1252            }
1253        }
1254        seen.into_iter().collect()
1255    }
1256
1257    /// Local obligations only; a basis does not satisfy its dependency scope.
1258    pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1259        let artifact = self.artifacts.get(artifact_id)?;
1260        Some(if artifact.basis == Some(true) {
1261            ReviewRequirement::Basis
1262        } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1263            ReviewRequirement::Evals
1264        } else if !artifact.parts.is_empty() {
1265            ReviewRequirement::Parts
1266        } else {
1267            ReviewRequirement::Unreviewed
1268        })
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests;