huskies: merge 686_refactor_decompose_server_src_io_watcher_rs_1202_lines

Manual merge resolution: feature branch deleted watcher.rs and split
into watcher/{mod,events,sweep,tests}.rs, while master modified the
old watcher.rs (738's FS-shadow stripping). The auto-resolver kept
both, producing an ambiguous-module compile error.

Resolution: drop watcher.rs (feature's delete wins). The new
watcher/mod.rs absorbs the FS-shadow code semantically — gates pass
(cargo check, clippy --all-targets -D warnings, fmt --check, 29/29
io::watcher tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-27 21:55:04 +00:00
parent be7b7025d5
commit 574df48ff3
5 changed files with 1215 additions and 473 deletions
+362
View File
@@ -0,0 +1,362 @@
//! Filesystem watcher for `.huskies/project.toml` and `.huskies/agents.toml`.
//!
//! Watches config files for changes and broadcasts a [`WatcherEvent`] to all
//! connected WebSocket clients so the frontend can reload the agent roster
//! without a server restart.
//!
//! Work-item pipeline events (stage transitions) are driven by CRDT state
//! changes via [`crate::crdt_state::subscribe`], not by filesystem events.
//!
//! # 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.
//!
//! # Submodules
//! - [`events`] — [`WatcherEvent`] enum definition.
//! - [`sweep`] — periodic sweep of `5_done` → `6_archived`.
mod events;
mod sweep;
pub use events::WatcherEvent;
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;
/// 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")
}
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
///
/// Used by the CRDT-to-watcher bridge (in `main.rs`) to derive the action and
/// commit message for `WatcherEvent::WorkItem` events.
pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
use crate::pipeline_state::Stage;
let (action, msg) = match Stage::from_dir(stage)? {
Stage::Backlog => ("create", format!("huskies: create {item_id}")),
Stage::Coding => ("start", format!("huskies: start {item_id}")),
Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")),
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
};
Some((action, msg))
}
/// 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;
}
};
// Watch config files for hot-reload. Work-item directories are NOT
// watched — CRDT state transitions drive pipeline events now.
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()
);
}
}
slog!("[watcher] watching config files for hot-reload");
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);
}
// Work-item file changes are intentionally ignored.
// CRDT state transitions handle pipeline events.
}
}
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;
}
}
});
}
// ── Test-only helpers (legacy; retained for the test suite) ───────────────
/// Return the pipeline stage name for a path if it is a `.md` file living
/// directly inside one of the known work subdirectories, otherwise `None`.
///
/// Explicitly returns `None` for any path under `.huskies/worktrees/` so
/// that code changes made by agents in their isolated worktrees are never
/// auto-committed to master by the watcher.
///
/// Retained for tests; no longer called in production (CRDT drives events).
#[cfg(test)]
fn stage_for_path(path: &Path) -> Option<String> {
// Reject any path that passes through the worktrees directory.
if path.components().any(|c| c.as_os_str() == "worktrees") {
return None;
}
if path.extension().is_none_or(|e| e != "md") {
return None;
}
let stage = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())?;
matches!(
stage,
"1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived"
)
.then(|| stage.to_string())
}
/// Stage all changes in the work directory and commit with the given message.
///
/// Uses `git add -A .huskies/work/` to catch both additions and deletions in
/// a single commit. Returns `Ok(true)` if a commit was made, `Ok(false)` if
/// there was nothing to commit, and `Err` for unexpected failures.
///
/// Retained for tests; no longer called in production (CRDT drives events).
#[cfg(test)]
fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, String> {
let work_rel = PathBuf::from(".huskies").join("work");
let add_out = std::process::Command::new("git")
.args(["add", "-A"])
.arg(&work_rel)
.current_dir(git_root)
.output()
.map_err(|e| format!("git add: {e}"))?;
if !add_out.status.success() {
return Err(format!(
"git add failed: {}",
String::from_utf8_lossy(&add_out.stderr)
));
}
let commit_out = std::process::Command::new("git")
.args(["commit", "-m", message])
.current_dir(git_root)
.output()
.map_err(|e| format!("git commit: {e}"))?;
if commit_out.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&commit_out.stderr);
let stdout = String::from_utf8_lossy(&commit_out.stdout);
if stdout.contains("nothing to commit") || stderr.contains("nothing to commit") {
return Ok(false);
}
Err(format!("git commit failed: {stderr}"))
}
/// Stages that represent meaningful git checkpoints (creation and archival).
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
/// that don't need to be committed.
///
/// Retained for tests; no longer called in production (CRDT drives events).
#[cfg(test)]
const COMMIT_WORTHY_STAGES: &[&str] = &["1_backlog", "5_done", "6_archived"];
/// Return `true` if changes in `stage` should be committed to git.
///
/// Retained for tests; no longer called in production (CRDT drives events).
#[cfg(test)]
fn should_commit_stage(stage: &str) -> bool {
COMMIT_WORTHY_STAGES.contains(&stage)
}
/// Process a batch of pending (path → stage) entries: commit and broadcast.
///
/// Only files that still exist on disk are used to derive the commit message
/// (they represent the destination of a move or a new file). Deletions are
/// captured by `git add -A .huskies/work/` automatically.
///
/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits.
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
///
/// Retained for tests; no longer called in production (CRDT drives events).
#[cfg(test)]
fn flush_pending(
pending: &std::collections::HashMap<PathBuf, String>,
git_root: &Path,
event_tx: &broadcast::Sender<WatcherEvent>,
) {
use crate::io::story_metadata::clear_front_matter_field;
// Separate into files that exist (additions) vs gone (deletions).
let mut additions: Vec<(&PathBuf, &str)> = Vec::new();
for (path, stage) in pending {
if path.exists() {
additions.push((path, stage.as_str()));
}
}
// Pick the commit message from the first addition (the meaningful side of a move).
// If there are only deletions, use a generic message.
let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() {
let item = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
if let Some((act, msg)) = stage_metadata(stage, item) {
(act, item.to_string(), msg)
} else {
return;
}
} else {
// Only deletions — pick any pending path for the item name.
let Some((path, _)) = pending.iter().next() else {
return;
};
let item = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
(
"remove",
item.to_string(),
format!("huskies: remove {item}"),
)
};
// Strip stale merge_failure front matter from any story that has left 4_merge/.
for (path, stage) in &additions {
if *stage != "4_merge"
&& let Err(e) = clear_front_matter_field(path, "merge_failure")
{
slog!(
"[watcher] Warning: could not clear merge_failure from {}: {e}",
path.display()
);
}
}
// Only commit for terminal stages; intermediate moves are broadcast-only.
let dest_stage = additions.first().map_or("unknown", |(_, s)| *s);
let should_commit = should_commit_stage(dest_stage);
if should_commit {
slog!("[watcher] flush: {commit_msg}");
match git_add_work_and_commit(git_root, &commit_msg) {
Ok(committed) => {
if committed {
slog!("[watcher] committed: {commit_msg}");
} else {
slog!("[watcher] skipped (already committed): {commit_msg}");
}
}
Err(e) => {
slog!("[watcher] git error: {e}");
return;
}
}
} else {
slog!("[watcher] flush (broadcast-only): {commit_msg}");
}
// For move operations, find the source stage from deleted entries with matching item_id.
let from_stage: Option<String> = if !additions.is_empty() {
pending
.iter()
.filter(|(path, _)| !path.exists())
.find(|(path, _)| path.file_stem().and_then(|s| s.to_str()) == Some(item_id.as_str()))
.map(|(_, stage)| stage.clone())
} else {
None
};
// Always broadcast the event so connected WebSocket clients stay in sync.
let evt = WatcherEvent::WorkItem {
stage: dest_stage.to_string(),
item_id,
action: action.to_string(),
commit_msg,
from_stage,
};
let _ = event_tx.send(evt);
}
#[cfg(test)]
mod tests;