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