Wire breadcrumbs into the bread event fabric (app id crumbs)
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:
Breadway 2026-08-15 21:38:16 +08:00
parent 0f48b1499d
commit 9009404536
11 changed files with 588 additions and 19 deletions

View file

@ -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
View 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 }),
);
}

View file

@ -8,6 +8,7 @@
//! compiled binary.
pub mod app;
pub mod bread_events;
pub mod config;
pub mod flow;
pub mod nm;

View file

@ -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(())
}

View file

@ -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");
}
}