warble_claude_code/
resolve.rs1use crate::error::DispatchError;
11use crate::ir::{ComponentNode, OutcomeKind, RealizationKind, TriggerKind};
12use crate::targets::{
13 CapabilityEntry, CapabilityOutcome, CapabilityProfile, Criticality, ProvidedBy,
14};
15use serde::Serialize;
16use std::collections::HashSet;
17
18const BLAST_RADIUS_CAPABILITY: &str = "blast_radius";
21
22#[derive(Debug, Clone, Serialize)]
23pub struct ResolvedCapability {
24 pub capability: String,
25 pub outcome: CapabilityOutcome,
26 pub provided_by: ProvidedBy,
27 pub criticality: Criticality,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub note: Option<String>,
30}
31
32pub type ResolutionReport = Vec<ResolvedCapability>;
33
34fn unknown_capability_entry() -> CapabilityEntry {
37 CapabilityEntry {
38 outcome: CapabilityOutcome::Fail,
39 via: None,
40 provided_by: ProvidedBy::None,
41 criticality: Criticality::SafetyCritical,
42 note: Some(
43 "capability is not declared in the target's capability profile — unknown means it cannot be guaranteed",
44 ),
45 }
46}
47
48fn implied_capabilities(node: &ComponentNode) -> Vec<String> {
50 let mut implied = Vec::new();
51
52 if node.realization_kind == RealizationKind::Skill {
53 let distinct_tiers: HashSet<&str> =
54 node.llm_calls.iter().map(|c| c.tier.as_str()).collect();
55 if distinct_tiers.len() > 1 {
56 implied.push("llm:per_step_tier".to_string());
57 }
58 }
59
60 match node.trigger.kind {
61 TriggerKind::Scheduled => implied.push("scheduler".to_string()),
62 TriggerKind::Event => implied.push("event_bus".to_string()),
63 TriggerKind::OneShot => {}
64 }
65
66 if node
71 .effect
72 .outcome
73 .emits
74 .as_ref()
75 .is_some_and(|e| !e.is_empty())
76 {
77 implied.push("event_bus".to_string());
78 }
79
80 if !node.effect.render_blocks.is_empty() {
81 implied.push("render_contract".to_string());
82 }
83
84 if node.effect.outcome.kind == OutcomeKind::Mutation {
93 implied.push("version_control".to_string());
94 if node.effect.outcome.target.as_deref() == Some("context") {
95 implied.push("context_write_authz".to_string());
96 } else {
97 implied.push("write_authz".to_string());
98 }
99 }
100
101 implied
102}
103
104fn has_fine_grained_binding(node: &ComponentNode) -> bool {
108 node.context_binding
109 .resolved
110 .as_ref()
111 .and_then(|resolved| resolved.get("lineage"))
112 .and_then(|lineage| lineage.get("resolvable"))
113 .and_then(|resolvable| resolvable.as_bool())
114 .unwrap_or(false)
115}
116
117pub fn collect_required_capabilities(node: &ComponentNode) -> Vec<String> {
119 let mut seen = HashSet::new();
120 let mut out = Vec::new();
121 for cap in node
122 .required_capabilities
123 .iter()
124 .cloned()
125 .chain(implied_capabilities(node))
126 {
127 if seen.insert(cap.clone()) {
128 out.push(cap);
129 }
130 }
131 out
132}
133
134pub fn resolve_capabilities(
137 node: &ComponentNode,
138 target_id: &str,
139 profile: &CapabilityProfile,
140) -> Result<ResolutionReport, DispatchError> {
141 let required = collect_required_capabilities(node);
142 let fallback = unknown_capability_entry();
143 let fine_grained_binding = has_fine_grained_binding(node);
144
145 let report: ResolutionReport = required
146 .into_iter()
147 .map(|capability| {
148 let e = profile.get(capability.as_str()).unwrap_or(&fallback);
149 if capability == BLAST_RADIUS_CAPABILITY && fine_grained_binding {
153 ResolvedCapability {
154 capability,
155 outcome: CapabilityOutcome::Native,
156 provided_by: ProvidedBy::Warble,
157 criticality: e.criticality,
158 note: None,
159 }
160 } else if capability == BLAST_RADIUS_CAPABILITY {
161 ResolvedCapability {
162 capability,
163 outcome: CapabilityOutcome::Fail,
164 provided_by: e.provided_by,
165 criticality: e.criticality,
166 note: Some(
167 "requires fine_grained_binding (coarse binding on this target)".to_string(),
168 ),
169 }
170 } else {
171 ResolvedCapability {
172 capability,
173 outcome: e.outcome,
174 provided_by: e.provided_by,
175 criticality: e.criticality,
176 note: e.note.map(|s| s.to_string()),
177 }
178 }
179 })
180 .collect();
181
182 if let Some(failed) = report.iter().find(|r| r.outcome == CapabilityOutcome::Fail) {
183 let reason = failed
184 .note
185 .clone()
186 .unwrap_or_else(|| "unsupported on this target".to_string());
187 return Err(DispatchError(format!(
188 "{}: fail on {target_id} ({reason}) — component '{}' cannot be dispatched",
189 failed.capability, node.verb
190 )));
191 }
192
193 Ok(report)
194}