[AZ-640] Bootstrap Rust workspace, CI/Docker, observability scaffold
ci/woodpecker/push/build-arm Pipeline failed

Lands the first task of the implementation epic AZ-626: a cargo workspace
with 14 crates (shared + autopilot binary + 12 component crates), a
multi-stage Dockerfile + dev/test compose stacks, a Woodpecker CI pipeline,
the on-airframe systemd unit with flight-gate wiring, three environment
TOML configs, and the canonical entity catalogue from data_model.md as
`shared::models`.

Per-AC verification (full detail in
_docs/03_implementation/batch_01_cycle1_report.md):

- AC-1 cargo check --workspace clean
- AC-2 cargo test --workspace passes; per-crate it_compiles() <0.01 s
- AC-6 cargo build/test --no-default-features clean; VlmClient default
       impl returns VlmAssessment::disabled()
- AC-9 tracing-subscriber emits JSON logs with ts/level/target/fields
- AC-10 runtime::ensure_state_directories creates mapobjects/, audit/,
        pending_pushes/ under storage.state_dir

Deferred to external infra (artifacts written, verification re-runs in CI
and in downstream tasks):
- AC-3 Woodpecker runner; CI yml in place
- AC-4 docker-compose mocks land with AZ-660/AZ-644/AZ-675
- AC-5 SITL conformance lands with AZ-641/AZ-648/AZ-652
- AC-7 aarch64 cross-compile via cargo-zigbuild stage
- AC-8 systemd unit (Linux + systemd host)

Layering invariants from module-layout.md hold: shared (L1) imports
nothing; Layer 2 actor crates import only shared; Layer 3 coordinators
(operator_bridge, mission_executor) import only their documented Layer 2
deps; Layer 4 (scan_controller) imports its documented Layer 2 + Layer 3
deps; the autopilot binary (L5) is the only consumer of every component.

