Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers
This commit is contained in:
parent
89c5849539
commit
1208c5d1b7
29 changed files with 4098 additions and 339 deletions
470
breadd/src/adapters/filesystem.rs
Normal file
470
breadd/src/adapters/filesystem.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent};
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
/// Files/directories directly inside a watched root whose presence marks it
|
||||
/// as a recognizable project.
|
||||
const MARKERS: [&str; 4] = [".git", "Cargo.toml", "package.json", "go.mod"];
|
||||
|
||||
/// Directory names that are excluded from `file.changed` noise anywhere in a
|
||||
/// watched tree. `.git` and `node_modules` are fully silent; `target`,
|
||||
/// `dist`, and `build` additionally get a `build_artifact.created` signal
|
||||
/// when a new file appears inside them.
|
||||
const EXCLUDED_DIRS: [&str; 5] = [".git", "target", "node_modules", "dist", "build"];
|
||||
|
||||
/// Debounce window for `file.changed` — editors frequently perform several
|
||||
/// writes (temp file + rename, fsync, etc.) for a single logical save.
|
||||
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Watches a set of project-root glob patterns for filesystem activity and
|
||||
/// reports project detection, plain file changes, and build-artifact
|
||||
/// creation as [`RawEvent`]s.
|
||||
#[derive(Clone)]
|
||||
pub struct FilesystemAdapter {
|
||||
/// Raw, unexpanded root patterns as configured (e.g. `["~/Projects/*"]`).
|
||||
roots: Vec<String>,
|
||||
}
|
||||
|
||||
impl FilesystemAdapter {
|
||||
pub fn new(roots: Vec<String>) -> Self {
|
||||
Self { roots }
|
||||
}
|
||||
|
||||
/// Expand `~` and a single `*` glob segment in each configured pattern
|
||||
/// into concrete directory paths. Patterns are not required to exist on
|
||||
/// disk yet — existence is checked by the caller.
|
||||
fn resolve_roots(&self) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for pattern in &self.roots {
|
||||
let expanded = expand_path(pattern);
|
||||
out.extend(expand_glob(&expanded));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Scan each concrete root for project markers and emit a `"detected"`
|
||||
/// event for any root that has at least one. Mirrors the
|
||||
/// `enumerate_existing` convention used by the udev/bluetooth adapters:
|
||||
/// called once before `run()`, best-effort, never fails the daemon.
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
|
||||
for root in self.resolve_roots() {
|
||||
if !root.is_dir() {
|
||||
debug!(
|
||||
"filesystem: root {} does not exist, skipping enumeration",
|
||||
root.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let markers = detect_markers(&root);
|
||||
if markers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Filesystem,
|
||||
kind: "detected".to_string(),
|
||||
payload: json!({
|
||||
"root": root.to_string_lossy(),
|
||||
"markers": markers,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for FilesystemAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"filesystem"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let mut existing_roots = Vec::new();
|
||||
for root in self.resolve_roots() {
|
||||
if root.is_dir() {
|
||||
existing_roots.push(root);
|
||||
} else {
|
||||
warn!(
|
||||
"filesystem: configured root {} does not exist, skipping",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if existing_roots.is_empty() {
|
||||
debug!("filesystem adapter: no existing roots to watch");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_watch(existing_roots, tx).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up a recursive `notify` watch on each root and bridges its
|
||||
/// synchronous callback into the async `tx` channel via a blocking task.
|
||||
///
|
||||
/// Roots that fail to watch (e.g. inotify watch-limit exhaustion) are logged
|
||||
/// and skipped; the adapter keeps watching whatever roots succeeded.
|
||||
async fn run_watch(roots: Vec<PathBuf>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let (std_tx, std_rx) = std_mpsc::channel::<notify::Result<Event>>();
|
||||
|
||||
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
|
||||
let _ = std_tx.send(res);
|
||||
})?;
|
||||
|
||||
let mut watched_roots = Vec::new();
|
||||
for root in &roots {
|
||||
match watcher.watch(root, RecursiveMode::Recursive) {
|
||||
Ok(()) => watched_roots.push(root.clone()),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"filesystem: failed to watch {} ({e}), skipping this root",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if watched_roots.is_empty() {
|
||||
warn!("filesystem adapter: no roots could be watched, exiting");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The blocking task owns the receiving end and the debounce table; it
|
||||
// runs until `std_rx` disconnects (watcher dropped) or `tx` is closed
|
||||
// (daemon shutting down). `watcher` is kept alive in this async fn's
|
||||
// stack across the await below so its background thread keeps feeding
|
||||
// `std_rx` for as long as this future is polled.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut last_seen: HashMap<PathBuf, Instant> = HashMap::new();
|
||||
while let Ok(res) = std_rx.recv() {
|
||||
let event = match res {
|
||||
Ok(event) => event,
|
||||
Err(e) => {
|
||||
debug!("filesystem watch error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for path in &event.paths {
|
||||
let Some(root) = watched_roots.iter().find(|r| path.starts_with(r)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(raw) = classify(root, path, &event.kind, &mut last_seen) {
|
||||
if tx.blocking_send(raw).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
drop(watcher);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classifies a single notify path event into a `RawEvent`, or `None` if it
|
||||
/// should be silent (inside `.git`/`node_modules`, or debounced).
|
||||
fn classify(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
kind: &EventKind,
|
||||
last_seen: &mut HashMap<PathBuf, Instant>,
|
||||
) -> Option<RawEvent> {
|
||||
let relative = path.strip_prefix(root).unwrap_or(path);
|
||||
|
||||
match excluded_dir_component(relative) {
|
||||
Some("target" | "dist" | "build") => {
|
||||
if matches!(kind, EventKind::Create(_)) {
|
||||
Some(RawEvent {
|
||||
source: AdapterSource::Filesystem,
|
||||
kind: "build_artifact.created".to_string(),
|
||||
payload: json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"project_root": root.to_string_lossy(),
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// `.git` / `node_modules`: fully silent, never emit.
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = last_seen.get(path) {
|
||||
if now.duration_since(*last) < DEBOUNCE_WINDOW {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
last_seen.insert(path.to_path_buf(), now);
|
||||
|
||||
Some(RawEvent {
|
||||
source: AdapterSource::Filesystem,
|
||||
kind: "file.changed".to_string(),
|
||||
payload: json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"project_root": root.to_string_lossy(),
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first excluded directory name found anywhere among the
|
||||
/// components of `relative`, or `None` if it isn't under any of them.
|
||||
fn excluded_dir_component(relative: &Path) -> Option<&'static str> {
|
||||
for component in relative.components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
let name = name.to_str().unwrap_or("");
|
||||
if let Some(excluded) = EXCLUDED_DIRS.iter().find(|e| **e == name) {
|
||||
return Some(excluded);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Checks `root` for the presence of any recognized project marker
|
||||
/// directly inside it (not recursively).
|
||||
fn detect_markers(root: &Path) -> Vec<String> {
|
||||
MARKERS
|
||||
.iter()
|
||||
.filter(|marker| root.join(marker).exists())
|
||||
.map(|marker| marker.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expands a single `*` path component (if present) into every directory
|
||||
/// entry of its parent. Patterns without a `*` are returned unchanged.
|
||||
/// Only one level of globbing is supported, matching the config contract
|
||||
/// (e.g. `~/Projects/*`, not `~/Projects/**`).
|
||||
fn expand_glob(path: &Path) -> Vec<PathBuf> {
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let Some(star_idx) = components.iter().position(|c| c.as_os_str() == "*") else {
|
||||
return vec![path.to_path_buf()];
|
||||
};
|
||||
|
||||
let parent: PathBuf = components[..star_idx].iter().collect();
|
||||
let suffix: PathBuf = components[star_idx + 1..].iter().collect();
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(&parent) else {
|
||||
debug!(
|
||||
"filesystem: cannot read {} to expand glob pattern",
|
||||
parent.display()
|
||||
);
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|p| {
|
||||
if suffix.as_os_str().is_empty() {
|
||||
p
|
||||
} else {
|
||||
p.join(&suffix)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn excluded_dir_component_finds_git_at_top_level() {
|
||||
assert_eq!(excluded_dir_component(Path::new(".git/HEAD")), Some(".git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_dir_component_finds_target_nested_deeply() {
|
||||
assert_eq!(
|
||||
excluded_dir_component(Path::new("crates/foo/target/debug/build/out.o")),
|
||||
Some("target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_dir_component_finds_node_modules() {
|
||||
assert_eq!(
|
||||
excluded_dir_component(Path::new("web/node_modules/lodash/index.js")),
|
||||
Some("node_modules")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_dir_component_none_for_ordinary_source_file() {
|
||||
assert_eq!(excluded_dir_component(Path::new("src/main.rs")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_silent_under_git() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/.git/HEAD");
|
||||
let result = classify(
|
||||
root,
|
||||
path,
|
||||
&EventKind::Create(notify::event::CreateKind::File),
|
||||
&mut last_seen,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_silent_under_node_modules() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/node_modules/foo/index.js");
|
||||
let result = classify(
|
||||
root,
|
||||
path,
|
||||
&EventKind::Modify(notify::event::ModifyKind::Any),
|
||||
&mut last_seen,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_build_artifact_created_in_target() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/target/debug/breadd");
|
||||
let result = classify(
|
||||
root,
|
||||
path,
|
||||
&EventKind::Create(notify::event::CreateKind::File),
|
||||
&mut last_seen,
|
||||
);
|
||||
let event = result.expect("expected build_artifact.created event");
|
||||
assert_eq!(event.kind, "build_artifact.created");
|
||||
assert_eq!(event.payload["path"], json!("/proj/target/debug/breadd"));
|
||||
assert_eq!(event.payload["project_root"], json!("/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_silent_for_modify_in_target_not_create() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/target/debug/breadd");
|
||||
let result = classify(
|
||||
root,
|
||||
path,
|
||||
&EventKind::Modify(notify::event::ModifyKind::Data(
|
||||
notify::event::DataChange::Any,
|
||||
)),
|
||||
&mut last_seen,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_file_changed_for_ordinary_source_file() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/src/main.rs");
|
||||
let result = classify(
|
||||
root,
|
||||
path,
|
||||
&EventKind::Modify(notify::event::ModifyKind::Any),
|
||||
&mut last_seen,
|
||||
);
|
||||
let event = result.expect("expected file.changed event");
|
||||
assert_eq!(event.kind, "file.changed");
|
||||
assert_eq!(event.payload["path"], json!("/proj/src/main.rs"));
|
||||
assert_eq!(event.payload["project_root"], json!("/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_debounces_rapid_repeat_events_for_same_path() {
|
||||
let mut last_seen = HashMap::new();
|
||||
let root = Path::new("/proj");
|
||||
let path = Path::new("/proj/src/main.rs");
|
||||
let kind = EventKind::Modify(notify::event::ModifyKind::Any);
|
||||
|
||||
let first = classify(root, path, &kind, &mut last_seen);
|
||||
assert!(first.is_some());
|
||||
|
||||
let second = classify(root, path, &kind, &mut last_seen);
|
||||
assert!(second.is_none(), "second rapid event should be debounced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_markers_finds_cargo_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||
let markers = detect_markers(dir.path());
|
||||
assert_eq!(markers, vec!["Cargo.toml".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_markers_finds_multiple() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join(".git")).unwrap();
|
||||
fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||
let mut markers = detect_markers(dir.path());
|
||||
markers.sort();
|
||||
let mut expected = vec![".git".to_string(), "package.json".to_string()];
|
||||
expected.sort();
|
||||
assert_eq!(markers, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_markers_empty_for_plain_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(detect_markers(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_glob_returns_single_path_unchanged_without_star() {
|
||||
let path = Path::new("/home/user/Projects/bread");
|
||||
assert_eq!(expand_glob(path), vec![path.to_path_buf()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_glob_expands_star_to_subdirectories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("alpha")).unwrap();
|
||||
fs::create_dir(dir.path().join("beta")).unwrap();
|
||||
fs::write(dir.path().join("not-a-dir.txt"), "x").unwrap();
|
||||
|
||||
let pattern = dir.path().join("*");
|
||||
let mut results = expand_glob(&pattern);
|
||||
results.sort();
|
||||
|
||||
let mut expected = vec![dir.path().join("alpha"), dir.path().join("beta")];
|
||||
expected.sort();
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_glob_returns_empty_for_nonexistent_parent() {
|
||||
let pattern = Path::new("/definitely/does/not/exist/*");
|
||||
assert!(expand_glob(pattern).is_empty());
|
||||
}
|
||||
}
|
||||
524
breadd/src/adapters/git.rs
Normal file
524
breadd/src/adapters/git.rs
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
//! Polls a configured set of project roots for git dirty/clean transitions
|
||||
//! and ahead/behind-upstream changes.
|
||||
//!
|
||||
//! Scope note: this adapter owns exactly two event families —
|
||||
//! `state.dirty`/`state.clean` and `ahead_behind.changed`. It deliberately
|
||||
//! does **not** emit anything for HEAD changes as such (commits, checkouts,
|
||||
//! branch switches), even though `git status`/`rev-list` are re-run against
|
||||
//! every tracked repo on every tick and will absolutely see those
|
||||
//! transitions too. A separate, hook-based path (`post-commit`/
|
||||
//! `post-checkout` invoking a CLI tool) owns `bread.git.commit.created` /
|
||||
//! `bread.git.branch.changed`; if this poller also emitted on the same
|
||||
//! transitions, both paths would fire for one real-world event. (An earlier
|
||||
//! version of this file tried to skip the subprocess check on ticks where
|
||||
//! `.git/HEAD`/`.git/index` hadn't changed mtime, as an optimization — that
|
||||
//! silently broke the primary use case, since a plain working-tree edit
|
||||
//! never touches either file. Every repo is checked every tick now; see the
|
||||
//! comment at the top of the poll loop below.)
|
||||
//!
|
||||
//! Structurally this mirrors `power.rs`: a plain `tokio::time::interval`
|
||||
//! poll loop with no external socket, looping forever and relying on the
|
||||
//! supervisor's outer `tokio::select!` (in `Manager::spawn_adapter`) against
|
||||
//! `shutdown_rx` to cancel the future — this adapter does not check
|
||||
//! `tx.is_closed()` itself, and channel-send failures are propagated with
|
||||
//! `?` exactly as `power.rs` does.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, Semaphore};
|
||||
use tokio::time::{interval, Duration};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
/// Default poll interval: frequent enough to feel responsive for a dirty/
|
||||
/// clean or ahead/behind indicator, infrequent enough that idle repos cost
|
||||
/// nothing beyond a couple of `stat(2)` calls per tick.
|
||||
const DEFAULT_POLL_INTERVAL_SECS: u64 = 2;
|
||||
|
||||
/// Concurrency cap: caps how many `git` subprocesses may be running at once
|
||||
/// across all repos that changed in a given tick. A `Semaphore` is used
|
||||
/// (rather than fully sequential processing) because typical dev machines
|
||||
/// have many idle repos under e.g. `~/Projects/*`, and processing a burst of
|
||||
/// simultaneously-touched repos (e.g. right after a `git fetch --all` across
|
||||
/// a monorepo forest, or a batch checkout script) one at a time would add
|
||||
/// unnecessary tail latency; 4 keeps subprocess fan-out modest without
|
||||
/// meaningfully serializing typical ticks (where usually 0-1 repos changed).
|
||||
const MAX_CONCURRENT_GIT_OPS: usize = 4;
|
||||
|
||||
/// Polls a set of project roots for git dirty/clean and ahead/behind-upstream
|
||||
/// transitions.
|
||||
///
|
||||
/// Construct with [`GitAdapter::new`], passing root path patterns such as
|
||||
/// `["~/Projects/*"]`. Patterns are expanded via [`bread_shared::expand_path`]
|
||||
/// (for a leading `~`) and, if the final path segment is a literal `*`,
|
||||
/// glob-expanded one level deep via `std::fs::read_dir` on the parent
|
||||
/// directory (no glob crate dependency; only a single trailing `*` segment
|
||||
/// is supported, matching the spec this adapter was built against).
|
||||
#[derive(Clone)]
|
||||
pub struct GitAdapter {
|
||||
root_patterns: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
}
|
||||
|
||||
impl GitAdapter {
|
||||
/// Uses [`DEFAULT_POLL_INTERVAL_SECS`].
|
||||
pub fn new(root_patterns: Vec<String>) -> Self {
|
||||
Self::with_interval(
|
||||
root_patterns,
|
||||
Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS),
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as [`GitAdapter::new`] but with an explicit poll interval —
|
||||
/// primarily for tests, but also available if a future config key wants
|
||||
/// to expose it.
|
||||
pub fn with_interval(root_patterns: Vec<String>, poll_interval: Duration) -> Self {
|
||||
Self {
|
||||
root_patterns,
|
||||
poll_interval,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for GitAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"git"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("git adapter started");
|
||||
|
||||
let mut tracks = discover_repos(&self.root_patterns);
|
||||
if tracks.is_empty() {
|
||||
debug!("git adapter: no git repositories found under configured roots");
|
||||
}
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_GIT_OPS));
|
||||
let mut ticker = interval(self.poll_interval);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
// Every tracked repo is checked every tick. An earlier version of
|
||||
// this gated the check on `.git/HEAD`/`.git/index` mtime changing
|
||||
// first, but that misses the single most common transition this
|
||||
// adapter exists to report: plain working-tree edits (creating,
|
||||
// editing, or deleting a file) make `git status --porcelain`
|
||||
// dirty without ever touching HEAD or the index, so that gate
|
||||
// silently never fired for it. Subprocess fan-out is still
|
||||
// bounded by the semaphore below, and `state.dirty`/`state.clean`/
|
||||
// `ahead_behind.changed` are only actually emitted on a real
|
||||
// transition (see the apply pass), so an unchanged repo costs a
|
||||
// `git status --porcelain` (and occasionally `rev-list`) per
|
||||
// tick, not an emitted event.
|
||||
let needs_check: Vec<usize> = (0..tracks.len()).collect();
|
||||
|
||||
// --- Check pass: bounded-concurrency git subprocesses. ---
|
||||
let mut handles = Vec::with_capacity(needs_check.len());
|
||||
for idx in needs_check {
|
||||
let repo_path = tracks[idx].path.clone();
|
||||
let permit = semaphore.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit.acquire_owned().await;
|
||||
let dirty = check_dirty(&repo_path).await;
|
||||
let ahead_behind = check_ahead_behind(&repo_path).await;
|
||||
(idx, dirty, ahead_behind)
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Apply pass: sequential, so per-repo last-known state
|
||||
// doesn't need its own lock. ---
|
||||
for handle in handles {
|
||||
let (idx, dirty_result, ahead_behind_result) = match handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
warn!("git adapter: check task failed to join: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let track = &mut tracks[idx];
|
||||
|
||||
match dirty_result {
|
||||
Ok(dirty) => {
|
||||
if track.last_dirty != Some(dirty) {
|
||||
let kind = if dirty { "state.dirty" } else { "state.clean" };
|
||||
track.last_dirty = Some(dirty);
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Git,
|
||||
kind: kind.to_string(),
|
||||
payload: json!({ "repo": track.path.to_string_lossy() }),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"git adapter: status check failed for {}: {e}",
|
||||
track.path.display()
|
||||
),
|
||||
}
|
||||
|
||||
match ahead_behind_result {
|
||||
Ok(Some((branch, ahead, behind))) => {
|
||||
if track.last_ahead_behind != Some((ahead, behind)) {
|
||||
track.last_ahead_behind = Some((ahead, behind));
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Git,
|
||||
kind: "ahead_behind.changed".to_string(),
|
||||
payload: json!({
|
||||
"repo": track.path.to_string_lossy(),
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"branch": branch,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// No upstream configured for the current branch — not an
|
||||
// error, just nothing to report for this repo this tick.
|
||||
Ok(None) => {}
|
||||
Err(e) => debug!(
|
||||
"git adapter: ahead/behind check failed for {}: {e}",
|
||||
track.path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-repo tracking state carried between poll ticks.
|
||||
struct RepoTrack {
|
||||
/// Worktree root — what gets passed to `git -C <path>` and reported in
|
||||
/// event payloads.
|
||||
path: PathBuf,
|
||||
last_dirty: Option<bool>,
|
||||
last_ahead_behind: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl RepoTrack {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
last_dirty: None,
|
||||
last_ahead_behind: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands configured root patterns and resolves each concrete root to a
|
||||
/// [`RepoTrack`] if (and only if) it's a recognizable git checkout. Roots
|
||||
/// that aren't git repos are skipped with a debug log, not an error — the
|
||||
/// configured glob is expected to sweep up non-repo directories routinely
|
||||
/// (e.g. `~/Projects/*` catching a README-only folder).
|
||||
fn discover_repos(root_patterns: &[String]) -> Vec<RepoTrack> {
|
||||
let mut tracks = Vec::new();
|
||||
for root in expand_roots(root_patterns) {
|
||||
match resolve_git_dir(&root) {
|
||||
Some(_git_dir) => tracks.push(RepoTrack::new(root)),
|
||||
None => debug!(
|
||||
"git adapter: {} is not a git repository, skipping",
|
||||
root.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
tracks
|
||||
}
|
||||
|
||||
/// Expands `~` (via `bread_shared::expand_path`) and a single trailing `*`
|
||||
/// path segment (via a one-level `std::fs::read_dir` on the parent
|
||||
/// directory — no glob crate). Patterns without a trailing `*` are used
|
||||
/// literally. Only directories are kept when expanding a `*`.
|
||||
fn expand_roots(patterns: &[String]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for pattern in patterns {
|
||||
let expanded = expand_path(pattern);
|
||||
if expanded.file_name().map(|n| n == "*").unwrap_or(false) {
|
||||
let parent = expanded.parent().unwrap_or_else(|| Path::new("."));
|
||||
match std::fs::read_dir(parent) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => debug!("git adapter: cannot glob {}: {e}", parent.display()),
|
||||
}
|
||||
} else {
|
||||
out.push(expanded);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolves `<root>/.git` to the actual git directory to use for `HEAD`/
|
||||
/// `index` stats, handling both shapes:
|
||||
/// - a plain directory (the common case) — used as-is;
|
||||
/// - a `.git` *file* containing `gitdir: <path>` (worktrees and submodules)
|
||||
/// — the pointer is read and resolved (relative to `root` if not
|
||||
/// absolute), and canonicalized on a best-effort basis.
|
||||
///
|
||||
/// Chose to fully resolve the worktree/submodule case rather than skip it,
|
||||
/// since it's a common setup (git worktrees especially) and the extra
|
||||
/// parsing is small. Returns `None` if `<root>/.git` doesn't exist or is
|
||||
/// some other unrecognized shape.
|
||||
fn resolve_git_dir(root: &Path) -> Option<PathBuf> {
|
||||
let dotgit = root.join(".git");
|
||||
let meta = std::fs::symlink_metadata(&dotgit).ok()?;
|
||||
|
||||
if meta.is_dir() {
|
||||
return Some(dotgit);
|
||||
}
|
||||
|
||||
if meta.is_file() {
|
||||
let content = std::fs::read_to_string(&dotgit).ok()?;
|
||||
let gitdir_str = parse_gitdir_file(&content)?;
|
||||
let gitdir_path = PathBuf::from(gitdir_str);
|
||||
let resolved = if gitdir_path.is_absolute() {
|
||||
gitdir_path
|
||||
} else {
|
||||
root.join(gitdir_path)
|
||||
};
|
||||
return Some(std::fs::canonicalize(&resolved).unwrap_or(resolved));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parses the contents of a worktree/submodule `.git` file, e.g.
|
||||
/// `gitdir: /home/user/Projects/repo/.git/worktrees/feature\n`, returning
|
||||
/// the path portion.
|
||||
fn parse_gitdir_file(content: &str) -> Option<&str> {
|
||||
content
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("gitdir:"))
|
||||
.map(|rest| rest.trim())
|
||||
}
|
||||
|
||||
/// Runs `git -C <repo> status --porcelain` and reports whether the repo is
|
||||
/// dirty (any output) or clean (empty output).
|
||||
async fn check_dirty(repo: &Path) -> Result<bool> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["status", "--porcelain"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to spawn git status: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"git status exited with {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(!stdout.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Runs `git -C <repo> rev-list --left-right --count HEAD...@{upstream}` and
|
||||
/// `git -C <repo> rev-parse --abbrev-ref HEAD`, returning
|
||||
/// `Some((branch, ahead, behind))`. Returns `Ok(None)` (not an error) when
|
||||
/// there's no upstream configured for the current branch, since that's the
|
||||
/// expected/common state for plenty of repos, not a failure.
|
||||
async fn check_ahead_behind(repo: &Path) -> Result<Option<(String, u32, u32)>> {
|
||||
let rev_list = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to spawn git rev-list: {e}"))?;
|
||||
|
||||
if !rev_list.status.success() {
|
||||
// Most commonly: "no upstream configured for branch". Treat any
|
||||
// non-zero exit here as "nothing to report", not an adapter error.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&rev_list.stdout);
|
||||
let Some((ahead, behind)) = parse_ahead_behind(&stdout) else {
|
||||
return Err(anyhow!("unexpected git rev-list output: {stdout:?}"));
|
||||
};
|
||||
|
||||
let branch_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to spawn git rev-parse: {e}"))?;
|
||||
|
||||
let branch = if branch_output.status.success() {
|
||||
String::from_utf8_lossy(&branch_output.stdout)
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
"HEAD".to_string()
|
||||
};
|
||||
|
||||
Ok(Some((branch, ahead, behind)))
|
||||
}
|
||||
|
||||
/// Parses `git rev-list --left-right --count HEAD...@{upstream}` output.
|
||||
///
|
||||
/// With `HEAD...@{upstream}`, `HEAD` is the left side and `@{upstream}` is
|
||||
/// the right side, so `--left-right --count` prints "<left-only-count>
|
||||
/// <right-only-count>" — i.e. "<ahead> <behind>", in that order,
|
||||
/// whitespace-separated (typically a single tab).
|
||||
fn parse_ahead_behind(output: &str) -> Option<(u32, u32)> {
|
||||
let mut parts = output.split_whitespace();
|
||||
let ahead = parts.next()?.parse::<u32>().ok()?;
|
||||
let behind = parts.next()?.parse::<u32>().ok()?;
|
||||
Some((ahead, behind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- parse_ahead_behind ---
|
||||
|
||||
#[test]
|
||||
fn parse_ahead_behind_parses_tab_separated_counts() {
|
||||
assert_eq!(parse_ahead_behind("3\t2\n"), Some((3, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ahead_behind_parses_space_separated_counts() {
|
||||
assert_eq!(parse_ahead_behind("0 5"), Some((0, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ahead_behind_handles_zero_zero() {
|
||||
assert_eq!(parse_ahead_behind("0\t0"), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ahead_behind_rejects_malformed_output() {
|
||||
assert_eq!(parse_ahead_behind(""), None);
|
||||
assert_eq!(parse_ahead_behind("only-one"), None);
|
||||
assert_eq!(parse_ahead_behind("not a number\t2"), None);
|
||||
}
|
||||
|
||||
// --- parse_gitdir_file ---
|
||||
|
||||
#[test]
|
||||
fn parse_gitdir_file_extracts_path() {
|
||||
assert_eq!(
|
||||
parse_gitdir_file("gitdir: /home/user/repo/.git/worktrees/feature\n"),
|
||||
Some("/home/user/repo/.git/worktrees/feature")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gitdir_file_trims_whitespace() {
|
||||
assert_eq!(
|
||||
parse_gitdir_file("gitdir: ../.git/modules/sub \n"),
|
||||
Some("../.git/modules/sub")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gitdir_file_rejects_unrecognized_content() {
|
||||
assert_eq!(parse_gitdir_file("not a gitdir pointer\n"), None);
|
||||
assert_eq!(parse_gitdir_file(""), None);
|
||||
}
|
||||
|
||||
// --- expand_roots ---
|
||||
|
||||
#[test]
|
||||
fn expand_roots_uses_literal_path_without_trailing_star() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo = dir.path().join("myrepo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
|
||||
let pattern = repo.to_string_lossy().to_string();
|
||||
let roots = expand_roots(&[pattern]);
|
||||
|
||||
assert_eq!(roots, vec![repo]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_roots_globs_single_trailing_star() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo_a = dir.path().join("repo-a");
|
||||
let repo_b = dir.path().join("repo-b");
|
||||
std::fs::create_dir(&repo_a).unwrap();
|
||||
std::fs::create_dir(&repo_b).unwrap();
|
||||
// A stray file alongside the directories must not be treated as a root.
|
||||
std::fs::write(dir.path().join("not-a-dir.txt"), b"hi").unwrap();
|
||||
|
||||
let pattern = format!("{}/*", dir.path().to_string_lossy());
|
||||
let mut roots = expand_roots(&[pattern]);
|
||||
roots.sort();
|
||||
|
||||
let mut expected = vec![repo_a, repo_b];
|
||||
expected.sort();
|
||||
assert_eq!(roots, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_roots_skips_unreadable_glob_parent_without_panicking() {
|
||||
let pattern = "/definitely/does/not/exist/*".to_string();
|
||||
let roots = expand_roots(&[pattern]);
|
||||
assert!(roots.is_empty());
|
||||
}
|
||||
|
||||
// --- resolve_git_dir ---
|
||||
|
||||
#[test]
|
||||
fn resolve_git_dir_finds_standard_directory_git() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let repo = dir.path().join("repo");
|
||||
let dotgit = repo.join(".git");
|
||||
std::fs::create_dir_all(&dotgit).unwrap();
|
||||
|
||||
assert_eq!(resolve_git_dir(&repo), Some(dotgit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_git_dir_resolves_worktree_style_git_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let main_repo_gitdir = dir.path().join("main-repo").join(".git");
|
||||
std::fs::create_dir_all(&main_repo_gitdir).unwrap();
|
||||
|
||||
let worktree = dir.path().join("worktree-checkout");
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
std::fs::write(
|
||||
worktree.join(".git"),
|
||||
format!("gitdir: {}\n", main_repo_gitdir.to_string_lossy()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_git_dir(&worktree).expect("should resolve worktree gitdir");
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&resolved).unwrap(),
|
||||
std::fs::canonicalize(&main_repo_gitdir).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_git_dir_returns_none_for_non_repo() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let not_a_repo = dir.path().join("just-a-folder");
|
||||
std::fs::create_dir(¬_a_repo).unwrap();
|
||||
|
||||
assert_eq!(resolve_git_dir(¬_a_repo), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,15 @@ use crate::core::config::Config;
|
|||
use crate::core::supervisor::spawn_supervised;
|
||||
|
||||
pub mod bluetooth;
|
||||
pub mod filesystem;
|
||||
pub mod git;
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod network_rtnetlink;
|
||||
pub mod podman;
|
||||
pub mod power;
|
||||
pub mod power_upower;
|
||||
pub mod systemd;
|
||||
pub mod udev;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -104,6 +108,32 @@ impl Manager {
|
|||
}
|
||||
}
|
||||
|
||||
// Filesystem/git/systemd all default to "no roots/units configured",
|
||||
// in which case they'd have nothing to do — skip spawning them
|
||||
// entirely rather than running an adapter that can never emit.
|
||||
if self.config.adapters.filesystem.enabled
|
||||
&& !self.config.adapters.filesystem.roots.is_empty()
|
||||
{
|
||||
let adapter =
|
||||
filesystem::FilesystemAdapter::new(self.config.adapters.filesystem.roots.clone());
|
||||
adapter.enumerate_existing(&self.raw_tx).await;
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.git.enabled && !self.config.adapters.git.roots.is_empty() {
|
||||
let adapter = git::GitAdapter::new(self.config.adapters.git.roots.clone());
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.systemd.enabled && !self.config.adapters.systemd.units.is_empty() {
|
||||
let adapter = systemd::SystemdAdapter::new(self.config.adapters.systemd.units.clone());
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.podman.enabled {
|
||||
self.spawn_adapter(podman::PodmanAdapter::new());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
304
breadd/src/adapters/podman.rs
Normal file
304
breadd/src/adapters/podman.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
/// Watches `podman events --format json` for container lifecycle changes and
|
||||
/// forwards them as [`RawEvent`]s.
|
||||
///
|
||||
/// This is the first adapter in the codebase wrapping a child process rather
|
||||
/// than a socket/D-Bus/netlink connection, so the process-lifecycle handling
|
||||
/// here (kill-on-drop, treating any exit as an error so the supervisor
|
||||
/// retries) is bespoke rather than following an existing pattern.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PodmanAdapter;
|
||||
|
||||
impl PodmanAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PodmanAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for PodmanAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"podman"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("podman adapter starting");
|
||||
|
||||
// kill_on_drop(true) ensures that if this future is cancelled (e.g. the
|
||||
// supervisor tears the adapter down on daemon shutdown, or `tokio::select!`
|
||||
// in Manager::spawn_adapter races it against the shutdown signal), the
|
||||
// `podman events` child is killed rather than left running as an orphan
|
||||
// with its stdout pipe silently discarded.
|
||||
let mut child = match Command::new("podman")
|
||||
.args(["events", "--format", "json"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
info!("podman binary not found; will retry on backoff");
|
||||
return Err(anyhow!("podman binary not found: {e}"));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!("failed to spawn podman events: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("podman events: child had no stdout"))?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
loop {
|
||||
let line = match lines.next_line().await {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => {
|
||||
// EOF: the child's stdout closed, meaning the process exited.
|
||||
// Never surface this as Ok(()) — the supervisor treats a clean
|
||||
// `run()` return as "stop forever," but a dead `podman events`
|
||||
// process is exactly the kind of thing we want retried.
|
||||
let _ = child.kill().await;
|
||||
return Err(anyhow!("podman events exited"));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(anyhow!("podman events read error: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
debug!("podman events: skipping unparseable line: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((kind, payload)) = map_podman_event(&value) {
|
||||
if tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Podman,
|
||||
kind,
|
||||
payload,
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
return Err(anyhow!("podman adapter: downstream channel closed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a single `podman events --format json` line and maps it to a
|
||||
/// `(kind, payload)` pair for `RawEvent`, or `None` if the event should be
|
||||
/// ignored.
|
||||
///
|
||||
/// Only `Type == "container"` events are handled. Action mapping:
|
||||
/// - `start` -> `container.started`
|
||||
/// - `died` -> `container.stopped`
|
||||
/// - `stop` / `remove` -> ignored (see de-dup note below)
|
||||
/// - `health_status` -> `container.health_status`
|
||||
/// - anything else -> ignored
|
||||
///
|
||||
/// De-dup choice: podman commonly emits `died` followed by `stop` (and
|
||||
/// sometimes `remove`) for a single container exit. Emitting on all three
|
||||
/// would fire `container.stopped` multiple times for one real-world
|
||||
/// transition, which is worse for Lua module authors (who'd need to
|
||||
/// de-duplicate themselves) than missing the rare case where a container is
|
||||
/// stopped without ever having been in a running state that produced `died`.
|
||||
/// `died` fires in the overwhelmingly common paths (normal exit, kill, crash),
|
||||
/// so it's used as the sole trigger for `container.stopped` and `stop`/`remove`
|
||||
/// are dropped.
|
||||
fn map_podman_event(value: &Value) -> Option<(String, Value)> {
|
||||
let event_type = value.get("Type").and_then(|v| v.as_str())?;
|
||||
if event_type != "container" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let action = value.get("Action").and_then(|v| v.as_str())?;
|
||||
|
||||
let actor = value.get("Actor");
|
||||
let id = actor
|
||||
.and_then(|a| a.get("ID"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let attributes = actor.and_then(|a| a.get("Attributes"));
|
||||
let name = attributes
|
||||
.and_then(|a| a.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let image = attributes
|
||||
.and_then(|a| a.get("image"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
match action {
|
||||
"start" => Some((
|
||||
"container.started".to_string(),
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"image": image,
|
||||
}),
|
||||
)),
|
||||
"died" => Some((
|
||||
"container.stopped".to_string(),
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
}),
|
||||
)),
|
||||
"stop" | "remove" => {
|
||||
// Intentionally ignored — see de-dup note on map_podman_event above.
|
||||
None
|
||||
}
|
||||
"health_status" => {
|
||||
let health = attributes
|
||||
.and_then(|a| a.get("health_status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
Some((
|
||||
"container.health_status".to_string(),
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"health": health,
|
||||
}),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn container_event(action: &str, extra_attrs: Value) -> Value {
|
||||
let mut attributes = json!({
|
||||
"name": "my-container",
|
||||
"image": "docker.io/library/nginx:latest",
|
||||
});
|
||||
if let (Some(attrs_obj), Some(extra_obj)) =
|
||||
(attributes.as_object_mut(), extra_attrs.as_object())
|
||||
{
|
||||
for (k, v) in extra_obj {
|
||||
attrs_obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"Type": "container",
|
||||
"Action": action,
|
||||
"Actor": {
|
||||
"ID": "abc123fullid",
|
||||
"Attributes": attributes,
|
||||
},
|
||||
"Status": action,
|
||||
"time": 1_700_000_000,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_start_event() {
|
||||
let event = container_event("start", json!({}));
|
||||
let (kind, payload) = map_podman_event(&event).expect("should map start event");
|
||||
assert_eq!(kind, "container.started");
|
||||
assert_eq!(payload["id"], "abc123fullid");
|
||||
assert_eq!(payload["name"], "my-container");
|
||||
assert_eq!(payload["image"], "docker.io/library/nginx:latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_died_event() {
|
||||
let event = container_event("died", json!({}));
|
||||
let (kind, payload) = map_podman_event(&event).expect("should map died event");
|
||||
assert_eq!(kind, "container.stopped");
|
||||
assert_eq!(payload["id"], "abc123fullid");
|
||||
assert_eq!(payload["name"], "my-container");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_stop_event_to_avoid_double_emit_with_died() {
|
||||
let event = container_event("stop", json!({}));
|
||||
assert!(map_podman_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_remove_event() {
|
||||
let event = container_event("remove", json!({}));
|
||||
assert!(map_podman_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_health_status_event() {
|
||||
let event = container_event("health_status", json!({ "health_status": "healthy" }));
|
||||
let (kind, payload) = map_podman_event(&event).expect("should map health_status event");
|
||||
assert_eq!(kind, "container.health_status");
|
||||
assert_eq!(payload["id"], "abc123fullid");
|
||||
assert_eq!(payload["name"], "my-container");
|
||||
assert_eq!(payload["health"], "healthy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_action() {
|
||||
let event = container_event("exec_die", json!({}));
|
||||
assert!(map_podman_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_container_type() {
|
||||
let event = json!({
|
||||
"Type": "network",
|
||||
"Action": "start",
|
||||
"Actor": { "ID": "netid", "Attributes": {} },
|
||||
});
|
||||
assert!(map_podman_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_fall_back_to_unknown() {
|
||||
let event = json!({
|
||||
"Type": "container",
|
||||
"Action": "start",
|
||||
"Actor": { "ID": "onlyid" },
|
||||
});
|
||||
let (kind, payload) = map_podman_event(&event).expect("should still map");
|
||||
assert_eq!(kind, "container.started");
|
||||
assert_eq!(payload["id"], "onlyid");
|
||||
assert_eq!(payload["name"], "unknown");
|
||||
assert_eq!(payload["image"], "unknown");
|
||||
}
|
||||
}
|
||||
293
breadd/src/adapters/systemd.rs
Normal file
293
breadd/src/adapters/systemd.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
use zbus::{Connection, Message, MessageStream};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
const MANAGER_DEST: &str = "org.freedesktop.systemd1";
|
||||
const MANAGER_PATH: &str = "/org/freedesktop/systemd1";
|
||||
const MANAGER_IFACE: &str = "org.freedesktop.systemd1.Manager";
|
||||
const UNIT_IFACE: &str = "org.freedesktop.systemd1.Unit";
|
||||
const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties";
|
||||
|
||||
/// Watches an allowlist of `systemd --user` units on the session bus and emits
|
||||
/// start/stop/failure lifecycle events.
|
||||
///
|
||||
/// Only units named in the allowlist are tracked — subscribing to every user
|
||||
/// unit's transitions is noisy (timers, transient scopes, etc. fire
|
||||
/// constantly), so we filter down to what the user's config explicitly named.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SystemdAdapter {
|
||||
units: Vec<String>,
|
||||
}
|
||||
|
||||
impl SystemdAdapter {
|
||||
pub fn new(units: Vec<String>) -> Self {
|
||||
Self { units }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for SystemdAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"systemd"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("systemd adapter starting");
|
||||
|
||||
let conn = Connection::session()
|
||||
.await
|
||||
.map_err(|e| anyhow!("systemd session bus unavailable: {e}"))?;
|
||||
|
||||
// Job/property signals aren't delivered until a client asks the manager
|
||||
// to start tracking them.
|
||||
conn.call_method(
|
||||
Some(MANAGER_DEST),
|
||||
MANAGER_PATH,
|
||||
Some(MANAGER_IFACE),
|
||||
"Subscribe",
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("systemd Manager.Subscribe failed: {e}"))?;
|
||||
|
||||
// Resolve each allowlisted unit name to its object path up front, so
|
||||
// PropertiesChanged messages (which arrive addressed by path, not name)
|
||||
// can be matched back to a unit without a lookup on every message. A
|
||||
// unit that fails to resolve (not currently loaded, typo'd name, etc.)
|
||||
// is skipped rather than failing the whole adapter — it simply won't be
|
||||
// watched for `unit.failed` until the adapter restarts.
|
||||
let mut path_to_unit: HashMap<String, String> = HashMap::new();
|
||||
for unit in &self.units {
|
||||
match get_unit_path(&conn, unit).await {
|
||||
Ok(path) => {
|
||||
debug!("systemd resolved unit '{unit}' -> {path}");
|
||||
path_to_unit.insert(path, unit.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("systemd: could not resolve unit '{unit}' (not loaded?): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = MessageStream::from(&conn);
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(message) => {
|
||||
if let Some(event) =
|
||||
handle_message(&conn, &message, &self.units, &path_to_unit).await
|
||||
{
|
||||
if tx.send(event).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => debug!("systemd stream error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a unit name to its `/org/freedesktop/systemd1/unit/...` object path
|
||||
/// via `Manager.GetUnit`.
|
||||
async fn get_unit_path(conn: &Connection, unit_name: &str) -> Result<String> {
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some(MANAGER_DEST),
|
||||
MANAGER_PATH,
|
||||
Some(MANAGER_IFACE),
|
||||
"GetUnit",
|
||||
&(unit_name,),
|
||||
)
|
||||
.await?;
|
||||
let path: OwnedObjectPath = msg.body()?;
|
||||
Ok(path.as_str().to_string())
|
||||
}
|
||||
|
||||
/// Read the current `ActiveState` property (`"active"`, `"inactive"`,
|
||||
/// `"failed"`, ...) off a resolved unit object path.
|
||||
async fn query_active_state(conn: &Connection, unit_path: &str) -> Option<String> {
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some(MANAGER_DEST),
|
||||
unit_path,
|
||||
Some(PROPS_IFACE),
|
||||
"Get",
|
||||
&(UNIT_IFACE, "ActiveState"),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let value: OwnedValue = msg.body().ok()?;
|
||||
serde_json::to_value(&value)
|
||||
.ok()?
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
conn: &Connection,
|
||||
message: &Message,
|
||||
units: &[String],
|
||||
path_to_unit: &HashMap<String, String>,
|
||||
) -> Option<RawEvent> {
|
||||
let header = message.header().ok()?;
|
||||
let interface = header.interface().ok()??.as_str().to_string();
|
||||
let member = header.member().ok()??.as_str().to_string();
|
||||
let path = header
|
||||
.path()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Start/stop: a job affecting one of our allowlisted units has completed.
|
||||
// JobRemoved alone doesn't say whether the job was a start or a stop (or
|
||||
// what it settled on), so we re-query ActiveState once the job is done to
|
||||
// find out what actually happened. "failed" is deliberately left to the
|
||||
// PropertiesChanged branch below, so each transition is only emitted once.
|
||||
if interface == MANAGER_IFACE && member == "JobRemoved" {
|
||||
let (_id, _job_path, unit_name, _result): (u32, OwnedObjectPath, String, String) =
|
||||
message.body().ok()?;
|
||||
if !units.iter().any(|u| u == &unit_name) {
|
||||
return None;
|
||||
}
|
||||
let unit_path = get_unit_path(conn, &unit_name).await.ok()?;
|
||||
let state = query_active_state(conn, &unit_path).await?;
|
||||
let kind = active_state_to_kind(&state)?;
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Systemd,
|
||||
kind: kind.to_string(),
|
||||
payload: json!({ "unit": unit_name }),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// Failed: ActiveState flipped to "failed" on a unit we resolved at startup.
|
||||
// This covers unit failures that occur without an explicit job completing
|
||||
// from our point of view (e.g. a crash detected asynchronously).
|
||||
if interface == PROPS_IFACE && member == "PropertiesChanged" {
|
||||
let unit_name = path_to_unit.get(&path)?;
|
||||
let (iface, changed, _invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||
message.body().ok()?;
|
||||
if iface != UNIT_IFACE {
|
||||
return None;
|
||||
}
|
||||
let changed_json = serde_json::to_value(&changed).ok()?;
|
||||
if !is_failed_transition(&changed_json) {
|
||||
return None;
|
||||
}
|
||||
let result = failure_result(&changed_json);
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Systemd,
|
||||
kind: "unit.failed".to_string(),
|
||||
payload: json!({ "unit": unit_name, "result": result }),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Map a unit's `ActiveState` to the lifecycle event kind it represents.
|
||||
/// `"failed"` is intentionally excluded — that transition is reported via the
|
||||
/// dedicated `PropertiesChanged` branch instead, so it isn't double-reported
|
||||
/// once here and once there. Intermediate states (`activating`, `deactivating`,
|
||||
/// `reloading`) aren't a resting state yet, so they're ignored too.
|
||||
fn active_state_to_kind(state: &str) -> Option<&'static str> {
|
||||
match state {
|
||||
"active" => Some("unit.started"),
|
||||
"inactive" | "dead" => Some("unit.stopped"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a decoded `PropertiesChanged` payload represents a transition into
|
||||
/// the `failed` active state.
|
||||
fn is_failed_transition(changed: &serde_json::Value) -> bool {
|
||||
changed
|
||||
.get("ActiveState")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == "failed")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extract the `Result` property (e.g. `"exit-code"`, `"timeout"`) from a
|
||||
/// decoded `PropertiesChanged` payload, if it was included in this batch.
|
||||
fn failure_result(changed: &serde_json::Value) -> Option<String> {
|
||||
changed
|
||||
.get("Result")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn active_state_to_kind_maps_active_to_started() {
|
||||
assert_eq!(active_state_to_kind("active"), Some("unit.started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_state_to_kind_maps_inactive_and_dead_to_stopped() {
|
||||
assert_eq!(active_state_to_kind("inactive"), Some("unit.stopped"));
|
||||
assert_eq!(active_state_to_kind("dead"), Some("unit.stopped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_state_to_kind_excludes_failed() {
|
||||
// Failed transitions are reported via PropertiesChanged instead, so
|
||||
// JobRemoved handling must not also emit for this state.
|
||||
assert_eq!(active_state_to_kind("failed"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_state_to_kind_ignores_transitional_states() {
|
||||
assert_eq!(active_state_to_kind("activating"), None);
|
||||
assert_eq!(active_state_to_kind("deactivating"), None);
|
||||
assert_eq!(active_state_to_kind("reloading"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_failed_transition_detects_failed_active_state() {
|
||||
let changed = json!({ "ActiveState": "failed", "SubState": "failed" });
|
||||
assert!(is_failed_transition(&changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_failed_transition_ignores_other_active_states() {
|
||||
let changed = json!({ "ActiveState": "active" });
|
||||
assert!(!is_failed_transition(&changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_failed_transition_ignores_unrelated_property_changes() {
|
||||
// A PropertiesChanged batch that doesn't touch ActiveState at all
|
||||
// (e.g. just MemoryCurrent ticking) must not be treated as a failure.
|
||||
let changed = json!({ "MemoryCurrent": 12345 });
|
||||
assert!(!is_failed_transition(&changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_result_extracts_reason_when_present() {
|
||||
let changed = json!({ "ActiveState": "failed", "Result": "exit-code" });
|
||||
assert_eq!(failure_result(&changed), Some("exit-code".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_result_is_none_when_absent() {
|
||||
let changed = json!({ "ActiveState": "failed" });
|
||||
assert_eq!(failure_result(&changed), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,14 @@ pub struct AdaptersConfig {
|
|||
pub network: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub bluetooth: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub filesystem: RootsConfig,
|
||||
#[serde(default)]
|
||||
pub systemd: SystemdConfig,
|
||||
#[serde(default)]
|
||||
pub podman: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub git: RootsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -81,6 +89,30 @@ pub struct PowerConfig {
|
|||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Shared shape for adapters scoped to a list of project-root glob patterns
|
||||
/// (e.g. `~/Projects/*`) — used by both the filesystem and git adapters.
|
||||
/// `roots` defaults to empty: these adapters do nothing until the user opts
|
||||
/// in with actual paths, since there's no universally-safe default directory
|
||||
/// to watch.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RootsConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub roots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SystemdConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Allowlist of `systemd --user` unit names to watch. Empty by default —
|
||||
/// subscribing to every user unit's transitions is noisy, so nothing is
|
||||
/// watched until the user names specific units.
|
||||
#[serde(default)]
|
||||
pub units: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EventsConfig {
|
||||
#[serde(default = "default_dedup_window")]
|
||||
|
|
@ -150,6 +182,24 @@ impl Default for PowerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for RootsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
roots: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemdConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
units: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -206,7 +256,20 @@ fn config_path() -> PathBuf {
|
|||
expand_home("~/.config/bread/breadd.toml")
|
||||
}
|
||||
|
||||
/// Expands a leading `~/`. `~/.config/...` paths specifically prefer
|
||||
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
|
||||
/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point`
|
||||
/// / `lua.module_path` values (`"~/.config/bread/init.lua"` and
|
||||
/// `"~/.config/bread/modules"`) would silently ignore `XDG_CONFIG_HOME` even
|
||||
/// though the config file that sets them was found via that same variable,
|
||||
/// which is exactly the kind of inconsistency that made init.lua/module
|
||||
/// loading silently no-op for a XDG_CONFIG_HOME-only test setup.
|
||||
fn expand_home(input: &str) -> PathBuf {
|
||||
if let Some(stripped) = input.strip_prefix("~/.config/") {
|
||||
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg_config).join(stripped);
|
||||
}
|
||||
}
|
||||
if let Some(stripped) = input.strip_prefix("~/") {
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return Path::new(&home).join(stripped);
|
||||
|
|
@ -451,8 +514,9 @@ log_level = "trace"
|
|||
|
||||
#[test]
|
||||
fn lua_entry_point_and_module_path_expand_tilde() {
|
||||
let _g = EnvGuard::new(&["HOME"]);
|
||||
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.lua_entry_point(),
|
||||
|
|
@ -464,6 +528,27 @@ log_level = "trace"
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_and_module_path_prefer_xdg_config_home_when_set() {
|
||||
// config_path() (finding breadd.toml itself) already prefers
|
||||
// XDG_CONFIG_HOME over HOME; the `~/.config/...` defaults for
|
||||
// entry_point/module_path must resolve consistently with it, or a
|
||||
// XDG_CONFIG_HOME-only setup (no matching $HOME/.config layout)
|
||||
// silently fails to find its own init.lua/modules.
|
||||
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.lua_entry_point(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/init.lua")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.lua_module_path(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/modules")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_returns_absolute_path_unchanged() {
|
||||
let mut cfg = Config::default();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
|
|
@ -26,16 +26,23 @@ impl EventNormalizer {
|
|||
}
|
||||
|
||||
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut out = match raw.source {
|
||||
let mut out = match &raw.source {
|
||||
AdapterSource::Udev => self.normalize_udev(raw),
|
||||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||
AdapterSource::Power => self.normalize_power(raw),
|
||||
AdapterSource::Network => self.normalize_network(raw),
|
||||
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
|
||||
AdapterSource::Terminal => self.normalize_terminal(raw),
|
||||
AdapterSource::Git => self.normalize_git(raw),
|
||||
AdapterSource::Filesystem => self.normalize_filesystem(raw),
|
||||
AdapterSource::Systemd => self.normalize_systemd(raw),
|
||||
AdapterSource::Podman => self.normalize_podman(raw),
|
||||
AdapterSource::Remote => self.normalize_remote(raw),
|
||||
AdapterSource::App(_) => self.normalize_app(raw),
|
||||
AdapterSource::System => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
};
|
||||
|
|
@ -418,7 +425,105 @@ impl EventNormalizer {
|
|||
}]
|
||||
}
|
||||
|
||||
// Adapter contracts: each of these adapters emits `RawEvent.kind` already
|
||||
// namespaced for its family (e.g. filesystem emits "file.changed",
|
||||
// "detected", "build_artifact.created"), so normalization here is just a
|
||||
// `bread.<family>.` prefix — except systemd (`unit.*` -> `service.*`) and
|
||||
// podman's health-status rename, which need a small rewrite.
|
||||
fn normalize_terminal(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.terminal.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_remote(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.remote.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_git(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.git.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_filesystem(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.project.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_systemd(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
// Adapter emits "unit.started"/"unit.stopped"/"unit.failed"; the public
|
||||
// namespace is `service.*`, not `unit.*`.
|
||||
let suffix = raw.kind.strip_prefix("unit.").unwrap_or(raw.kind.as_str());
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.service.{suffix}"),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_podman(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
// Adapter emits "container.started"/"container.stopped"/"container.health_status";
|
||||
// the public name for the latter is `container.health.changed`.
|
||||
let event = if raw.kind == "container.health_status" {
|
||||
"bread.container.health.changed".to_string()
|
||||
} else {
|
||||
format!("bread.{}", raw.kind)
|
||||
};
|
||||
vec![BreadEvent {
|
||||
event,
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Sibling `bread*` app events. Unlike the other sources, `raw.kind`
|
||||
/// already carries the full dotted event name (the IPC boundary builds
|
||||
/// it that way before construction), so this is validate-and-wrap, not
|
||||
/// a transform. The namespace check is defense in depth — the IPC layer
|
||||
/// already validates before constructing the `RawEvent` — so a
|
||||
/// malformed event here is dropped silently rather than treated as an
|
||||
/// adapter failure.
|
||||
fn normalize_app(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let AdapterSource::App(app) = &raw.source else {
|
||||
return vec![];
|
||||
};
|
||||
if !validate_app_namespace(app, &raw.kind) {
|
||||
return vec![];
|
||||
}
|
||||
vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn accept(&self, event: &BreadEvent) -> bool {
|
||||
// Terminal commands legitimately repeat (running the same command twice
|
||||
// in quick succession); the dedup window exists for noisy hardware
|
||||
// signals, not user-initiated terminal activity, so exempt it.
|
||||
if matches!(&event.source, AdapterSource::Terminal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let key = format!("{}:{}", event.event, event.data);
|
||||
let now = event.timestamp;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub struct RuntimeState {
|
|||
pub power: PowerState,
|
||||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
pub workflows: Vec<WorkflowStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -128,3 +129,34 @@ pub enum ModuleLoadState {
|
|||
Degraded,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Introspectable state for a `bread.workflow` instance, surfaced via the
|
||||
/// `workflows.list` IPC method. One entry per workflow *name* — starting a
|
||||
/// workflow with a name that's already running replaces its entry (this is
|
||||
/// a live-status registry, not a run history).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowStatus {
|
||||
pub name: String,
|
||||
pub state: WorkflowState,
|
||||
/// The most recent `bread.workflow.step(label)` call inside the body,
|
||||
/// if any.
|
||||
pub step: Option<String>,
|
||||
pub started_at: u64,
|
||||
pub updated_at: u64,
|
||||
/// Set when `state` is `Failed`: the captured Lua error message.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkflowState {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
/// The `opts.deadline` timer fired before the workflow reached a
|
||||
/// terminal state. Note: since a suspended coroutine isn't forcibly
|
||||
/// killed, it's possible (rare) for a workflow to still complete after
|
||||
/// this and overwrite the status again — this is a status marker, not
|
||||
/// a hard cancellation.
|
||||
TimedOut,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use std::sync::Arc;
|
|||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
|
||||
use bread_shared::apps::{is_known_app, validate_app_namespace};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
|
@ -20,6 +21,14 @@ use crate::adapters::AdapterStatus;
|
|||
use crate::core::state_engine::StateHandle;
|
||||
use crate::lua::RuntimeHandle;
|
||||
|
||||
/// The Bread Automation API version (Lua API surface + IPC methods + event
|
||||
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
|
||||
/// Stability & Versioning" section. Bump the minor version when adding
|
||||
/// something new-but-additive (a binding, an event, an IPC param); bump the
|
||||
/// major version only for a breaking change, which should not happen inside
|
||||
/// this daemon's v1 lifetime per that section's stated policy.
|
||||
const API_VERSION: &str = "1.2.0";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
socket_path: PathBuf,
|
||||
|
|
@ -27,6 +36,7 @@ pub struct Server {
|
|||
event_tx: broadcast::Sender<BreadEvent>,
|
||||
lua_runtime: RuntimeHandle,
|
||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||
raw_tx: mpsc::Sender<RawEvent>,
|
||||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
subscription_count: Arc<AtomicU64>,
|
||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
|
|
@ -61,6 +71,7 @@ impl Server {
|
|||
event_tx: broadcast::Sender<BreadEvent>,
|
||||
lua_runtime: RuntimeHandle,
|
||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||
raw_tx: mpsc::Sender<RawEvent>,
|
||||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
subscription_count: Arc<AtomicU64>,
|
||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
|
|
@ -71,6 +82,7 @@ impl Server {
|
|||
event_tx,
|
||||
lua_runtime,
|
||||
emit_tx,
|
||||
raw_tx,
|
||||
adapter_status,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
|
|
@ -141,9 +153,7 @@ impl Server {
|
|||
error: Some(format!("parse error: {e}")),
|
||||
};
|
||||
write_half
|
||||
.write_all(
|
||||
format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes(),
|
||||
)
|
||||
.write_all(format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes())
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -208,6 +218,10 @@ impl Server {
|
|||
let full = self.state_handle.state_dump().await;
|
||||
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
|
||||
}
|
||||
"workflows.list" => {
|
||||
let full = self.state_handle.state_dump().await;
|
||||
Ok(full.get("workflows").cloned().unwrap_or_else(|| json!([])))
|
||||
}
|
||||
"modules.reload" => {
|
||||
let started = Instant::now();
|
||||
if let Err(err) = self.lua_runtime.reload().await {
|
||||
|
|
@ -252,18 +266,71 @@ impl Server {
|
|||
Ok(json!({ "active": name }))
|
||||
}
|
||||
"emit" => {
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||
if self
|
||||
.emit_tx
|
||||
.send(BreadEvent::new(event, AdapterSource::System, data))
|
||||
.is_err()
|
||||
{
|
||||
return Err((id, "emit channel closed".to_string()));
|
||||
|
||||
// Sourced emit: hook-originated events (shell/git/ssh) and
|
||||
// sibling bread* app events both go through the same
|
||||
// RawEvent -> normalizer pipeline as in-process adapters,
|
||||
// instead of being tagged System. `source` is restricted to
|
||||
// the fixed hook-fed set plus registered app ids — allowing
|
||||
// arbitrary sources here would let any socket client spoof
|
||||
// e.g. a power/bluetooth event, or another app's namespace.
|
||||
if let Some(source_str) = req.params.get("source").and_then(Value::as_str) {
|
||||
let source = match source_str {
|
||||
"terminal" => AdapterSource::Terminal,
|
||||
"git" => AdapterSource::Git,
|
||||
"remote" => AdapterSource::Remote,
|
||||
other if is_known_app(other) => AdapterSource::App(other.to_string()),
|
||||
other => {
|
||||
return Err((
|
||||
id,
|
||||
format!("source '{other}' is not externally injectable"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(kind) = req.params.get("kind").and_then(Value::as_str) else {
|
||||
return Err((id, "missing kind for sourced emit".to_string()));
|
||||
};
|
||||
// For a sibling-app source, `kind` is the full dotted event
|
||||
// name (e.g. "bread.clip.copied"), not a bare suffix — it
|
||||
// must live inside that app's own namespace.
|
||||
if let AdapterSource::App(app) = &source {
|
||||
if !validate_app_namespace(app, kind) {
|
||||
return Err((
|
||||
id,
|
||||
format!(
|
||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.raw_tx
|
||||
.send(RawEvent {
|
||||
source,
|
||||
kind: kind.to_string(),
|
||||
payload: data,
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err((id, "raw channel closed".to_string()));
|
||||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
} else {
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
if self
|
||||
.emit_tx
|
||||
.send(BreadEvent::new(event, AdapterSource::System, data))
|
||||
.is_err()
|
||||
{
|
||||
return Err((id, "emit channel closed".to_string()));
|
||||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
}
|
||||
"health" => {
|
||||
let uptime_ms = self.started_at.elapsed().as_millis();
|
||||
|
|
@ -278,6 +345,7 @@ impl Server {
|
|||
"ok": true,
|
||||
"pid": self.pid,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"api_version": API_VERSION,
|
||||
"uptime_ms": uptime_ms,
|
||||
"socket": self.socket_path.to_string_lossy(),
|
||||
"adapters": adapters,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ use tracing::{error, info, warn};
|
|||
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
|
||||
use crate::core::state_engine::StateHandle;
|
||||
use crate::core::subscriptions::SubscriptionId;
|
||||
use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState};
|
||||
use crate::core::types::{
|
||||
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
|
||||
};
|
||||
use bread_shared::now_unix_ms;
|
||||
|
||||
pub enum LuaMessage {
|
||||
|
|
@ -1006,6 +1008,7 @@ impl LuaEngine {
|
|||
globals.set("bread", bread)?;
|
||||
self.install_require_loader()?;
|
||||
self.install_wait_helper()?;
|
||||
self.install_workflow_helpers()?;
|
||||
self.install_log_helpers()?;
|
||||
self.install_debounce()?;
|
||||
Ok(())
|
||||
|
|
@ -1135,10 +1138,7 @@ impl LuaEngine {
|
|||
|
||||
let (ordered, dep_errors) = order_module_decls(decls);
|
||||
|
||||
let mut decl_map = self
|
||||
.module_decls
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let mut decl_map = self.module_decls.lock().unwrap_or_else(|e| e.into_inner());
|
||||
decl_map.clear();
|
||||
for decl in &ordered {
|
||||
decl_map.insert(decl.name.clone(), decl.clone());
|
||||
|
|
@ -1173,10 +1173,7 @@ impl LuaEngine {
|
|||
}
|
||||
}
|
||||
|
||||
*self
|
||||
.module_order
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = load_order;
|
||||
*self.module_order.lock().unwrap_or_else(|e| e.into_inner()) = load_order;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1685,6 +1682,359 @@ impl LuaEngine {
|
|||
.exec()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bread.workflow` (define/start/step/status/list) and the multi-condition
|
||||
/// wait helpers `bread.wait_any`/`bread.wait_all`. Composition (spawning,
|
||||
/// yielding, timeouts) is plain Lua built on the existing `bread.spawn`/
|
||||
/// `bread.on`/`bread.once`/`bread.after`/`bread.cancel`/`bread.off`
|
||||
/// primitives from [`install_wait_helper`](Self::install_wait_helper) —
|
||||
/// mirroring how that method itself works. Only the *introspectable
|
||||
/// status* piece needs a Rust host bridge (the `__workflow_*` functions
|
||||
/// below), since `workflows.list` is served over IPC from the async side
|
||||
/// while the workflow body runs as a Lua coroutine on this dedicated Lua
|
||||
/// thread; both sides read/write the same `Arc<RwLock<RuntimeState>>`
|
||||
/// that `module_store_get`/`module_store_set` already use for exactly
|
||||
/// this kind of cross-thread bridging.
|
||||
fn install_workflow_helpers(&self) -> Result<()> {
|
||||
let globals = self.lua.globals();
|
||||
let bread: Table = globals.get("bread")?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let register_fn = self.lua.create_function(move |_lua, name: String| {
|
||||
workflow_register(&state_arc, &name);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("__workflow_register", register_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let step_fn = self
|
||||
.lua
|
||||
.create_function(move |_lua, (name, label): (String, String)| {
|
||||
workflow_step(&state_arc, &name, &label);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("__workflow_step", step_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let finish_fn = self.lua.create_function(move |_lua, name: String| {
|
||||
workflow_finish(&state_arc, &name);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("__workflow_finish", finish_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let fail_fn = self
|
||||
.lua
|
||||
.create_function(move |_lua, (name, error): (String, String)| {
|
||||
workflow_fail(&state_arc, &name, &error);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("__workflow_fail", fail_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let timeout_fn = self.lua.create_function(move |_lua, name: String| {
|
||||
workflow_timeout(&state_arc, &name);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("__workflow_timeout", timeout_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let status_fn = self.lua.create_function(move |lua, name: String| {
|
||||
match workflow_status_json(&state_arc, &name) {
|
||||
Some(json) => lua
|
||||
.to_value(&json)
|
||||
.map_err(|e| LuaError::external(e.to_string())),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?;
|
||||
bread.set("__workflow_status", status_fn)?;
|
||||
|
||||
let state_arc = self.state_handle.state_arc();
|
||||
let list_fn = self.lua.create_function(move |lua, ()| {
|
||||
let json = workflow_list_json(&state_arc);
|
||||
lua.to_value(&json)
|
||||
.map_err(|e| LuaError::external(e.to_string()))
|
||||
})?;
|
||||
bread.set("__workflow_list", list_fn)?;
|
||||
|
||||
self.lua
|
||||
.load(
|
||||
r#"
|
||||
bread.wait_any = function(patterns, opts)
|
||||
if type(patterns) ~= "table" then
|
||||
error("bread.wait_any requires a table of patterns")
|
||||
end
|
||||
opts = opts or {}
|
||||
local co = coroutine.running()
|
||||
if not co then
|
||||
error("bread.wait_any must be called inside a coroutine")
|
||||
end
|
||||
local ids = {}
|
||||
local timer
|
||||
local resumed = false
|
||||
local function cleanup()
|
||||
for _, id in ipairs(ids) do
|
||||
bread.off(id)
|
||||
end
|
||||
if timer then
|
||||
bread.cancel(timer)
|
||||
end
|
||||
end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local id = bread.once(pattern, function(event)
|
||||
if resumed then return end
|
||||
resumed = true
|
||||
cleanup()
|
||||
coroutine.resume(co, event, pattern)
|
||||
end)
|
||||
table.insert(ids, id)
|
||||
end
|
||||
if opts.timeout then
|
||||
timer = bread.after(opts.timeout, function()
|
||||
if resumed then return end
|
||||
resumed = true
|
||||
cleanup()
|
||||
coroutine.resume(co, nil, nil)
|
||||
end)
|
||||
end
|
||||
return coroutine.yield()
|
||||
end
|
||||
|
||||
bread.wait_all = function(patterns, opts)
|
||||
if type(patterns) ~= "table" then
|
||||
error("bread.wait_all requires a table of patterns")
|
||||
end
|
||||
opts = opts or {}
|
||||
local co = coroutine.running()
|
||||
if not co then
|
||||
error("bread.wait_all must be called inside a coroutine")
|
||||
end
|
||||
local remaining = {}
|
||||
local count = 0
|
||||
for _, p in ipairs(patterns) do
|
||||
if remaining[p] == nil then
|
||||
remaining[p] = true
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
local results = {}
|
||||
local got = 0
|
||||
local timer
|
||||
local resumed = false
|
||||
local ids = {}
|
||||
local function finish(timed_out)
|
||||
if resumed then return end
|
||||
resumed = true
|
||||
for _, id in ipairs(ids) do
|
||||
bread.off(id)
|
||||
end
|
||||
if timer then
|
||||
bread.cancel(timer)
|
||||
end
|
||||
if timed_out then
|
||||
results.timed_out = true
|
||||
end
|
||||
coroutine.resume(co, results)
|
||||
end
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local id = bread.once(pattern, function(event)
|
||||
if remaining[pattern] then
|
||||
remaining[pattern] = nil
|
||||
results[pattern] = event
|
||||
got = got + 1
|
||||
if got >= count then
|
||||
finish(false)
|
||||
end
|
||||
end
|
||||
end)
|
||||
table.insert(ids, id)
|
||||
end
|
||||
if opts.timeout then
|
||||
timer = bread.after(opts.timeout, function()
|
||||
finish(true)
|
||||
end)
|
||||
end
|
||||
return coroutine.yield()
|
||||
end
|
||||
|
||||
bread.workflow = {}
|
||||
local __workflow_bodies = {}
|
||||
local __co_to_workflow = setmetatable({}, { __mode = "k" })
|
||||
|
||||
bread.workflow.define = function(name, fn)
|
||||
if type(name) ~= "string" then
|
||||
error("bread.workflow.define requires a name string")
|
||||
end
|
||||
__workflow_bodies[name] = fn
|
||||
end
|
||||
|
||||
bread.workflow.start = function(name, opts)
|
||||
local fn = __workflow_bodies[name]
|
||||
if not fn then
|
||||
error("bread.workflow.start: no workflow defined with name '" .. tostring(name) .. "'")
|
||||
end
|
||||
opts = opts or {}
|
||||
|
||||
bread.__workflow_register(name)
|
||||
|
||||
local deadline_timer
|
||||
if opts.deadline then
|
||||
deadline_timer = bread.after(opts.deadline, function()
|
||||
bread.__workflow_timeout(name)
|
||||
end)
|
||||
end
|
||||
|
||||
local co = coroutine.create(function()
|
||||
local ok, err = pcall(fn, opts.args)
|
||||
if deadline_timer then
|
||||
bread.cancel(deadline_timer)
|
||||
end
|
||||
if ok then
|
||||
bread.__workflow_finish(name)
|
||||
else
|
||||
bread.__workflow_fail(name, tostring(err))
|
||||
end
|
||||
end)
|
||||
__co_to_workflow[co] = name
|
||||
|
||||
local ok, err = coroutine.resume(co)
|
||||
if not ok then
|
||||
bread.__workflow_fail(name, tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
bread.workflow.step = function(label)
|
||||
local co = coroutine.running()
|
||||
local name = co and __co_to_workflow[co]
|
||||
if not name then
|
||||
error("bread.workflow.step must be called inside a running workflow body")
|
||||
end
|
||||
bread.__workflow_step(name, label)
|
||||
end
|
||||
|
||||
bread.workflow.status = function(name)
|
||||
return bread.__workflow_status(name)
|
||||
end
|
||||
|
||||
bread.workflow.list = function()
|
||||
return bread.__workflow_list()
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.exec()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_register(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
|
||||
let mut guard = loop {
|
||||
if let Ok(g) = state_arc.try_write() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let now = now_unix_ms();
|
||||
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
|
||||
entry.state = WorkflowState::Running;
|
||||
entry.step = None;
|
||||
entry.started_at = now;
|
||||
entry.updated_at = now;
|
||||
entry.error = None;
|
||||
} else {
|
||||
guard.workflows.push(WorkflowStatus {
|
||||
name: name.to_string(),
|
||||
state: WorkflowState::Running,
|
||||
step: None,
|
||||
started_at: now,
|
||||
updated_at: now,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_step(state_arc: &Arc<RwLock<RuntimeState>>, name: &str, label: &str) {
|
||||
let mut guard = loop {
|
||||
if let Ok(g) = state_arc.try_write() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
|
||||
entry.step = Some(label.to_string());
|
||||
entry.updated_at = now_unix_ms();
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_finish(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
|
||||
let mut guard = loop {
|
||||
if let Ok(g) = state_arc.try_write() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
|
||||
entry.state = WorkflowState::Done;
|
||||
entry.updated_at = now_unix_ms();
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_fail(state_arc: &Arc<RwLock<RuntimeState>>, name: &str, error: &str) {
|
||||
let mut guard = loop {
|
||||
if let Ok(g) = state_arc.try_write() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
|
||||
entry.state = WorkflowState::Failed;
|
||||
entry.error = Some(error.to_string());
|
||||
entry.updated_at = now_unix_ms();
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_timeout(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
|
||||
let mut guard = loop {
|
||||
if let Ok(g) = state_arc.try_write() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
|
||||
// Don't clobber a workflow that already reached a terminal state
|
||||
// between the deadline firing and this callback running.
|
||||
if entry.state == WorkflowState::Running {
|
||||
entry.state = WorkflowState::TimedOut;
|
||||
entry.updated_at = now_unix_ms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_status_json(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) -> Option<JsonValue> {
|
||||
let guard = loop {
|
||||
if let Ok(g) = state_arc.try_read() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let entry = guard.workflows.iter().find(|w| w.name == name)?;
|
||||
serde_json::to_value(entry).ok()
|
||||
}
|
||||
|
||||
fn workflow_list_json(state_arc: &Arc<RwLock<RuntimeState>>) -> JsonValue {
|
||||
let guard = loop {
|
||||
if let Ok(g) = state_arc.try_read() {
|
||||
break g;
|
||||
}
|
||||
std::hint::spin_loop();
|
||||
std::thread::yield_now();
|
||||
};
|
||||
serde_json::to_value(&guard.workflows).unwrap_or_else(|_| JsonValue::Array(vec![]))
|
||||
}
|
||||
|
||||
fn order_module_decls(decls: Vec<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String, String)>) {
|
||||
|
|
@ -2312,7 +2662,9 @@ where
|
|||
.build()
|
||||
{
|
||||
Ok(rt) => rt.block_on(factory()),
|
||||
Err(e) => Err(anyhow::anyhow!("bluetooth query: failed to build tokio runtime: {e}")),
|
||||
Err(e) => Err(anyhow::anyhow!(
|
||||
"bluetooth query: failed to build tokio runtime: {e}"
|
||||
)),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ async fn main() -> Result<()> {
|
|||
});
|
||||
}
|
||||
|
||||
let ipc_raw_tx = raw_tx.clone();
|
||||
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
|
||||
adapter_manager.start_all().await?;
|
||||
|
||||
|
|
@ -111,6 +112,7 @@ async fn main() -> Result<()> {
|
|||
event_stream_tx,
|
||||
lua_runtime.clone(),
|
||||
normalized_tx,
|
||||
ipc_raw_tx,
|
||||
adapter_status,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue