1use std::collections::{BTreeMap, HashMap};
8
9use serde_json::Value;
10
11use crate::node::StepNode;
12use crate::{CapabilityManifest, CapabilityPin, GuardKind};
13
14#[derive(Debug, thiserror::Error)]
16pub enum NodeError {
17 #[error("unknown node type '{0}' (not registered)")]
19 UnknownType(String),
20 #[error("node '{node_type}' has invalid config: {reason}")]
22 InvalidConfig {
23 node_type: String,
25 reason: String,
27 },
28 #[error("invalid capability manifest: {0}")]
30 InvalidCapability(String),
31 #[error("capability '{0}' is already registered")]
33 DuplicateCapability(String),
34}
35
36pub type StepFactory = fn(config: &Value) -> Result<Box<dyn StepNode>, NodeError>;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum FieldType {
43 String,
45 Number,
47 Bool,
49 Array,
51 Object,
53 Any,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct FieldSpec {
60 pub key: String,
62 pub ty: FieldType,
64 pub required: bool,
66}
67
68impl NodeSchema {
69 pub fn to_json_schema(&self) -> Value {
71 let mut properties = serde_json::Map::new();
72 let mut required = Vec::new();
73 for field in &self.fields {
74 let schema = match field.ty {
75 FieldType::String => serde_json::json!({"type":"string"}),
76 FieldType::Number => serde_json::json!({"type":"number"}),
77 FieldType::Bool => serde_json::json!({"type":"boolean"}),
78 FieldType::Array => serde_json::json!({"type":"array"}),
79 FieldType::Object => serde_json::json!({"type":"object"}),
80 FieldType::Any => serde_json::json!({}),
81 };
82 properties.insert(field.key.clone(), schema);
83 if field.required {
84 required.push(field.key.clone());
85 }
86 }
87 serde_json::json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
88 }
89}
90
91impl FieldSpec {
92 pub fn required(key: impl Into<String>, ty: FieldType) -> Self {
94 Self {
95 key: key.into(),
96 ty,
97 required: true,
98 }
99 }
100
101 pub fn optional(key: impl Into<String>, ty: FieldType) -> Self {
103 Self {
104 key: key.into(),
105 ty,
106 required: false,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113pub struct NodeSchema {
114 pub fields: Vec<FieldSpec>,
116}
117
118#[derive(Debug, Clone)]
121pub struct NodeRegistry {
122 steps: HashMap<String, StepFactory>,
123 ingress: std::collections::HashSet<String>,
124 fan_out: std::collections::HashSet<String>,
125 guard_kinds: HashMap<String, GuardKind>,
126 capabilities: BTreeMap<CapabilityPin, CapabilityManifest>,
127 capability_steps: BTreeMap<CapabilityPin, StepFactory>,
128 capability_schemas: BTreeMap<CapabilityPin, NodeSchema>,
129 capability_guards: BTreeMap<CapabilityPin, GuardKind>,
130 capability_fan_out: std::collections::BTreeSet<CapabilityPin>,
131 schemas: HashMap<String, NodeSchema>,
132}
133
134impl NodeRegistry {
135 pub fn empty() -> Self {
137 Self {
138 steps: HashMap::new(),
139 ingress: Default::default(),
140 fan_out: Default::default(),
141 guard_kinds: Default::default(),
142 capabilities: Default::default(),
143 capability_steps: Default::default(),
144 capability_schemas: Default::default(),
145 capability_guards: Default::default(),
146 capability_fan_out: Default::default(),
147 schemas: HashMap::new(),
148 }
149 }
150
151 pub fn with_builtins() -> Self {
153 let mut r = Self::empty();
154 crate::builtins::register_builtins(&mut r);
155 r
156 }
157
158 pub fn register_step(&mut self, node_type: impl Into<String>, factory: StepFactory) {
160 self.steps.insert(node_type.into(), factory);
161 }
162
163 pub fn register_ingress(&mut self, node_type: impl Into<String>) {
165 self.ingress.insert(node_type.into());
166 }
167
168 pub fn register_fan_out(&mut self, node_type: impl Into<String>) {
171 self.fan_out.insert(node_type.into());
172 }
173
174 pub fn is_fan_out_capable(&self, node_type: &str) -> bool {
176 self.fan_out.contains(node_type)
177 }
178
179 pub fn register_side_effect_guard(
181 &mut self,
182 node_type: impl Into<String>,
183 ) -> Result<(), NodeError> {
184 self.register_guard(node_type, GuardKind::Authorization)
185 }
186
187 pub fn register_guard(
189 &mut self,
190 node_type: impl Into<String>,
191 kind: GuardKind,
192 ) -> Result<(), NodeError> {
193 let node_type = node_type.into();
194 let mut manifests = self
195 .capabilities
196 .values()
197 .filter(|manifest| manifest.id == node_type);
198 if let Some(manifest) = manifests.next() {
199 if manifests.next().is_some() {
200 return Err(NodeError::InvalidCapability(format!(
201 "guard capability '{node_type}' has multiple versions; register an exact implementation"
202 )));
203 }
204 if manifest.kind != crate::CapabilityKind::Guard {
205 return Err(NodeError::InvalidCapability(format!(
206 "capability '{node_type}' manifest is not a guard"
207 )));
208 }
209 if manifest.guard_kind != Some(kind) {
210 return Err(NodeError::InvalidCapability(format!(
211 "guard capability '{node_type}' implementation guard {:?} does not match manifest guard {:?}",
212 kind, manifest.guard_kind
213 )));
214 }
215 }
216 self.guard_kinds.insert(node_type, kind);
217 Ok(())
218 }
219
220 pub fn guard_kind(&self, node_type: &str) -> Option<GuardKind> {
222 self.capability(node_type)
223 .and_then(|manifest| manifest.guard_kind)
224 .or_else(|| self.guard_kinds.get(node_type).copied())
225 }
226
227 pub fn is_side_effect_guard(&self, node_type: &str) -> bool {
229 self.guard_kind(node_type).is_some()
230 }
231
232 pub fn is_ingress(&self, node_type: &str) -> bool {
234 self.ingress.contains(node_type) || node_type.starts_with("ingress.")
235 }
236
237 pub fn is_step(&self, node_type: &str) -> bool {
239 self.steps.contains_key(node_type)
240 || self
241 .capabilities
242 .values()
243 .any(|manifest| manifest.id == node_type)
244 }
245
246 pub fn build_step(
248 &self,
249 node_type: &str,
250 config: &Value,
251 ) -> Result<Box<dyn StepNode>, NodeError> {
252 let factory = self
253 .steps
254 .get(node_type)
255 .ok_or_else(|| NodeError::UnknownType(node_type.to_string()))?;
256 factory(config)
257 }
258
259 pub fn known_step_types(&self) -> impl Iterator<Item = &str> {
261 self.steps.keys().map(|s| s.as_str())
262 }
263
264 pub fn register_schema(&mut self, node_type: impl Into<String>, schema: NodeSchema) {
266 self.schemas.insert(node_type.into(), schema);
267 }
268
269 pub fn schema(&self, node_type: &str) -> Option<&NodeSchema> {
271 self.schemas.get(node_type)
272 }
273
274 pub fn authoring_schemas(&self) -> Vec<(String, Value)> {
276 let mut entries = self
277 .schemas
278 .iter()
279 .map(|(id, schema)| (id.clone(), schema.to_json_schema()))
280 .collect::<Vec<_>>();
281 entries.sort_by(|left, right| left.0.cmp(&right.0));
282 entries
283 }
284
285 pub fn register_capability(&mut self, manifest: CapabilityManifest) -> Result<(), NodeError> {
287 manifest
288 .validate()
289 .map_err(|error| NodeError::InvalidCapability(error.to_string()))?;
290 let pin = CapabilityPin {
291 id: manifest.id.clone(),
292 contract_version: manifest.contract_version.clone(),
293 content_digest: manifest.content_digest.clone(),
294 };
295 if self.capabilities.contains_key(&pin) {
296 return Err(NodeError::DuplicateCapability(manifest.id));
297 }
298 if let Some(guard) = self.guard_kinds.get(&manifest.id).copied() {
299 if self
300 .capabilities
301 .values()
302 .any(|registered| registered.id == manifest.id)
303 {
304 return Err(NodeError::InvalidCapability(format!(
305 "guard capability '{}' has multiple versions; register an exact implementation",
306 manifest.id
307 )));
308 }
309 if manifest.kind != crate::CapabilityKind::Guard {
310 return Err(NodeError::InvalidCapability(format!(
311 "capability '{}' manifest is not a guard",
312 manifest.id
313 )));
314 }
315 if manifest.guard_kind != Some(guard) {
316 return Err(NodeError::InvalidCapability(format!(
317 "guard capability '{}' manifest guard {:?} does not match implementation guard {:?}",
318 manifest.id, manifest.guard_kind, guard
319 )));
320 }
321 }
322 self.capabilities.insert(pin, manifest);
323 Ok(())
324 }
325
326 pub fn register_capability_implementation(
328 &mut self,
329 pin: CapabilityPin,
330 factory: StepFactory,
331 schema: Option<NodeSchema>,
332 guard: Option<GuardKind>,
333 fan_out: bool,
334 ) -> Result<(), NodeError> {
335 let manifest = self.capabilities.get(&pin).ok_or_else(|| {
336 NodeError::InvalidCapability(format!(
337 "capability '{}' implementation has no registered manifest at {} ({})",
338 pin.id, pin.contract_version, pin.content_digest
339 ))
340 })?;
341 if guard != manifest.guard_kind {
342 return Err(NodeError::InvalidCapability(format!(
343 "capability '{}' implementation guard {:?} does not match manifest guard {:?}",
344 pin.id, guard, manifest.guard_kind
345 )));
346 }
347 if self.capability_steps.contains_key(&pin) {
348 return Err(NodeError::DuplicateCapability(pin.id));
349 }
350 self.capability_steps.insert(pin.clone(), factory);
351 if let Some(schema) = schema {
352 self.capability_schemas.insert(pin.clone(), schema);
353 }
354 if let Some(guard) = guard {
355 self.capability_guards.insert(pin.clone(), guard);
356 }
357 if fan_out {
358 self.capability_fan_out.insert(pin);
359 }
360 Ok(())
361 }
362
363 pub fn capability(&self, id: &str) -> Option<&CapabilityManifest> {
365 let mut matches = self
366 .capabilities
367 .values()
368 .filter(|manifest| manifest.id == id);
369 let manifest = matches.next()?;
370 matches.next().is_none().then_some(manifest)
371 }
372
373 pub fn capability_by_pin(&self, pin: &CapabilityPin) -> Option<&CapabilityManifest> {
375 self.capabilities.get(pin)
376 }
377
378 pub fn for_capability_pins(&self, pins: &[CapabilityPin]) -> Result<Self, NodeError> {
380 let mut selected = self.clone();
381 selected.capabilities.clear();
382 selected
383 .fan_out
384 .retain(|node_type| !pins.iter().any(|pin| pin.id == *node_type));
385 for pin in pins {
386 selected.guard_kinds.remove(&pin.id);
387 let manifest = self.capability_by_pin(pin).ok_or_else(|| {
388 NodeError::InvalidCapability(format!(
389 "capability '{}' is unavailable at {} ({})",
390 pin.id, pin.contract_version, pin.content_digest
391 ))
392 })?;
393 selected.capabilities.insert(pin.clone(), manifest.clone());
394 let versions = self
395 .capabilities
396 .keys()
397 .filter(|candidate| candidate.id == pin.id)
398 .count();
399 if let Some(factory) = self.capability_steps.get(pin) {
400 selected.steps.insert(pin.id.clone(), *factory);
401 } else if versions > 1 && self.steps.contains_key(&pin.id) {
402 return Err(NodeError::InvalidCapability(format!(
403 "capability '{}' has multiple versions but no executable implementation for {} ({})",
404 pin.id, pin.contract_version, pin.content_digest
405 )));
406 }
407 if let Some(schema) = self.capability_schemas.get(pin) {
408 selected.schemas.insert(pin.id.clone(), schema.clone());
409 } else if versions > 1 && self.schemas.contains_key(&pin.id) {
410 return Err(NodeError::InvalidCapability(format!(
411 "capability '{}' has multiple versions but no authoring schema for {} ({})",
412 pin.id, pin.contract_version, pin.content_digest
413 )));
414 }
415 if let Some(manifest_guard) = manifest.guard_kind {
416 let guard = match self.capability_guards.get(pin).copied() {
417 Some(guard) => guard,
418 None => match self.guard_kinds.get(&pin.id).copied() {
419 Some(guard) if versions == 1 && guard == manifest_guard => guard,
420 Some(guard) if versions == 1 => {
421 return Err(NodeError::InvalidCapability(format!(
422 "guard capability '{}' implementation guard {:?} does not match manifest guard {:?}",
423 pin.id, guard, manifest_guard
424 )));
425 }
426 Some(_) => {
427 return Err(NodeError::InvalidCapability(format!(
428 "guard capability '{}' has multiple versions but no exact guard implementation for {} ({})",
429 pin.id, pin.contract_version, pin.content_digest
430 )));
431 }
432 None if !self.steps.contains_key(&pin.id) => manifest_guard,
433 None => {
434 return Err(NodeError::InvalidCapability(format!(
435 "guard capability '{}' has no guard implementation for {} ({})",
436 pin.id, pin.contract_version, pin.content_digest
437 )));
438 }
439 },
440 };
441 selected.guard_kinds.insert(pin.id.clone(), guard);
442 } else if self.steps.contains_key(&pin.id) && self.guard_kinds.contains_key(&pin.id) {
443 return Err(NodeError::InvalidCapability(format!(
444 "capability '{}' implementation declares a guard role but its manifest is not a guard",
445 pin.id
446 )));
447 }
448 if self.capability_fan_out.contains(pin) {
449 selected.fan_out.insert(pin.id.clone());
450 }
451 }
452 Ok(selected)
453 }
454
455 pub fn capability_manifests(&self) -> impl Iterator<Item = &CapabilityManifest> {
457 self.capabilities.values()
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::{CapabilityKind, Effect, IdempotencyMode};
465
466 struct Noop;
467
468 #[async_trait::async_trait]
469 impl StepNode for Noop {
470 async fn process(
471 &self,
472 event: &crate::Event,
473 _ctx: &crate::WorkflowContext,
474 ) -> crate::StepResult {
475 crate::StepResult::Pass(event.clone())
476 }
477 }
478
479 fn noop(_: &Value) -> Result<Box<dyn StepNode>, NodeError> {
480 Ok(Box::new(Noop))
481 }
482
483 #[test]
484 fn capability_versions_are_indexed_and_selected_by_full_pin() {
485 let manifest = |version: &str, digest: &str| {
486 CapabilityManifest::action(
487 "action.versioned",
488 version,
489 digest,
490 Effect::ExternalWrite,
491 IdempotencyMode::Native,
492 true,
493 )
494 };
495 let v1 = manifest("1", "digest-v1");
496 let v2 = manifest("2", "digest-v2");
497 let pin = CapabilityPin {
498 id: v1.id.clone(),
499 contract_version: v1.contract_version.clone(),
500 content_digest: v1.content_digest.clone(),
501 };
502 let mut registry = NodeRegistry::empty();
503 registry.register_capability(v1.clone()).unwrap();
504 registry.register_capability(v2).unwrap();
505
506 assert!(registry.capability("action.versioned").is_none());
507 assert_eq!(registry.capability_by_pin(&pin), Some(&v1));
508 let selected = registry.for_capability_pins(&[pin]).unwrap();
509 assert_eq!(selected.capability("action.versioned"), Some(&v1));
510 }
511
512 #[test]
513 fn versioned_capability_selects_exact_factory_schema_and_guard() {
514 let manifest = |version: &str, digest: &str| {
515 let mut manifest = CapabilityManifest::action(
516 "guard.versioned",
517 version,
518 digest,
519 Effect::Pure,
520 IdempotencyMode::Native,
521 true,
522 );
523 manifest.kind = CapabilityKind::Guard;
524 manifest.guard_kind = Some(GuardKind::Authorization);
525 manifest
526 };
527 let v1 = manifest("1", "digest-v1");
528 let v2 = manifest("2", "digest-v2");
529 let pin = CapabilityPin {
530 id: v1.id.clone(),
531 contract_version: v1.contract_version.clone(),
532 content_digest: v1.content_digest.clone(),
533 };
534 let schema = NodeSchema {
535 fields: vec![FieldSpec::required("approved", FieldType::Bool)],
536 };
537 let mut registry = NodeRegistry::empty();
538 registry.register_capability(v1).unwrap();
539 registry.register_capability(v2).unwrap();
540 let mismatch = registry
541 .register_capability_implementation(
542 pin.clone(),
543 noop,
544 None,
545 Some(GuardKind::Freshness),
546 false,
547 )
548 .unwrap_err();
549 assert!(matches!(mismatch, NodeError::InvalidCapability(_)));
550 registry
551 .register_capability_implementation(
552 pin.clone(),
553 noop,
554 Some(schema.clone()),
555 Some(GuardKind::Authorization),
556 false,
557 )
558 .unwrap();
559
560 let selected = registry.for_capability_pins(&[pin]).unwrap();
561 assert_eq!(selected.schema("guard.versioned"), Some(&schema));
562 assert_eq!(
563 selected.guard_kind("guard.versioned"),
564 Some(GuardKind::Authorization)
565 );
566 assert!(selected.build_step("guard.versioned", &Value::Null).is_ok());
567 }
568
569 #[test]
570 fn versioned_capability_selects_fan_out_by_full_pin() {
571 let manifest = |version: &str, digest: &str| {
572 CapabilityManifest::action(
573 "transform.versioned",
574 version,
575 digest,
576 Effect::Pure,
577 IdempotencyMode::Native,
578 true,
579 )
580 };
581 let v1 = manifest("1", "digest-v1");
582 let v2 = manifest("2", "digest-v2");
583 let pin = |manifest: &CapabilityManifest| CapabilityPin {
584 id: manifest.id.clone(),
585 contract_version: manifest.contract_version.clone(),
586 content_digest: manifest.content_digest.clone(),
587 };
588 let mut registry = NodeRegistry::empty();
589 registry.register_capability(v1.clone()).unwrap();
590 registry.register_capability(v2.clone()).unwrap();
591 registry
592 .register_capability_implementation(pin(&v1), noop, None, None, true)
593 .unwrap();
594 registry
595 .register_capability_implementation(pin(&v2), noop, None, None, false)
596 .unwrap();
597
598 assert!(registry
599 .for_capability_pins(&[pin(&v1)])
600 .unwrap()
601 .is_fan_out_capable("transform.versioned"));
602 assert!(!registry
603 .for_capability_pins(&[pin(&v2)])
604 .unwrap()
605 .is_fan_out_capable("transform.versioned"));
606 }
607
608 #[test]
609 fn legacy_guard_registration_must_match_the_manifest() {
610 let mut manifest = CapabilityManifest::action(
611 "guard.legacy",
612 "1",
613 "digest",
614 Effect::Pure,
615 IdempotencyMode::None,
616 false,
617 );
618 manifest.kind = CapabilityKind::Guard;
619 manifest.guard_kind = Some(GuardKind::Authorization);
620 let pin = CapabilityPin {
621 id: manifest.id.clone(),
622 contract_version: manifest.contract_version.clone(),
623 content_digest: manifest.content_digest.clone(),
624 };
625 let mut persisted = NodeRegistry::empty();
626 persisted.register_capability(manifest.clone()).unwrap();
627 assert_eq!(
628 persisted
629 .for_capability_pins(std::slice::from_ref(&pin))
630 .unwrap()
631 .guard_kind(&manifest.id),
632 manifest.guard_kind
633 );
634
635 let mut legacy = persisted;
636 legacy.register_step(&manifest.id, noop);
637 assert!(matches!(
638 legacy.register_guard(&manifest.id, GuardKind::Freshness),
639 Err(NodeError::InvalidCapability(reason)) if reason.contains("does not match")
640 ));
641 legacy
642 .register_guard(&manifest.id, GuardKind::Authorization)
643 .unwrap();
644 assert_eq!(
645 legacy
646 .for_capability_pins(&[pin])
647 .unwrap()
648 .guard_kind(&manifest.id),
649 manifest.guard_kind
650 );
651
652 let action = CapabilityManifest::action(
653 "action.legacy",
654 "1",
655 "digest",
656 Effect::Pure,
657 IdempotencyMode::None,
658 false,
659 );
660 let action_pin = CapabilityPin {
661 id: action.id.clone(),
662 contract_version: action.contract_version.clone(),
663 content_digest: action.content_digest.clone(),
664 };
665 let mut non_guard = NodeRegistry::empty();
666 non_guard.register_capability(action.clone()).unwrap();
667 assert!(non_guard
668 .for_capability_pins(std::slice::from_ref(&action_pin))
669 .is_ok());
670 non_guard.register_step(&action.id, noop);
671 assert!(matches!(
672 non_guard.register_guard(&action.id, GuardKind::Authorization),
673 Err(NodeError::InvalidCapability(reason)) if reason.contains("manifest is not a guard")
674 ));
675 assert!(non_guard
676 .for_capability_pins(&[action_pin])
677 .unwrap()
678 .guard_kind(&action.id)
679 .is_none());
680 }
681
682 #[test]
683 fn manifest_registration_must_match_a_legacy_guard() {
684 let manifest = |version: &str, kind| {
685 let mut manifest = CapabilityManifest::action(
686 "guard.reverse",
687 version,
688 format!("digest-{version}"),
689 Effect::Pure,
690 IdempotencyMode::None,
691 false,
692 );
693 manifest.kind = CapabilityKind::Guard;
694 manifest.guard_kind = Some(kind);
695 manifest
696 };
697 let mut registry = NodeRegistry::empty();
698 registry
699 .register_guard("guard.reverse", GuardKind::Authorization)
700 .unwrap();
701 assert!(matches!(
702 registry.register_capability(manifest("1", GuardKind::Freshness)),
703 Err(NodeError::InvalidCapability(reason)) if reason.contains("does not match")
704 ));
705 registry
706 .register_capability(manifest("1", GuardKind::Authorization))
707 .unwrap();
708 assert!(matches!(
709 registry.register_capability(manifest("2", GuardKind::Authorization)),
710 Err(NodeError::InvalidCapability(reason)) if reason.contains("multiple versions")
711 ));
712
713 let mut non_guard = NodeRegistry::empty();
714 non_guard
715 .register_guard("action.reverse", GuardKind::Authorization)
716 .unwrap();
717 assert!(matches!(
718 non_guard.register_capability(CapabilityManifest::action(
719 "action.reverse",
720 "1",
721 "digest",
722 Effect::Pure,
723 IdempotencyMode::None,
724 false,
725 )),
726 Err(NodeError::InvalidCapability(reason)) if reason.contains("manifest is not a guard")
727 ));
728 }
729}