cargo fmt --all --check + cargo clippy --all-targets -- -D warnings both
clean. Jira AZ-640 transitioned to In Progress at the start of this batch;
the matching In Testing transition follows this commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 11:52:40 +03:00
parent bc40ea7300
commit a1ce3a6903
70 changed files with 4997 additions and 12 deletions
+70
View File
@@ -0,0 +1,70 @@
//! Monotonic and wall-clock binding.
//!
//! `MonoClock` is authoritative for tick budgets, telemetry-skew compensation,
//! and inter-frame correlation. `WallClock` is GPS-bound when locked and NTP at
//! boot. Drift > 200 ms surfaces as yellow health on the affected component.
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockSource {
Gnss,
Host,
Coast,
}
/// Process-monotonic clock — never goes backwards, immune to NTP adjustments.
#[derive(Debug, Clone, Copy)]
pub struct MonoClock {
boot: Instant,
}
impl MonoClock {
pub fn new() -> Self {
Self {
boot: Instant::now(),
}
}
/// Nanoseconds since this clock was constructed.
pub fn elapsed_ns(&self) -> u64 {
self.boot.elapsed().as_nanos() as u64
}
}
impl Default for MonoClock {
fn default() -> Self {
Self::new()
}
}
/// Wall-clock binding — produced from `MonoClock` via the active `ClockSource`.
/// Drift beyond the threshold MUST be surfaced as a yellow health detail.
#[derive(Debug, Clone)]
pub struct WallClock {
pub source: ClockSource,
}
impl WallClock {
pub fn new(source: ClockSource) -> Self {
Self { source }
}
pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
chrono::Utc::now()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mono_clock_is_monotonic() {
let clock = MonoClock::new();
let t1 = clock.elapsed_ns();
let t2 = clock.elapsed_ns();
assert!(t2 >= t1, "monotonic clock went backwards: {t1} -> {t2}");
}
}
+143
View File
@@ -0,0 +1,143 @@
//! TOML configuration loader.
//!
//! All non-secret configuration lives in `config/<env>.toml`. Secrets come from
//! environment variables (named by `*_env` keys), never from the TOML itself.
//! See `_docs/02_document/deployment/containerization.md §6`.
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{AutopilotError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub health: HealthConfig,
pub observability: ObservabilityConfig,
pub storage: StorageConfig,
pub rtsp: RtspConfig,
pub gimbal: GimbalConfig,
pub mavlink: MavlinkConfig,
pub missions_api: MissionsApiConfig,
pub ground_station: GroundStationConfig,
pub detections: DetectionsConfig,
pub vlm: VlmConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthConfig {
pub bind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservabilityConfig {
pub log_format: String,
pub default_log_filter: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
pub state_dir: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RtspConfig {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GimbalConfig {
pub endpoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavlinkConfig {
pub connection: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionsApiConfig {
pub endpoint: String,
pub auth_env: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundStationConfig {
pub endpoint: String,
pub auth_env: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionsConfig {
pub endpoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VlmConfig {
pub enabled: bool,
pub ipc_socket: String,
}
pub struct ConfigLoader;
impl ConfigLoader {
/// Load + parse a TOML config from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Config> {
let raw = std::fs::read_to_string(path.as_ref())
.map_err(|e| AutopilotError::Config(format!("cannot read {:?}: {e}", path.as_ref())))?;
let config: Config = toml::from_str(&raw)?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
const DEV_CONFIG: &str = r#"
[health]
bind = "127.0.0.1:8080"
[observability]
log_format = "json"
default_log_filter = "info"
[storage]
state_dir = "/var/lib/autopilot"
[rtsp]
url = "rtsp://127.0.0.1:8554/mock"
[gimbal]
endpoint = "127.0.0.1:6000"
[mavlink]
connection = "udp://127.0.0.1:14550"
[missions_api]
endpoint = "http://127.0.0.1:8443"
auth_env = "MISSIONS_API_TOKEN"
[ground_station]
endpoint = "http://127.0.0.1:8444"
auth_env = "GROUND_STATION_TOKEN"
[detections]
endpoint = "http://127.0.0.1:50051"
[vlm]
enabled = false
ipc_socket = "/var/run/vila/ipc.sock"
"#;
#[test]
fn config_parses_canonical_layout() {
// Act
let config: Config = toml::from_str(DEV_CONFIG).expect("dev config must parse");
// Assert
assert_eq!(config.health.bind, "127.0.0.1:8080");
assert!(!config.vlm.enabled);
assert_eq!(config.missions_api.auth_env, "MISSIONS_API_TOKEN");
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Cross-component traits.
//!
//! These traits let one component push into another's transport without
//! importing the receiving crate. The composition root in
//! `crates/autopilot/src/runtime.rs` wires concrete implementations.
use async_trait::async_trait;
use crate::error::Result;
use crate::models::detection::DetectionBatch;
use crate::models::frame::Frame;
use crate::models::operator::OperatorCommand;
use crate::models::vlm::VlmAssessment;
/// Telemetry uplink. Implemented by `telemetry_stream`, consumed by
/// `operator_bridge` (for overlay/POI surfacing) and `mavlink_layer` (for
/// piggybacked flight telemetry).
#[async_trait]
pub trait TelemetrySink: Send + Sync {
async fn push_frame(&self, frame: Frame) -> Result<()>;
async fn push_detections(&self, batch: DetectionBatch) -> Result<()>;
}
/// MAVLink command surface. Implemented by `mavlink_layer`, consumed by
/// `mission_executor` and other components that need to emit MAVLink commands.
#[async_trait]
pub trait MavlinkSink: Send + Sync {
async fn send_raw(&self, msg: Vec<u8>) -> Result<()>;
}
/// Tier-3 visual-language-model provider. Default impl in `vlm_client` returns
/// `VlmAssessment { status: Disabled, label: Inconclusive, ... }` when the
/// `vlm` feature is off, satisfying the optionality contract.
#[async_trait]
pub trait VlmProvider: Send + Sync {
async fn assess(&self, roi: Vec<u8>, prompt: String) -> Result<VlmAssessment>;
}
/// Operator-command dispatch. Implemented by `operator_bridge`, fed by the
/// composition root from `telemetry_stream`'s downlink.
#[async_trait]
pub trait OperatorCommandSink: Send + Sync {
async fn dispatch(&self, command: OperatorCommand) -> Result<()>;
}
+51
View File
@@ -0,0 +1,51 @@
//! Workspace-wide error type and result alias.
//!
//! Specific component errors funnel into `AutopilotError` at crate boundaries;
//! internal modules may use their own narrower error types but MUST convert at
//! the public API surface.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AutopilotError {
#[error("configuration error: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serialization(String),
#[error("missing dependency: {0}")]
MissingDependency(String),
#[error("not implemented: {0}")]
NotImplemented(&'static str),
#[error("network error: {0}")]
Network(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("validation failed: {0}")]
Validation(String),
#[error("internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, AutopilotError>;
impl From<serde_json::Error> for AutopilotError {
fn from(value: serde_json::Error) -> Self {
AutopilotError::Serialization(value.to_string())
}
}
impl From<toml::de::Error> for AutopilotError {
fn from(value: toml::de::Error) -> Self {
AutopilotError::Config(value.to_string())
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Per-component health model.
//!
//! Each component exposes `health() -> ComponentHealth`. `autopilot::health_server`
//! aggregates these into the `/health` JSON shape documented in
//! `_docs/02_document/deployment/containerization.md §7`.
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthLevel {
Green,
Yellow,
Red,
Disabled,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComponentHealth {
pub level: HealthLevel,
pub component: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ComponentHealth {
pub fn green(component: &'static str) -> Self {
Self {
level: HealthLevel::Green,
component,
detail: None,
}
}
pub fn yellow(component: &'static str, detail: impl Into<String>) -> Self {
Self {
level: HealthLevel::Yellow,
component,
detail: Some(detail.into()),
}
}
pub fn red(component: &'static str, detail: impl Into<String>) -> Self {
Self {
level: HealthLevel::Red,
component,
detail: Some(detail.into()),
}
}
pub fn disabled(component: &'static str) -> Self {
Self {
level: HealthLevel::Disabled,
component,
detail: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AggregatedHealth {
pub status: HealthLevel,
pub components: Vec<ComponentHealth>,
pub last_state_change: chrono::DateTime<chrono::Utc>,
}
impl AggregatedHealth {
/// Aggregate per-component readings into a single status.
///
/// A component in `Disabled` does not affect aggregation. Otherwise:
/// any `Red` → `Red`; else any `Yellow` → `Yellow`; else `Green`.
pub fn aggregate(components: Vec<ComponentHealth>) -> Self {
let mut status = HealthLevel::Green;
for c in &components {
match c.level {
HealthLevel::Red => {
status = HealthLevel::Red;
break;
}
HealthLevel::Yellow if status != HealthLevel::Red => {
status = HealthLevel::Yellow;
}
_ => {}
}
}
Self {
status,
components,
last_state_change: chrono::Utc::now(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aggregate_red_dominates() {
// Arrange
let inputs = vec![
ComponentHealth::green("a"),
ComponentHealth::yellow("b", "lagging"),
ComponentHealth::red("c", "down"),
];
// Act
let agg = AggregatedHealth::aggregate(inputs);
// Assert
assert_eq!(agg.status, HealthLevel::Red);
}
#[test]
fn aggregate_yellow_when_no_red() {
// Arrange
let inputs = vec![
ComponentHealth::green("a"),
ComponentHealth::yellow("b", "lagging"),
];
// Act
let agg = AggregatedHealth::aggregate(inputs);
// Assert
assert_eq!(agg.status, HealthLevel::Yellow);
}
#[test]
fn aggregate_green_when_all_green_or_disabled() {
// Arrange
let inputs = vec![
ComponentHealth::green("a"),
ComponentHealth::disabled("vlm"),
];
// Act
let agg = AggregatedHealth::aggregate(inputs);
// Assert
assert_eq!(agg.status, HealthLevel::Green);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Shared foundation crate for the autopilot workspace.
//!
//! Owns canonical DTOs, configuration, error type, health model, observability,
//! clock binding, and cross-component traits. Every other crate depends on
//! `shared`; `shared` depends on nothing else in the workspace.
pub mod clock;
pub mod config;
pub mod contracts;
pub mod error;
pub mod health;
pub mod models;
pub mod observability;
pub use error::{AutopilotError, Result};
+24
View File
@@ -0,0 +1,24 @@
//! `Detection`, `DetectionBatch` — per `data_model.md §2 Perception entities`.
use serde::{Deserialize, Serialize};
use super::frame::BoundingBox;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Detection {
pub class_id: u32,
pub class_name: String,
pub confidence: f32,
pub bbox_normalized: BoundingBox,
#[serde(skip_serializing_if = "Option::is_none")]
pub mask_or_polyline: Option<Vec<u8>>,
pub source_frame_seq: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionBatch {
pub frame_seq: u64,
pub detections: Vec<Detection>,
pub latency_ms: u32,
pub model_version: String,
}
+34
View File
@@ -0,0 +1,34 @@
//! `Frame`, `BoundingBox` — per `data_model.md §2 Perception entities`.
use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PixelFormat {
Nv12,
Yuv420p,
Rgb24,
}
#[derive(Debug, Clone)]
pub struct Frame {
pub seq: u64,
pub capture_ts_monotonic_ns: u64,
pub decode_ts_monotonic_ns: u64,
pub pixels: Arc<Bytes>,
pub width: u32,
pub height: u32,
pub pix_fmt: PixelFormat,
pub ai_locked: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BoundingBox {
pub x_min: f32,
pub y_min: f32,
pub x_max: f32,
pub y_max: f32,
}
+12
View File
@@ -0,0 +1,12 @@
//! `GimbalState` — per `data_model.md §4 Action / piloting entities`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GimbalState {
pub yaw: f32,
pub pitch: f32,
pub zoom: f32,
pub ts_monotonic_ns: u64,
pub command_in_flight: bool,
}
+121
View File
@@ -0,0 +1,121 @@
//! `MapObject`, `MapObjectObservation`, `MapObjectsBundle`, `IgnoredItem` —
//! per `data_model.md §3 Decision entities`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::mission::Coordinate;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MapObjectSource {
CentralPulled,
LocalObserved,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapObject {
pub h3_cell: u64,
pub mgrs_key: String,
pub class: String,
pub class_group: String,
pub gps_lat: f64,
pub gps_lon: f64,
pub size_width_m: f32,
pub size_length_m: f32,
pub confidence: f32,
pub first_seen: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
pub mission_id: String,
pub source: MapObjectSource,
pub pending_upload: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum DiffKind {
New,
Moved,
Existing,
RemovedCandidate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapObjectObservation {
pub id: Uuid,
pub h3_cell: u64,
pub class: String,
pub class_group: String,
pub mission_id: String,
pub uav_id: String,
pub observed_at_monotonic_ns: u64,
pub observed_at_wallclock: DateTime<Utc>,
pub gps_lat: f64,
pub gps_lon: f64,
pub mgrs: String,
pub size_width_m: f32,
pub size_length_m: f32,
pub confidence: f32,
pub diff_kind: DiffKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_evidence: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetentionScope {
Mission,
Session,
UntilExpiry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IgnoredItemSource {
CentralPulled,
LocalAppended,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IgnoredItem {
pub id: Uuid,
pub mgrs: String,
pub h3_cell: u64,
pub class_group: String,
pub decline_time: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operator_id: Option<String>,
pub mission_id: String,
pub retention_scope: RetentionScope,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
pub source: IgnoredItemSource,
pub pending_upload: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BundleFreshness {
Fresh,
Stale,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapObjectsBundle {
pub schema_version: String,
pub mission_id: String,
/// `[NW, SE]` bounding box.
pub bbox: [Coordinate; 2],
#[serde(default)]
pub map_objects: Vec<MapObject>,
#[serde(default)]
pub observations: Vec<MapObjectObservation>,
#[serde(default)]
pub ignored_items: Vec<IgnoredItem>,
pub as_of: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub freshness: Option<BundleFreshness>,
}
+82
View File
@@ -0,0 +1,82 @@
//! `Coordinate`, `Geofence`, `MissionItem`, `MissionWaypoint` — per
//! `data_model.md §4 Action / piloting entities`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Coordinate {
pub latitude: f64,
pub longitude: f64,
pub altitude_m: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum GeofenceKind {
Inclusion,
Exclusion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Geofence {
pub kind: GeofenceKind,
pub vertices: Vec<Coordinate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissionItemKind {
Waypoint,
Search,
RegionSearch,
Return,
TargetFollowBreakpoint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionItem {
pub id: Uuid,
pub kind: MissionItemKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub at: Option<Coordinate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub region: Vec<Coordinate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cruise_speed_mps: Option<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub target_classes: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MavFrame {
MavFrameGlobalRelativeAlt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MavCommand {
MavCmdNavTakeoff,
MavCmdNavWaypoint,
MavCmdNavLand,
MavCmdDoChangeSpeed,
MavCmdNavReturnToLaunch,
MavCmdDoSetMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MissionWaypoint {
pub seq: u16,
pub frame: MavFrame,
pub command: MavCommand,
pub current: bool,
pub auto_continue: bool,
pub param_1: f32,
pub param_2: f32,
pub param_3: f32,
pub param_4: f32,
pub lat_deg_e7: i32,
pub lon_deg_e7: i32,
pub alt_m: f32,
}
+15
View File
@@ -0,0 +1,15 @@
//! Canonical entity catalogue per `_docs/02_document/data_model.md`.
//!
//! One submodule per entity grouping. Every other crate imports types from here
//! rather than redefining them.
pub mod detection;
pub mod frame;
pub mod gimbal;
pub mod mapobject;
pub mod mission;
pub mod movement;
pub mod operator;
pub mod poi;
pub mod tier2;
pub mod vlm;
+42
View File
@@ -0,0 +1,42 @@
//! `MovementCandidate` — per `data_model.md §2 Perception entities`.
use serde::{Deserialize, Serialize};
use super::frame::BoundingBox;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ResidualVelocity {
/// Image-coordinate direction; unit vector.
pub dx: f32,
pub dy: f32,
/// Magnitude in normalised image units per second.
pub magnitude: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TelemetryQuality {
Synced,
Degraded,
Unsynced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ZoomBand {
/// `Level 1` wide sweep.
ZoomedOut,
/// `Level 2` zoom-in hold.
ZoomedIn,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MovementCandidate {
pub frame_seq: u64,
pub bbox_normalized: BoundingBox,
#[serde(skip_serializing_if = "Option::is_none")]
pub residual_velocity_estimate: Option<ResidualVelocity>,
pub telemetry_quality: TelemetryQuality,
pub source_frame_ts_monotonic_ns: u64,
pub source_zoom_band: ZoomBand,
}
+34
View File
@@ -0,0 +1,34 @@
//! `OperatorCommand` — per `data_model.md §4 Action / piloting entities`.
//!
//! Every operator command carries an authenticated envelope. The signature
//! scheme is open (architecture.md Q9); `operator_bridge::internal::auth`
//! validates the envelope before any handler sees the decoded payload.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperatorCommandKind {
ConfirmPoi,
DeclinePoi,
StartTargetFollow,
ReleaseTargetFollow,
AcknowledgeBitDegraded,
SafetyOverride,
MissionAbort,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorCommand {
pub command_id: Uuid,
pub session_token: String,
pub sequence_number: u64,
pub issued_at_wallclock: DateTime<Utc>,
pub kind: OperatorCommandKind,
pub payload: serde_json::Value,
/// Signature over (session_token, sequence_number, kind, payload). Scheme
/// TBD per architecture.md Q9.
pub signature: Vec<u8>,
}
+49
View File
@@ -0,0 +1,49 @@
//! `POI` — per `data_model.md §3 Decision entities`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::tier2::Tier2Evidence;
use super::vlm::VlmStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VlmPipelineStatus {
NotRequested,
Pending,
Ok,
Timeout,
SchemaInvalid,
IpcError,
Disabled,
}
impl From<VlmStatus> for VlmPipelineStatus {
fn from(s: VlmStatus) -> Self {
match s {
VlmStatus::Ok => Self::Ok,
VlmStatus::Timeout => Self::Timeout,
VlmStatus::SchemaInvalid => Self::SchemaInvalid,
VlmStatus::IpcError => Self::IpcError,
VlmStatus::Disabled => Self::Disabled,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Poi {
pub id: Uuid,
pub confidence: f32,
pub mgrs: String,
pub class: String,
pub class_group: String,
pub source_detection_ids: Vec<Uuid>,
pub enqueued_at: DateTime<Utc>,
pub priority: f32,
pub decline_suppressed: bool,
pub vlm_status: VlmPipelineStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier2_evidence: Option<Tier2Evidence>,
pub deadline: DateTime<Utc>,
}
+36
View File
@@ -0,0 +1,36 @@
//! `Tier2Evidence` — per `data_model.md §2 Perception entities`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecommendedNextAction {
PanFollowFootpath,
HoldEndpoint,
PanBroad,
ReturnToZoomOut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier2Status {
Ok,
Timeout,
Oversize,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tier2Evidence {
pub roi_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub path_freshness: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub concealment_score: Option<f32>,
pub recommended_next_action: RecommendedNextAction,
pub source_detections: Vec<Uuid>,
pub status: Tier2Status,
}
+55
View File
@@ -0,0 +1,55 @@
//! `VlmAssessment` — per `data_model.md §2 Perception entities`.
//!
//! Status semantics: any value other than `Ok` MUST produce
//! `label = Inconclusive` (or `Error` for a critical failure). The
//! `scan_controller` MUST NOT promote a POI to a confirmed target on a non-`Ok`
//! `VlmAssessment`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VlmLabel {
ConfirmedConcealedPosition,
Rejected,
Inconclusive,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VlmStatus {
Ok,
Timeout,
SchemaInvalid,
IpcError,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VlmAssessment {
pub label: VlmLabel,
pub confidence: f32,
pub evidence_spans: Vec<String>,
pub reason: String,
pub status: VlmStatus,
pub latency_ms: u32,
pub model_version: String,
}
impl VlmAssessment {
/// The `vlm_disabled` no-op assessment returned by the default
/// `VlmProvider` impl when the binary is built without `--features vlm`
/// or `vlm.enabled = false` in config.
pub fn disabled() -> Self {
Self {
label: VlmLabel::Inconclusive,
confidence: 0.0,
evidence_spans: Vec::new(),
reason: "vlm disabled".into(),
status: VlmStatus::Disabled,
latency_ms: 0,
model_version: String::new(),
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Observability initialisation.
//!
//! Per `_docs/02_document/deployment/observability.md`, the autopilot emits
//! JSON-formatted log records to stdout containing at least: `ts`, `ts_mono_ns`,
//! `level`, `target`, `event`. Initialisation reads the `RUST_LOG` env var (or
//! the `default_log_filter` config fallback) and the `log_format` setting.
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
/// Output format for the tracing layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
/// Structured JSON to stdout — production default.
Json,
/// Human-readable colour output — dev shells only.
Pretty,
}
impl LogFormat {
pub fn parse(s: &str) -> Self {
match s {
"json" => LogFormat::Json,
"pretty" => LogFormat::Pretty,
_ => LogFormat::Json,
}
}
}
/// Initialise `tracing-subscriber` with the configured format and filter.
///
/// `default_filter` is used when the `RUST_LOG` env var is unset.
/// Safe to call exactly once at startup.
pub fn init(
format: LogFormat,
default_filter: &str,
) -> Result<(), tracing_subscriber::util::TryInitError> {
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter));
let registry = tracing_subscriber::registry().with(env_filter);
match format {
LogFormat::Json => registry
.with(
fmt::layer()
.json()
.with_target(true)
.with_current_span(false)
.with_span_list(false),
)
.try_init(),
LogFormat::Pretty => registry.with(fmt::layer().with_target(true)).try_init(),
}
}
/// Canonical log field constants (mirrors observability.md §2).
pub mod fields {
pub const TS: &str = "ts";
pub const TS_MONO_NS: &str = "ts_mono_ns";
pub const LEVEL: &str = "level";
pub const TARGET: &str = "target";
pub const EVENT: &str = "event";
pub const FRAME_SEQ: &str = "frame_seq";
pub const POI_ID: &str = "poi_id";
pub const COMMAND_ID: &str = "command_id";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_format_parses_known_values() {
assert_eq!(LogFormat::parse("json"), LogFormat::Json);
assert_eq!(LogFormat::parse("pretty"), LogFormat::Pretty);
// Unknown values fall back to JSON (the production-safe default).
assert_eq!(LogFormat::parse("xml"), LogFormat::Json);
}
}