Skip to main content

ranvier_core/
schematic.rs

1use crate::metadata::StepMetadata;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use uuid::Uuid;
6
7/// 스키마 버전 상수
8pub const SCHEMA_VERSION: &str = "1.0";
9
10fn default_schema_version() -> String {
11    SCHEMA_VERSION.to_string()
12}
13
14fn parse_schema_version(version: &str) -> Option<(u64, u64)> {
15    let trimmed = version.trim();
16    if trimmed.is_empty() {
17        return None;
18    }
19
20    let mut parts = trimmed.split('.');
21    let major = parts.next()?.parse().ok()?;
22    let minor = parts.next().unwrap_or("0").parse().ok()?;
23    Some((major, minor))
24}
25
26/// Returns true when the provided schematic schema version is supported by this crate.
27///
28/// Compatibility is evaluated at major-version level.
29pub fn is_supported_schema_version(version: &str) -> bool {
30    let Some((major, _)) = parse_schema_version(version) else {
31        return false;
32    };
33    let Some((supported_major, _)) = parse_schema_version(SCHEMA_VERSION) else {
34        return false;
35    };
36    major == supported_major
37}
38
39/// The Static Analysis View of a Circuit.
40///
41/// `Schematic` is the graph representation extracted from the Axon Builder.
42/// It is used for visualization, documentation, and verification.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Schematic {
45    /// 스키마 버전 (호환성 체크용)
46    #[serde(default = "default_schema_version")]
47    pub schema_version: String,
48    /// Circuit 고유 식별자
49    pub id: String,
50    /// Circuit 이름
51    pub name: String,
52    /// 설명
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub description: Option<String>,
55    /// 생성 시각
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub generated_at: Option<DateTime<Utc>>,
58    /// 노드 목록
59    pub nodes: Vec<Node>,
60    /// 엣지 목록
61    pub edges: Vec<Edge>,
62}
63
64impl Default for Schematic {
65    fn default() -> Self {
66        Self {
67            schema_version: SCHEMA_VERSION.to_string(),
68            id: Uuid::new_v4().to_string(),
69            name: String::new(),
70            description: None,
71            generated_at: Some(Utc::now()),
72            nodes: Vec::new(),
73            edges: Vec::new(),
74        }
75    }
76}
77
78impl Schematic {
79    pub fn new(name: impl Into<String>) -> Self {
80        Self {
81            name: name.into(),
82            ..Default::default()
83        }
84    }
85
86    pub fn is_supported_schema_version(&self) -> bool {
87        is_supported_schema_version(&self.schema_version)
88    }
89
90    /// 기존 ID를 유지하면서 새 Schematic 생성
91    pub fn with_id(name: impl Into<String>, id: impl Into<String>) -> Self {
92        Self {
93            id: id.into(),
94            name: name.into(),
95            ..Default::default()
96        }
97    }
98}
99
100/// 소스 코드 위치 정보 (Studio Code↔Node 매핑용)
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SourceLocation {
103    /// 파일 경로 (프로젝트 루트 기준 상대 경로)
104    pub file: String,
105    /// 라인 번호 (1-indexed)
106    pub line: u32,
107    /// 컬럼 번호 (optional)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub column: Option<u32>,
110}
111
112impl SourceLocation {
113    pub fn new(file: impl Into<String>, line: u32) -> Self {
114        Self {
115            file: file.into(),
116            line,
117            column: None,
118        }
119    }
120
121    pub fn with_column(file: impl Into<String>, line: u32, column: u32) -> Self {
122        Self {
123            file: file.into(),
124            line,
125            column: Some(column),
126        }
127    }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Position {
132    pub x: f32,
133    pub y: f32,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Node {
138    pub id: String, // Uuid typically
139    pub kind: NodeKind,
140    pub label: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub description: Option<String>,
143    pub input_type: String,
144    pub output_type: String, // Primary output type for Next
145    pub resource_type: String,
146    pub metadata: StepMetadata,
147    /// Optional transition-level Bus capability policy metadata.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub bus_capability: Option<BusCapabilitySchema>,
150    /// 소스 코드 위치 (Studio Code↔Node 매핑용)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub source_location: Option<SourceLocation>,
153    /// Visual position in schematic
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub position: Option<Position>,
156    /// Schematic-level Saga compensation routing.
157    /// Points to the node ID that handles compensation for this node.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub compensation_node_id: Option<String>,
160    /// JSON Schema for the node's input type.
161    /// Populated via `.with_input_schema::<T>()` or `#[transition(schema)]`.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub input_schema: Option<serde_json::Value>,
164    /// JSON Schema for the node's output type.
165    /// Populated via `.with_output_schema::<T>()` or `#[transition(schema)]`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub output_schema: Option<serde_json::Value>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct BusCapabilitySchema {
172    #[serde(skip_serializing_if = "Vec::is_empty", default)]
173    pub allow: Vec<String>,
174    #[serde(skip_serializing_if = "Vec::is_empty", default)]
175    pub deny: Vec<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub enum NodeKind {
180    Ingress,                  // Handler / Start
181    Atom,                     // Single action
182    Synapse,                  // Connection point / Branch
183    Egress,                   // Response / End
184    Subgraph(Box<Schematic>), // Nested graph
185    FanOut,                   // Parallel split point
186    FanIn,                    // Parallel join point
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub enum EdgeType {
191    Linear,         // Outcome::Next
192    Branch(String), // Outcome::Branch(id)
193    Jump,           // Outcome::Jump
194    Fault,          // Outcome::Fault
195    Parallel,       // Parallel branch (FanOut -> branch)
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct Edge {
200    pub from: String,
201    pub to: String,
202    pub kind: EdgeType,
203    pub label: Option<String>,
204}
205
206/// Defines how an in-flight workflow instance should be handled during a schema migration.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub enum MigrationStrategy {
209    /// Stop and fail the in-flight instance.
210    Fail,
211    /// Wait for the instance to complete on the old version before migrating.
212    CompleteOnOldVersion,
213    /// Migrate the active node from the old ID to the new ID.
214    MigrateActiveNode {
215        old_node_id: String,
216        new_node_id: String,
217    },
218    /// Resume from a specific fallback node.
219    FallbackToNode(String),
220    /// Abandon current state and resume from the Ingress node of the new version.
221    ResumeFromStart,
222}
223
224/// Trait for transforming workflow payload between schema versions.
225///
226/// Implement this to define custom payload transformations when migrating
227/// in-flight workflows across schematic versions.
228pub trait SchemaMigrationMapper: Send + Sync {
229    /// Transform the old state payload into the new version's expected format.
230    fn map_state(&self, old_state: &serde_json::Value) -> anyhow::Result<serde_json::Value>;
231}
232
233/// A snapshot migration definition indicating how to move state from one schema version to another.
234#[derive(Clone, Serialize, Deserialize)]
235pub struct SnapshotMigration {
236    /// Optional human-readable name for this migration.
237    pub name: Option<String>,
238    /// The unique identifier of the old schematic version.
239    pub from_version: String,
240    /// The unique identifier of the new schematic version.
241    pub to_version: String,
242    /// The default strategy to apply if a node-specific strategy is not provided.
243    pub default_strategy: MigrationStrategy,
244    /// Node-specific migration strategies, keyed by the active node ID in the old version.
245    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
246    pub node_mapping: HashMap<String, MigrationStrategy>,
247    /// Optional payload mapper for transforming state between versions.
248    #[serde(skip)]
249    pub payload_mapper: Option<std::sync::Arc<dyn SchemaMigrationMapper>>,
250}
251
252impl std::fmt::Debug for SnapshotMigration {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.debug_struct("SnapshotMigration")
255            .field("name", &self.name)
256            .field("from_version", &self.from_version)
257            .field("to_version", &self.to_version)
258            .field("default_strategy", &self.default_strategy)
259            .field("node_mapping", &self.node_mapping)
260            .field(
261                "payload_mapper",
262                &self.payload_mapper.as_ref().map(|_| ".."),
263            )
264            .finish()
265    }
266}
267
268/// A registry of available snapshot migrations for a specific circuit.
269///
270/// Use this to look up how to move from a persisted version to the currently running version.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct MigrationRegistry {
273    pub circuit_id: String,
274    pub migrations: Vec<SnapshotMigration>,
275}
276
277impl MigrationRegistry {
278    pub fn new(circuit_id: impl Into<String>) -> Self {
279        Self {
280            circuit_id: circuit_id.into(),
281            migrations: Vec::new(),
282        }
283    }
284
285    pub fn register(&mut self, migration: SnapshotMigration) {
286        self.migrations.push(migration);
287    }
288
289    /// Finds a direct migration from `from_version` to `to_version`.
290    pub fn find_migration(&self, from: &str, to: &str) -> Option<&SnapshotMigration> {
291        self.migrations
292            .iter()
293            .find(|m| m.from_version == from && m.to_version == to)
294    }
295
296    /// Finds a multi-hop migration path from `from_version` to `to_version`.
297    ///
298    /// Returns an ordered list of migrations to apply sequentially.
299    /// Uses BFS to find the shortest path. Returns `None` if no path exists.
300    pub fn find_migration_path(&self, from: &str, to: &str) -> Option<Vec<&SnapshotMigration>> {
301        if from == to {
302            return Some(Vec::new());
303        }
304        // Direct hop
305        if let Some(direct) = self.find_migration(from, to) {
306            return Some(vec![direct]);
307        }
308        // BFS for multi-hop
309        let mut queue: std::collections::VecDeque<(String, Vec<usize>)> =
310            std::collections::VecDeque::new();
311        let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
312        visited.insert(from.to_string());
313        for (i, m) in self.migrations.iter().enumerate() {
314            if m.from_version == from {
315                visited.insert(m.to_version.clone());
316                if m.to_version == to {
317                    return Some(vec![&self.migrations[i]]);
318                }
319                queue.push_back((m.to_version.clone(), vec![i]));
320            }
321        }
322        while let Some((current, path)) = queue.pop_front() {
323            for (i, m) in self.migrations.iter().enumerate() {
324                if m.from_version == current && !visited.contains(&m.to_version) {
325                    let mut new_path = path.clone();
326                    new_path.push(i);
327                    if m.to_version == to {
328                        return Some(new_path.iter().map(|&idx| &self.migrations[idx]).collect());
329                    }
330                    visited.insert(m.to_version.clone());
331                    queue.push_back((m.to_version.clone(), new_path));
332                }
333            }
334        }
335        None
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_schematic_default_has_version_and_id() {
345        let schematic = Schematic::new("Test Circuit");
346        assert_eq!(schematic.schema_version, SCHEMA_VERSION);
347        assert!(schematic.is_supported_schema_version());
348        assert!(!schematic.id.is_empty());
349        assert!(schematic.generated_at.is_some());
350    }
351
352    #[test]
353    fn test_schematic_serialization_with_new_fields() {
354        let schematic = Schematic::new("Test");
355        let json = serde_json::to_string_pretty(&schematic).unwrap();
356
357        assert!(json.contains("schema_version"));
358        assert!(json.contains("\"1.0\""));
359        assert!(json.contains("generated_at"));
360    }
361
362    #[test]
363    fn test_source_location_optional_in_json() {
364        let schematic = Schematic::new("Test");
365        let json = serde_json::to_string(&schematic).unwrap();
366
367        // description과 source_location은 None이면 JSON에서 생략됨
368        assert!(!json.contains("description"));
369    }
370
371    #[test]
372    fn test_source_location_creation() {
373        let loc = SourceLocation::new("src/main.rs", 42);
374        assert_eq!(loc.file, "src/main.rs");
375        assert_eq!(loc.line, 42);
376        assert!(loc.column.is_none());
377
378        let loc_with_col = SourceLocation::with_column("src/lib.rs", 10, 5);
379        assert_eq!(loc_with_col.column, Some(5));
380    }
381
382    #[test]
383    fn test_schema_version_defaults_when_missing_in_json() {
384        let json = r#"{
385            "id": "test-id",
386            "name": "Legacy Schematic",
387            "nodes": [],
388            "edges": []
389        }"#;
390        let schematic: Schematic = serde_json::from_str(json).unwrap();
391        assert_eq!(schematic.schema_version, SCHEMA_VERSION);
392        assert!(schematic.is_supported_schema_version());
393    }
394
395    #[test]
396    fn test_supported_schema_version_major_compatibility() {
397        assert!(is_supported_schema_version("1"));
398        assert!(is_supported_schema_version("1.0"));
399        assert!(is_supported_schema_version("1.1"));
400        assert!(is_supported_schema_version("1.0.9"));
401        assert!(!is_supported_schema_version("2.0"));
402        assert!(!is_supported_schema_version(""));
403        assert!(!is_supported_schema_version("invalid"));
404    }
405
406    #[test]
407    fn test_migration_registry_lookup() {
408        let mut registry = MigrationRegistry::new("test-circuit");
409        let migration = SnapshotMigration {
410            name: Some("v1 to v2".to_string()),
411            from_version: "1.0".to_string(),
412            to_version: "2.0".to_string(),
413            default_strategy: MigrationStrategy::Fail,
414            node_mapping: HashMap::new(),
415            payload_mapper: None,
416        };
417        registry.register(migration);
418
419        let found = registry.find_migration("1.0", "2.0");
420        assert!(found.is_some());
421        assert_eq!(found.unwrap().name, Some("v1 to v2".to_string()));
422
423        let not_found = registry.find_migration("1.0", "3.0");
424        assert!(not_found.is_none());
425    }
426
427    #[test]
428    fn test_node_deserializes_without_schema_fields() {
429        // RQ13: Backward compatibility — old JSON without input_schema/output_schema
430        let json = r#"{
431            "id": "node-1",
432            "kind": "Atom",
433            "label": "OldNode",
434            "input_type": "i32",
435            "output_type": "i32",
436            "resource_type": "()",
437            "metadata": {
438                "id": "00000000-0000-0000-0000-000000000000",
439                "label": "OldNode",
440                "description": null,
441                "inputs": [],
442                "outputs": []
443            }
444        }"#;
445        let node: Node = serde_json::from_str(json).unwrap();
446        assert_eq!(node.label, "OldNode");
447        assert!(node.input_schema.is_none());
448        assert!(node.output_schema.is_none());
449    }
450
451    #[test]
452    fn test_node_serializes_schema_fields_when_present() {
453        let node = Node {
454            id: "node-s".to_string(),
455            kind: NodeKind::Atom,
456            label: "WithSchema".to_string(),
457            description: None,
458            input_type: "MyInput".to_string(),
459            output_type: "MyOutput".to_string(),
460            resource_type: "()".to_string(),
461            metadata: StepMetadata::default(),
462            bus_capability: None,
463            source_location: None,
464            position: None,
465            compensation_node_id: None,
466            input_schema: Some(serde_json::json!({"type": "object"})),
467            output_schema: Some(serde_json::json!({"type": "string"})),
468        };
469        let json = serde_json::to_value(&node).unwrap();
470        assert_eq!(json["input_schema"], serde_json::json!({"type": "object"}));
471        assert_eq!(json["output_schema"], serde_json::json!({"type": "string"}));
472    }
473
474    #[test]
475    fn test_node_omits_schema_fields_when_none() {
476        let node = Node {
477            id: "node-n".to_string(),
478            kind: NodeKind::Atom,
479            label: "NoSchema".to_string(),
480            description: None,
481            input_type: "i32".to_string(),
482            output_type: "i32".to_string(),
483            resource_type: "()".to_string(),
484            metadata: StepMetadata::default(),
485            bus_capability: None,
486            source_location: None,
487            position: None,
488            compensation_node_id: None,
489            input_schema: None,
490            output_schema: None,
491        };
492        let json = serde_json::to_value(&node).unwrap();
493        let obj = json.as_object().unwrap();
494        assert!(!obj.contains_key("input_schema"));
495        assert!(!obj.contains_key("output_schema"));
496    }
497
498    #[test]
499    fn test_schematic_with_schema_nodes_roundtrip() {
500        let mut schematic = Schematic::new("SchemaTest");
501        schematic.nodes.push(Node {
502            id: "n1".to_string(),
503            kind: NodeKind::Atom,
504            label: "Step1".to_string(),
505            description: None,
506            input_type: "Request".to_string(),
507            output_type: "Response".to_string(),
508            resource_type: "()".to_string(),
509            metadata: StepMetadata::default(),
510            bus_capability: None,
511            source_location: None,
512            position: None,
513            compensation_node_id: None,
514            input_schema: Some(serde_json::json!({"type": "object", "required": ["name"]})),
515            output_schema: None,
516        });
517
518        let json = serde_json::to_string(&schematic).unwrap();
519        let deserialized: Schematic = serde_json::from_str(&json).unwrap();
520
521        assert_eq!(deserialized.nodes.len(), 1);
522        assert!(deserialized.nodes[0].input_schema.is_some());
523        assert!(deserialized.nodes[0].output_schema.is_none());
524        assert_eq!(
525            deserialized.nodes[0].input_schema.as_ref().unwrap()["required"][0],
526            "name"
527        );
528    }
529
530    #[test]
531    fn test_legacy_schematic_json_deserializes() {
532        // RQ13: Full schematic from pre-v0.20 (no input_schema/output_schema on nodes)
533        let json = r#"{
534            "schema_version": "1.0",
535            "id": "legacy-1",
536            "name": "LegacyCircuit",
537            "nodes": [{
538                "id": "n1",
539                "kind": "Ingress",
540                "label": "Start",
541                "input_type": "String",
542                "output_type": "String",
543                "resource_type": "()",
544                "metadata": {
545                    "id": "00000000-0000-0000-0000-000000000000",
546                    "label": "Start",
547                    "description": null,
548                    "inputs": [],
549                    "outputs": []
550                }
551            }],
552            "edges": []
553        }"#;
554        let schematic: Schematic = serde_json::from_str(json).unwrap();
555        assert_eq!(schematic.name, "LegacyCircuit");
556        assert_eq!(schematic.nodes.len(), 1);
557        assert!(schematic.nodes[0].input_schema.is_none());
558        assert!(schematic.nodes[0].output_schema.is_none());
559    }
560
561    #[test]
562    fn test_multi_hop_migration_path() {
563        let mut registry = MigrationRegistry::new("test-circuit");
564        registry.register(SnapshotMigration {
565            name: Some("v1→v2".to_string()),
566            from_version: "1.0".to_string(),
567            to_version: "2.0".to_string(),
568            default_strategy: MigrationStrategy::ResumeFromStart,
569            node_mapping: HashMap::new(),
570            payload_mapper: None,
571        });
572        registry.register(SnapshotMigration {
573            name: Some("v2→v3".to_string()),
574            from_version: "2.0".to_string(),
575            to_version: "3.0".to_string(),
576            default_strategy: MigrationStrategy::ResumeFromStart,
577            node_mapping: HashMap::new(),
578            payload_mapper: None,
579        });
580
581        // Direct hop
582        let path = registry.find_migration_path("1.0", "2.0").unwrap();
583        assert_eq!(path.len(), 1);
584
585        // Multi-hop
586        let path = registry.find_migration_path("1.0", "3.0").unwrap();
587        assert_eq!(path.len(), 2);
588        assert_eq!(path[0].from_version, "1.0");
589        assert_eq!(path[0].to_version, "2.0");
590        assert_eq!(path[1].from_version, "2.0");
591        assert_eq!(path[1].to_version, "3.0");
592
593        // Same version (no-op)
594        let path = registry.find_migration_path("1.0", "1.0").unwrap();
595        assert!(path.is_empty());
596
597        // No path
598        let path = registry.find_migration_path("1.0", "4.0");
599        assert!(path.is_none());
600    }
601}