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: crate::cache::IgnorePatterns,
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: crate::cache::IgnorePatterns,
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: Default::default(),
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, .. } => {
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                Ok(())
784            }
785        }
786    }
787}
788
789#[derive(Debug, Clone, Deserialize)]
790#[serde(rename_all = "snake_case", deny_unknown_fields)]
791pub struct ArtifactDeclaration {
792    pub name: ArtifactName,
793    #[serde(default)]
794    pub tags: Vec<String>,
795    #[serde(default, deserialize_with = "declared_evals")]
796    pub evals: Vec<EvalDeclaration>,
797    #[serde(default)]
798    pub views: Views,
799    #[serde(default, deserialize_with = "present")]
800    pub basis: Option<bool>,
801    #[serde(
802        default = "default_fingerprint",
803        deserialize_with = "declared_fingerprint"
804    )]
805    pub fingerprint: Option<Fingerprint>,
806}
807
808impl ArtifactDeclaration {
809    /// Keep each of its schemas once in `schemas`, shared with the equal ones already read.
810    fn share(&mut self, schemas: &mut schemas::Schemas) {
811        for tool in self.views.agent_tools.values_mut() {
812            if let AgentTool::Command(tool) = tool {
813                tool.input_schema = schemas.tool(&tool.input_schema);
814            }
815        }
816        for eval in &mut self.evals {
817            for schema in [&mut eval.body.pass_schema, &mut eval.body.fail_schema]
818                .into_iter()
819                .flatten()
820            {
821                *schema = schemas.verdict(schema);
822            }
823        }
824    }
825
826    fn validate(
827        &self,
828        location: &Location<'_, '_>,
829        schemas: &mut schemas::Schemas,
830    ) -> Result<(), String> {
831        location
832            .child("name")
833            .check(artifact_name(&self.name, "Artifact name"))?;
834        location.child("tags").check(tags(&self.tags))?;
835        for eval in &self.evals {
836            eval.validate(&location.child("evals").key(&eval.id), schemas)
837                .map_err(|message| format!("Eval {}: {message}", eval.id))?;
838        }
839        if self.basis == Some(true) && !self.evals.is_empty() {
840            return Err(location
841                .child("basis")
842                .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
843        }
844        self.views.validate(&location.child("views"), schemas)?;
845        if let Some(fingerprint) = &self.fingerprint {
846            fingerprint.validate(&location.child("fingerprint"))?;
847        }
848        Ok(())
849    }
850}
851
852/// Eval ids live in table keys, not fields; BTreeMap also fixes their discovery order.
853fn declared_evals<'de, D: serde::Deserializer<'de>>(
854    deserializer: D,
855) -> Result<Vec<EvalDeclaration>, D::Error> {
856    use serde::de::Error;
857    BTreeMap::<String, EvalFields>::deserialize(deserializer)?
858        .into_iter()
859        .map(|(id, fields)| {
860            let id = id.parse().map_err(D::Error::custom)?;
861            EvalDeclaration::new(id, fields).map_err(D::Error::custom)
862        })
863        .collect()
864}
865
866/// Validate an Artifact declaration without opening scripts or declared inputs.
867pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
868    parse_declaration_in(source, &mut schemas::Schemas::default())
869}
870
871/// [`parse_declaration`] as one of a read's declarations, its schemas shared through `schemas`.
872fn parse_declaration_in(
873    source: &str,
874    schemas: &mut schemas::Schemas,
875) -> Result<ArtifactDeclaration, String> {
876    let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
877    let span = table.span();
878    let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
879    let location = Location::root(source, &document);
880    for (key, value) in document.get_ref().as_table().unwrap() {
881        reject_non_json_values(value, key.get_ref().as_ref(), source)?;
882    }
883    if document.get_ref().get("family").is_some() {
884        return Err(location
885            .child("family")
886            .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
887    }
888    for key in ["stale_key", "stale"] {
889        if document.get_ref().get(key).is_some() {
890            return Err(location.child(key).error(format!(
891                "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
892            )));
893        }
894    }
895    if let Some(evals) = document
896        .get_ref()
897        .get("evals")
898        .and_then(|value| value.get_ref().as_table())
899    {
900        for (id, eval) in evals {
901            if eval.get_ref().get("gate").is_some() {
902                return Err(location
903                    .child("evals")
904                    .child(id.get_ref())
905                    .child("gate")
906                    .error(format!(
907                        "Eval {}: gate was removed in 0.16.0; remove it. A Human sign-off never holds up other evals.",
908                        id.get_ref()
909                    )));
910            }
911            if eval.get_ref().get("result_check").is_some() {
912                return Err(location
913                    .child("evals")
914                    .child(id.get_ref())
915                    .child("result_check")
916                    .error(format!(
917                        "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
918                        id.get_ref()
919                    )));
920            }
921        }
922    }
923    // TOML buffers enum/table-key deserialization. Check lexical fields at their
924    // own source positions before constructing validated identities, not in discovery.
925    validate_identity_locations(&location, &document)?;
926    // These sum types use JSON maps internally; pin field errors to their original spans.
927    if let Some(fingerprint) = document.get_ref().get("fingerprint")
928        && let Some(fields) = fingerprint.get_ref().as_table()
929    {
930        let fingerprint_location = location.child("fingerprint");
931        if fields.contains_key("script") {
932            fingerprint_location.deserialize::<ScriptForm>()?;
933        } else if !fields.contains_key("dependencies") {
934            fingerprint_location.deserialize::<ArtifactsumForm>()?;
935        }
936    }
937    if let Some(tools) = document
938        .get_ref()
939        .get("views")
940        .and_then(|views| views.get_ref().get("agent_tools"))
941        .and_then(|tools| tools.get_ref().as_table())
942    {
943        for (name, tool) in tools {
944            if tool.get_ref().as_table().is_some() {
945                let tool_location = location
946                    .child("views")
947                    .child("agent_tools")
948                    .child(name.get_ref());
949                if tool.get_ref().get("builtin").is_some() {
950                    tool_location.deserialize::<BuiltinTool>()?;
951                } else {
952                    tool_location.deserialize::<CommandTool>()?;
953                }
954            }
955        }
956    }
957    if let Some(tools) = document
958        .get_ref()
959        .get("views")
960        .and_then(|views| views.get_ref().get("human_tools"))
961        .and_then(|tools| tools.get_ref().as_table())
962    {
963        for (name, _) in tools {
964            location
965                .child("views")
966                .child("human_tools")
967                .child(name.get_ref())
968                .deserialize::<HumanTool>()?;
969        }
970    }
971    if let Some(sessions) = document
972        .get_ref()
973        .get("views")
974        .and_then(|views| views.get_ref().get("agent_sessions"))
975        .and_then(|sessions| sessions.get_ref().as_table())
976    {
977        for (name, _) in sessions {
978            location
979                .child("views")
980                .child("agent_sessions")
981                .child(name.get_ref())
982                .deserialize::<ToolSession>()?;
983        }
984    }
985    let mut declaration =
986        ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
987            .map_err(|mut error| {
988                error.set_input(Some(source));
989                error.to_string()
990            })?;
991    declaration.share(schemas);
992    declaration.validate(&location, schemas)?;
993    Ok(declaration)
994}
995
996fn validate_identity_locations(
997    location: &Location<'_, '_>,
998    document: &toml::Spanned<toml::de::DeValue<'_>>,
999) -> Result<(), String> {
1000    use toml::de::DeValue;
1001    if let Some(name) = document.get_ref().get("name")
1002        && let DeValue::String(name) = name.get_ref()
1003    {
1004        location
1005            .child("name")
1006            .check(artifact_name(name, "Artifact name"))?;
1007    }
1008    if let Some(evals) = document
1009        .get_ref()
1010        .get("evals")
1011        .and_then(|value| value.get_ref().as_table())
1012    {
1013        for (id, eval) in evals {
1014            let eval_location = location.child("evals").key(id.get_ref());
1015            eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1016            if let Some(model) = eval
1017                .get_ref()
1018                .get("profile")
1019                .and_then(|profile| profile.get_ref().get("model"))
1020                && let DeValue::String(model) = model.get_ref()
1021            {
1022                eval_location
1023                    .child("profile")
1024                    .child("model")
1025                    .check(text(model, "Agent profile model"))?;
1026            }
1027            if let Some(variants) = eval
1028                .get_ref()
1029                .get("profile_variants")
1030                .and_then(|value| value.get_ref().as_table())
1031            {
1032                for (name, profile) in variants {
1033                    let variant = eval_location.child("profile_variants").key(name.get_ref());
1034                    variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1035                    if let Some(model) = profile.get_ref().get("model")
1036                        && let DeValue::String(model) = model.get_ref()
1037                    {
1038                        variant
1039                            .child("model")
1040                            .check(text(model, "Agent profile model"))?;
1041                    }
1042                }
1043            }
1044        }
1045    }
1046    Ok(())
1047}
1048
1049/// Reject TOML values that would become lossy or implementation-specific owner JSON.
1050fn reject_non_json_values(
1051    value: &toml::Spanned<toml::de::DeValue<'_>>,
1052    path: &str,
1053    source: &str,
1054) -> Result<(), String> {
1055    use toml::de::DeValue;
1056    let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1057    match value.get_ref() {
1058        DeValue::Datetime(_) => Err(diagnostic(
1059            "TOML date/time values have no JSON equivalent; use a quoted string.",
1060        )),
1061        DeValue::Float(number)
1062            if matches!(
1063                number.as_str(),
1064                "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1065            ) =>
1066        {
1067            Err(diagnostic(
1068                "TOML non-finite floats have no JSON equivalent.",
1069            ))
1070        }
1071        DeValue::Array(items) => {
1072            for (index, item) in items.iter().enumerate() {
1073                reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1074            }
1075            Ok(())
1076        }
1077        DeValue::Table(table) => {
1078            for (key, value) in table {
1079                reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1080            }
1081            Ok(())
1082        }
1083        _ => Ok(()),
1084    }
1085}
1086
1087pub use artifactize_tools::scope::ArtifactKind;
1088
1089/// Child paths with their target Artifacts as tool-side IDs. Discovery validated both, so a
1090/// failure here is a bug.
1091fn tool_children(
1092    children: &BTreeMap<ChildPrefix, ArtifactName>,
1093) -> BTreeMap<artifactize_tools::scope::ChildPrefix, artifactize_tools::scope::ArtifactId> {
1094    children
1095        .iter()
1096        .map(|(path, name)| {
1097            (
1098                artifactize_tools::scope::ChildPrefix::new(path.as_ref())
1099                    .expect("validated child prefix"),
1100                crate::scope::tool_id(name),
1101            )
1102        })
1103        .collect()
1104}
1105
1106#[derive(Debug, Clone, Serialize)]
1107#[serde(rename_all = "camelCase")]
1108pub struct Artifact {
1109    #[serde(with = "crate::platform::path_serde")]
1110    pub path: PathBuf,
1111    pub kind: ArtifactKind,
1112    pub children: BTreeMap<ChildPrefix, ArtifactName>,
1113    pub name: ArtifactName,
1114    pub tags: Vec<String>,
1115    pub views: Views,
1116    pub basis: Option<bool>,
1117    pub fingerprint: Option<Fingerprint>,
1118}
1119
1120impl Artifact {
1121    pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1122        artifactize_tools::scope::Artifact {
1123            path: self.path.clone(),
1124            kind: self.kind,
1125            name: self.name.to_string(),
1126            children: tool_children(&self.children),
1127        }
1128    }
1129
1130    /// Runtime commands and relative declarations use the containing folder.
1131    pub fn folder(&self) -> &Path {
1132        folder_of(self.kind, &self.path)
1133    }
1134
1135    pub fn file_name(&self) -> Option<&str> {
1136        (self.kind == ArtifactKind::File)
1137            .then(|| self.path.file_name().and_then(|name| name.to_str()))
1138            .flatten()
1139    }
1140
1141    pub fn declaration_path(&self) -> PathBuf {
1142        declaration_of(self.kind, &self.path)
1143    }
1144
1145    /// Where it is declared.
1146    pub fn site(&self) -> Site {
1147        Site {
1148            kind: self.kind,
1149            path: self.path.clone(),
1150        }
1151    }
1152}
1153
1154#[derive(Debug, Serialize)]
1155pub struct Eval {
1156    /// Workspace-qualified id; declaration.id remains the owner's local id.
1157    pub id: EvalId,
1158    pub target: ArtifactName,
1159    pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1160    pub deps: Vec<ArtifactName>,
1161    pub declaration: EvalDeclaration,
1162    /// The `profileVariants` entry selected for this execution, if any: an execution option,
1163    /// recorded with results but never part of the saved declaration.
1164    #[serde(skip)]
1165    pub variant: Option<ProfileVariantName>,
1166}
1167
1168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1169pub enum ReviewRequirement {
1170    Basis,
1171    Unreviewed,
1172    Evals,
1173}
1174
1175#[derive(Debug)]
1176pub struct RepoConfig {
1177    pub root: PathBuf,
1178    pub artifacts: BTreeMap<ArtifactName, Artifact>,
1179    pub evals: Vec<Eval>,
1180    pub relations: Vec<crate::scope::Relation>,
1181}
1182
1183impl RepoConfig {
1184    /// Local obligations only; a basis does not satisfy its dependency scope.
1185    pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1186        let artifact = self.artifacts.get(artifact_id)?;
1187        Some(if artifact.basis == Some(true) {
1188            ReviewRequirement::Basis
1189        } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1190            ReviewRequirement::Evals
1191        } else {
1192            ReviewRequirement::Unreviewed
1193        })
1194    }
1195}
1196
1197#[cfg(test)]
1198mod tests;