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