warble_claude_code/
resolve.rs1use crate::error::DispatchError;
11use crate::ir::{ComponentNode, OutcomeKind, 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(std::borrow::Cow::Borrowed(
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 let distinct_tiers: HashSet<&str> = node.llm_calls.iter().map(|c| c.tier.as_str()).collect();
58 if distinct_tiers.len() > 1 {
59 implied.push("llm:per_step_tier".to_string());
60 }
61
62 match node.trigger.kind {
63 TriggerKind::Scheduled => implied.push("scheduler".to_string()),
64 TriggerKind::Event => implied.push("event_bus".to_string()),
65 TriggerKind::OneShot => {}
66 }
67
68 if node
73 .effect
74 .outcome
75 .emits
76 .as_ref()
77 .is_some_and(|e| !e.is_empty())
78 {
79 implied.push("event_bus".to_string());
80 }
81
82 if !node.effect.render_blocks.is_empty() {
83 implied.push("render_contract".to_string());
84 }
85
86 if node.effect.outcome.kind == OutcomeKind::Mutation {
95 implied.push("version_control".to_string());
96 if node.effect.outcome.target.as_deref() == Some("context") {
97 implied.push("context_write_authz".to_string());
98 } else {
99 implied.push("write_authz".to_string());
100 }
101 }
102
103 implied
104}
105
106fn has_fine_grained_binding(node: &ComponentNode) -> bool {
110 node.context_binding
111 .resolved
112 .as_ref()
113 .and_then(|resolved| resolved.get("lineage"))
114 .and_then(|lineage| lineage.get("resolvable"))
115 .and_then(|resolvable| resolvable.as_bool())
116 .unwrap_or(false)
117}
118
119pub fn collect_required_capabilities(node: &ComponentNode) -> Vec<String> {
121 let mut seen = HashSet::new();
122 let mut out = Vec::new();
123 for cap in node
124 .required_capabilities
125 .iter()
126 .cloned()
127 .chain(implied_capabilities(node))
128 {
129 if seen.insert(cap.clone()) {
130 out.push(cap);
131 }
132 }
133 out
134}
135
136pub fn resolve_capabilities(
139 node: &ComponentNode,
140 target_id: &str,
141 profile: &CapabilityProfile,
142) -> Result<ResolutionReport, DispatchError> {
143 let required = collect_required_capabilities(node);
144 let fallback = unknown_capability_entry();
145 let fine_grained_binding = has_fine_grained_binding(node);
146
147 let report: ResolutionReport = required
148 .into_iter()
149 .map(|capability| {
150 let e = profile.get(capability.as_str()).unwrap_or(&fallback);
151 if capability == BLAST_RADIUS_CAPABILITY && fine_grained_binding {
155 ResolvedCapability {
156 capability,
157 outcome: CapabilityOutcome::Native,
158 provided_by: ProvidedBy::Warble,
159 criticality: e.criticality,
160 note: None,
161 }
162 } else if capability == BLAST_RADIUS_CAPABILITY {
163 ResolvedCapability {
164 capability,
165 outcome: CapabilityOutcome::Fail,
166 provided_by: e.provided_by,
167 criticality: e.criticality,
168 note: Some(
169 "requires fine_grained_binding (coarse binding on this target)".to_string(),
170 ),
171 }
172 } else {
173 ResolvedCapability {
174 capability,
175 outcome: e.outcome,
176 provided_by: e.provided_by,
177 criticality: e.criticality,
178 note: e.note.as_deref().map(str::to_string),
179 }
180 }
181 })
182 .collect();
183
184 if let Some(failed) = report.iter().find(|r| r.outcome == CapabilityOutcome::Fail) {
185 let reason = failed
186 .note
187 .clone()
188 .unwrap_or_else(|| "unsupported on this target".to_string());
189 return Err(DispatchError(format!(
190 "{}: fail on {target_id} ({reason}) — component '{}' cannot be dispatched",
191 failed.capability, node.verb
192 )));
193 }
194
195 Ok(report)
196}