ecto/muni · Archived

depot-services-review

Reviews Rust microservices in the depot/ directory with focus on Axum web framework, SQLx database patterns, Docker deployment, and WebSocket communication. Use when reviewing depot service changes (discovery, dispatch, map-api, gps-status, mapper), adding new endpoints, modifying database schemas, implementing WebSocket protocols, or debugging service integration issues. Covers Tokio async patterns, REST/WebSocket API design, PostgreSQL migrations, error handling, health checks, and Docker mul…

First seen Jan 26, 2026

Installation

$ npx skills add ecto/muni --skill depot-services-review

Summary

  • Reviews Rust microservices in the depot/ directory with focus on Axum web framework, SQLx database patterns, Docker deployment, and WebSocket communication.
  • Use when reviewing depot service changes (discovery, dispatch, map-api, gps-status, mapper), adding new endpoints, modifying database schemas, implementing WebSocket protocols, or debugging service integration issues.
  • Covers Tokio async patterns, REST/WebSocket API design, PostgreSQL migrations, error handling, health checks, and Docker multi-stage builds.

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from ecto/muni · top by installs.

npx skills add ecto/muni

Browse all from ecto/muni

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 35
License MIT
Default branch main
Open issues 1
Status Archived

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Grep, Glob, Bash(cargo:check), Bash(cargo:test), Bash(docker:build)

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 29,791 B
  • docs SUMMARY.md 544 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 4 installs

SKILL.md

Depot Services Review Skill

Reviews Rust microservices in the depot/ directory that provide base station infrastructure for rover fleet operations.

Overview

The depot consists of 5 Rust-based microservices that handle rover discovery, mission dispatch, mapping, and GPS infrastructure:

Services Covered

Service Port Purpose Database WebSocket
discovery 4860 Rover registration, heartbeat tracking, session API No Yes (operator updates)
dispatch 4890 Mission planning, task assignment, zone management PostgreSQL Yes (rover + console)
map-api 4870 Serve processed maps and sessions No No
gps-status 4880 RTK base station monitoring No Yes (console updates)
mapper - Map processing orchestrator (batch job) No No

Shared Technology Stack

All services use a consistent technology stack:

Runtime & Async:

  • Rust Edition 2021
  • Tokio 1.41+ (async runtime with rt-multi-thread, macros, net)
  • Futures 0.3 (async utilities)

Web Framework:

  • Axum 0.7-0.8 (web server, routing, WebSocket)
  • Tower-HTTP 0.6 (middleware: CORS, compression, tracing)
  • Hyper (underlying HTTP implementation)

Database (dispatch only):

  • SQLx 0.8 (async PostgreSQL client)
  • PostgreSQL 16+ (database server in Docker)
  • JSON/JSONB for flexible schema fields

Serialization:

  • Serde 1.0 (derive macros)
  • serde_json 1.0 (JSON encoding/decoding)

Error Handling:

  • thiserror 2.0 (library error types)
  • anyhow (application-level error handling)

Logging:

  • tracing 0.1 (structured logging)
  • tracing-subscriber 0.3 (log formatting, env filtering)

Other:

  • UUID 1.0 (unique identifiers with v4 generation)
  • chrono 0.4 (timestamps with serde support)

Architecture Pattern

All services follow a consistent structure:

depot/<service>/
├── Cargo.toml           # Dependencies and metadata
├── Dockerfile           # Multi-stage build (alpine-based)
├── src/
│   └── main.rs          # Single-file service (300-1200 LOC)
└── migrations/          # SQL migrations (dispatch only)
    └── 001_initial.sql

Code Structure (main.rs):

//! Service documentation

// Imports
use axum::{...};
use tokio::...;

// Type definitions
struct AppState { ... }
type SharedState = Arc<AppState>;

// Main function
#[tokio::main]
async fn main() {
    // 1. Initialize logging
    // 2. Load configuration from env vars
    // 3. Initialize state (DB connection, channels, etc.)
    // 4. Create router with routes
    // 5. Bind to port and serve
}

