huskies: merge 1010
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user