Files
huskies/server/src/io/watcher/mod.rs
T

226 lines
9.3 KiB
Rust
Raw Normal View History

2026-05-14 08:07:43 +00:00
//! Filesystem watcher for `.huskies/project.toml`, `.huskies/agents.toml`,
//! and `.huskies/worktrees/*/PLAN.md`.
//!
//! Watches config files for changes and broadcasts a [`WatcherEvent`] to all
//! connected WebSocket clients so the frontend can reload the agent roster
2026-05-14 08:07:43 +00:00
//! without a server restart. Also watches worktree PLAN.md files and updates
//! the typed [`crate::pipeline_state::PlanState`] in the CRDT whenever a
//! PLAN.md is created, modified, or removed.
//!
//! # Debouncing
//! Config-file events are buffered for 300 ms after the last activity to avoid
//! duplicate broadcasts when an editor writes multiple events in quick succession.
2026-05-14 08:07:43 +00:00
//! PLAN.md events are applied immediately without debouncing.
//!
//! # Submodules
//! - [`events`] — [`WatcherEvent`] enum definition.
//! - [`sweep`] — periodic sweep of `5_done` → `6_archived`.
mod events;
mod sweep;
pub use events::WatcherEvent;
2026-05-14 08:48:11 +00:00
pub(crate) use sweep::spawn_done_to_archived_subscriber;
pub(crate) use sweep::sweep_done_to_archived;
use crate::slog;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
2026-05-14 08:07:43 +00:00
/// Extract the story ID from a path of the form
/// `{git_root}/.huskies/worktrees/{story_id}/PLAN.md`.
///
/// Returns `Some(story_id)` when `path` is exactly a `PLAN.md` file one level
/// inside the worktrees directory. Returns `None` for any other path.
pub fn extract_story_id_from_plan_path(path: &Path, git_root: &Path) -> Option<String> {
if path.file_name()? != "PLAN.md" {
return None;
}
let parent = path.parent()?;
let expected_worktrees = git_root.join(".huskies").join("worktrees");
if parent.parent()? != expected_worktrees {
return None;
}
Some(parent.file_name()?.to_str()?.to_string())
}
/// Determine the [`PlanState`] for a PLAN.md file at `path`.
///
/// - File absent or unreadable → [`PlanState::Missing`]
/// - File contains `<TBD>` → [`PlanState::Drafted`]
/// - File exists with no `<TBD>` → [`PlanState::Confirmed`]
pub fn plan_state_for_path(path: &Path) -> crate::pipeline_state::PlanState {
use crate::pipeline_state::PlanState;
match std::fs::read_to_string(path) {
Ok(content) if content.contains("<TBD>") => PlanState::Drafted,
Ok(_) => PlanState::Confirmed,
Err(_) => PlanState::Missing,
}
}
/// Return `true` if `path` is the root-level `.huskies/project.toml` or
/// `.huskies/agents.toml`, i.e. `{git_root}/.huskies/{project,agents}.toml`.
///
/// Returns `false` for paths inside worktree directories (paths containing
/// a `worktrees` component).
pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
// Reject any path that passes through the worktrees directory.
if path.components().any(|c| c.as_os_str() == "worktrees") {
return false;
}
let huskies = git_root.join(".huskies");
path == huskies.join("project.toml") || path == huskies.join("agents.toml")
}
2026-05-13 13:17:46 +00:00
/// Map a typed pipeline stage to a (action, commit-message-prefix) pair.
///
2026-05-13 13:17:46 +00:00
/// Used by the CRDT-to-watcher bridge to derive the action and commit message
/// for `WatcherEvent::WorkItem` events.
pub fn stage_metadata(
stage: &crate::pipeline_state::Stage,
item_id: &str,
) -> (&'static str, String) {
use crate::pipeline_state::Stage;
2026-05-13 13:17:46 +00:00
match stage {
2026-04-29 17:38:38 +00:00
Stage::Upcoming => ("create", format!("huskies: triage {item_id}")),
Stage::Backlog => ("create", format!("huskies: create {item_id}")),
2026-05-13 22:50:13 +00:00
Stage::Coding { .. } => ("start", format!("huskies: start {item_id}")),
2026-04-29 22:42:59 +00:00
Stage::Blocked { .. } => ("block", format!("huskies: block {item_id}")),
Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")),
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
2026-04-29 23:28:57 +00:00
Stage::MergeFailure { .. } => {
("merge_failure", format!("huskies: merge_failure {item_id}"))
}
2026-05-13 06:05:01 +00:00
Stage::MergeFailureFinal { .. } => (
"merge_failure_final",
format!("huskies: merge_failure_final {item_id}"),
),
Stage::Frozen { .. } => ("freeze", format!("huskies: freeze {item_id}")),
Stage::ReviewHold { .. } => ("review_hold", format!("huskies: review_hold {item_id}")),
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
2026-05-13 16:43:19 +00:00
Stage::Abandoned { .. } => ("abandon", format!("huskies: abandon {item_id}")),
Stage::Superseded { .. } => ("supersede", format!("huskies: supersede {item_id}")),
Stage::Rejected { .. } => ("reject", format!("huskies: reject {item_id}")),
2026-05-13 13:17:46 +00:00
}
}
/// Start the filesystem watcher on a dedicated OS thread.
///
/// Watches `.huskies/project.toml` and `.huskies/agents.toml` for config
/// hot-reload, and periodically sweeps `5_done/` → `6_archived/` via CRDT.
///
/// Work-item pipeline events (stage transitions) are no longer driven by
/// filesystem events — they originate from CRDT state changes via
/// [`crate::crdt_state::subscribe`].
///
/// `git_root` — project root (passed to `git` commands and config loading).
/// `event_tx` — broadcast sender for `ConfigChanged` events.
pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender<WatcherEvent>) {
std::thread::spawn(move || {
let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher: RecommendedWatcher = match recommended_watcher(move |res| {
let _ = notify_tx.send(res);
}) {
Ok(w) => w,
Err(e) => {
slog!("[watcher] failed to create watcher: {e}");
return;
}
};
2026-05-14 08:07:43 +00:00
// Watch config files for hot-reload.
let huskies = git_root.join(".huskies");
for config_file in [huskies.join("project.toml"), huskies.join("agents.toml")] {
if config_file.exists()
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
{
slog!(
"[watcher] failed to watch config file {}: {e}",
config_file.display()
);
}
}
2026-05-14 08:07:43 +00:00
// Watch worktrees directory for PLAN.md changes.
let worktrees_dir = huskies.join("worktrees");
if worktrees_dir.exists()
&& let Err(e) = watcher.watch(&worktrees_dir, RecursiveMode::Recursive)
{
slog!("[watcher] failed to watch worktrees dir: {e}");
}
slog!("[watcher] watching config files and worktree PLAN.md files");
const DEBOUNCE: Duration = Duration::from_millis(300);
// Whether a config file change is pending in the current debounce window.
let mut config_changed_pending = false;
let mut deadline: Option<Instant> = None;
loop {
// How long until the debounce window closes (or wait for next event).
let timeout = deadline.map_or(Duration::from_secs(60), |d| {
d.saturating_duration_since(Instant::now())
});
let flush = match notify_rx.recv_timeout(timeout) {
Ok(Ok(event)) => {
let is_relevant_kind = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if is_relevant_kind {
for path in event.paths {
if is_config_file(&path, &git_root) {
slog!("[watcher] config change detected: {}", path.display());
config_changed_pending = true;
deadline = Some(Instant::now() + DEBOUNCE);
2026-05-14 08:07:43 +00:00
} else if let Some(story_id) =
extract_story_id_from_plan_path(&path, &git_root)
{
let plan_state = plan_state_for_path(&path);
slog!(
"[watcher] PLAN.md changed for '{}': {:?}",
story_id,
plan_state
);
crate::crdt_state::set_plan_state(&story_id, plan_state);
}
}
}
false
}
Ok(Err(e)) => {
slog!("[watcher] notify error: {e}");
false
}
// Debounce window expired — time to flush.
Err(mpsc::RecvTimeoutError::Timeout) => true,
Err(mpsc::RecvTimeoutError::Disconnected) => {
slog!("[watcher] channel disconnected, shutting down");
break;
}
};
if flush {
if config_changed_pending {
slog!("[watcher] broadcasting agent_config_changed");
let _ = event_tx.send(WatcherEvent::ConfigChanged);
config_changed_pending = false;
}
deadline = None;
}
}
});
}
#[cfg(test)]
mod tests;