// Handler functions
async fn endpoint_handler(...) -> impl IntoResponse { ... }

// Helper functions
fn utility_function(...) { ... }

Shared Patterns Review Checklist

1. Axum Web Server Setup

  • Tokio runtime is configured correctly

- Uses #[tokio::main] macro for async main - Runtime features include at least: rt-multi-thread, macros, net - No blocking operations in async contexts

❌ BAD:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async { ... });
}

✅ GOOD:

#[tokio::main]
async fn main() {
    // Tokio runtime handles everything
}
  • Router is created with all routes defined

- Uses Router::new() with chained .route() calls - Groups related routes together (RESTful patterns) - WebSocket routes use get() method with ws.onupgrade() - State is attached with .withstate()

✅ GOOD:

let app = Router::new()
    // CRUD for zones
    .route("/zones", post(create_zone))
    .route("/zones", get(list_zones))
    .route("/zones/{id}", get(get_zone))
    .route("/zones/{id}", put(update_zone))
    .route("/zones/{id}", delete(delete_zone))
    // WebSocket
    .route("/ws", get(ws_handler))
    // Health check
    .route("/health", get(health))
    .layer(CorsLayer::permissive())
    .with_state(state);
  • CORS is configured appropriately

- Uses CorsLayer::permissive() for internal services - Applied as layer via .layer(CorsLayer::permissive()) - Enables cross-origin requests from console

✅ GOOD:

use tower_http::cors::CorsLayer;

Router::new()
    .route("/endpoint", get(handler))
    .layer(CorsLayer::permissive())  // Allows all origins for internal services
  • Server binds to correct address

- Port loaded from PORT env var with sensible default - Binds to 0.0.0.0 (all interfaces) for Docker compatibility - Uses tokio::net::TcpListener for async binding

✅ GOOD:

let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|p| p.parse().ok())
    .unwrap_or(4860);  // Service-specific default

let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();

Reference: See [api-design-patterns.md](./api-design-patterns.md) for more Axum patterns.

2. State Management

  • AppState struct contains all shared state

- Database pool (dispatch only) - Broadcast channels for WebSocket updates - Configuration (directories, URLs, etc.) - Connected client tracking (if applicable)

✅ GOOD:

struct AppState {
    db: PgPool,  // Database connection pool
    rovers: RwLock<HashMap<String, ConnectedRover>>,  // Mutable state
    broadcast_tx: broadcast::Sender<BroadcastMessage>,  // Update channel
}
  • State is wrapped in Arc for sharing

- Uses Arc<AppState> for clone-on-share semantics - Type alias SharedState = Arc<AppState> for convenience - Handlers receive State<SharedState> extractor

✅ GOOD:

type SharedState = Arc<AppState>;

let state = Arc::new(AppState::new(pool));

async fn handler(State(state): State<SharedState>) -> impl IntoResponse {
    // Access state here
}
  • Concurrent state access uses appropriate synchronization

- Immutable state: direct access via Arc - Mutable state: RwLock for many readers, few writers - Channels: broadcast for fan-out, mpsc for point-to-point - Never use Mutex around async operations

❌ BAD:

let rovers = state.rovers.lock().await;  // Mutex in async = deadlock risk
tokio::time::sleep(Duration::from_secs(5)).await;  // Holding lock across await!
drop(rovers);

✅ GOOD:

// Read
let rovers = state.rovers.read().await;
let count = rovers.len();
drop(rovers);  // Release lock quickly

// Write
let mut rovers = state.rovers.write().await;
rovers.insert(id, rover);
drop(rovers);  // Release lock quickly

3. Database Patterns (Dispatch Service)

  • SQLx connection pool is created correctly

- Uses PgPoolOptions::new() to configure pool - Sets maxconnections appropriately (10-20 for services) - Connection URL from DATABASEURL env var - Handles connection errors gracefully

✅ GOOD:

