story-kit: done 199_story_web_ui_submits_all_queued_items_at_once
This commit is contained in:
@@ -9,6 +9,41 @@ pub struct ProjectConfig {
|
||||
pub component: Vec<ComponentConfig>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -87,6 +122,8 @@ struct LegacyProjectConfig {
|
||||
#[serde(default)]
|
||||
component: Vec<ComponentConfig>,
|
||||
agent: Option<AgentConfig>,
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WatcherEvent>,
|
||||
watcher_config: WatcherConfig,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
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());
|
||||
|
||||
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<PathBuf, String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user