mecha10_behavior_runtime/actions/
capability_task.rs1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
18use async_trait::async_trait;
19use mecha10_core::Receiver;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
26struct TargetRequest {
27 #[serde(default)]
28 bbox: Option<Value>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct CapabilityTaskConfig {
33 capability: String,
34 task_complete_topic: String,
35 #[serde(default)]
36 target_request_topic: Option<String>,
37}
38
39pub struct CapabilityTaskNode {
40 capability: String,
41 task_complete_topic: String,
42 target_request_topic: Option<String>,
43 complete_receiver: Option<Receiver<Value>>,
44 target_receiver: Option<Receiver<Value>>,
45 complete: bool,
46 has_target: bool,
47 last_status: Option<NodeStatus>,
48}
49
50impl std::fmt::Debug for CapabilityTaskNode {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("CapabilityTaskNode")
53 .field("capability", &self.capability)
54 .field("task_complete_topic", &self.task_complete_topic)
55 .field("target_request_topic", &self.target_request_topic)
56 .field("complete_subscribed", &self.complete_receiver.is_some())
57 .field("target_subscribed", &self.target_receiver.is_some())
58 .field("complete", &self.complete)
59 .field("has_target", &self.has_target)
60 .finish()
61 }
62}
63
64impl CapabilityTaskNode {
65 pub fn from_json(config: Value) -> anyhow::Result<Self> {
69 let cfg: CapabilityTaskConfig = serde_json::from_value(config)?;
70 Ok(Self {
71 capability: cfg.capability,
72 task_complete_topic: cfg.task_complete_topic,
73 target_request_topic: cfg.target_request_topic,
74 complete_receiver: None,
75 target_receiver: None,
76 complete: false,
77 has_target: false,
78 last_status: None,
79 })
80 }
81
82 #[cfg(test)]
86 fn with_receivers(
87 capability: &str,
88 task_complete_topic: &str,
89 target_request_topic: Option<&str>,
90 complete_receiver: Receiver<Value>,
91 target_receiver: Option<Receiver<Value>>,
92 ) -> Self {
93 Self {
94 capability: capability.to_string(),
95 task_complete_topic: task_complete_topic.to_string(),
96 target_request_topic: target_request_topic.map(|s| s.to_string()),
97 complete_receiver: Some(complete_receiver),
98 target_receiver,
99 complete: false,
100 has_target: false,
101 last_status: None,
102 }
103 }
104
105 fn poll_target(&mut self) {
109 let Some(receiver) = self.target_receiver.as_mut() else {
110 return;
111 };
112
113 let mut latest = None;
114 while let Ok(value) = receiver.try_recv() {
115 match serde_json::from_value::<TargetRequest>(value) {
116 Ok(request) => latest = Some(request.bbox.is_some()),
117 Err(e) => tracing::warn!("CapabilityTaskNode: dropping malformed target request: {}", e),
118 }
119 }
120
121 if let Some(has_target) = latest {
122 self.has_target = has_target;
123 }
124 }
125
126 fn poll_complete(&mut self) {
130 if self.target_request_topic.is_none() && self.complete {
131 return;
132 }
133 let Some(receiver) = self.complete_receiver.as_mut() else {
134 return;
135 };
136
137 if receiver.try_recv().is_ok() {
138 self.complete = true;
139 self.has_target = false;
140 }
141 }
142}
143
144#[async_trait]
145impl BehaviorNode for CapabilityTaskNode {
146 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
147 if self.complete_receiver.is_none() {
148 self.complete_receiver = Some(ctx.subscribe_raw::<Value>(&self.task_complete_topic).await?);
149 }
150 if let Some(target_request_topic) = self.target_request_topic.clone() {
151 if self.target_receiver.is_none() {
152 self.target_receiver = Some(ctx.subscribe_raw::<Value>(&target_request_topic).await?);
153 }
154 }
155
156 let status = if self.target_request_topic.is_some() {
157 self.poll_target();
158 self.poll_complete();
159 if self.has_target {
160 NodeStatus::Running
161 } else {
162 NodeStatus::Failure
163 }
164 } else {
165 self.poll_complete();
166 if self.complete {
167 NodeStatus::Success
168 } else {
169 NodeStatus::Running
170 }
171 };
172
173 self.last_status = Some(status);
174 Ok(status)
175 }
176
177 fn name(&self) -> &str {
178 "capability_task"
179 }
180
181 fn active_capability(&self) -> Option<&str> {
182 Some(self.capability.as_str())
183 }
184
185 fn reset_self(&mut self) {
186 self.complete = false;
187 self.has_target = false;
188 }
189
190 fn status_snapshot(&self) -> TreeStatusSnapshot {
191 TreeStatusSnapshot {
192 name: self.name().to_string(),
193 status: self.last_status,
194 active_capability: self.active_capability().map(String::from),
195 children: Vec::new(),
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tokio::sync::mpsc;
204
205 fn task_only_node() -> (CapabilityTaskNode, mpsc::UnboundedSender<Value>) {
207 let (tx, rx) = mpsc::unbounded_channel();
208 (
209 CapabilityTaskNode::with_receivers("rl", "/rl/task_complete", None, Receiver::from(rx), None),
210 tx,
211 )
212 }
213
214 fn tracking_node() -> (
216 CapabilityTaskNode,
217 mpsc::UnboundedSender<Value>,
218 mpsc::UnboundedSender<Value>,
219 ) {
220 let (complete_tx, complete_rx) = mpsc::unbounded_channel();
221 let (target_tx, target_rx) = mpsc::unbounded_channel();
222 (
223 CapabilityTaskNode::with_receivers(
224 "instance-track",
225 "/instance-track/task_complete",
226 Some("/instance-track/target/request"),
227 Receiver::from(complete_rx),
228 Some(Receiver::from(target_rx)),
229 ),
230 complete_tx,
231 target_tx,
232 )
233 }
234
235 #[test]
236 fn declares_the_configured_capability() {
237 let (node, _tx) = task_only_node();
238 assert_eq!(node.active_capability(), Some("rl"));
239
240 let (node, _complete_tx, _target_tx) = tracking_node();
241 assert_eq!(node.active_capability(), Some("instance-track"));
242 }
243
244 #[test]
245 fn task_only_stays_incomplete_with_no_message() {
246 let (mut node, _tx) = task_only_node();
247 node.poll_complete();
248 assert!(!node.complete);
249 }
250
251 #[test]
252 fn task_only_completes_once_any_message_arrives() {
253 let (mut node, tx) = task_only_node();
254 tx.send(serde_json::json!({})).unwrap();
255
256 node.poll_complete();
257
258 assert!(node.complete);
259 }
260
261 #[test]
262 fn task_only_reset_self_clears_completion_for_a_later_pass() {
263 let (mut node, tx) = task_only_node();
264 tx.send(serde_json::json!({})).unwrap();
265 node.poll_complete();
266 assert!(node.complete);
267
268 node.reset_self();
269
270 assert!(!node.complete);
271 }
272
273 #[test]
274 fn tracking_no_target_ever_set_stays_without_a_target() {
275 let (mut node, _complete_tx, _target_tx) = tracking_node();
276 node.poll_target();
277 node.poll_complete();
278 assert!(!node.has_target);
279 }
280
281 #[test]
282 fn tracking_a_live_target_request_sets_has_target() {
283 let (mut node, _complete_tx, target_tx) = tracking_node();
284 target_tx
285 .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
286 .unwrap();
287
288 node.poll_target();
289
290 assert!(node.has_target);
291 }
292
293 #[test]
294 fn tracking_a_clear_request_unsets_has_target_even_with_no_task_complete() {
295 let (mut node, _complete_tx, target_tx) = tracking_node();
296 target_tx
297 .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
298 .unwrap();
299 node.poll_target();
300 assert!(node.has_target);
301
302 target_tx.send(serde_json::json!({ "bbox": null })).unwrap();
303 node.poll_target();
304
305 assert!(!node.has_target);
306 }
307
308 #[test]
309 fn tracking_task_complete_unsets_has_target_on_the_very_next_poll() {
310 let (mut node, complete_tx, target_tx) = tracking_node();
311 target_tx
312 .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
313 .unwrap();
314 node.poll_target();
315 assert!(node.has_target);
316
317 complete_tx.send(serde_json::json!({})).unwrap();
318 node.poll_complete();
319
320 assert!(
321 !node.has_target,
322 "task_complete must converge has_target to false without waiting for a separate clear request"
323 );
324 }
325
326 #[test]
327 fn tracking_reset_self_clears_has_target_for_a_later_pass() {
328 let (mut node, _complete_tx, target_tx) = tracking_node();
329 target_tx
330 .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
331 .unwrap();
332 node.poll_target();
333 assert!(node.has_target);
334
335 node.reset_self();
336
337 assert!(!node.has_target);
338 }
339
340 #[test]
341 fn from_json_requires_capability_and_task_complete_topic() {
342 let node = CapabilityTaskNode::from_json(serde_json::json!({
343 "capability": "rl",
344 "task_complete_topic": "/rl/task_complete"
345 }))
346 .unwrap();
347 assert_eq!(node.capability, "rl");
348 assert_eq!(node.task_complete_topic, "/rl/task_complete");
349 assert_eq!(node.target_request_topic, None);
350
351 assert!(CapabilityTaskNode::from_json(serde_json::json!({ "capability": "rl" })).is_err());
352 assert!(CapabilityTaskNode::from_json(serde_json::json!({})).is_err());
353 }
354
355 #[test]
356 fn from_json_reads_optional_target_request_topic() {
357 let node = CapabilityTaskNode::from_json(serde_json::json!({
358 "capability": "instance-track",
359 "task_complete_topic": "/instance-track/task_complete",
360 "target_request_topic": "/instance-track/target/request"
361 }))
362 .unwrap();
363 assert_eq!(node.capability, "instance-track");
364 assert_eq!(
365 node.target_request_topic,
366 Some("/instance-track/target/request".to_string())
367 );
368 }
369}