let database_url = std::env::var("DATABASE_URL")
    .expect("DATABASE_URL must be set");

let pool = PgPoolOptions::new()
    .max_connections(10)
    .connect(&database_url)
    .await
    .expect("Failed to connect to database");
  • Migrations are applied on startup

- Migration SQL files in migrations/ directory - Applied manually in run_migrations() function - Checks if migration already applied before running - Logs migration status

✅ GOOD:

async fn run_migrations(pool: &PgPool) {
    let migration = include_str!("../migrations/001_initial.sql");

    // Check if already migrated
    let table_exists: bool = sqlx::query_scalar(
        "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'zones')"
    )
    .fetch_one(pool)
    .await
    .unwrap_or(false);

    if !table_exists {
        info!("Running migration...");
        sqlx::raw_sql(migration).execute(pool).await.expect("Migration failed");
    }
}
  • Database queries use parameterized queries

- NEVER use string concatenation for SQL - Always use .bind() for parameters - Prevents SQL injection vulnerabilities

❌ BAD (SQL injection vulnerability):

let query = format!("SELECT * FROM zones WHERE id = '{}'", user_input);
sqlx::query(&query).fetch_one(&pool).await?;

✅ GOOD:

sqlx::query_as::<_, Zone>(
    "SELECT * FROM zones WHERE id = $1"
)
.bind(id)  // Parameterized - safe!
.fetch_one(&pool)
.await?
  • Query results use appropriate methods

- .fetchone() - exactly one row expected, error if zero or multiple - .fetchoptional() - zero or one row, returns Option<T> - .fetch_all() - multiple rows, returns Vec<T> - .execute() - for INSERT/UPDATE/DELETE, returns rows affected

✅ GOOD:

// Get by ID - must exist
let zone: Zone = sqlx::query_as("SELECT ... WHERE id = $1")
    .bind(id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

// Get by ID - may not exist
let zone: Option<Zone> = sqlx::query_as("SELECT ... WHERE id = $1")
    .bind(id)
    .fetch_optional(&state.db)
    .await?;
  • JSONB fields are handled correctly

- Use #[sqlx(json)] attribute on struct fields - Serialize/deserialize with serde - Type must be serde_json::Value in database model - Cast to concrete types when needed

✅ GOOD:

#[derive(FromRow, Serialize)]
struct Zone {
    pub id: Uuid,
    #[sqlx(json)]
    pub waypoints: serde_json::Value,  // Stored as JSONB
}

// When parsing
let waypoints: Vec<Waypoint> = serde_json::from_value(zone.waypoints)?;

Reference: See [database-patterns.md](./database-patterns.md) for comprehensive database guidance.

4. Error Handling

  • Handler errors use Result with IntoResponse

- Return type: Result<impl IntoResponse, (StatusCode, String)> - Errors map to HTTP status codes - Error messages are user-friendly (not debug strings)

✅ GOOD:

async fn get_zone(
    State(state): State<SharedState>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    let zone = sqlx::query_as("...")
        .bind(id)
        .fetch_optional(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Zone not found".to_string()))?;

    Ok(Json(zone))
}
  • Custom error types implement IntoResponse

- Derive from thiserror::Error for libraries - Implement IntoResponse for Axum handlers - Map error variants to appropriate HTTP status codes

✅ GOOD:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Not found: {0}")]
    NotFound(String),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        let (status, message) = match &self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            ApiError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}
  • Database errors are handled appropriately

- .maperr() converts SQLx errors to HTTP responses - Use .okor() to convert Option to Result with NOT_FOUND - Log errors before returning them

✅ GOOD:

let zone: Zone = sqlx::query_as("SELECT ... WHERE id = $1")
    .bind(id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        warn!(error = %e, "Database query failed");
        (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
    })?
    .ok_or_else(|| {
        warn!(zone_id = %id, "Zone not found");
        (StatusCode::NOT_FOUND, "Zone not found".to_string())
    })?;

5. WebSocket Communication

  • WebSocket upgrade is handled correctly

- Handler accepts WebSocketUpgrade extractor - Returns ws.onupgrade(|socket| handlerfn(socket, state)) - Handler function is async fn taking WebSocket and state

✅ GOOD:

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(state): State<SharedState>,
) -> impl IntoResponse {
    ws.on_upgrade(|socket| handle_ws(socket, state))
}

