agentmux_srv\registry/
def_schema.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global **agent-definition** registry file format + per-row validation.
5//!
6//! Sibling of `schema.rs` (which describes the named *instance* record).
7//! This record carries a full agent **definition** so the roster can be
8//! reconstructed from any channel/version without joining the local
9//! channel's SQLite — the foundation of cross-channel agent persistence
10//! (`docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md`, P0.2).
11//!
12//! Layering: the `registry` module must NOT depend on `backend::storage`
13//! (which already depends on `registry` — that would cycle). So the fields
14//! of `AgentDefinition` are mirrored here as a self-contained struct; the
15//! `AgentDefinition <-> DefinitionRecordV1` conversion lives in
16//! `backend::storage` (the dependent side). Bumping
17//! `DEF_MAX_SUPPORTED_SCHEMA` is the additive-evolution path, exactly like
18//! the instance record.
19
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23/// Lowest envelope schema this binary will load.
24pub const DEF_MIN_SUPPORTED_SCHEMA: u32 = 1;
25/// Highest envelope schema this binary will write or read. Bumped per
26/// release that adds fields to the definition payload.
27pub const DEF_MAX_SUPPORTED_SCHEMA: u32 = 1;
28
29/// Serde default for `container_volumes` — preserves the db invariant that
30/// an omitted value is the empty JSON array `"[]"`, not `""` (which would
31/// surface via the read-path overlay). (reagent P2 on #1385.)
32fn default_container_volumes() -> String {
33    "[]".to_string()
34}
35
36/// On-disk envelope for a global agent-definition record. Stored at
37/// `<shared>/agents/definitions/<id>.json`.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct DefinitionRecord {
40    pub schema_version: u32,
41    pub data: DefinitionRecordV1,
42}
43
44/// A content blob (system prompt / mcp / env / soul / startup) attached to
45/// a definition — mirrors a `db_agent_content` row. Carried in the global
46/// record so a cross-channel agent launches with its instructions even
47/// though `db_agent_content` is per-version. (codex P1 on #1384.)
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
49pub struct DefContentBlob {
50    pub content_type: String,
51    pub content: String,
52}
53
54/// A skill attached to a definition — mirrors a `db_agent_skills` row.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
56pub struct DefSkillBlob {
57    pub id: String,
58    pub name: String,
59    #[serde(default)]
60    pub trigger: String,
61    #[serde(default)]
62    pub skill_type: String,
63    #[serde(default)]
64    pub description: String,
65    #[serde(default)]
66    pub content: String,
67}
68
69/// v1 definition payload — a faithful mirror of `AgentDefinition`'s
70/// columns, PLUS the per-definition content + skills blobs (which live in
71/// separate per-version tables and must travel with the definition for a
72/// cross-channel agent to launch with its instructions). New fields go
73/// here under `#[serde(default)]` (so older files still deserialize)
74/// paired with a `DEF_MAX_SUPPORTED_SCHEMA` bump.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
76pub struct DefinitionRecordV1 {
77    pub id: String,
78    #[serde(default)]
79    pub slug: String,
80    pub name: String,
81    #[serde(default)]
82    pub icon: String,
83    pub provider: String,
84    #[serde(default)]
85    pub description: String,
86    #[serde(default)]
87    pub working_directory: String,
88    #[serde(default)]
89    pub shell: String,
90    #[serde(default)]
91    pub provider_flags: String,
92    #[serde(default)]
93    pub auto_start: i64,
94    #[serde(default)]
95    pub restart_on_crash: i64,
96    #[serde(default)]
97    pub idle_timeout_minutes: i64,
98    #[serde(default)]
99    pub created_at: i64,
100    #[serde(default)]
101    pub agent_type: String,
102    #[serde(default)]
103    pub environment: String,
104    #[serde(default)]
105    pub agent_bus_id: String,
106    #[serde(default)]
107    pub is_seeded: i64,
108    #[serde(default)]
109    pub accounts: String,
110    #[serde(default)]
111    pub parent_id: String,
112    #[serde(default)]
113    pub branch_label: String,
114    #[serde(default)]
115    pub updated_at: i64,
116    #[serde(default)]
117    pub user_hidden: i64,
118    #[serde(default)]
119    pub container_image: String,
120    #[serde(default = "default_container_volumes")]
121    pub container_volumes: String,
122    #[serde(default)]
123    pub container_name: String,
124    /// System-prompt / mcp / env / soul / startup blobs (db_agent_content),
125    /// embedded so a cross-channel agent launches with its instructions.
126    #[serde(default)]
127    pub content: Vec<DefContentBlob>,
128    /// Skills (db_agent_skills) attached to this definition.
129    #[serde(default)]
130    pub skills: Vec<DefSkillBlob>,
131}
132
133#[derive(Debug, Error)]
134pub enum DefValidationError {
135    #[error("schema_version {version} outside supported [{min}, {max}]")]
136    UnsupportedSchema { version: u32, min: u32, max: u32 },
137    #[error("filename {filename:?} does not match data.id {id:?}")]
138    IdMismatch { filename: String, id: String },
139    #[error("required field missing: {0}")]
140    MissingField(&'static str),
141}
142
143/// Per-row validation. Mirrors `schema::validate`: reject anything that
144/// would surface a malformed definition to the roster. Failures are
145/// skipped (not auto-fixed), logged, and the file stays on disk for ops
146/// triage.
147pub fn validate(filename_stem: &str, rec: &DefinitionRecord) -> Result<(), DefValidationError> {
148    if rec.schema_version < DEF_MIN_SUPPORTED_SCHEMA || rec.schema_version > DEF_MAX_SUPPORTED_SCHEMA
149    {
150        return Err(DefValidationError::UnsupportedSchema {
151            version: rec.schema_version,
152            min: DEF_MIN_SUPPORTED_SCHEMA,
153            max: DEF_MAX_SUPPORTED_SCHEMA,
154        });
155    }
156    let d = &rec.data;
157    if d.id.is_empty() {
158        return Err(DefValidationError::MissingField("id"));
159    }
160    if d.id != filename_stem {
161        return Err(DefValidationError::IdMismatch {
162            filename: filename_stem.to_string(),
163            id: d.id.clone(),
164        });
165    }
166    if d.name.is_empty() {
167        return Err(DefValidationError::MissingField("name"));
168    }
169    if d.provider.is_empty() {
170        return Err(DefValidationError::MissingField("provider"));
171    }
172    Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn rec(id: &str) -> DefinitionRecord {
180        DefinitionRecord {
181            schema_version: 1,
182            data: DefinitionRecordV1 {
183                id: id.to_string(),
184                slug: id.to_string(),
185                name: "Demo".to_string(),
186                icon: "✦".to_string(),
187                provider: "claude".to_string(),
188                description: String::new(),
189                working_directory: String::new(),
190                shell: String::new(),
191                provider_flags: String::new(),
192                auto_start: 0,
193                restart_on_crash: 0,
194                idle_timeout_minutes: 0,
195                created_at: 1,
196                agent_type: "host".to_string(),
197                environment: String::new(),
198                agent_bus_id: String::new(),
199                is_seeded: 0,
200                accounts: String::new(),
201                parent_id: String::new(),
202                branch_label: String::new(),
203                updated_at: 1,
204                user_hidden: 0,
205                container_image: String::new(),
206                container_volumes: "[]".to_string(),
207                container_name: String::new(),
208                content: Vec::new(),
209                skills: Vec::new(),
210            },
211        }
212    }
213
214    #[test]
215    fn happy_path() {
216        validate("abc", &rec("abc")).unwrap();
217    }
218
219    #[test]
220    fn unsupported_schema_is_rejected() {
221        let mut r = rec("abc");
222        r.schema_version = 999;
223        assert!(matches!(
224            validate("abc", &r).unwrap_err(),
225            DefValidationError::UnsupportedSchema { .. }
226        ));
227    }
228
229    #[test]
230    fn filename_mismatch_is_rejected() {
231        assert!(matches!(
232            validate("xyz", &rec("abc")).unwrap_err(),
233            DefValidationError::IdMismatch { .. }
234        ));
235    }
236
237    #[test]
238    fn missing_name_is_rejected() {
239        let mut r = rec("abc");
240        r.data.name = String::new();
241        assert!(matches!(
242            validate("abc", &r).unwrap_err(),
243            DefValidationError::MissingField("name")
244        ));
245    }
246
247    #[test]
248    fn missing_provider_is_rejected() {
249        let mut r = rec("abc");
250        r.data.provider = String::new();
251        assert!(matches!(
252            validate("abc", &r).unwrap_err(),
253            DefValidationError::MissingField("provider")
254        ));
255    }
256
257    #[test]
258    fn unknown_fields_survive_round_trip() {
259        // A future field this binary doesn't know must deserialize cleanly
260        // (serde ignores unknown keys) so a newer writer's record still loads.
261        let raw = serde_json::json!({
262            "schema_version": 1,
263            "data": {
264                "id": "abc", "name": "Demo", "provider": "claude",
265                "future_field": "ignored"
266            }
267        });
268        let parsed: DefinitionRecord = serde_json::from_value(raw).unwrap();
269        assert_eq!(parsed.data.id, "abc");
270        assert_eq!(parsed.data.provider, "claude");
271        // Defaulted field absent from JSON now uses the "[]" db invariant.
272        assert_eq!(parsed.data.container_volumes, "[]");
273    }
274}