story-kit: done 199_story_web_ui_submits_all_queued_items_at_once
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user