async fn handle_ws(socket: WebSocket, state: SharedState) {
    let (mut sender, mut receiver) = socket.split();
    // ... WebSocket logic
}
  • WebSocket messages are properly parsed

- Use serdejson::fromstr() to parse text messages - Handle parse errors gracefully (don't crash connection) - Implement message protocol with tagged enum

✅ GOOD:

#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum RoverMessage {
    Register { rover_id: String },
    Progress { task_id: Uuid, progress: i32 },
}

// In handler
while let Some(msg) = receiver.next().await {
    let msg = match msg {
        Ok(Message::Text(text)) => text,
        Ok(Message::Close(_)) => break,
        _ => continue,
    };

    match serde_json::from_str::<RoverMessage>(&msg) {
        Ok(RoverMessage::Register { rover_id }) => { ... },
        Ok(RoverMessage::Progress { task_id, progress }) => { ... },
        Err(e) => warn!(error = %e, "Failed to parse message"),
    }
}
  • Broadcast channels are used correctly

- Create channel with broadcast::channel(capacity) - Subscribe with .subscribe() in each WebSocket handler - Send updates with .send() (returns Result) - Handle lagged receivers (broadcast can drop messages)

✅ GOOD:

// In AppState
struct AppState {
    broadcast_tx: broadcast::Sender<BroadcastMessage>,
}

impl AppState {
    fn new() -> Self {
        let (broadcast_tx, _rx) = broadcast::channel(256);  // Drop receiver
        Self { broadcast_tx }
    }

    fn broadcast(&self, msg: BroadcastMessage) {
        let _ = self.broadcast_tx.send(msg);  // Ignore if no receivers
    }
}

// In WebSocket handler
let mut rx = state.broadcast_tx.subscribe();
loop {
    tokio::select! {
        Ok(msg) = rx.recv() => {
            // Send to client
        }
        msg = receiver.next() => {
            // Handle incoming message
        }
    }
}
  • WebSocket cleanup is handled on disconnect

- Remove client from tracking map - Abort spawned tasks - Broadcast disconnect event if needed - No panics in cleanup code

✅ GOOD:

async fn handle_ws(socket: WebSocket, state: SharedState) {
    let mut rover_id: Option<String> = None;

    // WebSocket loop
    while let Some(msg) = receiver.next().await {
        // ... handle messages
        if let RoverMessage::Register { rover_id: id } = parsed {
            rover_id = Some(id.clone());
            // Register rover
        }
    }

    // Cleanup on disconnect
    if let Some(id) = rover_id {
        info!(rover_id = %id, "Client disconnected");
        let mut rovers = state.rovers.write().await;
        rovers.remove(&id);
        drop(rovers);

        state.broadcast(BroadcastMessage::Disconnected { rover_id: id });
    }
}

Reference: See [websocket-patterns.md](./websocket-patterns.md) for detailed WebSocket guidance.

6. Logging and Observability

  • Tracing is initialized early

- Use tracingsubscriber::fmt() in main() - Set env filter with fallback: RUSTLOG env var or default level - Initialize before any other operations

✅ GOOD:

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "discovery=info,sqlx=warn".into())
        )
        .init();

    info!("Service starting...");
    // ... rest of main
}
  • Log statements use structured logging

- Use info!, warn!, error!, debug! macros - Include context with key-value pairs - Use % for Display, ? for Debug formatting - Log important events: startup, requests, errors, shutdown

✅ GOOD:

info!(
    rover_id = %rover.id,
    address = %rover.address,
    "Rover registered"
);

warn!(
    task_id = %task_id,
    error = %error,
    "Task failed"
);
  • Health check endpoint exists

