Files
autopilot/crates/operator_bridge/src/lib.rs
T
Oleksandr Bezdieniezhnykh ccf929af69 [AZ-676] [AZ-677] [AZ-678] [AZ-679] telemetry+operator foundation
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>
2026-05-20 16:18:40 +03:00

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"));
}
}