story-kit: merge 151_story_split_archived_into_done_and_archived_with_time_based_promotion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// A lifecycle event emitted by the filesystem watcher.
|
||||
@@ -68,7 +68,8 @@ fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)>
|
||||
"2_current" => ("start", format!("story-kit: start {item_id}")),
|
||||
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
|
||||
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
|
||||
"5_archived" => ("accept", format!("story-kit: accept {item_id}")),
|
||||
"5_done" => ("done", format!("story-kit: done {item_id}")),
|
||||
"6_archived" => ("accept", format!("story-kit: accept {item_id}")),
|
||||
_ => return None,
|
||||
};
|
||||
Some((action, prefix))
|
||||
@@ -96,7 +97,7 @@ fn stage_for_path(path: &Path) -> Option<String> {
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())?;
|
||||
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_archived")
|
||||
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
|
||||
.then(|| stage.to_string())
|
||||
}
|
||||
|
||||
@@ -199,6 +200,66 @@ fn flush_pending(
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than
|
||||
/// `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);
|
||||
|
||||
let done_dir = work_dir.join("5_done");
|
||||
if !done_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(&done_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
slog!("[watcher] sweep: failed to read 5_done/: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let archived_dir = work_dir.join("6_archived");
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mtime = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let age = SystemTime::now()
|
||||
.duration_since(mtime)
|
||||
.unwrap_or_default();
|
||||
|
||||
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;
|
||||
}
|
||||
let dest = archived_dir.join(entry.file_name());
|
||||
match std::fs::rename(&path, &dest) {
|
||||
Ok(()) => {
|
||||
let item_id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
slog!("[watcher] sweep: promoted {item_id} → 6_archived/");
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[watcher] sweep: failed to move {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the filesystem watcher on a dedicated OS thread.
|
||||
///
|
||||
/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively).
|
||||
@@ -239,12 +300,19 @@ 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);
|
||||
|
||||
// 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;
|
||||
// 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)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
loop {
|
||||
// How long until the debounce window closes (or wait for next event).
|
||||
@@ -299,6 +367,13 @@ pub fn start_watcher(
|
||||
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 {
|
||||
last_sweep = now;
|
||||
sweep_done_to_archived(&work_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -422,7 +497,8 @@ mod tests {
|
||||
("1_upcoming", "create", "story-kit: create 10_story_x"),
|
||||
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
|
||||
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
|
||||
("5_archived", "accept", "story-kit: accept 10_story_x"),
|
||||
("5_done", "done", "story-kit: done 10_story_x"),
|
||||
("6_archived", "accept", "story-kit: accept 10_story_x"),
|
||||
];
|
||||
|
||||
for (stage, expected_action, expected_msg) in stages {
|
||||
@@ -530,8 +606,12 @@ mod tests {
|
||||
Some("2_current".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
stage_for_path(&base.join("5_archived/10_bug_bar.md")),
|
||||
Some("5_archived".to_string())
|
||||
stage_for_path(&base.join("5_done/10_bug_bar.md")),
|
||||
Some("5_done".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
stage_for_path(&base.join("6_archived/10_bug_bar.md")),
|
||||
Some("6_archived".to_string())
|
||||
);
|
||||
assert_eq!(stage_for_path(&base.join("other/file.md")), None);
|
||||
assert_eq!(
|
||||
@@ -571,7 +651,11 @@ mod tests {
|
||||
assert_eq!(action, "start");
|
||||
assert_eq!(msg, "story-kit: start 42_story_foo");
|
||||
|
||||
let (action, msg) = stage_metadata("5_archived", "42_story_foo").unwrap();
|
||||
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
|
||||
assert_eq!(action, "done");
|
||||
assert_eq!(msg, "story-kit: done 42_story_foo");
|
||||
|
||||
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
|
||||
assert_eq!(action, "accept");
|
||||
assert_eq!(msg, "story-kit: accept 42_story_foo");
|
||||
|
||||
@@ -615,4 +699,48 @@ mod tests {
|
||||
let other_root_config = PathBuf::from("/other/.story_kit/project.toml");
|
||||
assert!(!is_config_file(&other_root_config, &git_root));
|
||||
}
|
||||
|
||||
// ── sweep_done_to_archived ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sweep_moves_old_items_to_archived() {
|
||||
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 5 hours ago.
|
||||
let story_path = done_dir.join("10_story_old.md");
|
||||
fs::write(&story_path, "---\nname: old\n---\n").unwrap();
|
||||
let past = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(5 * 60 * 60))
|
||||
.unwrap();
|
||||
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
|
||||
.unwrap();
|
||||
|
||||
sweep_done_to_archived(&work_dir);
|
||||
|
||||
assert!(!story_path.exists(), "old item should be moved out of 5_done/");
|
||||
assert!(
|
||||
archived_dir.join("10_story_old.md").exists(),
|
||||
"old item should appear in 6_archived/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_keeps_recent_items_in_done() {
|
||||
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 with a recent mtime (now).
|
||||
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);
|
||||
|
||||
assert!(story_path.exists(), "recent item should remain in 5_done/");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user