- Endpoint at /health returning JSON - Includes service status and metrics - Returns 200 OK if service is healthy - Used by Docker healthcheck

✅ GOOD:

async fn health(State(state): State<SharedState>) -> impl IntoResponse {
    let rover_count = state.rovers.read().await.len();
    Json(serde_json::json!({
        "status": "ok",
        "rovers": rover_count
    }))
}

7. Configuration and Environment

  • Configuration loaded from environment variables

- Use std::env::var() for required config - Provide sensible defaults for optional config - Validate configuration early (fail fast) - Log configuration values (except secrets)

✅ GOOD:

// Required
let database_url = std::env::var("DATABASE_URL")
    .expect("DATABASE_URL must be set");

// Optional with default
let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|p| p.parse().ok())
    .unwrap_or(4860);

let sessions_dir = std::env::var("SESSIONS_DIR")
    .map(PathBuf::from)
    .unwrap_or_else(|_| PathBuf::from("/data/sessions"));

info!(
    port = port,
    sessions_dir = %sessions_dir.display(),
    "Configuration loaded"
);
  • Paths use PathBuf for cross-platform compatibility

- Never hardcode paths with / or \ - Use PathBuf::from() to convert from env vars - Use .join() to build paths - Use .display() for logging paths

✅ GOOD:

let base_dir = PathBuf::from(std::env::var("DATA_DIR").unwrap_or("/data".into()));
let sessions_path = base_dir.join("sessions").join(&rover_id);

info!(path = %sessions_path.display(), "Session path");

8. Docker Build and Deployment

  • Dockerfile uses multi-stage build

- Stage 1: rust:1.83-alpine for building - Stage 2: alpine:3.21 for runtime - Minimizes final image size (typically 10-20 MB)

✅ GOOD:

# Build stage
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
# Dummy build for caching
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
# Real build
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release

# Runtime stage
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/target/release/[service] /usr/local/bin/[service]
ENV PORT=4860
EXPOSE 4860
CMD ["[service]"]
  • Release profile is optimized

- lto = true - Link-time optimization - opt-level = "z" - Optimize for size - strip = true - Remove debug symbols

✅ GOOD in Cargo.toml:

[profile.release]
lto = true
opt-level = "z"
strip = true
  • Docker Compose service is configured correctly

- Container name follows pattern: depot-<service> - Restart policy: unless-stopped - Ports mapped correctly (host:container) - Environment variables passed from .env - Healthcheck defined - Dependencies declared with depends_on

✅ GOOD in docker-compose.yml:

discovery:
  build:
    context: ./discovery
    dockerfile: Dockerfile
  container_name: depot-discovery
  restart: unless-stopped
  ports:
    - "4860:4860"
  environment:
    - PORT=4860
    - RUST_LOG=discovery=info
  healthcheck:
    test: ["CMD", "wget", "-q", "-O-", "http://localhost:4860/health"]
    interval: 10s
    timeout: 3s
    retries: 3

Reference: See [docker-deployment.md](./docker-deployment.md) for comprehensive Docker patterns.

Service-Specific Reviews

Discovery Service (depot/discovery)

Purpose: Rover registration, heartbeat tracking, session file serving

Key Files:

  • src/main.rs - Main service implementation (~600 LOC)
  • Dockerfile - Multi-stage build

Endpoints:

  • POST /register - Register rover with metadata
  • POST /heartbeat/{id} - Update rover status with telemetry
  • GET /rovers - List all registered rovers (HTTP fallback)
  • GET /ws - WebSocket for live rover updates to operators
  • GET /api/sessions - List recorded sessions
  • GET /api/sessions/{roverid}/{sessiondir}/session.rrd - Serve session file
  • GET /health - Health check

Specific Concerns:

  • Rover timeout is implemented correctly

- Constant ROVERTIMEOUT = Duration::fromsecs(10) - Background task checks for stale rovers every 2 seconds - Online status computed on-the-fly in get_rovers()

  • Session file serving is secure

