huskies: merge 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events
This commit is contained in:
+37
-27
@@ -20,11 +20,9 @@
|
||||
//! the event so connected clients stay in sync.
|
||||
|
||||
use crate::config::{ProjectConfig, WatcherConfig};
|
||||
use crate::io::story_metadata::clear_front_matter_field;
|
||||
use crate::slog;
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
@@ -105,7 +103,10 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
||||
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
|
||||
///
|
||||
/// 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)> {
|
||||
let (action, prefix) = match stage {
|
||||
"1_backlog" => ("create", format!("huskies: create {item_id}")),
|
||||
"2_current" => ("start", format!("huskies: start {item_id}")),
|
||||
@@ -124,6 +125,9 @@ fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)>
|
||||
/// 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") {
|
||||
@@ -149,6 +153,9 @@ fn stage_for_path(path: &Path) -> Option<String> {
|
||||
/// 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");
|
||||
|
||||
@@ -188,9 +195,15 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
|
||||
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
||||
/// that don't need to be committed — they're only relevant while the server is
|
||||
/// running and are broadcast to WebSocket clients for real-time UI updates.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
@@ -203,11 +216,16 @@ fn should_commit_stage(stage: &str) -> bool {
|
||||
///
|
||||
/// 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: &HashMap<PathBuf, String>,
|
||||
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 {
|
||||
@@ -392,10 +410,16 @@ fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Dura
|
||||
|
||||
/// Start the filesystem watcher on a dedicated OS thread.
|
||||
///
|
||||
/// `work_dir` — absolute path to `.huskies/work/` (watched recursively).
|
||||
/// `git_root` — project root (passed to `git` commands as cwd, and used to
|
||||
/// derive the config file path `.huskies/project.toml`).
|
||||
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
|
||||
/// Watches `.huskies/project.toml` and `.huskies/agents.toml` for config
|
||||
/// hot-reload, and periodically sweeps `5_done/` → `6_archived/`.
|
||||
///
|
||||
/// Work-item pipeline events (stage transitions) are no longer driven by
|
||||
/// filesystem events — they originate from CRDT state changes via
|
||||
/// [`crate::crdt_state::subscribe`].
|
||||
///
|
||||
/// `work_dir` — absolute path to `.huskies/work/` (used for sweep only).
|
||||
/// `git_root` — project root (passed to `git` commands and config loading).
|
||||
/// `event_tx` — broadcast sender for `ConfigChanged` events.
|
||||
/// `watcher_config` — initial sweep configuration loaded from `project.toml`.
|
||||
pub fn start_watcher(
|
||||
work_dir: PathBuf,
|
||||
@@ -416,12 +440,8 @@ pub fn start_watcher(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = watcher.watch(&work_dir, RecursiveMode::Recursive) {
|
||||
slog!("[watcher] failed to watch {}: {e}", work_dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
// Also watch .huskies/project.toml and .huskies/agents.toml for hot-reload.
|
||||
// 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()
|
||||
@@ -434,7 +454,7 @@ pub fn start_watcher(
|
||||
}
|
||||
}
|
||||
|
||||
slog!("[watcher] watching {}", work_dir.display());
|
||||
slog!("[watcher] watching config files and running sweep timer");
|
||||
|
||||
const DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
@@ -447,8 +467,6 @@ pub fn start_watcher(
|
||||
watcher_config.done_retention_secs
|
||||
);
|
||||
|
||||
// Map path → stage for pending (uncommitted) work-item changes.
|
||||
let mut pending: HashMap<PathBuf, String> = HashMap::new();
|
||||
// Whether a config file change is pending in the current debounce window.
|
||||
let mut config_changed_pending = false;
|
||||
let mut deadline: Option<Instant> = None;
|
||||
@@ -466,9 +484,6 @@ pub fn start_watcher(
|
||||
|
||||
let flush = match notify_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(event)) => {
|
||||
// Track creates, modifies, AND removes. Removes are needed so
|
||||
// that standalone deletions trigger a flush, and so that moves
|
||||
// (which fire Remove + Create) land in the same debounce window.
|
||||
let is_relevant_kind = matches!(
|
||||
event.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
@@ -480,10 +495,9 @@ pub fn start_watcher(
|
||||
slog!("[watcher] config change detected: {}", path.display());
|
||||
config_changed_pending = true;
|
||||
deadline = Some(Instant::now() + DEBOUNCE);
|
||||
} else if let Some(stage) = stage_for_path(&path) {
|
||||
pending.insert(path, stage);
|
||||
deadline = Some(Instant::now() + DEBOUNCE);
|
||||
}
|
||||
// Work-item file changes are intentionally ignored.
|
||||
// CRDT state transitions handle pipeline events.
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -501,10 +515,6 @@ pub fn start_watcher(
|
||||
};
|
||||
|
||||
if flush {
|
||||
if !pending.is_empty() {
|
||||
flush_pending(&pending, &git_root, &event_tx);
|
||||
pending.clear();
|
||||
}
|
||||
if config_changed_pending {
|
||||
slog!("[watcher] broadcasting agent_config_changed");
|
||||
let _ = event_tx.send(WatcherEvent::ConfigChanged);
|
||||
|
||||
Reference in New Issue
Block a user