story-kit: done 199_story_web_ui_submits_all_queued_items_at_once

This commit is contained in:
Dave
2026-02-26 12:16:07 +00:00
parent f5f2716a3a
commit 2dbfd42c6e
5 changed files with 258 additions and 17 deletions

View File

@@ -9,6 +9,41 @@ pub struct ProjectConfig {
pub component: Vec<ComponentConfig>, pub component: Vec<ComponentConfig>,
#[serde(default)] #[serde(default)]
pub agent: Vec<AgentConfig>, pub agent: Vec<AgentConfig>,
#[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)] #[derive(Debug, Clone, Deserialize)]
@@ -87,6 +122,8 @@ struct LegacyProjectConfig {
#[serde(default)] #[serde(default)]
component: Vec<ComponentConfig>, component: Vec<ComponentConfig>,
agent: Option<AgentConfig>, agent: Option<AgentConfig>,
#[serde(default)]
watcher: WatcherConfig,
} }
impl Default for ProjectConfig { impl Default for ProjectConfig {
@@ -107,6 +144,7 @@ impl Default for ProjectConfig {
stage: None, stage: None,
inactivity_timeout_secs: default_inactivity_timeout_secs(), inactivity_timeout_secs: default_inactivity_timeout_secs(),
}], }],
watcher: WatcherConfig::default(),
} }
} }
} }
@@ -147,6 +185,7 @@ impl ProjectConfig {
let config = ProjectConfig { let config = ProjectConfig {
component: legacy.component, component: legacy.component,
agent: vec![agent], agent: vec![agent],
watcher: legacy.watcher,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
return Ok(config); return Ok(config);
@@ -166,6 +205,7 @@ impl ProjectConfig {
let config = ProjectConfig { let config = ProjectConfig {
component: legacy.component, component: legacy.component,
agent: vec![agent], agent: vec![agent],
watcher: legacy.watcher,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
Ok(config) Ok(config)
@@ -173,6 +213,7 @@ impl ProjectConfig {
Ok(ProjectConfig { Ok(ProjectConfig {
component: legacy.component, component: legacy.component,
agent: Vec::new(), agent: Vec::new(),
watcher: legacy.watcher,
}) })
} }
} }
@@ -517,4 +558,93 @@ model = "sonnet"
assert_eq!(config.agent[0].name, "main"); assert_eq!(config.agent[0].name, "main");
assert_eq!(config.agent[0].model, Some("sonnet".to_string())); 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);
}
} }

View File