- Path traversal attacks prevented (no .. in paths) - Files served from configured SESSIONS_DIR only - Tries both direct and nested session structures - Returns 404 if file doesn't exist

  • WebSocket updates are efficient

- Initial rover list sent on connection - Updates only sent when state changes - Broadcast channel prevents blocking on slow clients

Common Issues:

  • Forgetting to call state.notify() after state changes
  • Not dropping read locks before broadcasting (potential deadlock)
  • Not handling both session directory structures (direct vs nested)

Dispatch Service (depot/dispatch)

Purpose: Mission planning, zone management, task assignment to rovers

Key Files:

  • src/main.rs - Main service implementation (~1200 LOC)
  • migrations/001_initial.sql - Database schema
  • Dockerfile - Multi-stage build

Database Tables:

  • zones - Geographic areas (routes, polygons, points)
  • missions - Scheduled work definitions
  • tasks - Execution instances with progress tracking

Endpoints:

  • POST/GET/PUT/DELETE /zones - Zone CRUD
  • POST/GET/PUT/DELETE /missions - Mission CRUD
  • POST /missions/{id}/start - Start mission (creates task, assigns to rover)
  • POST /missions/{id}/stop - Stop mission (cancels active task)
  • GET/POST /tasks - Task management
  • GET /ws - WebSocket for rover connections
  • GET /ws/console - WebSocket for console updates
  • GET /health - Health check

Specific Concerns:

  • Task lifecycle is managed correctly

- States: pending → assigned → active → done/failed/cancelled - startedat set on first progress update - endedat set when task completes/fails/cancelled - Rover's current_task cleared when task ends

  • WebSocket protocol is implemented correctly

- Two WebSocket endpoints: /ws (rovers), /ws/console (operators) - Rover messages: Register, Progress, Complete, Failed - Dispatch messages: Task, Cancel - Broadcast messages: TaskUpdate, RoverUpdate, ZoneUpdate, MissionUpdate

  • Task assignment logic is correct

- Check if mission has preferred rover, verify connected - If no preference, find any available rover (no current task) - Create task in database with status=assigned - Update rover's current_task in memory - Send task to rover via WebSocket - Rollback if send fails

  • JSONB fields are validated

- zones.waypoints contains array of {x, y, theta?} objects - missions.schedule contains {trigger, cron?, loop} object - Validate structure before inserting (prevent invalid data)

Common Issues:

  • Not clearing rover.current_task when task ends (rover stuck)
  • Forgetting to broadcast updates to console clients
  • Not rolling back database changes if WebSocket send fails
  • Race condition between task creation and rover disconnect

Map API Service (depot/map-api)

Purpose: Serve processed maps and 3D assets to console and other clients

Key Files:

  • src/main.rs - Main service implementation (~450 LOC)
  • Dockerfile - Multi-stage build

Endpoints:

  • GET /maps - List all maps
  • GET /maps/{id} - Get map manifest (metadata)
  • GET /maps/{id}/{asset} - Download asset (splat.ply, pointcloud.laz, mesh.glb, thumbnail.jpg)
  • GET /sessions - List all sessions
  • GET /sessions/{id} - Get session metadata
  • GET /maps/{id}/sessions - Get sessions used to build map
  • GET /health - Health check

Specific Concerns:

  • Map manifest loading is efficient

- Reads maps/index.json for map list - Lazily loads manifests from maps/{name}/manifest.json - Caches manifests in RwLock<HashMap<Uuid, MapManifest>> - Reloads on each list/get request (eventual consistency)

  • Asset serving is correct

- Validates asset exists in manifest before serving - Sets correct Content-Type header for each asset type - Reads entire file into memory (acceptable for small assets) - Returns 404 if asset file missing

  • File paths are constructed safely

- Uses PathBuf::join() to build paths - Validates file exists before serving - No path traversal vulnerabilities

Common Issues:

  • Serving assets not listed in manifest (security issue)
  • Not setting Content-Type header (browser confusion)
  • Not handling missing files gracefully

