diff --git a/.story_kit/work/4_merge/199_story_web_ui_submits_all_queued_items_at_once.md b/.story_kit/work/5_done/199_story_web_ui_submits_all_queued_items_at_once.md similarity index 100% rename from .story_kit/work/4_merge/199_story_web_ui_submits_all_queued_items_at_once.md rename to .story_kit/work/5_done/199_story_web_ui_submits_all_queued_items_at_once.md diff --git a/server/src/config.rs b/server/src/config.rs index 6e634e1..6fbc000 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -9,6 +9,41 @@ pub struct ProjectConfig { pub component: Vec, #[serde(default)] pub agent: Vec, + #[serde(default)] + pub watcher: WatcherConfig, +} + +/// Configuration for the filesystem watcher's sweep behaviour. +/// +/// Controls how often the watcher checks `5_done/` for items to promote to +/// `6_archived/`, and how long items must remain in `5_done/` before promotion. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct WatcherConfig { + /// How often (in seconds) to check `5_done/` for items to archive. + /// Default: 60 seconds. + #[serde(default = "default_sweep_interval_secs")] + pub sweep_interval_secs: u64, + /// How long (in seconds) an item must remain in `5_done/` before being + /// moved to `6_archived/`. Default: 14400 (4 hours). + #[serde(default = "default_done_retention_secs")] + pub done_retention_secs: u64, +} + +impl Default for WatcherConfig { + fn default() -> Self { + Self { + sweep_interval_secs: default_sweep_interval_secs(), + done_retention_secs: default_done_retention_secs(), + } + } +} + +fn default_sweep_interval_secs() -> u64 { + 60 +} + +fn default_done_retention_secs() -> u64 { + 4 * 60 * 60 // 4 hours } #[derive(Debug, Clone, Deserialize)] @@ -87,6 +122,8 @@ struct LegacyProjectConfig { #[serde(default)] component: Vec, agent: Option, + #[serde(default)] + watcher: WatcherConfig, } impl Default for ProjectConfig { @@ -107,6 +144,7 @@ impl Default for ProjectConfig { stage: None, inactivity_timeout_secs: default_inactivity_timeout_secs(), }], + watcher: WatcherConfig::default(), } } } @@ -147,6 +185,7 @@ impl ProjectConfig { let config = ProjectConfig { component: legacy.component, agent: vec![agent], + watcher: legacy.watcher, }; validate_agents(&config.agent)?; return Ok(config); @@ -166,6 +205,7 @@ impl ProjectConfig { let config = ProjectConfig { component: legacy.component, agent: vec![agent], + watcher: legacy.watcher, }; validate_agents(&config.agent)?; Ok(config) @@ -173,6 +213,7 @@ impl ProjectConfig { Ok(ProjectConfig { component: legacy.component, agent: Vec::new(), + watcher: legacy.watcher, }) } } @@ -517,4 +558,93 @@ model = "sonnet" assert_eq!(config.agent[0].name, "main"); assert_eq!(config.agent[0].model, Some("sonnet".to_string())); } + + // ── WatcherConfig ────────────────────────────────────────────────────── + + #[test] + fn watcher_config_defaults_when_omitted() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 60); + assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); + } + + #[test] + fn watcher_config_custom_values() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 30 +done_retention_secs = 7200 + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 30); + assert_eq!(config.watcher.done_retention_secs, 7200); + } + + #[test] + fn watcher_config_partial_override() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 10 + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 10); + // done_retention_secs should fall back to the default (4 hours). + assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); + } + + #[test] + fn watcher_config_from_file() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("project.toml"), + r#" +[watcher] +sweep_interval_secs = 120 +done_retention_secs = 3600 + +[[agent]] +name = "coder" +"#, + ) + .unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 120); + assert_eq!(config.watcher.done_retention_secs, 3600); + } + + #[test] + fn watcher_config_default_when_no_file() { + let tmp = tempfile::tempdir().unwrap(); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.watcher, WatcherConfig::default()); + } + + #[test] + fn watcher_config_preserved_in_legacy_format() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 15 +done_retention_secs = 900 + +[agent] +command = "claude" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 15); + assert_eq!(config.watcher.done_retention_secs, 900); + assert_eq!(config.agent.len(), 1); + } } diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 23df218..26f1086 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -19,6 +19,7 @@ //! via exit-code inspection and silently skips the commit while still broadcasting //! the event so connected clients stay in sync. +use crate::config::{ProjectConfig, WatcherConfig}; use crate::slog; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher}; use serde::Serialize; @@ -205,12 +206,11 @@ fn flush_pending( } /// Scan `work/5_done/` and move any `.md` files whose mtime is older than -/// `DONE_RETENTION` to `work/6_archived/`. +/// `done_retention` to `work/6_archived/`. /// /// Called periodically from the watcher thread. File moves will trigger normal /// watcher events, which `flush_pending` will commit and broadcast. -fn sweep_done_to_archived(work_dir: &Path) { - const DONE_RETENTION: Duration = Duration::from_secs(4 * 60 * 60); +fn sweep_done_to_archived(work_dir: &Path, done_retention: Duration) { let done_dir = work_dir.join("5_done"); if !done_dir.exists() { @@ -242,7 +242,7 @@ fn sweep_done_to_archived(work_dir: &Path) { .duration_since(mtime) .unwrap_or_default(); - if age >= DONE_RETENTION { + if age >= done_retention { if let Err(e) = std::fs::create_dir_all(&archived_dir) { slog!("[watcher] sweep: failed to create 6_archived/: {e}"); continue; @@ -266,14 +266,16 @@ fn sweep_done_to_archived(work_dir: &Path) { /// Start the filesystem watcher on a dedicated OS thread. /// -/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively). -/// `git_root` — project root (passed to `git` commands as cwd, and used to -/// derive the config file path `.story_kit/project.toml`). -/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver. +/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively). +/// `git_root` — project root (passed to `git` commands as cwd, and used to +/// derive the config file path `.story_kit/project.toml`). +/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver. +/// `watcher_config` — initial sweep configuration loaded from `project.toml`. pub fn start_watcher( work_dir: PathBuf, git_root: PathBuf, event_tx: broadcast::Sender, + watcher_config: WatcherConfig, ) { std::thread::spawn(move || { let (notify_tx, notify_rx) = mpsc::channel::>(); @@ -304,8 +306,15 @@ pub fn start_watcher( slog!("[watcher] watching {}", work_dir.display()); const DEBOUNCE: Duration = Duration::from_millis(300); - /// How often to check 5_done/ for items to promote to 6_archived/. - const SWEEP_INTERVAL: Duration = Duration::from_secs(60); + + // Mutable sweep config — hot-reloaded when project.toml changes. + let mut sweep_interval = Duration::from_secs(watcher_config.sweep_interval_secs); + let mut done_retention = Duration::from_secs(watcher_config.done_retention_secs); + slog!( + "[watcher] sweep_interval={}s done_retention={}s", + watcher_config.sweep_interval_secs, + watcher_config.done_retention_secs + ); // Map path → stage for pending (uncommitted) work-item changes. let mut pending: HashMap = HashMap::new(); @@ -315,7 +324,7 @@ pub fn start_watcher( // Track when we last swept 5_done/ → 6_archived/. // Initialise to "now minus interval" so the first sweep runs on startup. let mut last_sweep = Instant::now() - .checked_sub(SWEEP_INTERVAL) + .checked_sub(sweep_interval) .unwrap_or_else(Instant::now); loop { @@ -368,15 +377,40 @@ pub fn start_watcher( if config_changed_pending { slog!("[watcher] broadcasting agent_config_changed"); let _ = event_tx.send(WatcherEvent::ConfigChanged); + + // Hot-reload sweep config from project.toml. + match ProjectConfig::load(&git_root) { + Ok(cfg) => { + let new_sweep = + Duration::from_secs(cfg.watcher.sweep_interval_secs); + let new_retention = + Duration::from_secs(cfg.watcher.done_retention_secs); + if new_sweep != sweep_interval + || new_retention != done_retention + { + slog!( + "[watcher] hot-reload: sweep_interval={}s done_retention={}s", + cfg.watcher.sweep_interval_secs, + cfg.watcher.done_retention_secs + ); + sweep_interval = new_sweep; + done_retention = new_retention; + } + } + Err(e) => { + slog!("[watcher] hot-reload: failed to parse config: {e}"); + } + } + config_changed_pending = false; } deadline = None; // Periodically promote old items from 5_done/ to 6_archived/. let now = Instant::now(); - if now.duration_since(last_sweep) >= SWEEP_INTERVAL { + if now.duration_since(last_sweep) >= sweep_interval { last_sweep = now; - sweep_done_to_archived(&work_dir); + sweep_done_to_archived(&work_dir, done_retention); } } } @@ -723,7 +757,8 @@ mod tests { filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) .unwrap(); - sweep_done_to_archived(&work_dir); + let retention = Duration::from_secs(4 * 60 * 60); + sweep_done_to_archived(&work_dir, retention); assert!(!story_path.exists(), "old item should be moved out of 5_done/"); assert!( @@ -743,8 +778,64 @@ mod tests { let story_path = done_dir.join("11_story_new.md"); fs::write(&story_path, "---\nname: new\n---\n").unwrap(); - sweep_done_to_archived(&work_dir); + let retention = Duration::from_secs(4 * 60 * 60); + sweep_done_to_archived(&work_dir, retention); assert!(story_path.exists(), "recent item should remain in 5_done/"); } + + #[test] + fn sweep_respects_custom_retention() { + let tmp = TempDir::new().unwrap(); + let work_dir = tmp.path().join(".story_kit").join("work"); + let done_dir = work_dir.join("5_done"); + let archived_dir = work_dir.join("6_archived"); + fs::create_dir_all(&done_dir).unwrap(); + + // Write a file and backdate its mtime to 2 minutes ago. + let story_path = done_dir.join("12_story_custom.md"); + fs::write(&story_path, "---\nname: custom\n---\n").unwrap(); + let past = SystemTime::now() + .checked_sub(Duration::from_secs(120)) + .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) + .unwrap(); + + // With a 1-minute retention, the 2-minute-old file should be swept. + sweep_done_to_archived(&work_dir, Duration::from_secs(60)); + + assert!( + !story_path.exists(), + "item older than custom retention should be moved" + ); + assert!( + archived_dir.join("12_story_custom.md").exists(), + "item should appear in 6_archived/" + ); + } + + #[test] + fn sweep_custom_retention_keeps_younger_items() { + let tmp = TempDir::new().unwrap(); + let work_dir = tmp.path().join(".story_kit").join("work"); + let done_dir = work_dir.join("5_done"); + fs::create_dir_all(&done_dir).unwrap(); + + // Write a file and backdate its mtime to 30 seconds ago. + let story_path = done_dir.join("13_story_young.md"); + fs::write(&story_path, "---\nname: young\n---\n").unwrap(); + let past = SystemTime::now() + .checked_sub(Duration::from_secs(30)) + .unwrap(); + filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)) + .unwrap(); + + // With a 1-minute retention, the 30-second-old file should stay. + sweep_done_to_archived(&work_dir, Duration::from_secs(60)); + + assert!( + story_path.exists(), + "item younger than custom retention should remain" + ); + } } diff --git a/server/src/main.rs b/server/src/main.rs index 74816d5..bfea949 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -73,7 +73,15 @@ async fn main() -> Result<(), std::io::Error> { if let Some(ref root) = *app_state.project_root.lock().unwrap() { let work_dir = root.join(".story_kit").join("work"); if work_dir.is_dir() { - io::watcher::start_watcher(work_dir, root.clone(), watcher_tx.clone()); + let watcher_config = config::ProjectConfig::load(root) + .map(|c| c.watcher) + .unwrap_or_default(); + io::watcher::start_watcher( + work_dir, + root.clone(), + watcher_tx.clone(), + watcher_config, + ); } } diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 3e034f0..6c81882 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -307,7 +307,7 @@ async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; - use crate::config::ComponentConfig; + use crate::config::{ComponentConfig, WatcherConfig}; use std::fs; use tempfile::TempDir; @@ -491,6 +491,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -507,6 +508,7 @@ mod tests { teardown: vec![], }], agent: vec![], + watcher: WatcherConfig::default(), }; // Should complete without panic run_setup_commands(tmp.path(), &config).await; @@ -523,6 +525,7 @@ mod tests { teardown: vec![], }], agent: vec![], + watcher: WatcherConfig::default(), }; // Setup command failures are non-fatal — should not panic or propagate run_setup_commands(tmp.path(), &config).await; @@ -539,6 +542,7 @@ mod tests { teardown: vec!["exit 1".to_string()], }], agent: vec![], + watcher: WatcherConfig::default(), }; // Teardown failures are best-effort — should not propagate assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); @@ -554,6 +558,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) .await @@ -576,6 +581,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; // First creation let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) @@ -614,6 +620,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; @@ -635,6 +642,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; create_worktree(&project_root, "88_remove_by_id", &config, 3001) .await @@ -660,6 +668,7 @@ mod tests { teardown: vec![], }], agent: vec![], + watcher: WatcherConfig::default(), }; // Even though setup commands fail, create_worktree must succeed // so the agent can start and fix the problem itself. @@ -684,6 +693,7 @@ mod tests { let empty_config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; // First creation — no setup commands, should succeed create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) @@ -698,6 +708,7 @@ mod tests { teardown: vec![], }], agent: vec![], + watcher: WatcherConfig::default(), }; // Second call — worktree exists, setup commands fail, must still succeed let result = @@ -719,6 +730,7 @@ mod tests { let config = ProjectConfig { component: vec![], agent: vec![], + watcher: WatcherConfig::default(), }; let info = create_worktree(&project_root, "77_remove_async", &config, 3001) .await