@@ -19,6 +19,7 @@
//! via exit-code inspection and silently skips the commit while still broadcasting //! via exit-code inspection and silently skips the commit while still broadcasting
//! the event so connected clients stay in sync. //! the event so connected clients stay in sync.
use crate::config::{ProjectConfig, WatcherConfig};
use crate::slog; use crate::slog;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher}; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
use serde::Serialize; use serde::Serialize;
@@ -205,12 +206,11 @@ fn flush_pending(
} }
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than /// 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 /// Called periodically from the watcher thread. File moves will trigger normal
/// watcher events, which `flush_pending` will commit and broadcast. /// watcher events, which `flush_pending` will commit and broadcast.
fn sweep_done_to_archived(work_dir: &Path) { fn sweep_done_to_archived(work_dir: &Path, done_retention: Duration) {
const DONE_RETENTION: Duration = Duration::from_secs(4 * 60 * 60);
let done_dir = work_dir.join("5_done"); let done_dir = work_dir.join("5_done");
if !done_dir.exists() { if !done_dir.exists() {
@@ -242,7 +242,7 @@ fn sweep_done_to_archived(work_dir: &Path) {
.duration_since(mtime) .duration_since(mtime)
.unwrap_or_default(); .unwrap_or_default();
if age >= DONE_RETENTION { if age >= done_retention {
if let Err(e) = std::fs::create_dir_all(&archived_dir) { if let Err(e) = std::fs::create_dir_all(&archived_dir) {
slog!("[watcher] sweep: failed to create 6_archived/: {e}"); slog!("[watcher] sweep: failed to create 6_archived/: {e}");
continue; continue;
@@ -270,10 +270,12 @@ fn sweep_done_to_archived(work_dir: &Path) {
/// `git_root` — project root (passed to `git` commands as cwd, and used to /// `git_root` — project root (passed to `git` commands as cwd, and used to
/// derive the config file path `.story_kit/project.toml`). /// derive the config file path `.story_kit/project.toml`).
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver. /// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
/// `watcher_config` — initial sweep configuration loaded from `project.toml`.
pub fn start_watcher( pub fn start_watcher(
work_dir: PathBuf, work_dir: PathBuf,
git_root: PathBuf, git_root: PathBuf,
event_tx: broadcast::Sender<WatcherEvent>, event_tx: broadcast::Sender<WatcherEvent>,
watcher_config: WatcherConfig,
) { ) {
std::thread::spawn(move || { std::thread::spawn(move || {
let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>(); let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>();
@@ -304,8 +306,15 @@ pub fn start_watcher(
slog!("[watcher] watching {}", work_dir.display()); slog!("[watcher] watching {}", work_dir.display());
const DEBOUNCE: Duration = Duration::from_millis(300); 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. // Map path → stage for pending (uncommitted) work-item changes.
let mut pending: HashMap<PathBuf, String> = HashMap::new(); let mut pending: HashMap<PathBuf, String> = HashMap::new();
@@ -315,7 +324,7 @@ pub fn start_watcher(
// Track when we last swept 5_done/ → 6_archived/. // Track when we last swept 5_done/ → 6_archived/.
// Initialise to "now minus interval" so the first sweep runs on startup. // Initialise to "now minus interval" so the first sweep runs on startup.
let mut last_sweep = Instant::now() let mut last_sweep = Instant::now()
.checked_sub(SWEEP_INTERVAL) .checked_sub(sweep_interval)
.unwrap_or_else(Instant::now); .unwrap_or_else(Instant::now);
loop { loop {
@@ -368,15 +377,40 @@ pub fn start_watcher(
if config_changed_pending { if config_changed_pending {
slog!("[watcher] broadcasting agent_config_changed"); slog!("[watcher] broadcasting agent_config_changed");
let _ = event_tx.send(WatcherEvent::ConfigChanged); 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; config_changed_pending = false;
} }
deadline = None; deadline = None;
// Periodically promote old items from 5_done/ to 6_archived/. // Periodically promote old items from 5_done/ to 6_archived/.
let now = Instant::now(); let now = Instant::now();
if now.duration_since(last_sweep) >= SWEEP_INTERVAL { if now.duration_since(last_sweep) >= sweep_interval {
last_sweep = now; 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)) filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap(); .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!(!story_path.exists(), "old item should be moved out of 5_done/");
assert!( assert!(
@@ -743,8 +778,64 @@ mod tests {
let story_path = done_dir.join("11_story_new.md"); let story_path = done_dir.join("11_story_new.md");
fs::write(&story_path, "---\nname: new\n---\n").unwrap(); 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/"); 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"
);
}
} }

View File

@@ -73,7 +73,15 @@ async fn main() -> Result<(), std::io::Error> {
if let Some(ref root) = *app_state.project_root.lock().unwrap() { if let Some(ref root) = *app_state.project_root.lock().unwrap() {
let work_dir = root.join(".story_kit").join("work"); let work_dir = root.join(".story_kit").join("work");
if work_dir.is_dir() { 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,
);
} }
} }

View File

@@ -307,7 +307,7 @@ async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::ComponentConfig; use crate::config::{ComponentConfig, WatcherConfig};
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
@@ -491,6 +491,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -507,6 +508,7 @@ mod tests {
teardown: vec![], teardown: vec![],
}], }],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -523,6 +525,7 @@ mod tests {
teardown: vec![], teardown: vec![],
}], }],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Setup command failures are non-fatal — should not panic or propagate // Setup command failures are non-fatal — should not panic or propagate
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -539,6 +542,7 @@ mod tests {
teardown: vec!["exit 1".to_string()], teardown: vec!["exit 1".to_string()],
}], }],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Teardown failures are best-effort — should not propagate // Teardown failures are best-effort — should not propagate
assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); assert!(run_teardown_commands(tmp.path(), &config).await.is_ok());
@@ -554,6 +558,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) let info = create_worktree(&project_root, "42_fresh_test", &config, 3001)
.await .await
@@ -576,6 +581,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// First creation // First creation
let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001)
@@ -614,6 +620,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await;
@@ -635,6 +642,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
create_worktree(&project_root, "88_remove_by_id", &config, 3001) create_worktree(&project_root, "88_remove_by_id", &config, 3001)
.await .await
@@ -660,6 +668,7 @@ mod tests {
teardown: vec![], teardown: vec![],
}], }],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Even though setup commands fail, create_worktree must succeed // Even though setup commands fail, create_worktree must succeed
// so the agent can start and fix the problem itself. // so the agent can start and fix the problem itself.
@@ -684,6 +693,7 @@ mod tests {
let empty_config = ProjectConfig { let empty_config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// First creation — no setup commands, should succeed // First creation — no setup commands, should succeed
create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001)
@@ -698,6 +708,7 @@ mod tests {
teardown: vec![], teardown: vec![],
}], }],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
// Second call — worktree exists, setup commands fail, must still succeed // Second call — worktree exists, setup commands fail, must still succeed
let result = let result =
@@ -719,6 +730,7 @@ mod tests {
let config = ProjectConfig { let config = ProjectConfig {
component: vec![], component: vec![],
agent: vec![], agent: vec![],
watcher: WatcherConfig::default(),
}; };
let info = create_worktree(&project_root, "77_remove_async", &config, 3001) let info = create_worktree(&project_root, "77_remove_async", &config, 3001)
.await .await