mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:31:10 +00:00
ccf929af69
Batch 15 ships the four foundation tickets sitting on top of AZ-675 (gRPC server) and AZ-667 (mapobjects_store hydrate): * AZ-676: telemetry_stream video path (rtsp_forward + bytes_inline) with ai_locked atomic + session counter, SubscribeVideo RPC. * AZ-677: MapObjects snapshot-on-subscribe + diff broadcast + reconnect-resync (StartThen stream-prepend pattern). * AZ-678: HmacOperatorValidator with per-session monotonic seq, in-process session registry + TTL, constant-time HMAC compare, rejection-reason counters, sliding 60 s sig-failure red-health gate. Trait OperatorCommandValidator in shared::contracts::operator_auth. * AZ-679: PoiSurfaceMapper produces OperatorPoiEvent per architecture §7.10; PoiDequeued events on rotate/age-out/complete; pushed via new TelemetrySink::push_operator_event extension on Topic::OperatorEvent. Cross-task wiring: TelemetrySink trait extended with push_operator_event; OperatorBridge gets optional builder methods with_telemetry_sink / with_validator (composition root wires in AZ-680). Workspace deps: hmac = "0.12"; per-crate adds bytes, serde_json, parking_lot, chrono, uuid, sha2, thiserror. Tests: 14/14 ACs verified locally (4 + 3 + 5 + 3 by AC) plus 6 supporting unit tests + 7 integration tests + 2 shared serde roundtrips. cargo clippy clean on touched crates. Cumulative review for batches 13-15 produced; verdict PASS_WITH_WARNINGS (0 Critical, 0 High, 1 Medium, 4 Low — all carry-overs or deferred-producer notes for AZ-680/AZ-684). Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
8.1 KiB
Rust
233 lines
8.1 KiB
Rust
//! `operator_bridge` — POI surfacing + operator command authentication.
|
|
//!
|
|
//! Real implementation in this batch:
|
|
//! - **AZ-678** `internal::auth::HmacOperatorValidator` — HMAC-SHA256
|
|
//! over `(session_token, sequence_number, payload)`; per-session
|
|
//! replay tracker; session registry with TTL; rejection-reason
|
|
//! counters; sliding-window red-health gate.
|
|
//! - **AZ-679** `internal::poi_surface::PoiSurfaceMapper` — wire-format
|
|
//! POI events + `PoiDequeued` events pushed through `TelemetrySink`.
|
|
//!
|
|
//! Real implementation lands in:
|
|
//! - AZ-680 `operator_bridge_command_dispatch`
|
|
//! - AZ-681 `operator_bridge_safety_and_bit_ack`
|
|
|
|
pub mod internal;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::mpsc;
|
|
|
|
use shared::contracts::{OperatorCommandSink, TelemetrySink};
|
|
use shared::error::{AutopilotError, Result};
|
|
use shared::health::{ComponentHealth, HealthLevel};
|
|
use shared::models::mission::Coordinate;
|
|
use shared::models::operator::OperatorCommand;
|
|
use shared::models::operator_event::{DequeueReason, PhotoMetadata};
|
|
use shared::models::poi::Poi;
|
|
|
|
pub use crate::internal::auth::{
|
|
AuthCounters, HmacOperatorValidator, HmacValidatorConfig, REJECTION_REASONS,
|
|
};
|
|
pub use crate::internal::poi_surface::{PoiSurfaceMapper, PoiSurfaceMetrics};
|
|
|
|
const NAME: &str = "operator_bridge";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperatorDecision {
|
|
Confirmed,
|
|
Declined,
|
|
TimedOut,
|
|
StartTargetFollow,
|
|
ReleaseTargetFollow,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MiddleWaypointHint {
|
|
pub mission_id: String,
|
|
pub at: Coordinate,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum TargetFollowEvent {
|
|
Start { target_id: String },
|
|
Release,
|
|
}
|
|
|
|
pub struct OperatorBridge {
|
|
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
|
|
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
|
middle_waypoint_rx: Option<mpsc::Receiver<MiddleWaypointHint>>,
|
|
target_follow_rx: Option<mpsc::Receiver<TargetFollowEvent>>,
|
|
/// AZ-679 — POI surface mapper. Optional so existing single-arg
|
|
/// constructors (used by tests + early scaffolding) keep working;
|
|
/// composition root wires the real `TelemetrySink` via
|
|
/// `with_telemetry_sink`.
|
|
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
|
|
/// AZ-678 — operator command validator. Same optional-pattern as
|
|
/// `poi_mapper` so legacy callers continue to compile until the
|
|
/// composition root wires it in.
|
|
validator: Option<Arc<HmacOperatorValidator>>,
|
|
}
|
|
|
|
impl OperatorBridge {
|
|
pub fn new(channel_capacity: usize) -> Self {
|
|
let (mw_tx, mw_rx) = mpsc::channel(channel_capacity);
|
|
let (tf_tx, tf_rx) = mpsc::channel(channel_capacity);
|
|
Self {
|
|
middle_waypoint_tx: mw_tx,
|
|
target_follow_tx: tf_tx,
|
|
middle_waypoint_rx: Some(mw_rx),
|
|
target_follow_rx: Some(tf_rx),
|
|
poi_mapper: None,
|
|
validator: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_telemetry_sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
|
|
self.poi_mapper = Some(Arc::new(PoiSurfaceMapper::new(sink)));
|
|
self
|
|
}
|
|
|
|
pub fn with_validator(mut self, validator: Arc<HmacOperatorValidator>) -> Self {
|
|
self.validator = Some(validator);
|
|
self
|
|
}
|
|
|
|
pub fn handle(&self) -> OperatorBridgeHandle {
|
|
OperatorBridgeHandle {
|
|
middle_waypoint_tx: self.middle_waypoint_tx.clone(),
|
|
target_follow_tx: self.target_follow_tx.clone(),
|
|
poi_mapper: self.poi_mapper.clone(),
|
|
validator: self.validator.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn take_middle_waypoint_receiver(&mut self) -> Option<mpsc::Receiver<MiddleWaypointHint>> {
|
|
self.middle_waypoint_rx.take()
|
|
}
|
|
|
|
pub fn take_target_follow_receiver(&mut self) -> Option<mpsc::Receiver<TargetFollowEvent>> {
|
|
self.target_follow_rx.take()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct OperatorBridgeHandle {
|
|
#[allow(dead_code)]
|
|
middle_waypoint_tx: mpsc::Sender<MiddleWaypointHint>,
|
|
#[allow(dead_code)]
|
|
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
|
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
|
|
validator: Option<Arc<HmacOperatorValidator>>,
|
|
}
|
|
|
|
impl OperatorBridgeHandle {
|
|
/// AZ-679 — surface a POI to the operator and await the decision.
|
|
/// Today returns `NotImplemented` (the decision loop is AZ-680);
|
|
/// the surface event itself IS pushed (via the configured
|
|
/// `TelemetrySink`), so the operator UI receives it.
|
|
pub async fn surface_poi(&self, poi: Poi) -> Result<OperatorDecision> {
|
|
match &self.poi_mapper {
|
|
Some(mapper) => {
|
|
mapper.surface(&poi, None).await?;
|
|
Err(AutopilotError::NotImplemented(
|
|
"operator_bridge::surface_poi → decision loop (AZ-680)",
|
|
))
|
|
}
|
|
None => Err(AutopilotError::NotImplemented(
|
|
"operator_bridge::surface_poi (no telemetry sink wired)",
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// AZ-679 — surface a POI together with photo metadata (preferred
|
|
/// path when the source detection carries an ROI snapshot).
|
|
pub async fn surface_poi_with_photo(
|
|
&self,
|
|
poi: &Poi,
|
|
photo_metadata: PhotoMetadata,
|
|
) -> Result<()> {
|
|
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
|
|
AutopilotError::Internal("surface_poi_with_photo: telemetry sink not wired".into())
|
|
})?;
|
|
mapper.surface(poi, Some(photo_metadata)).await.map(|_| ())
|
|
}
|
|
|
|
/// AZ-679 — emit a `PoiDequeued` event (rotation / age-out /
|
|
/// completion). Called by `scan_controller` through the bridge.
|
|
pub async fn emit_poi_dequeued(&self, poi_id: uuid::Uuid, reason: DequeueReason) -> Result<()> {
|
|
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
|
|
AutopilotError::Internal("emit_poi_dequeued: telemetry sink not wired".into())
|
|
})?;
|
|
mapper.emit_dequeued(poi_id, reason).await
|
|
}
|
|
|
|
pub fn poi_metrics(&self) -> Option<PoiSurfaceMetrics> {
|
|
self.poi_mapper.as_ref().map(|m| m.metrics())
|
|
}
|
|
|
|
pub fn health(&self) -> ComponentHealth {
|
|
let mut h = ComponentHealth::disabled(NAME);
|
|
if self.poi_mapper.is_none() && self.validator.is_none() {
|
|
return h;
|
|
}
|
|
// Once any sub-component is wired we surface green by default,
|
|
// upgrade to red if the validator's signature-failure window
|
|
// crosses the threshold (AC-5).
|
|
h.level = HealthLevel::Green;
|
|
if let Some(v) = &self.validator {
|
|
if v.health_is_red() {
|
|
h.level = HealthLevel::Red;
|
|
}
|
|
let c = v.counters();
|
|
h.detail = Some(format!(
|
|
"validated_total={} sig_invalid={} replay={} session_unknown={} session_expired={}",
|
|
c.validated_total(),
|
|
c.reason(shared::contracts::operator_auth::AuthError::SignatureInvalid),
|
|
c.reason(shared::contracts::operator_auth::AuthError::ReplayDetected),
|
|
c.reason(shared::contracts::operator_auth::AuthError::SessionUnknown),
|
|
c.reason(shared::contracts::operator_auth::AuthError::SessionExpired),
|
|
));
|
|
}
|
|
h
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl OperatorCommandSink for OperatorBridgeHandle {
|
|
async fn dispatch(&self, _command: OperatorCommand) -> Result<()> {
|
|
Err(AutopilotError::NotImplemented(
|
|
"operator_bridge::dispatch (AZ-680)",
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn it_compiles_without_wiring() {
|
|
let h = OperatorBridge::new(8).handle();
|
|
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
|
|
}
|
|
|
|
#[test]
|
|
fn health_green_once_validator_wired() {
|
|
// Arrange
|
|
let validator = Arc::new(HmacOperatorValidator::with_default_config());
|
|
|
|
// Act
|
|
let bridge = OperatorBridge::new(8).with_validator(validator);
|
|
let h = bridge.handle().health();
|
|
|
|
// Assert
|
|
assert_eq!(h.level, shared::health::HealthLevel::Green);
|
|
assert!(h.detail.unwrap().contains("validated_total=0"));
|
|
}
|
|
}
|