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