GPS Status Service (depot/gps-status)

Purpose: Monitor RTK base station status and broadcast to console

Key Files:

  • src/main.rs - Main service implementation (~400 LOC)
  • Dockerfile - Multi-stage build

Endpoints:

  • GET /status - Current RTK base station status
  • GET /ws - WebSocket for live status updates to console
  • GET /health - Health check

Specific Concerns:

  • RTK status is polled correctly

- Background task polls base station via serial/TCP - Parses RTCM3/NMEA messages for status - Broadcasts status updates via WebSocket - Handles base station disconnection gracefully

  • WebSocket updates are throttled

- Status sent at reasonable interval (1-5 seconds) - Prevents overwhelming clients with updates - Initial status sent on connection

Common Issues:

  • Not handling base station disconnection
  • Sending updates too frequently (CPU/bandwidth waste)
  • Not validating RTCM3 message checksums

Mapper Service (depot/mapper)

Purpose: Orchestrate map processing pipeline (batch job, not always running)

Key Files:

  • src/main.rs - Main orchestrator (~1000 LOC)
  • Dockerfile - Multi-stage build

Operation:

  • Runs as batch job (not a long-running service)
  • Scans sessions directory for new sessions
  • Queues sessions for processing
  • Invokes splat-worker for 3D reconstruction
  • Generates map manifests and assets
  • Updates index.json

Specific Concerns:

  • Session discovery is efficient

- Scans filesystem for new sessions - Reads metadata.json from each session - Filters by GPS bounds, frame counts, etc. - Deduplicates sessions already processed

  • Processing pipeline is fault-tolerant

- Tracks session status (pending, processing, processed, failed) - Retries failed sessions with exponential backoff - Logs errors for manual intervention - Doesn't block on failed sessions

  • Map manifest generation is correct

- Calculates GPS bounds from all sessions - Lists all available assets (splat, pointcloud, mesh, thumbnail) - Includes session references - Updates index.json atomically

Common Issues:

  • Not handling concurrent mapper invocations (file conflicts)
  • Not validating session metadata before processing
  • Not cleaning up temporary files on failure

Quick Commands

Development

# Check code (no build)
cargo check -p discovery
cargo check -p dispatch

# Build service
cargo build -p discovery --release

# Run tests
cargo test -p dispatch

# Run service locally (requires dependencies)
cd depot/discovery
PORT=4860 cargo run

# Run with logging
RUST_LOG=discovery=debug cargo run

Docker

# Build service image
cd depot/discovery
docker build -t depot-discovery .

# Run service container
docker run -p 4860:4860 -e RUST_LOG=info depot-discovery

# Build all services via Docker Compose
cd depot
docker compose build

# Start all services
docker compose up -d

# View logs
docker compose logs -f discovery

# Restart service
docker compose restart dispatch

# Stop all services
docker compose down

Database (Dispatch Only)

# Connect to PostgreSQL
docker compose exec postgres psql -U postgres -d dispatch

# View tables
\dt

# Query zones
SELECT id, name, zone_type FROM zones;

# Query tasks
SELECT id, status, rover_id, progress FROM tasks ORDER BY created_at DESC LIMIT 10;

# Reset database (DESTRUCTIVE)
docker compose down -v  # Removes volumes
docker compose up -d postgres
docker compose restart dispatch  # Migrations run on startup

References

  • [database-patterns.md](./database-patterns.md) - SQLx, migrations, queries, transactions
  • [api-design-patterns.md](./api-design-patterns.md) - Axum routing, middleware, error handling
  • [websocket-patterns.md](./websocket-patterns.md) - WebSocket protocols, broadcast channels
  • [docker-deployment.md](./docker-deployment.md) - Multi-stage builds, Docker Compose, healthchecks
  • [CLAUDE.md](../../../CLAUDE.md) - Project-wide conventions
  • [depot/README.md](../../../depot/README.md) - Depot architecture overview