Skip to main content

agm_core/model/
node.rs

1//! AGM node type (spec S11, S12).
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::code::CodeBlock;
8use super::context::AgentContext;
9use super::execution::ExecutionStatus;
10use super::fields::{
11    Confidence, FieldValue, NodeStatus, NodeType, Priority, SddPhase, Span, Stability, TicketAction,
12};
13use super::memory::MemoryEntry;
14use super::orchestration::ParallelGroup;
15use super::verify::VerifyCheck;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Node {
19    #[serde(rename = "node")]
20    pub id: String,
21    #[serde(rename = "type")]
22    pub node_type: NodeType,
23    pub summary: String,
24
25    // Control fields
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub priority: Option<Priority>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub stability: Option<Stability>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub confidence: Option<Confidence>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub status: Option<NodeStatus>,
34
35    // Relationship fields
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub depends: Option<Vec<String>>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub related_to: Option<Vec<String>>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub replaces: Option<Vec<String>>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub conflicts: Option<Vec<String>>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub see_also: Option<Vec<String>>,
46
47    // Operational fields
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub items: Option<Vec<String>>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub steps: Option<Vec<String>>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub fields: Option<Vec<String>>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub input: Option<Vec<String>>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub output: Option<Vec<String>>,
58
59    // Explanatory fields
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub detail: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub rationale: Option<Vec<String>>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub tradeoffs: Option<Vec<String>>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub resolution: Option<Vec<String>>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub examples: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub notes: Option<String>,
72
73    // Executable fields
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub code: Option<CodeBlock>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub code_blocks: Option<Vec<CodeBlock>>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub verify: Option<Vec<VerifyCheck>>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub agent_context: Option<AgentContext>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub target: Option<String>,
84
85    // Execution state fields
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub execution_status: Option<ExecutionStatus>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub executed_by: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub executed_at: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub execution_log: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub retry_count: Option<u32>,
96
97    // Orchestration fields
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub parallel_groups: Option<Vec<ParallelGroup>>,
100
101    // Memory fields
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub memory: Option<Vec<MemoryEntry>>,
104
105    // Context fields
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub scope: Option<Vec<String>>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub applies_when: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub valid_from: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub valid_until: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub tags: Option<Vec<String>>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub aliases: Option<Vec<String>>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub keywords: Option<Vec<String>>,
120
121    // Ticket fields (v1.2.0)
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub title: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub description: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub action: Option<TicketAction>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub sdd_phase: Option<SddPhase>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub prompt: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub assignee: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub labels: Option<Vec<String>>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub ticket_id: Option<String>,
138
139    // Extension fields
140    #[serde(flatten)]
141    pub extra_fields: BTreeMap<String, FieldValue>,
142
143    // Source span (not serialized)
144    #[serde(skip)]
145    pub span: Span,
146}
147
148impl Default for Node {
149    fn default() -> Self {
150        Self {
151            id: String::new(),
152            node_type: NodeType::Facts,
153            summary: String::new(),
154            priority: None,
155            stability: None,
156            confidence: None,
157            status: None,
158            depends: None,
159            related_to: None,
160            replaces: None,
161            conflicts: None,
162            see_also: None,
163            items: None,
164            steps: None,
165            fields: None,
166            input: None,
167            output: None,
168            detail: None,
169            rationale: None,
170            tradeoffs: None,
171            resolution: None,
172            examples: None,
173            notes: None,
174            code: None,
175            code_blocks: None,
176            verify: None,
177            agent_context: None,
178            target: None,
179            execution_status: None,
180            executed_by: None,
181            executed_at: None,
182            execution_log: None,
183            retry_count: None,
184            parallel_groups: None,
185            memory: None,
186            scope: None,
187            applies_when: None,
188            valid_from: None,
189            valid_until: None,
190            tags: None,
191            aliases: None,
192            keywords: None,
193            title: None,
194            description: None,
195            action: None,
196            sdd_phase: None,
197            prompt: None,
198            assignee: None,
199            labels: None,
200            ticket_id: None,
201            extra_fields: BTreeMap::new(),
202            span: Span::new(0, 0),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn minimal_node() -> Node {
212        Node {
213            id: "test.node".to_owned(),
214            node_type: NodeType::Facts,
215            summary: "a test node".to_owned(),
216            ..Default::default()
217        }
218    }
219
220    #[test]
221    fn test_node_minimal_serde_roundtrip() {
222        let node = minimal_node();
223        let json = serde_json::to_string(&node).unwrap();
224        let back: Node = serde_json::from_str(&json).unwrap();
225        assert_eq!(node.id, back.id);
226        assert_eq!(node.node_type, back.node_type);
227        assert_eq!(node.summary, back.summary);
228    }
229
230    #[test]
231    fn test_node_json_uses_spec_field_names() {
232        let node = minimal_node();
233        let json = serde_json::to_string(&node).unwrap();
234        assert!(json.contains("\"node\""));
235        assert!(json.contains("\"type\""));
236        assert!(!json.contains("\"id\""));
237        assert!(!json.contains("\"node_type\""));
238    }
239
240    #[test]
241    fn test_node_optional_fields_absent_in_json() {
242        let node = minimal_node();
243        let json = serde_json::to_string(&node).unwrap();
244        assert!(!json.contains("priority"));
245        assert!(!json.contains("depends"));
246        assert!(!json.contains("steps"));
247        assert!(!json.contains("code"));
248        assert!(!json.contains("execution_status"));
249        assert!(!json.contains("memory"));
250    }
251
252    #[test]
253    fn test_node_with_relationships_serde() {
254        let mut node = minimal_node();
255        node.depends = Some(vec!["auth.constraints".to_owned()]);
256        node.related_to = Some(vec!["auth.session".to_owned()]);
257        let json = serde_json::to_string(&node).unwrap();
258        let back: Node = serde_json::from_str(&json).unwrap();
259        assert_eq!(node.depends, back.depends);
260        assert_eq!(node.related_to, back.related_to);
261    }
262
263    #[test]
264    fn test_node_with_execution_state_serde() {
265        let mut node = minimal_node();
266        node.execution_status = Some(ExecutionStatus::Completed);
267        node.executed_by = Some("agent-01".to_owned());
268        node.executed_at = Some("2026-04-03T14:32:00Z".to_owned());
269        node.retry_count = Some(0);
270        let json = serde_json::to_string(&node).unwrap();
271        let back: Node = serde_json::from_str(&json).unwrap();
272        assert_eq!(node.execution_status, back.execution_status);
273        assert_eq!(node.executed_by, back.executed_by);
274    }
275
276    #[test]
277    fn test_node_extra_fields_preserved() {
278        let mut node = minimal_node();
279        node.extra_fields.insert(
280            "custom_field".to_owned(),
281            FieldValue::Scalar("value".to_owned()),
282        );
283        let json = serde_json::to_string(&node).unwrap();
284        assert!(json.contains("custom_field"));
285        let back: Node = serde_json::from_str(&json).unwrap();
286        assert_eq!(
287            back.extra_fields.get("custom_field"),
288            Some(&FieldValue::Scalar("value".to_owned()))
289        );
290    }
291
292    #[test]
293    fn test_node_span_not_serialized() {
294        let mut node = minimal_node();
295        node.span = Span::new(5, 20);
296        let json = serde_json::to_string(&node).unwrap();
297        assert!(!json.contains("start_line"));
298        assert!(!json.contains("end_line"));
299        assert!(!json.contains("span"));
300    }
301
302    #[test]
303    fn test_node_deserialize_from_spec_appendix_b() {
304        let json = r#"{
305            "node": "auth.login",
306            "type": "workflow",
307            "stability": "medium",
308            "priority": "critical",
309            "depends": ["auth.constraints", "auth.session"],
310            "input": ["host", "return_url"],
311            "output": ["redirect_url", "sid_cookie"],
312            "summary": "resolve tenant -> redirect -> callback -> create sid",
313            "steps": ["resolve tenant", "redirect to provider"],
314            "execution_status": "pending",
315            "retry_count": 0
316        }"#;
317        let node: Node = serde_json::from_str(json).unwrap();
318        assert_eq!(node.id, "auth.login");
319        assert_eq!(node.node_type, NodeType::Workflow);
320        assert_eq!(node.priority, Some(Priority::Critical));
321        assert_eq!(node.stability, Some(Stability::Medium));
322        assert_eq!(node.depends.as_ref().unwrap().len(), 2);
323        assert_eq!(node.steps.as_ref().unwrap().len(), 2);
324        assert_eq!(node.execution_status, Some(ExecutionStatus::Pending));
325        assert_eq!(node.retry_count, Some(0));
326    }
327}