mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 16: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:
@@ -0,0 +1,54 @@
|
||||
//! AZ-680 / AZ-681 — the typed acknowledgement returned by every
|
||||
//! dispatched operator command.
|
||||
//!
|
||||
//! The dispatcher does NOT propagate downstream errors verbatim into
|
||||
//! the operator UI — the surface here is a small fixed enum so the
|
||||
//! UI can colour-code the result and so the idempotency cache key
|
||||
//! space stays bounded.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable kebab-case reason strings emitted in
|
||||
/// [`CommandAck::Error::reason`]. Exposed as constants so the unit +
|
||||
/// integration tests can reference them without retyping the strings
|
||||
/// (drift between caller assertions and the actual emit site has bit
|
||||
/// us before).
|
||||
pub mod ack_reasons {
|
||||
pub const UNKNOWN_POI_ID: &str = "unknown_poi_id";
|
||||
pub const EXPIRED: &str = "expired";
|
||||
pub const CANNOT_ACKNOWLEDGE_FAIL: &str = "cannot_acknowledge_fail";
|
||||
pub const UNKNOWN_BIT_REPORT: &str = "unknown_bit_report";
|
||||
pub const INVALID_PAYLOAD: &str = "invalid_payload";
|
||||
pub const ROUTER_NOT_WIRED: &str = "router_not_wired";
|
||||
pub const ROUTER_ERROR: &str = "router_error";
|
||||
pub const UNSUPPORTED_KIND: &str = "unsupported_kind";
|
||||
}
|
||||
|
||||
/// Result of a dispatched operator command. Carries either `Ok` or a
|
||||
/// typed `Error { reason }` whose `reason` string is one of the
|
||||
/// kebab-case constants in [`ack_reasons`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CommandAck {
|
||||
Ok,
|
||||
Error { reason: String },
|
||||
}
|
||||
|
||||
impl CommandAck {
|
||||
pub fn error(reason: &str) -> Self {
|
||||
Self::Error {
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_ok(&self) -> bool {
|
||||
matches!(self, Self::Ok)
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Ok => None,
|
||||
Self::Error { reason } => Some(reason.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! AZ-681 — structured audit log for safety-critical operator commands.
|
||||
//!
|
||||
//! Per the task spec (AC-4): every dispatched `BitDegradedAck` and
|
||||
//! `SafetyOverride` writes an audit entry containing:
|
||||
//!
|
||||
//! - command id
|
||||
//! - timestamp (UTC, ms precision)
|
||||
//! - operator id (when known)
|
||||
//! - scope / duration (for `SafetyOverride`) or `report_id` (for
|
||||
//! `BitDegradedAck`)
|
||||
//! - outcome (`Ok` / `Error { reason }`)
|
||||
//!
|
||||
//! Entries MUST NEVER contain the raw signature bytes or the session
|
||||
//! token (AC-4). Callers pass already-redacted fields; the writer
|
||||
//! has no access to the signature in the first place.
|
||||
//!
|
||||
//! ## Why both a sink trait + a tracing default
|
||||
//!
|
||||
//! - The default ([`TracingAuditSink`]) emits one structured
|
||||
//! `tracing::info!` per entry — meets the spec's "file or
|
||||
//! structured logger" requirement and integrates with whatever
|
||||
//! tracing subscriber the composition root wires.
|
||||
//! - The trait ([`AuditSink`]) lets tests substitute a recording
|
||||
//! sink without piggy-backing on tracing's global subscriber
|
||||
//! state (which other tests can race against). The integration
|
||||
//! tests in `tests/dispatcher.rs` use the recording sink.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ack::CommandAck;
|
||||
use shared::models::operator::SafetyOverrideScope;
|
||||
|
||||
/// One entry in the audit log. Variants map 1:1 to the AZ-681
|
||||
/// command kinds.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AuditEntry {
|
||||
BitDegradedAck {
|
||||
command_id: Uuid,
|
||||
timestamp: DateTime<Utc>,
|
||||
operator_id: Option<String>,
|
||||
report_id: Uuid,
|
||||
outcome: CommandAck,
|
||||
},
|
||||
SafetyOverride {
|
||||
command_id: Uuid,
|
||||
timestamp: DateTime<Utc>,
|
||||
operator_id: Option<String>,
|
||||
scope: SafetyOverrideScope,
|
||||
duration_secs: u32,
|
||||
outcome: CommandAck,
|
||||
},
|
||||
}
|
||||
|
||||
/// Sink for audit entries. Composition root injects the concrete
|
||||
/// implementation; the default is [`TracingAuditSink`].
|
||||
#[async_trait]
|
||||
pub trait AuditSink: Send + Sync {
|
||||
async fn record(&self, entry: AuditEntry);
|
||||
}
|
||||
|
||||
/// Default sink — emits a single `tracing::info!` per entry. The
|
||||
/// structured fields are picked up by any `tracing_subscriber` JSON
|
||||
/// layer the composition root configures.
|
||||
pub struct TracingAuditSink;
|
||||
|
||||
impl TracingAuditSink {
|
||||
pub fn arc() -> Arc<dyn AuditSink> {
|
||||
Arc::new(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuditSink for TracingAuditSink {
|
||||
async fn record(&self, entry: AuditEntry) {
|
||||
match &entry {
|
||||
AuditEntry::BitDegradedAck {
|
||||
command_id,
|
||||
timestamp,
|
||||
operator_id,
|
||||
report_id,
|
||||
outcome,
|
||||
} => {
|
||||
tracing::info!(
|
||||
audit = "bit_degraded_ack",
|
||||
command_id = %command_id,
|
||||
timestamp = %timestamp.to_rfc3339(),
|
||||
operator_id = operator_id.as_deref().unwrap_or(""),
|
||||
report_id = %report_id,
|
||||
outcome = ?outcome,
|
||||
"operator_bridge audit: bit_degraded_ack"
|
||||
);
|
||||
}
|
||||
AuditEntry::SafetyOverride {
|
||||
command_id,
|
||||
timestamp,
|
||||
operator_id,
|
||||
scope,
|
||||
duration_secs,
|
||||
outcome,
|
||||
} => {
|
||||
tracing::info!(
|
||||
audit = "safety_override",
|
||||
command_id = %command_id,
|
||||
timestamp = %timestamp.to_rfc3339(),
|
||||
operator_id = operator_id.as_deref().unwrap_or(""),
|
||||
scope = scope.label(),
|
||||
duration_secs = duration_secs,
|
||||
outcome = ?outcome,
|
||||
"operator_bridge audit: safety_override"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AC-4 sanity: an entry serialised to JSON contains no
|
||||
/// signature/session_token field. The entry struct itself has
|
||||
/// no such field, so this is a static guarantee — but we
|
||||
/// assert on the JSON shape to lock the wire contract.
|
||||
#[test]
|
||||
fn entry_json_has_no_signature_or_session_token() {
|
||||
// Arrange
|
||||
let entry = AuditEntry::SafetyOverride {
|
||||
command_id: Uuid::new_v4(),
|
||||
timestamp: Utc::now(),
|
||||
operator_id: Some("op-1".into()),
|
||||
scope: SafetyOverrideScope::BatteryRtl,
|
||||
duration_secs: 60,
|
||||
outcome: CommandAck::Ok,
|
||||
};
|
||||
|
||||
// Act
|
||||
let json = serde_json::to_string(&entry).expect("serialises");
|
||||
|
||||
// Assert
|
||||
assert!(!json.contains("signature"));
|
||||
assert!(!json.contains("session_token"));
|
||||
assert!(json.contains("battery_rtl"));
|
||||
assert!(json.contains("\"duration_secs\":60"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! AZ-680 + AZ-681 — operator-command dispatcher.
|
||||
//!
|
||||
//! Sits between the validated-command boundary (AZ-678) and the
|
||||
//! downstream routers. Responsibilities:
|
||||
//!
|
||||
//! - Per-`command_id` idempotency (60 s TTL — AZ-680 AC-2).
|
||||
//! - POI-id validity + deadline checks for POI-bound commands
|
||||
//! (AZ-680 AC-3 / AC-4).
|
||||
//! - BIT-report severity gate for `AcknowledgeBitDegraded`
|
||||
//! (AZ-681 AC-2).
|
||||
//! - Routing — POI commands → `ScanCommandRouter`, BIT acks +
|
||||
//! safety overrides → `MissionSafetyRouter`.
|
||||
//! - Audit logging for every safety-critical command
|
||||
//! (AZ-681 AC-3 / AC-4).
|
||||
//!
|
||||
//! The dispatcher OWNS the registry / cache / audit sink and is
|
||||
//! constructed once by the composition root. It is cheap to clone
|
||||
//! (all internals are `Arc`s).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use shared::contracts::{BitReportSeverityLookup, MissionSafetyRouter, ScanCommandRouter};
|
||||
use shared::models::operator::{OperatorCommand, OperatorCommandKind, SafetyOverrideScope};
|
||||
|
||||
use crate::ack::{ack_reasons, CommandAck};
|
||||
use crate::internal::audit::{AuditEntry, AuditSink, TracingAuditSink};
|
||||
use crate::internal::idempotency::IdempotencyCache;
|
||||
use crate::internal::poi_registry::SurfacedPoiRegistry;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OperatorCommandDispatcher {
|
||||
pub(crate) registry: SurfacedPoiRegistry,
|
||||
cache: IdempotencyCache,
|
||||
audit: Arc<dyn AuditSink>,
|
||||
scan_router: Option<Arc<dyn ScanCommandRouter>>,
|
||||
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
|
||||
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
|
||||
}
|
||||
|
||||
impl OperatorCommandDispatcher {
|
||||
pub fn builder() -> OperatorCommandDispatcherBuilder {
|
||||
OperatorCommandDispatcherBuilder::default()
|
||||
}
|
||||
|
||||
/// Public test helper: peek into the idempotency cache. Used by
|
||||
/// the integration tests to assert AC-2 ("re-transmit returns
|
||||
/// cached ack").
|
||||
#[doc(hidden)]
|
||||
pub fn cache_len(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// AZ-680 / AZ-681 — dispatch one validated command. Returns the
|
||||
/// typed [`CommandAck`]. Idempotency is handled inside; callers
|
||||
/// just re-submit the same `command_id` on retransmit.
|
||||
pub async fn dispatch(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
let cmd_id = cmd.command_id;
|
||||
self.cache
|
||||
.get_or_insert_with(cmd_id, || async move { self.dispatch_inner(cmd).await })
|
||||
.await
|
||||
}
|
||||
|
||||
async fn dispatch_inner(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
match cmd.kind {
|
||||
OperatorCommandKind::ConfirmPoi
|
||||
| OperatorCommandKind::DeclinePoi
|
||||
| OperatorCommandKind::StartTargetFollow => self.dispatch_poi_bound(cmd).await,
|
||||
OperatorCommandKind::ReleaseTargetFollow => self.dispatch_via_scan_router(cmd).await,
|
||||
OperatorCommandKind::AcknowledgeBitDegraded => self.dispatch_bit_ack(cmd).await,
|
||||
OperatorCommandKind::SafetyOverride => self.dispatch_safety_override(cmd).await,
|
||||
OperatorCommandKind::MissionAbort => self.dispatch_via_scan_router(cmd).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POI-bound dispatch path: enforces `unknown_poi_id` (AC-3) +
|
||||
/// `expired` (AC-4) before forwarding to `scan_controller`.
|
||||
async fn dispatch_poi_bound(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
let poi_id = match poi_id_from_payload(&cmd.payload) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return CommandAck::error(ack_reasons::INVALID_PAYLOAD),
|
||||
};
|
||||
let Some(surfaced) = self.registry.get(poi_id) else {
|
||||
return CommandAck::error(ack_reasons::UNKNOWN_POI_ID);
|
||||
};
|
||||
if surfaced.deadline <= Utc::now() {
|
||||
return CommandAck::error(ack_reasons::EXPIRED);
|
||||
}
|
||||
self.dispatch_via_scan_router(cmd).await
|
||||
}
|
||||
|
||||
async fn dispatch_via_scan_router(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
let Some(router) = self.scan_router.as_ref() else {
|
||||
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
|
||||
};
|
||||
match router.route(cmd).await {
|
||||
Ok(()) => CommandAck::Ok,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "scan router rejected operator command");
|
||||
CommandAck::error(ack_reasons::ROUTER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_bit_ack(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
let payload = match BitAckPayload::from_value(&cmd.payload) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let ack = CommandAck::error(ack_reasons::INVALID_PAYLOAD);
|
||||
self.audit_bit(&cmd, Uuid::nil(), &ack).await;
|
||||
return ack;
|
||||
}
|
||||
};
|
||||
let ack = self.evaluate_bit_ack(&cmd, &payload).await;
|
||||
self.audit_bit(&cmd, payload.report_id, &ack).await;
|
||||
ack
|
||||
}
|
||||
|
||||
async fn evaluate_bit_ack(&self, cmd: &OperatorCommand, payload: &BitAckPayload) -> CommandAck {
|
||||
let Some(severity) = self.bit_severity.as_ref() else {
|
||||
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
|
||||
};
|
||||
match severity.is_acknowledgeable(payload.report_id).await {
|
||||
Some(true) => match self.safety_router.as_ref() {
|
||||
Some(router) => match router
|
||||
.acknowledge_bit_degraded(payload.report_id, payload.operator_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => CommandAck::Ok,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mission safety router rejected bit ack");
|
||||
CommandAck::error(ack_reasons::ROUTER_ERROR)
|
||||
}
|
||||
},
|
||||
None => CommandAck::error(ack_reasons::ROUTER_NOT_WIRED),
|
||||
},
|
||||
Some(false) => CommandAck::error(ack_reasons::CANNOT_ACKNOWLEDGE_FAIL),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
command_id = %cmd.command_id,
|
||||
report_id = %payload.report_id,
|
||||
"bit_degraded_ack: unknown report id"
|
||||
);
|
||||
CommandAck::error(ack_reasons::UNKNOWN_BIT_REPORT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_safety_override(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
let payload = match SafetyOverridePayload::from_value(&cmd.payload) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let ack = CommandAck::error(ack_reasons::INVALID_PAYLOAD);
|
||||
self.audit_safety(&cmd, None, 0, &ack).await;
|
||||
return ack;
|
||||
}
|
||||
};
|
||||
let ack = self.apply_safety_override(&payload).await;
|
||||
self.audit_safety(&cmd, Some(payload.scope), payload.duration_secs, &ack)
|
||||
.await;
|
||||
ack
|
||||
}
|
||||
|
||||
async fn apply_safety_override(&self, payload: &SafetyOverridePayload) -> CommandAck {
|
||||
let Some(router) = self.safety_router.as_ref() else {
|
||||
return CommandAck::error(ack_reasons::ROUTER_NOT_WIRED);
|
||||
};
|
||||
match router
|
||||
.apply_safety_override(
|
||||
payload.scope,
|
||||
payload.duration_secs,
|
||||
payload.operator_id.clone(),
|
||||
payload.rationale.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => CommandAck::Ok,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mission safety router rejected safety override");
|
||||
CommandAck::error(ack_reasons::ROUTER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn audit_bit(&self, cmd: &OperatorCommand, report_id: Uuid, outcome: &CommandAck) {
|
||||
self.audit
|
||||
.record(AuditEntry::BitDegradedAck {
|
||||
command_id: cmd.command_id,
|
||||
timestamp: Utc::now(),
|
||||
operator_id: cmd
|
||||
.payload
|
||||
.get("operator_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
report_id,
|
||||
outcome: outcome.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn audit_safety(
|
||||
&self,
|
||||
cmd: &OperatorCommand,
|
||||
scope: Option<SafetyOverrideScope>,
|
||||
duration_secs: u32,
|
||||
outcome: &CommandAck,
|
||||
) {
|
||||
self.audit
|
||||
.record(AuditEntry::SafetyOverride {
|
||||
command_id: cmd.command_id,
|
||||
timestamp: Utc::now(),
|
||||
operator_id: cmd
|
||||
.payload
|
||||
.get("operator_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
scope: scope.unwrap_or(SafetyOverrideScope::BatteryRtl),
|
||||
duration_secs,
|
||||
outcome: outcome.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Builder
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OperatorCommandDispatcherBuilder {
|
||||
registry: Option<SurfacedPoiRegistry>,
|
||||
cache: Option<IdempotencyCache>,
|
||||
audit: Option<Arc<dyn AuditSink>>,
|
||||
scan_router: Option<Arc<dyn ScanCommandRouter>>,
|
||||
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
|
||||
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
|
||||
}
|
||||
|
||||
impl OperatorCommandDispatcherBuilder {
|
||||
pub fn registry(mut self, r: SurfacedPoiRegistry) -> Self {
|
||||
self.registry = Some(r);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn idempotency_cache(mut self, c: IdempotencyCache) -> Self {
|
||||
self.cache = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn audit_sink(mut self, s: Arc<dyn AuditSink>) -> Self {
|
||||
self.audit = Some(s);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scan_router(mut self, r: Arc<dyn ScanCommandRouter>) -> Self {
|
||||
self.scan_router = Some(r);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn safety_router(mut self, r: Arc<dyn MissionSafetyRouter>) -> Self {
|
||||
self.safety_router = Some(r);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bit_severity(mut self, s: Arc<dyn BitReportSeverityLookup>) -> Self {
|
||||
self.bit_severity = Some(s);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> OperatorCommandDispatcher {
|
||||
OperatorCommandDispatcher {
|
||||
registry: self.registry.unwrap_or_default(),
|
||||
cache: self
|
||||
.cache
|
||||
.unwrap_or_else(IdempotencyCache::with_default_ttl),
|
||||
audit: self.audit.unwrap_or_else(TracingAuditSink::arc),
|
||||
scan_router: self.scan_router,
|
||||
safety_router: self.safety_router,
|
||||
bit_severity: self.bit_severity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payload extraction
|
||||
// ============================================================================
|
||||
|
||||
/// Extract `poi_id` from a POI-bound command payload.
|
||||
///
|
||||
/// Wire shape: `{ "poi_id": "<uuid>" }`. Anything else is a hard
|
||||
/// `invalid_payload` error — the auth layer guarantees the payload
|
||||
/// bytes weren't tampered with, but the operator UI might still send
|
||||
/// the wrong shape on a build-skew between client and autopilot.
|
||||
fn poi_id_from_payload(payload: &serde_json::Value) -> Result<Uuid, ()> {
|
||||
let v = payload.get("poi_id").and_then(|v| v.as_str()).ok_or(())?;
|
||||
Uuid::parse_str(v).map_err(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BitAckPayload {
|
||||
report_id: Uuid,
|
||||
#[serde(default)]
|
||||
operator_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BitAckPayload {
|
||||
fn from_value(v: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(v.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SafetyOverridePayload {
|
||||
scope: SafetyOverrideScope,
|
||||
duration_secs: u32,
|
||||
operator_id: String,
|
||||
#[serde(default)]
|
||||
rationale: String,
|
||||
}
|
||||
|
||||
impl SafetyOverridePayload {
|
||||
fn from_value(v: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(v.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn poi_id_extracts_uuid() {
|
||||
// Arrange
|
||||
let id = Uuid::new_v4();
|
||||
let v = json!({ "poi_id": id.to_string() });
|
||||
|
||||
// Act + Assert
|
||||
assert_eq!(poi_id_from_payload(&v).unwrap(), id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_id_missing_is_err() {
|
||||
// Arrange
|
||||
let v = json!({ "other": "x" });
|
||||
|
||||
// Act + Assert
|
||||
assert!(poi_id_from_payload(&v).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_ack_payload_round_trip() {
|
||||
// Arrange
|
||||
let id = Uuid::new_v4();
|
||||
let v = json!({ "report_id": id.to_string(), "operator_id": "op1" });
|
||||
|
||||
// Act
|
||||
let p = BitAckPayload::from_value(&v).expect("parse");
|
||||
|
||||
// Assert
|
||||
assert_eq!(p.report_id, id);
|
||||
assert_eq!(p.operator_id, Some("op1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_override_payload_round_trip() {
|
||||
// Arrange
|
||||
let v = json!({
|
||||
"scope": "battery_rtl",
|
||||
"duration_secs": 60,
|
||||
"operator_id": "op1",
|
||||
"rationale": "post-mission RTL too aggressive"
|
||||
});
|
||||
|
||||
// Act
|
||||
let p = SafetyOverridePayload::from_value(&v).expect("parse");
|
||||
|
||||
// Assert
|
||||
assert_eq!(p.scope, SafetyOverrideScope::BatteryRtl);
|
||||
assert_eq!(p.duration_secs, 60);
|
||||
assert_eq!(p.operator_id, "op1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! AZ-680 — per-`command_id` idempotency cache.
|
||||
//!
|
||||
//! The spec (AC-2): "Re-transmit returns cached ack". A 60 s sliding
|
||||
//! window over `command_id → CommandAck` so the operator UI can
|
||||
//! safely retransmit on a flaky modem without causing the autopilot
|
||||
//! to double-dispatch.
|
||||
//!
|
||||
//! Design notes:
|
||||
//!
|
||||
//! - Lazy eviction. `get_or_insert_with` purges expired entries before
|
||||
//! inserting. We do not run a background sweeper task — at the
|
||||
//! command rate of ≤5 confirms/min (operator workflow), the cache
|
||||
//! stays small and per-call eviction is cheap.
|
||||
//! - Returns the *cached* ack on hit; on miss, runs the supplied
|
||||
//! future, caches its result, returns it. The future is NOT spawned
|
||||
//! — the caller awaits it.
|
||||
//! - Cache key is the full `Uuid`; the operator UI generates fresh
|
||||
//! `command_id`s per logical command, so collisions imply a true
|
||||
//! retransmit and we want to honour that.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ack::CommandAck;
|
||||
|
||||
/// Default TTL per AZ-680 spec.
|
||||
pub const DEFAULT_IDEMPOTENCY_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
ack: CommandAck,
|
||||
cached_at: Instant,
|
||||
}
|
||||
|
||||
/// Bounded-by-TTL idempotency cache. Cheap to `clone` (internals are
|
||||
/// an `Arc<Mutex<_>>`).
|
||||
#[derive(Clone)]
|
||||
pub struct IdempotencyCache {
|
||||
ttl: Duration,
|
||||
inner: Arc<Mutex<HashMap<Uuid, Entry>>>,
|
||||
}
|
||||
|
||||
impl IdempotencyCache {
|
||||
pub fn new(ttl: Duration) -> Self {
|
||||
Self {
|
||||
ttl,
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_default_ttl() -> Self {
|
||||
Self::new(DEFAULT_IDEMPOTENCY_TTL)
|
||||
}
|
||||
|
||||
/// Returns the cached ack if `command_id` is present and not
|
||||
/// expired; otherwise runs `produce`, caches its result, and
|
||||
/// returns it. Concurrent calls with the same `command_id` MAY
|
||||
/// each execute `produce` once — that is acceptable here because
|
||||
/// the downstream routers themselves are idempotent for the same
|
||||
/// validated payload (the router-level side effect is the same
|
||||
/// across retries; the registry/queue lookups deduplicate POI
|
||||
/// state). The cache's primary role is to short-circuit
|
||||
/// re-transmits that arrive seconds later, not to serialise
|
||||
/// concurrent dispatchers of the same id.
|
||||
pub async fn get_or_insert_with<F, Fut>(&self, command_id: Uuid, produce: F) -> CommandAck
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = CommandAck>,
|
||||
{
|
||||
if let Some(cached) = self.get(command_id) {
|
||||
return cached;
|
||||
}
|
||||
let ack = produce().await;
|
||||
self.insert(command_id, ack.clone());
|
||||
ack
|
||||
}
|
||||
|
||||
/// Snapshot lookup — also evicts expired entries opportunistically.
|
||||
pub fn get(&self, command_id: Uuid) -> Option<CommandAck> {
|
||||
let mut guard = self.inner.lock();
|
||||
self.evict_expired(&mut guard);
|
||||
guard.get(&command_id).map(|e| e.ack.clone())
|
||||
}
|
||||
|
||||
fn insert(&self, command_id: Uuid, ack: CommandAck) {
|
||||
let mut guard = self.inner.lock();
|
||||
self.evict_expired(&mut guard);
|
||||
guard.insert(
|
||||
command_id,
|
||||
Entry {
|
||||
ack,
|
||||
cached_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn evict_expired(&self, guard: &mut HashMap<Uuid, Entry>) {
|
||||
let now = Instant::now();
|
||||
guard.retain(|_, e| now.duration_since(e.cached_at) < self.ttl);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
let mut guard = self.inner.lock();
|
||||
self.evict_expired(&mut guard);
|
||||
guard.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
#[tokio::test]
|
||||
async fn miss_then_hit_runs_once() {
|
||||
// Arrange
|
||||
let cache = IdempotencyCache::with_default_ttl();
|
||||
let id = Uuid::new_v4();
|
||||
let count = AtomicU32::new(0);
|
||||
|
||||
// Act
|
||||
let _ = cache
|
||||
.get_or_insert_with(id, || async {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
CommandAck::Ok
|
||||
})
|
||||
.await;
|
||||
let _ = cache
|
||||
.get_or_insert_with(id, || async {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
CommandAck::Ok
|
||||
})
|
||||
.await;
|
||||
|
||||
// Assert
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl_expiry_re_runs_producer() {
|
||||
// Arrange — short TTL to keep the test fast.
|
||||
let cache = IdempotencyCache::new(Duration::from_millis(20));
|
||||
let id = Uuid::new_v4();
|
||||
let count = AtomicU32::new(0);
|
||||
|
||||
// Act
|
||||
let _ = cache
|
||||
.get_or_insert_with(id, || async {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
CommandAck::Ok
|
||||
})
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
let _ = cache
|
||||
.get_or_insert_with(id, || async {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
CommandAck::Ok
|
||||
})
|
||||
.await;
|
||||
|
||||
// Assert
|
||||
assert_eq!(count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Internal modules for `operator_bridge`. Not part of the public API.
|
||||
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod dispatcher;
|
||||
pub mod idempotency;
|
||||
pub mod poi_registry;
|
||||
pub mod poi_surface;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! AZ-680 — currently-surfaced POI registry.
|
||||
//!
|
||||
//! Tracks the subset of POIs that have been pushed to the operator UI
|
||||
//! and have not yet been dequeued. The dispatcher consults this
|
||||
//! registry to reject:
|
||||
//!
|
||||
//! - `Confirm` / `Decline` / `StartTargetFollow` for unknown
|
||||
//! `poi_id`s (AC-3 → `unknown_poi_id`).
|
||||
//! - Commands whose POI deadline has elapsed (AC-4 → `expired`).
|
||||
//!
|
||||
//! The registry is intentionally a plain `HashMap` behind a
|
||||
//! [`parking_lot::Mutex`] — the dispatcher's lock window is short
|
||||
//! (one O(1) lookup + one O(1) remove). A `RwLock` would not buy us
|
||||
//! anything because the dispatcher writes on every confirm/decline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
/// Snapshot of the POI fields the dispatcher needs to enforce
|
||||
/// validity + deadline checks without holding a reference to the
|
||||
/// full [`Poi`] struct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SurfacedPoi {
|
||||
pub poi_id: Uuid,
|
||||
pub mgrs: String,
|
||||
pub class_group: String,
|
||||
pub deadline: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<&Poi> for SurfacedPoi {
|
||||
fn from(poi: &Poi) -> Self {
|
||||
Self {
|
||||
poi_id: poi.id,
|
||||
mgrs: poi.mgrs.clone(),
|
||||
class_group: poi.class_group.clone(),
|
||||
deadline: poi.deadline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory registry of surfaced-but-not-dequeued POIs. Cheap to
|
||||
/// `clone` — internals are an `Arc<Mutex<_>>`.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct SurfacedPoiRegistry {
|
||||
inner: Arc<Mutex<HashMap<Uuid, SurfacedPoi>>>,
|
||||
}
|
||||
|
||||
impl SurfacedPoiRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record a surfaced POI. Overwrites any prior entry with the
|
||||
/// same id (the POI was re-surfaced after a rotation).
|
||||
pub fn record(&self, poi: SurfacedPoi) {
|
||||
self.inner.lock().insert(poi.poi_id, poi);
|
||||
}
|
||||
|
||||
/// Remove a POI from the surfaced set. Called when the POI is
|
||||
/// dequeued (rotated, aged out, or operator-decided).
|
||||
pub fn forget(&self, poi_id: Uuid) {
|
||||
self.inner.lock().remove(&poi_id);
|
||||
}
|
||||
|
||||
/// Look up a surfaced POI. Returns `None` if the id has never
|
||||
/// been surfaced or has already been dequeued.
|
||||
pub fn get(&self, poi_id: Uuid) -> Option<SurfacedPoi> {
|
||||
self.inner.lock().get(&poi_id).cloned()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.lock().len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn surfaced(deadline_secs: i64) -> SurfacedPoi {
|
||||
SurfacedPoi {
|
||||
poi_id: Uuid::new_v4(),
|
||||
mgrs: "33UWP05".into(),
|
||||
class_group: "vehicle".into(),
|
||||
deadline: Utc::now() + Duration::seconds(deadline_secs),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_then_get_returns_clone() {
|
||||
// Arrange
|
||||
let r = SurfacedPoiRegistry::new();
|
||||
let p = surfaced(120);
|
||||
r.record(p.clone());
|
||||
|
||||
// Act
|
||||
let got = r.get(p.poi_id).expect("must be present");
|
||||
|
||||
// Assert
|
||||
assert_eq!(got, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_removes_entry() {
|
||||
// Arrange
|
||||
let r = SurfacedPoiRegistry::new();
|
||||
let p = surfaced(120);
|
||||
r.record(p.clone());
|
||||
|
||||
// Act
|
||||
r.forget(p.poi_id);
|
||||
|
||||
// Assert
|
||||
assert!(r.get(p.poi_id).is_none());
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `operator_bridge` — POI surfacing + operator command authentication.
|
||||
//! `operator_bridge` — POI surfacing + operator command authentication
|
||||
//! + dispatch.
|
||||
//!
|
||||
//! Real implementation in this batch:
|
||||
//! - **AZ-678** `internal::auth::HmacOperatorValidator` — HMAC-SHA256
|
||||
@@ -7,11 +8,15 @@
|
||||
//! 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`
|
||||
//! - **AZ-680** `internal::dispatcher::OperatorCommandDispatcher` —
|
||||
//! POI-bound dispatch path, per-`command_id` idempotency cache,
|
||||
//! unknown-POI + expired-deadline gates.
|
||||
//! - **AZ-681** `internal::dispatcher::OperatorCommandDispatcher` —
|
||||
//! BIT-degraded ack severity gate + `SafetyOverride` forwarding
|
||||
//! into `mission_executor` via `MissionSafetyRouter`; structured
|
||||
//! audit log entry per safety command.
|
||||
|
||||
pub mod ack;
|
||||
pub mod internal;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -20,7 +25,10 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use shared::contracts::{OperatorCommandSink, TelemetrySink};
|
||||
use shared::contracts::{
|
||||
BitReportSeverityLookup, MissionSafetyRouter, OperatorCommandSink, ScanCommandRouter,
|
||||
TelemetrySink,
|
||||
};
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::{ComponentHealth, HealthLevel};
|
||||
use shared::models::mission::Coordinate;
|
||||
@@ -28,9 +36,16 @@ use shared::models::operator::OperatorCommand;
|
||||
use shared::models::operator_event::{DequeueReason, PhotoMetadata};
|
||||
use shared::models::poi::Poi;
|
||||
|
||||
pub use crate::ack::{ack_reasons, CommandAck};
|
||||
pub use crate::internal::audit::{AuditEntry, AuditSink, TracingAuditSink};
|
||||
pub use crate::internal::auth::{
|
||||
AuthCounters, HmacOperatorValidator, HmacValidatorConfig, REJECTION_REASONS,
|
||||
};
|
||||
pub use crate::internal::dispatcher::{
|
||||
OperatorCommandDispatcher, OperatorCommandDispatcherBuilder,
|
||||
};
|
||||
pub use crate::internal::idempotency::{IdempotencyCache, DEFAULT_IDEMPOTENCY_TTL};
|
||||
pub use crate::internal::poi_registry::{SurfacedPoi, SurfacedPoiRegistry};
|
||||
pub use crate::internal::poi_surface::{PoiSurfaceMapper, PoiSurfaceMetrics};
|
||||
|
||||
const NAME: &str = "operator_bridge";
|
||||
@@ -71,6 +86,20 @@ pub struct OperatorBridge {
|
||||
/// `poi_mapper` so legacy callers continue to compile until the
|
||||
/// composition root wires it in.
|
||||
validator: Option<Arc<HmacOperatorValidator>>,
|
||||
/// AZ-680 — currently-surfaced POI registry. Shared between the
|
||||
/// `surface_poi` / `emit_poi_dequeued` write-side and the
|
||||
/// dispatcher's POI-id validity check.
|
||||
poi_registry: SurfacedPoiRegistry,
|
||||
/// AZ-680 / AZ-681 — command dispatcher. Optional until both the
|
||||
/// scan + safety routers are wired; without it `dispatch` returns
|
||||
/// `router_not_wired`.
|
||||
dispatcher: Option<Arc<OperatorCommandDispatcher>>,
|
||||
/// Builder-only accumulators for the dispatcher's routers + sink.
|
||||
/// Consumed in [`OperatorBridge::with_dispatcher`].
|
||||
scan_router: Option<Arc<dyn ScanCommandRouter>>,
|
||||
safety_router: Option<Arc<dyn MissionSafetyRouter>>,
|
||||
bit_severity: Option<Arc<dyn BitReportSeverityLookup>>,
|
||||
audit_sink: Option<Arc<dyn AuditSink>>,
|
||||
}
|
||||
|
||||
impl OperatorBridge {
|
||||
@@ -84,6 +113,12 @@ impl OperatorBridge {
|
||||
target_follow_rx: Some(tf_rx),
|
||||
poi_mapper: None,
|
||||
validator: None,
|
||||
poi_registry: SurfacedPoiRegistry::new(),
|
||||
dispatcher: None,
|
||||
scan_router: None,
|
||||
safety_router: None,
|
||||
bit_severity: None,
|
||||
audit_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,12 +132,63 @@ impl OperatorBridge {
|
||||
self
|
||||
}
|
||||
|
||||
/// AZ-680 — wire `scan_controller`'s [`ScanCommandRouter`] impl.
|
||||
pub fn with_scan_router(mut self, router: Arc<dyn ScanCommandRouter>) -> Self {
|
||||
self.scan_router = Some(router);
|
||||
self
|
||||
}
|
||||
|
||||
/// AZ-681 — wire `mission_executor`'s [`MissionSafetyRouter`] impl.
|
||||
pub fn with_safety_router(mut self, router: Arc<dyn MissionSafetyRouter>) -> Self {
|
||||
self.safety_router = Some(router);
|
||||
self
|
||||
}
|
||||
|
||||
/// AZ-681 — wire `mission_executor`'s
|
||||
/// [`BitReportSeverityLookup`] impl.
|
||||
pub fn with_bit_severity_lookup(mut self, lookup: Arc<dyn BitReportSeverityLookup>) -> Self {
|
||||
self.bit_severity = Some(lookup);
|
||||
self
|
||||
}
|
||||
|
||||
/// AZ-681 — override the default tracing audit sink. Used by
|
||||
/// integration tests; production wires the default.
|
||||
pub fn with_audit_sink(mut self, sink: Arc<dyn AuditSink>) -> Self {
|
||||
self.audit_sink = Some(sink);
|
||||
self
|
||||
}
|
||||
|
||||
/// AZ-680 / AZ-681 — finalise the dispatcher. Returns `self` so
|
||||
/// the call can sit at the end of the builder chain. Idempotent
|
||||
/// (calling twice rebuilds the dispatcher with the most-recent
|
||||
/// wiring) — this matters because the composition root sometimes
|
||||
/// re-runs the wiring sequence on subsystem restart.
|
||||
pub fn with_dispatcher(mut self) -> Self {
|
||||
let mut builder = OperatorCommandDispatcher::builder().registry(self.poi_registry.clone());
|
||||
if let Some(r) = self.scan_router.clone() {
|
||||
builder = builder.scan_router(r);
|
||||
}
|
||||
if let Some(r) = self.safety_router.clone() {
|
||||
builder = builder.safety_router(r);
|
||||
}
|
||||
if let Some(s) = self.bit_severity.clone() {
|
||||
builder = builder.bit_severity(s);
|
||||
}
|
||||
if let Some(s) = self.audit_sink.clone() {
|
||||
builder = builder.audit_sink(s);
|
||||
}
|
||||
self.dispatcher = Some(Arc::new(builder.build()));
|
||||
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(),
|
||||
poi_registry: self.poi_registry.clone(),
|
||||
dispatcher: self.dispatcher.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +199,15 @@ impl OperatorBridge {
|
||||
pub fn take_target_follow_receiver(&mut self) -> Option<mpsc::Receiver<TargetFollowEvent>> {
|
||||
self.target_follow_rx.take()
|
||||
}
|
||||
|
||||
/// AZ-680 — clone of the surfaced-POI registry. Exposed so the
|
||||
/// composition root can pre-seed entries on subsystem restart
|
||||
/// and so integration tests can register POIs without spinning
|
||||
/// up a TelemetrySink. The registry is also wired into the
|
||||
/// dispatcher.
|
||||
pub fn surfaced_registry(&self) -> SurfacedPoiRegistry {
|
||||
self.poi_registry.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -123,19 +218,33 @@ pub struct OperatorBridgeHandle {
|
||||
target_follow_tx: mpsc::Sender<TargetFollowEvent>,
|
||||
poi_mapper: Option<Arc<PoiSurfaceMapper>>,
|
||||
validator: Option<Arc<HmacOperatorValidator>>,
|
||||
/// AZ-680 — registry of surfaced-but-not-dequeued POIs. The
|
||||
/// dispatcher consults this for unknown-id + deadline checks.
|
||||
poi_registry: SurfacedPoiRegistry,
|
||||
dispatcher: Option<Arc<OperatorCommandDispatcher>>,
|
||||
}
|
||||
|
||||
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.
|
||||
/// AZ-679 + AZ-680 — surface a POI to the operator. Records the
|
||||
/// POI in the dispatcher's validity registry so subsequent
|
||||
/// confirm/decline/start-follow commands resolve. The event itself
|
||||
/// is pushed via the configured `TelemetrySink`.
|
||||
///
|
||||
/// Returns `OperatorDecision::Confirmed`/`Declined`/... is NOT
|
||||
/// the responsibility of this method any more — the decision
|
||||
/// arrives asynchronously via `dispatch` and the operator UI
|
||||
/// applies it. The legacy `Result<OperatorDecision>` shape is
|
||||
/// retained for callers that have not yet migrated; today the
|
||||
/// method returns `NotImplemented` after the surface emits, and
|
||||
/// `scan_controller` should use the non-decision-returning path
|
||||
/// in `surface_poi_with_photo` instead.
|
||||
pub async fn surface_poi(&self, poi: Poi) -> Result<OperatorDecision> {
|
||||
match &self.poi_mapper {
|
||||
Some(mapper) => {
|
||||
self.poi_registry.record(SurfacedPoi::from(&poi));
|
||||
mapper.surface(&poi, None).await?;
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::surface_poi → decision loop (AZ-680)",
|
||||
"operator_bridge::surface_poi → decision is async via dispatch (AZ-680)",
|
||||
))
|
||||
}
|
||||
None => Err(AutopilotError::NotImplemented(
|
||||
@@ -144,8 +253,9 @@ impl OperatorBridgeHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// AZ-679 — surface a POI together with photo metadata (preferred
|
||||
/// path when the source detection carries an ROI snapshot).
|
||||
/// AZ-679 + AZ-680 — surface a POI together with photo metadata
|
||||
/// (preferred path when the source detection carries an ROI
|
||||
/// snapshot). Records the POI in the dispatcher's registry.
|
||||
pub async fn surface_poi_with_photo(
|
||||
&self,
|
||||
poi: &Poi,
|
||||
@@ -154,18 +264,39 @@ impl OperatorBridgeHandle {
|
||||
let mapper = self.poi_mapper.as_ref().ok_or_else(|| {
|
||||
AutopilotError::Internal("surface_poi_with_photo: telemetry sink not wired".into())
|
||||
})?;
|
||||
self.poi_registry.record(SurfacedPoi::from(poi));
|
||||
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.
|
||||
/// AZ-679 + AZ-680 — emit a `PoiDequeued` event (rotation /
|
||||
/// age-out / completion). Removes the POI from the dispatcher's
|
||||
/// registry so any further confirm/decline for the same id
|
||||
/// resolves to `unknown_poi_id`.
|
||||
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())
|
||||
})?;
|
||||
self.poi_registry.forget(poi_id);
|
||||
mapper.emit_dequeued(poi_id, reason).await
|
||||
}
|
||||
|
||||
/// AZ-680 / AZ-681 — dispatch a validated operator command and
|
||||
/// return the typed [`CommandAck`]. The dispatcher must be wired
|
||||
/// via `OperatorBridge::with_dispatcher`; without it every
|
||||
/// command returns `router_not_wired`.
|
||||
pub async fn dispatch_command(&self, cmd: OperatorCommand) -> CommandAck {
|
||||
match &self.dispatcher {
|
||||
Some(d) => d.dispatch(cmd).await,
|
||||
None => CommandAck::error(ack_reasons::ROUTER_NOT_WIRED),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test/observability hook: peek the surfaced-POI registry.
|
||||
#[doc(hidden)]
|
||||
pub fn surfaced_poi_count(&self) -> usize {
|
||||
self.poi_registry.len()
|
||||
}
|
||||
|
||||
pub fn poi_metrics(&self) -> Option<PoiSurfaceMetrics> {
|
||||
self.poi_mapper.as_ref().map(|m| m.metrics())
|
||||
}
|
||||
@@ -197,12 +328,25 @@ impl OperatorBridgeHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// AZ-680 — wire the bridge into the `OperatorCommandSink` trait so
|
||||
/// `telemetry_stream`'s downlink can forward validated commands
|
||||
/// uniformly. The trait surface is binary (`Result<()>`); the typed
|
||||
/// [`CommandAck`] surfaces through [`OperatorBridgeHandle::dispatch_command`]
|
||||
/// for callers that need the rejection reason. The trait impl maps:
|
||||
///
|
||||
/// - `CommandAck::Ok` → `Ok(())`
|
||||
/// - `CommandAck::Error { reason }` → `Err(AutopilotError::Validation(reason))`
|
||||
///
|
||||
/// This keeps the trait minimal while still propagating actionable
|
||||
/// rejection reasons to downstream consumers that only see the
|
||||
/// trait surface.
|
||||
#[async_trait]
|
||||
impl OperatorCommandSink for OperatorBridgeHandle {
|
||||
async fn dispatch(&self, _command: OperatorCommand) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"operator_bridge::dispatch (AZ-680)",
|
||||
))
|
||||
async fn dispatch(&self, command: OperatorCommand) -> Result<()> {
|
||||
match self.dispatch_command(command).await {
|
||||
CommandAck::Ok => Ok(()),
|
||||
CommandAck::Error { reason } => Err(AutopilotError::Validation(reason)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user