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