WAM v0.2.0: channels, node identity, peer sync Three ticket channels: - system: machine-to-machine (CI, health, refunds) - request: bidirectional human-system (approvals, investigations) - task: human-to-human async coordination Distributed sync across tailnet: - Each WAM instance has a persistent node_id (UUID in SQLite meta table) - Tickets carry origin node_id and channel - Sync endpoints: GET /sync/pull?since=<rfc3339>, POST /sync/push - Background sync loop pulls from peers every 30s - Last-writer-wins conflict resolution on updated_at - Sync cursors tracked per peer to avoid re-processing - Peers configured via --peer flag: wam serve --peer http://host:7890 Schema migration: adds channel, node_id columns to tickets table, sync_cursors table, meta table. Backwards-compatible with v0.1 DBs. TUI: channel column + filter (t key), node column in list view. CLI: --channel flag on create, channel filter on list. 7 tests (2 new: sync upsert last-writer-wins, node ID persistence).
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 21:24 UTC
Commit:
61ee4ef495229673226b51352a88772ee6c906f1Parent:
8 files changed,
+903 insertions,
-78 deletions
"windows-link",][[package]]name = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "cfg_aliases"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"[[package]]name = "chrono"version = "0.4.44" "windows-sys 0.61.2",][[package]]name = "displaydoc"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "either"version = "1.15.0"checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen",][[package]]name = "getrandom"version = "0.3.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 5.3.0", "wasip2", "wasm-bindgen",][[package]]dependencies = [ "cfg-if", "libc", "r-efi", "r-efi 6.0.0", "wasip2", "wasip3",] "pin-project-lite", "smallvec", "tokio", "want",][[package]]name = "hyper-rustls"version = "0.27.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", "webpki-roots",][[package]]source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing",][[package]] "cc",][[package]]name = "icu_collections"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec",][[package]]name = "icu_locale_core"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec",][[package]]name = "icu_normalizer"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec",][[package]]name = "icu_normalizer_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"[[package]]name = "icu_properties"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec",][[package]]name = "icu_properties_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"[[package]]name = "icu_provider"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec",][[package]]name = "id-arena"version = "2.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"[[package]]name = "idna"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"dependencies = [ "idna_adapter", "smallvec", "utf8_iter",][[package]]name = "idna_adapter"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"dependencies = [ "icu_normalizer", "icu_properties",][[package]]name = "indenter"version = "0.3.4" "syn",][[package]]name = "ipnet"version = "2.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"[[package]]name = "iri-string"version = "0.7.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"dependencies = [ "memchr", "serde",][[package]]name = "is_terminal_polyfill"version = "1.70.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",]source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"[[package]]name = "litemap"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"[[package]]name = "lock_api"version = "0.4.14" "hashbrown 0.15.5",][[package]]name = "lru-slab"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"[[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"[[package]]name = "potential_utf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"dependencies = [ "zerovec",][[package]]name = "ppv-lite86"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"dependencies = [ "zerocopy",][[package]]name = "prettyplease"version = "0.2.37" "unicode-ident",][[package]]name = "quinn"version = "0.11.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", "thiserror", "tokio", "tracing", "web-time",][[package]]name = "quinn-proto"version = "0.11.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", "rand", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "thiserror", "tinyvec", "tracing", "web-time",][[package]]name = "quinn-udp"version = "0.5.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.59.0",][[package]]name = "quote"version = "1.0.45" "proc-macro2",][[package]]name = "r-efi"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"[[package]]name = "r-efi"version = "6.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"[[package]]name = "rand"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"dependencies = [ "rand_chacha", "rand_core",][[package]]name = "rand_chacha"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"dependencies = [ "ppv-lite86", "rand_core",][[package]]name = "rand_core"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"dependencies = [ "getrandom 0.3.4",][[package]]name = "ratatui"version = "0.29.0" "thiserror",][[package]]name = "reqwest"version = "0.12.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"dependencies = [ "base64", "bytes", "futures-core", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "webpki-roots",][[package]]name = "ring"version = "0.17.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"dependencies = [ "cc", "cfg-if", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0",][[package]]name = "rusqlite"version = "0.34.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"[[package]]name = "rustc-hash"version = "2.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"[[package]]name = "rustix"version = "0.38.44" "windows-sys 0.59.0",][[package]]name = "rustls"version = "0.23.39"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"dependencies = [ "once_cell", "ring", "rustls-pki-types",Lines truncated
rusqlite = { version = "0.34", features = ["bundled"] }serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }urlencoding = "2"uuid = { version = "1", features = ["v4"] }//! HTTP API for programmatic ticket management.//! HTTP API for programmatic ticket management and peer sync.//!//! Designed to run on the tailnet -- tailnet membership is the auth boundary,//! so no HMAC or token auth is needed.//! Designed to run on the tailnet -- tailnet membership is the auth boundary.use std::sync::{Arc, Mutex};use std::sync::Arc;use tokio::sync::Mutex;use axum::{ Json, Router,use serde::Deserialize;use crate::db::{self, ListFilter};use crate::types::{NewTicket, Priority, Status};use crate::types::{Channel, NewTicket, Priority, Status, Ticket};/// Shared state: SQLite connection behind a mutex.pub type Db = Arc<Mutex<Connection>>;/// Shared state: SQLite connection + node identity.#[derive(Clone)]pub struct AppState { pub db: Arc<Mutex<Connection>>, pub node_id: String,}/// Build the axum router.pub fn router(conn: Connection) -> Router { let db: Db = Arc::new(Mutex::new(conn)); Router::new()/// Start the HTTP server, optionally syncing with peers.pub async fn serve( conn: Connection, port: u16, peers: Vec<String>,) -> color_eyre::eyre::Result<()> { let node_id = db::get_or_create_node_id(&conn)?; eprintln!("node: {}", &node_id[..8]); let app_state = AppState { db: Arc::new(Mutex::new(conn)), node_id, }; // Spawn sync loop if peers are configured if !peers.is_empty() { let sync_state = app_state.clone(); let sync_peers = peers.clone(); tokio::spawn(async move { sync_loop(sync_state, sync_peers).await; }); } let app = Router::new() .route("/tickets", post(create_ticket)) .route("/tickets", get(list_tickets)) .route("/tickets/{id}", get(get_ticket)) .route("/tickets/{id}", patch(update_ticket)) .with_state(db)} .route("/sync/pull", get(sync_pull)) .route("/sync/push", post(sync_push)) .route("/sync/node", get(sync_node_info)) .with_state(app_state);/// Start the HTTP server on the given port.pub async fn serve(conn: Connection, port: u16) -> color_eyre::eyre::Result<()> { let app = router(conn); let addr = format!("0.0.0.0:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; eprintln!("wam serving on {addr}"); if !peers.is_empty() { eprintln!("syncing with {} peer(s)", peers.len()); } axum::serve(listener, app).await?; Ok(())}// -- Handlers -----------------------------------------------------------------// -- Ticket handlers ----------------------------------------------------------/// POST /ticketsasync fn create_ticket( State(db): State<Db>, State(state): State<AppState>, Json(new): Json<NewTicket>,) -> impl IntoResponse { let conn = db.lock().unwrap(); match db::create_ticket(&conn, &new) { let conn = state.db.lock().await; match db::create_ticket(&conn, &new, &state.node_id) { Ok(ticket) => (StatusCode::CREATED, Json(serde_json::json!(ticket))).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, }}/// Query params for GET /tickets.#[derive(Debug, Deserialize, Default)]pub struct ListQuery { pub status: Option<String>, pub priority: Option<String>, pub channel: Option<String>, pub source: Option<String>, pub search: Option<String>,}/// GET /ticketsasync fn list_tickets( State(db): State<Db>, State(state): State<AppState>, Query(q): Query<ListQuery>,) -> impl IntoResponse { let status = q.status.as_deref().and_then(|s| s.parse::<Status>().ok()); let priority = q.priority.as_deref().and_then(|s| s.parse::<Priority>().ok()); let channel = q.channel.as_deref().and_then(|s| s.parse::<Channel>().ok()); let conn = db.lock().unwrap(); let conn = state.db.lock().await; let filter = ListFilter { status, priority, channel, source: q.source.as_deref(), search: q.search.as_deref(), };/// GET /tickets/:idasync fn get_ticket( State(db): State<Db>, State(state): State<AppState>, Path(id): Path<String>,) -> impl IntoResponse { let conn = db.lock().unwrap(); let conn = state.db.lock().await; match db::get_ticket(&conn, &id) { Ok(ticket) => Json(serde_json::json!(ticket)).into_response(), Err(_) => ( }}/// PATCH /tickets/:id body.#[derive(Debug, Deserialize)]pub struct UpdateBody { pub status: Option<String>,/// PATCH /tickets/:idasync fn update_ticket( State(db): State<Db>, State(state): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateBody>,) -> impl IntoResponse { let conn = db.lock().unwrap(); let conn = state.db.lock().await; // Resolve prefix to full ID first let ticket = match db::get_ticket(&conn, &id) { Ok(t) => t, Err(_) => { } } // Return updated ticket match db::get_ticket(&conn, &ticket.id) { Ok(t) => Json(serde_json::json!(t)).into_response(), Err(e) => ( .into_response(), }}// -- Sync endpoints -----------------------------------------------------------#[derive(Debug, Deserialize)]pub struct SyncPullQuery { /// RFC3339 timestamp. Returns tickets updated after this time. pub since: String,}/// GET /sync/pull?since=<rfc3339> — peer pulls tickets updated after timestampasync fn sync_pull( State(state): State<AppState>, Query(q): Query<SyncPullQuery>,) -> impl IntoResponse { let conn = state.db.lock().await; match db::tickets_since(&conn, &q.since) { Ok(tickets) => Json(serde_json::json!({ "tickets": tickets, "count": tickets.len(), "node_id": state.node_id, })) .into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) .into_response(), }}/// POST /sync/push — peer pushes tickets to usasync fn sync_push( State(state): State<AppState>, Json(tickets): Json<Vec<Ticket>>,) -> impl IntoResponse { let conn = state.db.lock().await; let mut accepted = 0u32; let mut rejected = 0u32; for ticket in &tickets { match db::upsert_synced_ticket(&conn, ticket) { Ok(true) => accepted += 1, Ok(false) => rejected += 1, Err(e) => { eprintln!("sync upsert error for {}: {e}", ticket.short_id()); rejected += 1; } } } Json(serde_json::json!({ "accepted": accepted, "rejected": rejected, }))}/// GET /sync/node — returns this node's identityasync fn sync_node_info(State(state): State<AppState>) -> impl IntoResponse { Json(serde_json::json!({ "node_id": state.node_id, }))}// -- Background sync loop -----------------------------------------------------/// Periodically pull from all peers and push local changes.async fn sync_loop(state: AppState, peers: Vec<String>) { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build() .unwrap(); loop { tokio::time::sleep(std::time::Duration::from_secs(30)).await; for peer in &peers { if let Err(e) = sync_with_peer(&state, &client, peer).await { eprintln!("sync with {peer}: {e}"); } } }}/// Pull new tickets from a peer, then push our new tickets to them.async fn sync_with_peer( state: &AppState, client: &reqwest::Client, peer_url: &str,) -> Result<(), Box<dyn std::error::Error>> { let conn = state.db.lock().await; // Get our cursor for this peer (default to epoch) let cursor = db::get_sync_cursor(&conn, peer_url)? .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); drop(conn); // Release lock before HTTP // Pull from peer let pull_url = format!("{peer_url}/sync/pull?since={}", urlencoding::encode(&cursor)); let resp: serde_json::Value = client.get(&pull_url).send().await?.json().await?; let tickets: Vec<Ticket> = serde_json::from_value( resp.get("tickets").cloned().unwrap_or(serde_json::json!([])), )?; if !tickets.is_empty() { let conn = state.db.lock().await; let mut latest_updated = cursor.clone(); for ticket in &tickets { db::upsert_synced_ticket(&conn, ticket)?; let ts = ticket.updated_at.to_rfc3339(); if ts > latest_updated { latest_updated = ts; } } db::set_sync_cursor(&conn, peer_url, &latest_updated)?; drop(conn); eprintln!("sync: pulled {} ticket(s) from {peer_url}", tickets.len()); } // Push our changes to peer (tickets updated since their last pull from us) // We use the same cursor — they'll filter by last-writer-wins let conn = state.db.lock().await; let our_tickets = db::tickets_since(&conn, &cursor)?; drop(conn); if !our_tickets.is_empty() { let push_url = format!("{peer_url}/sync/push"); let resp: serde_json::Value = client .post(&push_url) .json(&our_tickets) .send() .await? .json() .await?; let accepted = resp.get("accepted").and_then(|v| v.as_u64()).unwrap_or(0); if accepted > 0 { eprintln!("sync: pushed {accepted} ticket(s) to {peer_url}"); } } Ok(())}use clap::{Parser, Subcommand};use crate::types::{Priority, Status};use crate::types::{Channel, Priority, Status};#[derive(Parser)]#[command(name = "wam", about = "Whack-a-Mole -- ticket manager")]#[command(name = "wam", about = "Whack-a-Mole -- distributed ticket manager")]pub struct Cli { #[command(subcommand)] pub command: Option<Command>, /// Priority (low, medium, high, critical) #[arg(short, long, default_value = "medium")] priority: Priority, /// Channel (system, request, task) #[arg(short, long, default_value = "task")] channel: Channel, /// Source system (e.g. "refund-escalation", "pom") #[arg(short, long, default_value = "manual")] source: String, /// Filter by priority #[arg(short, long)] priority: Option<Priority>, /// Filter by channel #[arg(short, long)] channel: Option<Channel>, /// Filter by source #[arg(long)] source: Option<String>, /// Ticket ID (or unique prefix) id: String, }, /// Start the HTTP API server (tailnet-only, no auth required) /// Start the HTTP API server with optional peer sync Serve { /// Port to listen on #[arg(short, long, default_value = "7890")] port: u16, /// Peer WAM URLs to sync with (repeatable) #[arg(long)] peer: Vec<String>, },}//! SQLite data layer for tickets.//! SQLite data layer for tickets with sync support.use chrono::{DateTime, Utc};use color_eyre::eyre::{Result, WrapErr, eyre};use rusqlite::{Connection, Row, params};use crate::types::{NewTicket, Priority, Status, Ticket};use crate::types::{Channel, NewTicket, Priority, Status, Ticket};/// Open (or create) the WAM database and run migrations.pub fn open_db() -> Result<Connection> { body TEXT, priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', channel TEXT NOT NULL DEFAULT 'system', node_id TEXT NOT NULL DEFAULT '', source TEXT, source_ref TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, resolved_at TEXT )", ); CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sync_cursors ( peer_url TEXT PRIMARY KEY, last_synced TEXT NOT NULL );", )?; // Add channel/node_id columns if migrating from v0.1 schema let has_channel: bool = conn .prepare("SELECT channel FROM tickets LIMIT 0") .is_ok(); if !has_channel { conn.execute_batch( "ALTER TABLE tickets ADD COLUMN channel TEXT NOT NULL DEFAULT 'system'; ALTER TABLE tickets ADD COLUMN node_id TEXT NOT NULL DEFAULT '';", )?; } Ok(())}/// Get or create this node's persistent identity.pub fn get_or_create_node_id(conn: &Connection) -> Result<String> { let existing: Option<String> = conn .query_row("SELECT value FROM meta WHERE key = 'node_id'", [], |row| { row.get(0) }) .ok(); if let Some(id) = existing { return Ok(id); } let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO meta (key, value) VALUES ('node_id', ?1)", params![id], )?; Ok(id)}fn row_to_ticket(row: &Row) -> rusqlite::Result<Ticket> { let priority_str: String = row.get("priority")?; let status_str: String = row.get("status")?; let channel_str: String = row.get("channel")?; let created_str: String = row.get("created_at")?; let updated_str: String = row.get("updated_at")?; let resolved_str: Option<String> = row.get("resolved_at")?; body: row.get("body")?, priority: priority_str.parse().unwrap_or(Priority::Medium), status: status_str.parse().unwrap_or(Status::Open), channel: channel_str.parse().unwrap_or(Channel::System), node_id: row.get("node_id")?, source: row.get("source")?, source_ref: row.get("source_ref")?, created_at: DateTime::parse_from_rfc3339(&created_str)}/// Create a new ticket. Returns the created ticket.pub fn create_ticket(conn: &Connection, new: &NewTicket) -> Result<Ticket> {pub fn create_ticket(conn: &Connection, new: &NewTicket, node_id: &str) -> Result<Ticket> { let id = uuid::Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO tickets (id, title, body, priority, status, source, source_ref, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?7)", "INSERT INTO tickets (id, title, body, priority, status, channel, node_id, source, source_ref, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?9, ?9)", params![ id, new.title, new.body, new.priority.to_string(), new.channel.to_string(), node_id, new.source, new.source_ref, now,pub struct ListFilter<'a> { pub status: Option<Status>, pub priority: Option<Priority>, pub channel: Option<Channel>, pub source: Option<&'a str>, pub search: Option<&'a str>,} bind_values.push(priority.to_string()); sql.push_str(&format!(" AND priority = ?{}", bind_values.len())); } if let Some(channel) = filter.channel { bind_values.push(channel.to_string()); sql.push_str(&format!(" AND channel = ?{}", bind_values.len())); } if let Some(source) = filter.source { bind_values.push(source.to_string()); sql.push_str(&format!(" AND source = ?{}", bind_values.len())); Ok(())}// -- Sync operations ----------------------------------------------------------/// Get all tickets updated after the given timestamp.pub fn tickets_since(conn: &Connection, since: &str) -> Result<Vec<Ticket>> { let mut stmt = conn.prepare( "SELECT * FROM tickets WHERE updated_at > ?1 ORDER BY updated_at ASC", )?; let tickets = stmt .query_map(params![since], row_to_ticket)? .collect::<rusqlite::Result<Vec<_>>>()?; Ok(tickets)}/// Upsert a ticket from a peer. Last-writer-wins based on updated_at./// Returns true if the ticket was inserted or updated.pub fn upsert_synced_ticket(conn: &Connection, ticket: &Ticket) -> Result<bool> { // Check if we have this ticket and if ours is newer let existing_updated: Option<String> = conn .query_row( "SELECT updated_at FROM tickets WHERE id = ?1", params![ticket.id], |row| row.get(0), ) .ok(); if let Some(ref existing) = existing_updated { let existing_dt = DateTime::parse_from_rfc3339(existing) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); if existing_dt >= ticket.updated_at { return Ok(false); // Ours is same or newer } } let resolved_at = ticket.resolved_at.map(|dt| dt.to_rfc3339()); conn.execute( "INSERT INTO tickets (id, title, body, priority, status, channel, node_id, source, source_ref, created_at, updated_at, resolved_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET title = excluded.title, body = excluded.body, priority = excluded.priority, status = excluded.status, channel = excluded.channel, source = excluded.source, source_ref = excluded.source_ref, updated_at = excluded.updated_at, resolved_at = excluded.resolved_at", params![ ticket.id, ticket.title, ticket.body, ticket.priority.to_string(), ticket.status.to_string(), ticket.channel.to_string(), ticket.node_id, ticket.source, ticket.source_ref, ticket.created_at.to_rfc3339(), ticket.updated_at.to_rfc3339(), resolved_at, ], )?; Ok(true)}/// Get the sync cursor for a peer (last synced timestamp).pub fn get_sync_cursor(conn: &Connection, peer_url: &str) -> Result<Option<String>> { let cursor: Option<String> = conn .query_row( "SELECT last_synced FROM sync_cursors WHERE peer_url = ?1", params![peer_url], |row| row.get(0), ) .ok(); Ok(cursor)}/// Update the sync cursor for a peer.pub fn set_sync_cursor(conn: &Connection, peer_url: &str, last_synced: &str) -> Result<()> { conn.execute( "INSERT INTO sync_cursors (peer_url, last_synced) VALUES (?1, ?2) ON CONFLICT(peer_url) DO UPDATE SET last_synced = excluded.last_synced", params![peer_url, last_synced], )?; Ok(())}#[cfg(test)]mod tests { use super::*; title: title.to_string(), body: None, priority: Priority::Medium, channel: Channel::System, source: Some("test".to_string()), source_ref: None, } #[test] fn create_and_get() { let conn = open_memory().unwrap(); let t = create_ticket(&conn, &test_new_ticket("fix the thing")).unwrap(); let node = get_or_create_node_id(&conn).unwrap(); let t = create_ticket(&conn, &test_new_ticket("fix the thing"), &node).unwrap(); assert_eq!(t.title, "fix the thing"); assert_eq!(t.status, Status::Open); assert_eq!(t.channel, Channel::System); assert_eq!(t.node_id, node); let fetched = get_ticket(&conn, &t.id).unwrap(); assert_eq!(fetched.id, t.id); #[test] fn prefix_match() { let conn = open_memory().unwrap(); let t = create_ticket(&conn, &test_new_ticket("test")).unwrap(); let node = get_or_create_node_id(&conn).unwrap(); let t = create_ticket(&conn, &test_new_ticket("test"), &node).unwrap(); let fetched = get_ticket(&conn, &t.id[..8]).unwrap(); assert_eq!(fetched.id, t.id); } #[test] fn list_with_filter() { let conn = open_memory().unwrap(); let node = get_or_create_node_id(&conn).unwrap(); create_ticket(&conn, &NewTicket { title: "urgent".into(), body: None, priority: Priority::Critical, channel: Channel::Request, source: Some("pom".into()), source_ref: None, }).unwrap(); create_ticket(&conn, &test_new_ticket("normal")).unwrap(); }, &node).unwrap(); create_ticket(&conn, &test_new_ticket("normal"), &node).unwrap(); let all = list_tickets(&conn, &ListFilter::default()).unwrap(); assert_eq!(all.len(), 2); // Critical should sort first assert_eq!(all[0].title, "urgent"); let critical_only = list_tickets(&conn, &ListFilter { priority: Some(Priority::Critical), let requests = list_tickets(&conn, &ListFilter { channel: Some(Channel::Request), ..Default::default() }).unwrap(); assert_eq!(critical_only.len(), 1); let pom_only = list_tickets(&conn, &ListFilter { source: Some("pom"), ..Default::default() }).unwrap(); assert_eq!(pom_only.len(), 1); assert_eq!(requests.len(), 1); } #[test] fn update_status_sets_resolved_at() { let conn = open_memory().unwrap(); let t = create_ticket(&conn, &test_new_ticket("resolve me")).unwrap(); let node = get_or_create_node_id(&conn).unwrap(); let t = create_ticket(&conn, &test_new_ticket("resolve me"), &node).unwrap(); assert!(t.resolved_at.is_none()); update_status(&conn, &t.id, Status::Resolved).unwrap(); assert!(updated.resolved_at.is_some()); } #[test] fn sync_upsert_last_writer_wins() { let conn = open_memory().unwrap(); let node = get_or_create_node_id(&conn).unwrap(); let t = create_ticket(&conn, &test_new_ticket("original"), &node).unwrap(); // Simulate a peer's version with a newer timestamp let mut peer_ticket = t.clone(); peer_ticket.title = "updated by peer".to_string(); peer_ticket.updated_at = Utc::now() + chrono::Duration::seconds(10); let changed = upsert_synced_ticket(&conn, &peer_ticket).unwrap(); assert!(changed); let fetched = get_ticket(&conn, &peer_ticket.id).unwrap(); assert_eq!(fetched.title, "updated by peer"); // Older update should be rejected let mut stale = peer_ticket.clone(); stale.title = "stale update".to_string(); stale.updated_at = Utc::now() - chrono::Duration::seconds(100); let changed = upsert_synced_ticket(&conn, &stale).unwrap(); assert!(!changed); let fetched = get_ticket(&conn, &peer_ticket.id).unwrap(); assert_eq!(fetched.title, "updated by peer"); // Not "stale update" } #[test] fn search_filter() { let conn = open_memory().unwrap(); create_ticket(&conn, &test_new_ticket("refund issue")).unwrap(); create_ticket(&conn, &test_new_ticket("build failure")).unwrap(); let node = get_or_create_node_id(&conn).unwrap(); create_ticket(&conn, &test_new_ticket("refund issue"), &node).unwrap(); create_ticket(&conn, &test_new_ticket("build failure"), &node).unwrap(); let results = list_tickets(&conn, &ListFilter { search: Some("refund"), assert_eq!(results.len(), 1); assert_eq!(results[0].title, "refund issue"); } #[test] fn node_id_persists() { let conn = open_memory().unwrap(); let id1 = get_or_create_node_id(&conn).unwrap(); let id2 = get_or_create_node_id(&conn).unwrap(); assert_eq!(id1, id2); }} color_eyre::install()?; let cli = cli::Cli::parse(); let conn = db::open_db()?; let node_id = db::get_or_create_node_id(&conn)?; match cli.command { None => tui::run(conn)?, None => tui::run(conn, node_id)?, Some(Command::Create { title, body, priority, source, source_ref }) => { Some(Command::Create { title, body, priority, channel, source, source_ref }) => { let ticket = db::create_ticket(&conn, &NewTicket { title, body, priority, channel, source: Some(source), source_ref, })?; println!("created {} ({})", ticket.short_id(), ticket.title); }, &node_id)?; println!("created {} [{}] ({})", ticket.short_id(), ticket.channel, ticket.title); } Some(Command::List { status, priority, source }) => { Some(Command::List { status, priority, channel, source }) => { let tickets = db::list_tickets(&conn, &ListFilter { status, priority, channel, source: source.as_deref(), ..Default::default() })?; return Ok(()); } println!("{:<10} {:<5} {:<12} {:<36} {}", "ID", "Pri", "Status", "Title", "Source"); println!("{}", "-".repeat(75)); println!("{:<10} {:<8} {:<5} {:<12} {:<30} {}", "ID", "Channel", "Pri", "Status", "Title", "Node"); println!("{}", "-".repeat(80)); for t in &tickets { println!( "{:<10} {:<5} {:<12} {:<36} {}", "{:<10} {:<8} {:<5} {:<12} {:<30} {}", t.short_id(), t.channel, t.priority, t.status, truncate(&t.title, 36), t.source.as_deref().unwrap_or("-"), truncate(&t.title, 30), t.short_node(), ); } println!("\n{} ticket(s)", tickets.len()); let t = db::get_ticket(&conn, &id)?; println!("ID: {}", t.id); println!("Title: {}", t.title); println!("Channel: {}", t.channel); println!("Priority: {}", t.priority); println!("Status: {} {}", t.status.indicator(), t.status); println!("Node: {}", t.node_id); println!("Source: {}", t.source.as_deref().unwrap_or("-")); println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-")); println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC")); println!("closed {} ({})", t.short_id(), t.title); } Some(Command::Serve { port }) => { Some(Command::Serve { port, peer }) => { let rt = tokio::runtime::Runtime::new()?; rt.block_on(api::serve(conn, port))?; rt.block_on(api::serve(conn, port, peer))?; } }use rusqlite::Connection;use crate::db::{self, ListFilter};use crate::types::{Priority, Status, Ticket};use crate::types::{Channel, Priority, Status, Ticket};// -- View state ---------------------------------------------------------------struct App { conn: Connection, node_id: String, tickets: Vec<Ticket>, table_state: TableState, view: View, input_mode: InputMode, status_filter: Option<Status>, priority_filter: Option<Priority>, channel_filter: Option<Channel>, source_filter: Option<String>, all_sources: Vec<String>, search_query: String,}impl App { fn new(conn: Connection) -> Result<Self> { fn new(conn: Connection, node_id: String) -> Result<Self> { let mut app = Self { conn, node_id, tickets: Vec::new(), table_state: TableState::default(), view: View::List, input_mode: InputMode::Normal, status_filter: None, priority_filter: None, channel_filter: None, source_filter: None, all_sources: Vec::new(), search_query: String::new(), &ListFilter { status: self.status_filter, priority: self.priority_filter, channel: self.channel_filter, source: self.source_filter.as_deref(), search, }, Ok(()) } fn cycle_channel_filter(&mut self) -> Result<()> { self.channel_filter = match self.channel_filter { None => Some(Channel::System), Some(Channel::System) => Some(Channel::Request), Some(Channel::Request) => Some(Channel::Task), Some(Channel::Task) => None, }; self.refresh() } fn submit_create(&mut self) -> Result<()> { let title = self.create_input.trim().to_string(); if !title.is_empty() { title, body: None, priority: Priority::Medium, channel: Channel::Task, source: Some("manual".into()), source_ref: None, }, &self.node_id, )?; self.refresh()?; }// -- Entry point --------------------------------------------------------------pub fn run(conn: Connection) -> Result<()> {pub fn run(conn: Connection, node_id: String) -> Result<()> { let mut terminal = ratatui::init(); let mut app = App::new(conn)?; let mut app = App::new(conn, node_id)?; while app.running { terminal.draw(|f| render(&mut app, f))?; KeyCode::Char('f') => app.cycle_status_filter()?, KeyCode::Char('p') => app.cycle_priority_filter()?, KeyCode::Char('s') => app.cycle_source_filter()?, KeyCode::Char('t') => app.cycle_channel_filter()?, KeyCode::Char('/') => { app.search_query.clear(); app.input_mode = InputMode::Search; if let Some(priority) = app.priority_filter { filters.push(format!("pri:{priority}")); } if let Some(channel) = app.channel_filter { filters.push(format!("ch:{channel}")); } if let Some(ref source) = app.source_filter { filters.push(format!("src:{source}")); }}fn render_list(app: &mut App, f: &mut Frame, area: Rect) { let header = Row::new(["Pri", "Title", "Source", "Status", "Age"]) let header = Row::new(["Pri", "Title", "Ch", "Source", "Status", "Node", "Age"]) .style(Style::new().bold().underlined()); let rows: Vec<Row> = app Cell::from(format!(" {} ", t.priority.to_string().chars().next().unwrap_or(' '))) .style(pri_style), Cell::from(t.title.as_str()), Cell::from(t.channel.to_string()), Cell::from(t.source.as_deref().unwrap_or("-")), Cell::from(format!("{} {}", t.status.indicator(), t.status)), Cell::from(t.short_node()), Cell::from(t.age()), ]) }) let widths = [ Constraint::Length(5), Constraint::Fill(1), Constraint::Length(8), Constraint::Length(14), Constraint::Length(14), Constraint::Length(10), Constraint::Length(5), ]; Span::styled("Title: ", Style::new().bold()), Span::raw(&ticket.title), ]), Line::from(vec![ Span::styled("Channel: ", Style::new().bold()), Span::raw(ticket.channel.to_string()), ]), Line::from(vec![ Span::styled("Priority: ", Style::new().bold()), Span::styled(ticket.priority.to_string(), Style::new().fg(ticket.priority.color())), Span::styled("Status: ", Style::new().bold()), Span::raw(format!("{} {}", ticket.status.indicator(), ticket.status)), ]), Line::from(vec![ Span::styled("Node: ", Style::new().bold()), Span::raw(&ticket.node_id), ]), Line::from(vec![ Span::styled("Source: ", Style::new().bold()), Span::raw(ticket.source.as_deref().unwrap_or("-")), let hints = match app.view { View::List => match app.input_mode { InputMode::Search => "Enter: apply Esc: clear Type to search", InputMode::Normal => "j/k:nav Enter:open n:new o/i/r/c:status f:status p:pri s:src /:search q:quit", InputMode::Normal => "j/k:nav Enter:open n:new o/i/r/c:status f:status p:pri t:channel s:src /:search q:quit", }, View::Detail => "o/i/r/c:status Esc:back", View::Create => "Enter:create Esc:cancel",//! Core types: Priority, Status, Ticket.//! Core types: Priority, Status, Channel, Ticket.use std::fmt;use std::str::FromStr; Self::Critical => Color::Red, } }}impl fmt::Display for Priority { }}// -- Channel ------------------------------------------------------------------/// Ticket channel: who is communicating with whom.#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum Channel { /// System-to-system: automated alerts, CI results, health checks. /// No human response expected. System, /// Human-system bidirectional: approval requests, investigations. /// A system creates it, a human acts on it (or vice versa). Request, /// Human-to-human: async tasks, questions, coordination across machines. Task,}impl fmt::Display for Channel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::System => "system", Self::Request => "request", Self::Task => "task", }) }}impl FromStr for Channel { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { match s.to_lowercase().as_str() { "system" => Ok(Self::System), "request" => Ok(Self::Request), "task" => Ok(Self::Task), _ => Err(format!("unknown channel: {s}")), } }}// -- Ticket -------------------------------------------------------------------#[derive(Debug, Clone, Serialize, Deserialize)] pub body: Option<String>, pub priority: Priority, pub status: Status, pub channel: Channel, pub node_id: String, pub source: Option<String>, pub source_ref: Option<String>, pub created_at: DateTime<Utc>, pub fn short_id(&self) -> &str { &self.id[..8.min(self.id.len())] } /// Short node ID for display. pub fn short_node(&self) -> &str { &self.node_id[..8.min(self.node_id.len())] }}// -- NewTicket (for create) --------------------------------------------------- pub body: Option<String>, #[serde(default = "default_priority")] pub priority: Priority, #[serde(default = "default_channel")] pub channel: Channel, #[serde(default)] pub source: Option<String>, #[serde(default)]fn default_priority() -> Priority { Priority::Medium}fn default_channel() -> Channel { Channel::System}