Wire breadcrumbs into the bread event fabric (app id crumbs)
All checks were successful
check / check (push) Successful in 36s
All checks were successful
check / check (push) Successful in 36s
The watch daemon publishes bread.crumbs.profile.changed and bread.crumbs.health.changed on real transitions (not every poll tick) and honors bread.command.crumbs.set_profile via the existing state::set_profile path. BreadClient is fail-silent: if breadd is down, breadcrumbs behaves exactly as before. Document the contract in EVENTS.md. CLAUDE.md now points at CONTRIBUTING (single-trunk, no three-branch model), bakery, and EVENTS.md.
This commit is contained in:
parent
0f48b1499d
commit
9009404536
11 changed files with 588 additions and 19 deletions
15
src/app.rs
15
src/app.rs
|
|
@ -10,9 +10,9 @@ use std::time::Duration;
|
|||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::config::{Config, NetworkDef};
|
||||
use crate::state::State;
|
||||
use crate::state::{self, State};
|
||||
use crate::util::{self, command_exists, home_dir};
|
||||
use crate::{config, flow, nm, notify, watch};
|
||||
use crate::{config, flow, nm, watch};
|
||||
|
||||
const C_RESET: &str = "\x1b[0m";
|
||||
const C_BOLD: &str = "\x1b[1m";
|
||||
|
|
@ -288,16 +288,7 @@ fn cmd_profile(cfg: &mut Config, action: Option<ProfileCmd>) -> Result<i32, Stri
|
|||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Set { name, no_apply } => {
|
||||
if !cfg.profiles.contains_key(&name) {
|
||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||
}
|
||||
let st = State {
|
||||
profile: name.clone(),
|
||||
updated: crate::util::timestamp(),
|
||||
};
|
||||
st.save()?;
|
||||
notify::log(&format!("profile set -> {name}"));
|
||||
state::set_profile(cfg, &name)?;
|
||||
println!("profile = {C_BOLD}{name}{C_RESET}");
|
||||
if no_apply {
|
||||
return Ok(0);
|
||||
|
|
|
|||
97
src/bread_events.rs
Normal file
97
src/bread_events.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//! `bread.crumbs.*` event integration — optional, non-blocking. See
|
||||
//! `EVENTS.md` at the repo root for the full contract. breadcrumbs works
|
||||
//! identically with or without breadd running; every call here is
|
||||
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
|
||||
//! process) so a missing or restarting breadd never affects Wi-Fi
|
||||
//! automation itself.
|
||||
|
||||
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state;
|
||||
|
||||
/// This app's id in bread's sibling-app namespace registry
|
||||
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.crumbs.*`,
|
||||
/// commands arrive on `bread.command.crumbs.*`.
|
||||
pub const APP_ID: &str = "crumbs";
|
||||
|
||||
pub fn client() -> BreadClient {
|
||||
BreadClient::connect(APP_ID)
|
||||
}
|
||||
|
||||
pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) {
|
||||
client.emit(
|
||||
"bread.crumbs.profile.changed",
|
||||
serde_json::json!({ "from": from, "to": to }),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) {
|
||||
client.emit(
|
||||
"bread.crumbs.health.changed",
|
||||
serde_json::json!({
|
||||
"profile": profile,
|
||||
"health": health,
|
||||
"ssid": ssid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to
|
||||
/// real, existing breadcrumbs functionality today — there is no pin/select
|
||||
/// (or other) verb because breadcrumbs has no such concept. Unrecognized
|
||||
/// verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||
///
|
||||
/// Returns `true` when a profile was actually persisted, so the watch loop
|
||||
/// can wake immediately and re-evaluate instead of waiting out the current
|
||||
/// poll interval.
|
||||
///
|
||||
/// Emits `bread.crumbs.set_profile.done`/`.failed` per the confirmation
|
||||
/// convention in bread's Documentation.md.
|
||||
pub fn handle_command(event: &BreadEvent) -> bool {
|
||||
let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else {
|
||||
return false;
|
||||
};
|
||||
match verb {
|
||||
"set_profile" => handle_set_profile(event),
|
||||
other => {
|
||||
crate::notify::log(&format!(
|
||||
"watch: ignoring unrecognized bread.command.crumbs.{other}"
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_profile(event: &BreadEvent) -> bool {
|
||||
let Some(name) = event.data.get("profile").and_then(|v| v.as_str()) else {
|
||||
emit_set_profile_failed("missing string \"profile\" in command data");
|
||||
return false;
|
||||
};
|
||||
match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) {
|
||||
Ok(()) => {
|
||||
crate::notify::log(&format!(
|
||||
"watch: profile set via bread.command.crumbs.set_profile -> {name}"
|
||||
));
|
||||
client().emit(
|
||||
"bread.crumbs.set_profile.done",
|
||||
serde_json::json!({ "profile": name }),
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
emit_set_profile_failed(&e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_set_profile_failed(error: &str) {
|
||||
crate::notify::log(&format!(
|
||||
"watch: bread.command.crumbs.set_profile failed: {error}"
|
||||
));
|
||||
client().emit(
|
||||
"bread.crumbs.set_profile.failed",
|
||||
serde_json::json!({ "error": error }),
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
//! compiled binary.
|
||||
|
||||
pub mod app;
|
||||
pub mod bread_events;
|
||||
pub mod config;
|
||||
pub mod flow;
|
||||
pub mod nm;
|
||||
|
|
|
|||
21
src/state.rs
21
src/state.rs
|
|
@ -2,7 +2,7 @@ use std::fs;
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{state_dir, state_path};
|
||||
use crate::config::{state_dir, state_path, Config};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
|
|
@ -32,3 +32,22 @@ impl State {
|
|||
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist `name` as the active profile if it exists in `cfg`. Shared by the
|
||||
/// CLI `profile set` path and `bread.command.crumbs.set_profile` so they
|
||||
/// cannot drift. Does not run [`crate::flow::run`] — the CLI applies
|
||||
/// afterwards unless `--no-apply`, and the watch daemon picks the new
|
||||
/// profile up on its next tick.
|
||||
pub fn set_profile(cfg: &Config, name: &str) -> Result<(), String> {
|
||||
if !cfg.profiles.contains_key(name) {
|
||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||
}
|
||||
State {
|
||||
profile: name.to_string(),
|
||||
updated: crate::util::timestamp(),
|
||||
}
|
||||
.save()?;
|
||||
crate::notify::log(&format!("profile set -> {name}"));
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
49
src/watch.rs
49
src/watch.rs
|
|
@ -4,6 +4,9 @@ use std::sync::mpsc::{self, Receiver};
|
|||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
|
||||
use crate::bread_events;
|
||||
use crate::config::Config;
|
||||
use crate::flow;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
|
|
@ -28,6 +31,21 @@ pub enum Health {
|
|||
UnknownProfile,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
/// Wire name used in `bread.crumbs.health.changed` — the Rust variant
|
||||
/// as a string, not a prettier label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Health::Up => "Up",
|
||||
Health::DownNoNet => "DownNoNet",
|
||||
Health::DownTailscaleManual => "DownTailscaleManual",
|
||||
Health::DownTailscaleOther => "DownTailscaleOther",
|
||||
Health::NoAdapter => "NoAdapter",
|
||||
Health::UnknownProfile => "UnknownProfile",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||
// Checked before gather(): a profile missing from config would otherwise
|
||||
// silently fall back to "tailscale not required" and read as healthy off
|
||||
|
|
@ -134,7 +152,22 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
log("watch: started");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
spawn_nm_monitor(tx);
|
||||
spawn_nm_monitor(tx.clone());
|
||||
|
||||
// Long-lived, so this uses BreadClient::subscribe (a persistent
|
||||
// background thread with its own reconnect/backoff loop). breadd being
|
||||
// absent or restarting is transparent: the subscription just quietly
|
||||
// stops delivering commands until it reconnects. A successful
|
||||
// `set_profile` wakes this loop the same way `nmcli monitor` does, so
|
||||
// the new profile is applied on the next tick instead of waiting out
|
||||
// the current poll interval.
|
||||
let bread = BreadClient::connect(bread_events::APP_ID);
|
||||
let wake = tx;
|
||||
let _commands = bread.subscribe("bread.command.crumbs.**", move |event| {
|
||||
if bread_events::handle_command(&event) {
|
||||
let _ = wake.send(());
|
||||
}
|
||||
});
|
||||
|
||||
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||
if run_initial {
|
||||
|
|
@ -179,6 +212,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
&format!("{prev_profile} -> {profile}"),
|
||||
Urgency::Low,
|
||||
);
|
||||
bread_events::emit_profile_changed(&bread, &prev_profile, &profile);
|
||||
prev_profile = profile.clone();
|
||||
prev_health = None; // force re-evaluation/recovery for new profile
|
||||
last_flow_at = None; // allow immediate recovery on profile change
|
||||
|
|
@ -186,6 +220,9 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
|
||||
let (health, ssid) = classify(&cfg, &profile);
|
||||
let transition = prev_health.as_ref() != Some(&health);
|
||||
if transition {
|
||||
bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref());
|
||||
}
|
||||
|
||||
match &health {
|
||||
Health::Up => {
|
||||
|
|
@ -300,4 +337,14 @@ mod tests {
|
|||
let earlier = Instant::now();
|
||||
assert!(debounce_ready(Some(earlier), Duration::from_millis(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_as_str_is_the_variant_name() {
|
||||
assert_eq!(Health::Up.as_str(), "Up");
|
||||
assert_eq!(Health::DownNoNet.as_str(), "DownNoNet");
|
||||
assert_eq!(Health::DownTailscaleManual.as_str(), "DownTailscaleManual");
|
||||
assert_eq!(Health::DownTailscaleOther.as_str(), "DownTailscaleOther");
|
||||
assert_eq!(Health::NoAdapter.as_str(), "NoAdapter");
|
||||
assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue