mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 12:11:10 +00:00
[AZ-680] [AZ-681] operator_bridge command dispatch + safety lane
Add the operator-command dispatcher behind a typed CommandAck: 60 s per-command-id idempotency cache, surfaced-POI registry with unknown_poi_id + expired gates, BIT-degraded ack severity check, and SafetyOverride forwarding to mission_executor with structured audit log (redacts signature + session_token). Cross-layer wiring goes through three new traits in shared::contracts (ScanCommandRouter, MissionSafetyRouter, BitReportSeverityLookup) so operator_bridge stays free of direct scan_controller / mission_executor imports. scan_controller::ScanControllerHandle implements the scan router; a new mission_executor::SafetyDispatchHandle wraps the BIT ack channel + battery monitor handle and implements the safety router; BitControllerHandle gains a bounded (16-entry) report-severity cache for the lookup trait. scan_controller also picks up ConfirmPoi handling: PoiQueue::confirm removes the entry and SubmitOutcome::Confirmed carries the typed (target_mgrs, target_class) hint for AZ-684/AZ-686 downstream. Tests: 9 new integration tests in operator_bridge/tests/dispatcher.rs cover AZ-680 AC-1..AC-5 + AZ-681 AC-1..AC-4. scan_controller adds 2 ConfirmPoi tests. All modified-crate suites green; one pre-existing mission_executor state-machine test flake (already documented in _docs/_process_leftovers) updated to note ac1 also affected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,3 +20,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -66,6 +66,22 @@ pub struct DeclineAction {
|
||||
pub class_group: String,
|
||||
}
|
||||
|
||||
/// AZ-680 — information returned when a POI is confirmed (or selected
|
||||
/// for target-follow start). Mirrors [`DeclineAction`] so consumers
|
||||
/// downstream of the confirm path (AZ-684 evidence ladder, AZ-685
|
||||
/// mapobjects dispatch, AZ-686 gimbal issuance) get a typed
|
||||
/// `(target_mgrs, target_class)` hint without re-querying the queue.
|
||||
///
|
||||
/// The POI is removed from the queue as part of `confirm`. A
|
||||
/// subsequent confirm with the same `poi_id` returns `None`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfirmAction {
|
||||
pub poi_id: Uuid,
|
||||
pub target_mgrs: String,
|
||||
pub target_class: String,
|
||||
pub class_group: String,
|
||||
}
|
||||
|
||||
impl PoiQueue {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -145,6 +161,23 @@ impl PoiQueue {
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm a POI by id. Removes from queue; returns the typed
|
||||
/// `(target_mgrs, target_class)` hint that downstream consumers
|
||||
/// (AZ-684 evidence ladder, AZ-686 gimbal issuance) build the
|
||||
/// follow-up plan from. AZ-680 only needs the removal + the hint
|
||||
/// to be carried back through `submit_operator_cmd`'s return
|
||||
/// value.
|
||||
pub fn confirm(&mut self, poi_id: Uuid) -> Option<ConfirmAction> {
|
||||
let idx = self.entries.iter().position(|e| e.poi.id == poi_id)?;
|
||||
let entry = self.entries.swap_remove(idx);
|
||||
Some(ConfirmAction {
|
||||
poi_id: entry.poi.id,
|
||||
target_mgrs: entry.poi.mgrs,
|
||||
target_class: entry.poi.class,
|
||||
class_group: entry.poi.class_group,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop POIs whose deadline (set at insertion by the caller per
|
||||
/// the confidence-scaled window) has elapsed. Returns the IDs of
|
||||
/// forgotten POIs. NO `IgnoredItem` is created — timeout =
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use shared::contracts::ScanCommandRouter;
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::{ComponentHealth, HealthLevel};
|
||||
use shared::models::operator::{OperatorCommand, OperatorCommandKind};
|
||||
@@ -44,7 +46,8 @@ pub mod internal;
|
||||
|
||||
pub use internal::frame_rate_guard::{FrameRateGuard, FrameRateGuardConfig};
|
||||
pub use internal::poi_queue::{
|
||||
age_factor, decision_window, priority_score, DeclineAction, PoiQueue, SURFACE_CAP_PER_WINDOW,
|
||||
age_factor, decision_window, priority_score, ConfirmAction, DeclineAction, PoiQueue,
|
||||
SURFACE_CAP_PER_WINDOW,
|
||||
};
|
||||
pub use internal::state_machine::transitions::{transition, TransitionCtx};
|
||||
pub use internal::state_machine::{RejectReason, ScanState, TransitionOutcome, Trigger};
|
||||
@@ -153,11 +156,14 @@ pub struct ScanMetrics {
|
||||
|
||||
/// Result of [`ScanControllerHandle::submit_operator_cmd`]. `Accepted`
|
||||
/// means the command was applied with no return data; `Declined`
|
||||
/// carries the dispatchable IgnoredItem action AZ-685 must persist.
|
||||
/// carries the dispatchable IgnoredItem action AZ-685 must persist;
|
||||
/// `Confirmed` carries the typed `(target_mgrs, target_class)` hint
|
||||
/// AZ-684 / AZ-686 build a follow-up plan from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SubmitOutcome {
|
||||
Accepted,
|
||||
Declined(DeclineAction),
|
||||
Confirmed(ConfirmAction),
|
||||
}
|
||||
|
||||
fn poi_id_from_payload(payload: &serde_json::Value) -> Result<Uuid> {
|
||||
@@ -268,6 +274,18 @@ impl ScanControllerHandle {
|
||||
action
|
||||
}
|
||||
|
||||
/// AZ-680 — confirm a POI (or target-follow start). Looks up the
|
||||
/// POI by id, removes it from the queue, and returns the typed
|
||||
/// `(target_mgrs, target_class)` hint for downstream consumers.
|
||||
///
|
||||
/// The FSM-side follow-through (zoom-in trigger, target-follow
|
||||
/// transition) is AZ-684's evidence-ladder scope and is NOT
|
||||
/// performed here — this method only resolves the queue entry.
|
||||
pub async fn confirm_poi(&self, poi_id: Uuid) -> Option<ConfirmAction> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.poi_queue.confirm(poi_id)
|
||||
}
|
||||
|
||||
pub async fn poi_queue_len(&self) -> usize {
|
||||
self.inner.lock().await.poi_queue.len()
|
||||
}
|
||||
@@ -279,20 +297,24 @@ impl ScanControllerHandle {
|
||||
|
||||
/// Translate an operator command into a trigger and apply it.
|
||||
///
|
||||
/// AZ-682 / AZ-683 mapping (subset complete):
|
||||
/// Mapping (AZ-682 / AZ-683 / AZ-680):
|
||||
///
|
||||
/// - `MissionAbort` → `Trigger::OperatorAbort` (AZ-682).
|
||||
/// - `ReleaseTargetFollow` → `Trigger::OperatorReleaseFollow`
|
||||
/// (AZ-682).
|
||||
/// - `DeclinePoi { poi_id }` → queue decline; returns the
|
||||
/// resulting `DeclineAction` in [`SubmitOutcome::Declined`]
|
||||
/// for the caller (AZ-685 mapobjects dispatch) to persist
|
||||
/// (AZ-683).
|
||||
/// - `ConfirmPoi` / `StartTargetFollow` → still
|
||||
/// `NotImplemented(AZ-684)` since ROI / target_id resolution
|
||||
/// needs the evidence ladder.
|
||||
/// - `AcknowledgeBitDegraded` / `SafetyOverride` →
|
||||
/// `NotImplemented(AZ-684)`.
|
||||
/// - `DeclinePoi { poi_id }` → queue decline; returns
|
||||
/// [`SubmitOutcome::Declined`] for the caller (AZ-685
|
||||
/// mapobjects dispatch) to persist (AZ-683).
|
||||
/// - `ConfirmPoi { poi_id }` / `StartTargetFollow { poi_id }` →
|
||||
/// queue lookup + removal; returns
|
||||
/// [`SubmitOutcome::Confirmed`] carrying the typed
|
||||
/// `(target_mgrs, target_class)` hint (AZ-680). The FSM-side
|
||||
/// follow-through (zoom-in trigger, target-follow transition)
|
||||
/// is AZ-684's scope.
|
||||
/// - `AcknowledgeBitDegraded` / `SafetyOverride` are NOT
|
||||
/// handled here — those go to `mission_executor` via the
|
||||
/// `MissionSafetyRouter` path wired by `operator_bridge`
|
||||
/// (AZ-681). Receiving one in this method is a routing bug.
|
||||
pub async fn submit_operator_cmd(&self, command: OperatorCommand) -> Result<SubmitOutcome> {
|
||||
match command.kind {
|
||||
OperatorCommandKind::MissionAbort => {
|
||||
@@ -313,16 +335,21 @@ impl ScanControllerHandle {
|
||||
}
|
||||
}
|
||||
OperatorCommandKind::ConfirmPoi | OperatorCommandKind::StartTargetFollow => {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
|
||||
))
|
||||
let poi_id = poi_id_from_payload(&command.payload)?;
|
||||
match self.confirm_poi(poi_id).await {
|
||||
Some(action) => Ok(SubmitOutcome::Confirmed(action)),
|
||||
None => Err(AutopilotError::Validation(format!(
|
||||
"{:?}: unknown poi_id {poi_id}",
|
||||
command.kind
|
||||
))),
|
||||
}
|
||||
}
|
||||
OperatorCommandKind::AcknowledgeBitDegraded | OperatorCommandKind::SafetyOverride => {
|
||||
Err(AutopilotError::Validation(format!(
|
||||
"scan_controller does not handle {:?}; route via MissionSafetyRouter",
|
||||
command.kind
|
||||
)))
|
||||
}
|
||||
OperatorCommandKind::AcknowledgeBitDegraded => Err(AutopilotError::NotImplemented(
|
||||
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
|
||||
)),
|
||||
OperatorCommandKind::SafetyOverride => Err(AutopilotError::NotImplemented(
|
||||
"scan_controller::submit_operator_cmd (AZ-684 evidence ladder)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +427,22 @@ impl ScanControllerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// AZ-680 — adapter for the `shared::contracts::ScanCommandRouter`
|
||||
/// trait so `operator_bridge` (Layer 3) can dispatch operator
|
||||
/// commands into `scan_controller` (Layer 4) without importing this
|
||||
/// crate directly. Forwards to the inherent
|
||||
/// [`ScanControllerHandle::submit_operator_cmd`] and discards the
|
||||
/// `SubmitOutcome` (the trait surface is intentionally minimal —
|
||||
/// `operator_bridge` does not need the typed hint; AZ-685 wires the
|
||||
/// `Confirmed`/`Declined` actions into `mapobjects_store` through a
|
||||
/// different path).
|
||||
#[async_trait]
|
||||
impl ScanCommandRouter for ScanControllerHandle {
|
||||
async fn route(&self, command: OperatorCommand) -> Result<()> {
|
||||
self.submit_operator_cmd(command).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -153,7 +153,73 @@ async fn decline_poi_via_operator_command_emits_action() {
|
||||
assert_eq!(action.mgrs, "decline-me");
|
||||
assert_eq!(action.class_group, "armor");
|
||||
}
|
||||
SubmitOutcome::Accepted => panic!("decline must return Declined action"),
|
||||
other => panic!("decline must return Declined action, got {other:?}"),
|
||||
}
|
||||
assert_eq!(h.poi_queue_len().await, 0);
|
||||
}
|
||||
|
||||
/// AZ-680 — ConfirmPoi via operator command returns
|
||||
/// `SubmitOutcome::Confirmed` with the typed target hint and drains
|
||||
/// the POI from the queue.
|
||||
#[tokio::test]
|
||||
async fn confirm_poi_via_operator_command_emits_action() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
let p = poi(0.8, "confirm-me");
|
||||
let id = p.id;
|
||||
let expected_class = p.class.clone();
|
||||
let expected_group = p.class_group.clone();
|
||||
h.submit_poi_candidate(p, 0.5).await;
|
||||
|
||||
let cmd = OperatorCommand {
|
||||
command_id: Uuid::new_v4(),
|
||||
session_token: "s".to_string(),
|
||||
sequence_number: 1,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload: json!({ "poi_id": id.to_string() }),
|
||||
signature: vec![],
|
||||
};
|
||||
|
||||
// Act
|
||||
let outcome = h.submit_operator_cmd(cmd).await.expect("confirm accepted");
|
||||
|
||||
// Assert
|
||||
match outcome {
|
||||
SubmitOutcome::Confirmed(action) => {
|
||||
assert_eq!(action.poi_id, id);
|
||||
assert_eq!(action.target_mgrs, "confirm-me");
|
||||
assert_eq!(action.target_class, expected_class);
|
||||
assert_eq!(action.class_group, expected_group);
|
||||
}
|
||||
other => panic!("confirm must return Confirmed action, got {other:?}"),
|
||||
}
|
||||
assert_eq!(h.poi_queue_len().await, 0);
|
||||
}
|
||||
|
||||
/// AZ-680 — ConfirmPoi for an unknown poi_id must NOT silently
|
||||
/// succeed. Returns a `Validation` error so `operator_bridge` can
|
||||
/// surface a typed NACK to the operator UI.
|
||||
#[tokio::test]
|
||||
async fn confirm_poi_unknown_id_is_validation_error() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
let cmd = OperatorCommand {
|
||||
command_id: Uuid::new_v4(),
|
||||
session_token: "s".to_string(),
|
||||
sequence_number: 1,
|
||||
issued_at_wallclock: Utc::now(),
|
||||
kind: OperatorCommandKind::ConfirmPoi,
|
||||
payload: json!({ "poi_id": Uuid::new_v4().to_string() }),
|
||||
signature: vec![],
|
||||
};
|
||||
|
||||
// Act
|
||||
let err = h
|
||||
.submit_operator_cmd(cmd)
|
||||
.await
|
||||
.expect_err("unknown poi must error");
|
||||
|
||||
// Assert
|
||||
assert!(matches!(err, shared::error::AutopilotError::Validation(_)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user