Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers

This commit is contained in:
Breadway 2026-07-19 03:05:57 +08:00
parent 89c5849539
commit 1208c5d1b7
29 changed files with 4098 additions and 339 deletions

View 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
View 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(&not_a_repo).unwrap();
assert_eq!(resolve_git_dir(&not_a_repo), None);
}
}

View file

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

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

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

View file

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

View file

@ -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;

View file

@ -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,
}

View file

@ -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,

View file

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

View file

@ -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,