huskies: merge 1010

This commit is contained in:
dave
2026-05-14 08:07:43 +00:00
parent 4520e0e6f9
commit 13ab97a615
27 changed files with 572 additions and 95 deletions
+57 -10
View File
@@ -1,15 +1,16 @@
//! Filesystem watcher for `.huskies/project.toml` and `.huskies/agents.toml`.
//! 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
//! 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.
//! 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.
//! PLAN.md events are applied immediately without debouncing.
//!
//! # Submodules
//! - [`events`] — [`WatcherEvent`] enum definition.
@@ -28,6 +29,37 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
use tokio::sync::broadcast;
/// 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`.
///
@@ -100,8 +132,7 @@ pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender<WatcherEvent
}
};
// Watch config files for hot-reload. Work-item directories are NOT
// watched — CRDT state transitions drive pipeline events now.
// 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()
@@ -114,7 +145,15 @@ pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender<WatcherEvent
}
}
slog!("[watcher] watching config files for hot-reload");
// 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);
@@ -141,9 +180,17 @@ pub fn start_watcher(git_root: PathBuf, event_tx: broadcast::Sender<WatcherEvent
slog!("[watcher] config change detected: {}", path.display());
config_changed_pending = true;
deadline = Some(Instant::now() + DEBOUNCE);
} 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);
}
// Work-item file changes are intentionally ignored.
// CRDT state transitions handle pipeline events.
}
}
false
+67 -1
View File
@@ -1,6 +1,66 @@
//! Tests for the filesystem config watcher.
use super::*;
// ── extract_story_id_from_plan_path ──────────────────────────────────────────
#[test]
fn extracts_story_id_from_plan_path() {
let git_root = PathBuf::from("/proj");
let plan = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/PLAN.md");
assert_eq!(
extract_story_id_from_plan_path(&plan, &git_root),
Some("42_story_foo".to_string())
);
}
#[test]
fn plan_path_wrong_filename_returns_none() {
let git_root = PathBuf::from("/proj");
let other = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/README.md");
assert!(extract_story_id_from_plan_path(&other, &git_root).is_none());
}
#[test]
fn plan_path_not_in_worktrees_returns_none() {
let git_root = PathBuf::from("/proj");
let nested = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/sub/PLAN.md");
assert!(extract_story_id_from_plan_path(&nested, &git_root).is_none());
}
#[test]
fn plan_path_wrong_root_returns_none() {
let git_root = PathBuf::from("/proj");
let other_root = PathBuf::from("/other/.huskies/worktrees/42_story_foo/PLAN.md");
assert!(extract_story_id_from_plan_path(&other_root, &git_root).is_none());
}
// ── plan_state_for_path ──────────────────────────────────────────────────────
#[test]
fn plan_state_missing_when_file_absent() {
use crate::pipeline_state::PlanState;
let path = PathBuf::from("/nonexistent/PLAN.md");
assert_eq!(plan_state_for_path(&path), PlanState::Missing);
}
#[test]
fn plan_state_drafted_when_file_contains_tbd() {
use crate::pipeline_state::PlanState;
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp.as_file(), "# Plan\n- step 1 <TBD>\n- step 2").unwrap();
assert_eq!(plan_state_for_path(tmp.path()), PlanState::Drafted);
}
#[test]
fn plan_state_confirmed_when_file_has_no_tbd() {
use crate::pipeline_state::PlanState;
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp.as_file(), "# Plan\n- step 1\n- step 2").unwrap();
assert_eq!(plan_state_for_path(tmp.path()), PlanState::Confirmed);
}
// ── is_config_file ────────────────────────────────────────────────────────
#[test]
@@ -54,7 +114,13 @@ fn stage_metadata_returns_correct_actions() {
use crate::pipeline_state::{GitSha, Stage};
use chrono::Utc;
let (action, msg) = stage_metadata(&Stage::Coding { claim: None }, "42_story_foo");
let (action, msg) = stage_metadata(
&Stage::Coding {
claim: None,
plan: Default::default(),
},
"42_story_foo",
);
assert_eq!(action, "start");
assert_eq!(msg, "huskies: start 42_story_foo");