Skip to main content

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    /// Explicit per-agent opt-in to the CLI's global (ambient) login when
125    /// no oauth-class account resolves at spawn (mirrors the SQLite v12
126    /// column — SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §2.3).
127    /// Additive under `#[serde(default)]` (older files read as 0 =
128    /// fail-by-default; older binaries ignore the extra field), same as
129    /// the container_* fields — no envelope bump.
130    #[serde(default)]
131    pub use_ambient_login: i64,
132    /// Per-agent opt-in letting a Warden Supervisor watcher agent
133    /// auto-continue this agent's session on turn-end (mirrors the SQLite
134    /// v17 column —
135    /// docs/analysis/ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md).
136    /// Additive under `#[serde(default)]`, same as `use_ambient_login` —
137    /// no envelope bump.
138    #[serde(default)]
139    pub auto_continue_enabled: i64,
140    /// System-prompt / mcp / env / soul / startup blobs (db_agent_content),
141    /// embedded so a cross-channel agent launches with its instructions.
142    #[serde(default)]
143    pub content: Vec<DefContentBlob>,
144    /// Skills (db_agent_skills) attached to this definition.
145    #[serde(default)]
146    pub skills: Vec<DefSkillBlob>,
147}
148
149#[derive(Debug, Error)]
150pub enum DefValidationError {
151    #[error("schema_version {version} outside supported [{min}, {max}]")]
152    UnsupportedSchema { version: u32, min: u32, max: u32 },
153    #[error("filename {filename:?} does not match data.id {id:?}")]
154    IdMismatch { filename: String, id: String },
155    #[error("required field missing: {0}")]
156    MissingField(&'static str),
157}
158
159/// Per-row validation. Mirrors `schema::validate`: reject anything that
160/// would surface a malformed definition to the roster. Failures are
161/// skipped (not auto-fixed), logged, and the file stays on disk for ops
162/// triage.
163pub fn validate(filename_stem: &str, rec: &DefinitionRecord) -> Result<(), DefValidationError> {
164    if rec.schema_version < DEF_MIN_SUPPORTED_SCHEMA || rec.schema_version > DEF_MAX_SUPPORTED_SCHEMA
165    {
166        return Err(DefValidationError::UnsupportedSchema {
167            version: rec.schema_version,
168            min: DEF_MIN_SUPPORTED_SCHEMA,
169            max: DEF_MAX_SUPPORTED_SCHEMA,
170        });
171    }
172    let d = &rec.data;
173    if d.id.is_empty() {
174        return Err(DefValidationError::MissingField("id"));
175    }
176    if d.id != filename_stem {
177        return Err(DefValidationError::IdMismatch {
178            filename: filename_stem.to_string(),
179            id: d.id.clone(),
180        });
181    }
182    if d.name.is_empty() {
183        return Err(DefValidationError::MissingField("name"));
184    }
185    if d.provider.is_empty() {
186        return Err(DefValidationError::MissingField("provider"));
187    }
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn rec(id: &str) -> DefinitionRecord {
196        DefinitionRecord {
197            schema_version: 1,
198            data: DefinitionRecordV1 {
199                id: id.to_string(),
200                slug: id.to_string(),
201                name: "Demo".to_string(),
202                icon: "✦".to_string(),
203                provider: "claude".to_string(),
204                description: String::new(),
205                working_directory: String::new(),
206                shell: String::new(),
207                provider_flags: String::new(),
208                auto_start: 0,
209                restart_on_crash: 0,
210                idle_timeout_minutes: 0,
211                created_at: 1,
212                agent_type: "host".to_string(),
213                environment: String::new(),
214                agent_bus_id: String::new(),
215                is_seeded: 0,
216                accounts: String::new(),
217                parent_id: String::new(),
218                branch_label: String::new(),
219                updated_at: 1,
220                user_hidden: 0,
221                container_image: String::new(),
222                container_volumes: "[]".to_string(),
223                container_name: String::new(),
224                use_ambient_login: 0,
225                auto_continue_enabled: 0,
226                content: Vec::new(),
227                skills: Vec::new(),
228            },
229        }
230    }
231
232    #[test]
233    fn happy_path() {
234        validate("abc", &rec("abc")).unwrap();
235    }
236
237    #[test]
238    fn unsupported_schema_is_rejected() {
239        let mut r = rec("abc");
240        r.schema_version = 999;
241        assert!(matches!(
242            validate("abc", &r).unwrap_err(),
243            DefValidationError::UnsupportedSchema { .. }
244        ));
245    }
246
247    #[test]
248    fn filename_mismatch_is_rejected() {
249        assert!(matches!(
250            validate("xyz", &rec("abc")).unwrap_err(),
251            DefValidationError::IdMismatch { .. }
252        ));
253    }
254
255    #[test]
256    fn missing_name_is_rejected() {
257        let mut r = rec("abc");
258        r.data.name = String::new();
259        assert!(matches!(
260            validate("abc", &r).unwrap_err(),
261            DefValidationError::MissingField("name")
262        ));
263    }
264
265    #[test]
266    fn missing_provider_is_rejected() {
267        let mut r = rec("abc");
268        r.data.provider = String::new();
269        assert!(matches!(
270            validate("abc", &r).unwrap_err(),
271            DefValidationError::MissingField("provider")
272        ));
273    }
274
275    #[test]
276    fn unknown_fields_survive_round_trip() {
277        // A future field this binary doesn't know must deserialize cleanly
278        // (serde ignores unknown keys) so a newer writer's record still loads.
279        let raw = serde_json::json!({
280            "schema_version": 1,
281            "data": {
282                "id": "abc", "name": "Demo", "provider": "claude",
283                "future_field": "ignored"
284            }
285        });
286        let parsed: DefinitionRecord = serde_json::from_value(raw).unwrap();
287        assert_eq!(parsed.data.id, "abc");
288        assert_eq!(parsed.data.provider, "claude");
289        // Defaulted field absent from JSON now uses the "[]" db invariant.
290        assert_eq!(parsed.data.container_volumes, "[]");
291    }
292}