From aef022c74c167d6dd19b41c5663cb58310d44cc0 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 17:01:57 +0000 Subject: [PATCH] story-kit: merge 151_story_split_archived_into_done_and_archived_with_time_based_promotion Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 34 ++++++++- Cargo.toml | 1 + server/Cargo.toml | 1 + server/src/agents.rs | 63 ++++++++-------- server/src/http/agents.rs | 4 +- server/src/http/mcp.rs | 8 +- server/src/http/workflow.rs | 8 +- server/src/io/watcher.rs | 142 ++++++++++++++++++++++++++++++++++-- 8 files changed, 212 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50fe2e3..ce69054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1158,6 +1169,17 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", + "redox_syscall 0.7.2", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1374,7 +1396,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -1700,6 +1722,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "regex" version = "1.12.3" @@ -2190,6 +2221,7 @@ dependencies = [ "bytes 1.11.1", "chrono", "eventsource-stream", + "filetime", "futures", "homedir", "ignore", diff --git a/Cargo.toml b/Cargo.toml index 806e6e4..00accd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ toml = "1.0.3+spec-1.1.0" uuid = { version = "1.21.0", features = ["v4", "serde"] } tokio-tungstenite = "0.28.0" walkdir = "2.5.0" +filetime = "0.2" diff --git a/server/Cargo.toml b/server/Cargo.toml index 37e9fb0..2032b86 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -33,3 +33,4 @@ walkdir = { workspace = true } tempfile = { workspace = true } tokio-tungstenite = { workspace = true } mockito = "1" +filetime = { workspace = true } diff --git a/server/src/agents.rs b/server/src/agents.rs index ede58be..c547cc9 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -2031,9 +2031,9 @@ fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf { project_root.join(".story_kit").join("work").join("1_upcoming") } -/// Return the archive directory path for a work item (always work/5_archived/). +/// Return the done directory path for a work item (always work/5_done/). fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf { - project_root.join(".story_kit").join("work").join("5_archived") + project_root.join(".story_kit").join("work").join("5_done") } /// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`. @@ -2075,21 +2075,22 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), Ok(()) } -/// Move a story from `work/2_current/` to `work/5_archived/` and auto-commit. +/// Move a story from `work/2_current/` to `work/5_done/` and auto-commit. /// -/// * If the story is in `2_current/`, it is moved to `5_archived/` and committed. -/// * If the story is in `4_merge/`, it is moved to `5_archived/` and committed. -/// * If the story is already in `5_archived/`, this is a no-op (idempotent). -/// * If the story is not found in `2_current/`, `4_merge/`, or `5_archived/`, an error is returned. +/// * If the story is in `2_current/`, it is moved to `5_done/` and committed. +/// * If the story is in `4_merge/`, it is moved to `5_done/` and committed. +/// * If the story is already in `5_done/` or `6_archived/`, this is a no-op (idempotent). +/// * If the story is not found in `2_current/`, `4_merge/`, `5_done/`, or `6_archived/`, an error is returned. pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".story_kit").join("work"); let current_path = sk.join("2_current").join(format!("{story_id}.md")); let merge_path = sk.join("4_merge").join(format!("{story_id}.md")); - let archived_dir = sk.join("5_archived"); - let archived_path = archived_dir.join(format!("{story_id}.md")); + let done_dir = sk.join("5_done"); + let done_path = done_dir.join(format!("{story_id}.md")); + let archived_path = sk.join("6_archived").join(format!("{story_id}.md")); - if archived_path.exists() { - // Already archived — idempotent, nothing to do. + if done_path.exists() || archived_path.exists() { + // Already in done or archived — idempotent, nothing to do. return Ok(()); } @@ -2104,17 +2105,17 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), )); }; - std::fs::create_dir_all(&archived_dir) - .map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?; - std::fs::rename(&source_path, &archived_path) - .map_err(|e| format!("Failed to move story '{story_id}' to 5_archived/: {e}"))?; + std::fs::create_dir_all(&done_dir) + .map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?; + std::fs::rename(&source_path, &done_path) + .map_err(|e| format!("Failed to move story '{story_id}' to 5_done/: {e}"))?; let from_dir = if source_path == current_path { "work/2_current/" } else { "work/4_merge/" }; - slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/"); + slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_done/"); Ok(()) } @@ -2192,11 +2193,11 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin Ok(()) } -/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_archived/` and auto-commit. +/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit. /// -/// * If the bug is in `2_current/`, it is moved to `5_archived/` and committed. -/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_archived/`. -/// * If the bug is already in `5_archived/`, this is a no-op (idempotent). +/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed. +/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_done/`. +/// * If the bug is already in `5_done/`, this is a no-op (idempotent). /// * If the bug is not found anywhere, an error is returned. pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> { let sk = project_root.join(".story_kit").join("work"); @@ -2220,12 +2221,12 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str }; std::fs::create_dir_all(&archive_dir) - .map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?; + .map_err(|e| format!("Failed to create work/5_done/ directory: {e}"))?; std::fs::rename(&source_path, &archive_path) - .map_err(|e| format!("Failed to move bug '{bug_id}' to 5_archived/: {e}"))?; + .map_err(|e| format!("Failed to move bug '{bug_id}' to 5_done/: {e}"))?; slog!( - "[lifecycle] Closed bug '{bug_id}' → work/5_archived/" + "[lifecycle] Closed bug '{bug_id}' → work/5_done/" ); Ok(()) @@ -3676,7 +3677,7 @@ mod tests { close_bug_to_archive(root, "2_bug_test").unwrap(); assert!(!current.join("2_bug_test.md").exists()); - assert!(root.join(".story_kit/work/5_archived/2_bug_test.md").exists()); + assert!(root.join(".story_kit/work/5_done/2_bug_test.md").exists()); } #[test] @@ -3691,7 +3692,7 @@ mod tests { close_bug_to_archive(root, "3_bug_test").unwrap(); assert!(!upcoming.join("3_bug_test.md").exists()); - assert!(root.join(".story_kit/work/5_archived/3_bug_test.md").exists()); + assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists()); } #[test] @@ -3944,7 +3945,7 @@ mod tests { move_story_to_archived(root, "22_story_test").unwrap(); assert!(!merge_dir.join("22_story_test.md").exists()); - assert!(root.join(".story_kit/work/5_archived/22_story_test.md").exists()); + assert!(root.join(".story_kit/work/5_done/22_story_test.md").exists()); } #[test] @@ -4036,10 +4037,10 @@ mod tests { report.success || report.gate_output.contains("Failed to run") || !report.gates_passed, "report should be coherent: {report:?}" ); - // Story should be archived if gates passed + // Story should be in done if gates passed if report.story_archived { - let archived = repo.join(".story_kit/work/5_archived/23_test.md"); - assert!(archived.exists(), "archived file should exist"); + let done = repo.join(".story_kit/work/5_done/23_test.md"); + assert!(done.exists(), "done file should exist"); } } @@ -5737,8 +5738,8 @@ theirs assert_eq!(remaining.len(), 1, "only the other story's agent should remain"); assert_eq!(remaining[0].story_id, "61_story_other"); - // Story file should be in 5_archived/ - assert!(root.join(".story_kit/work/5_archived/60_story_cleanup.md").exists()); + // Story file should be in 5_done/ + assert!(root.join(".story_kit/work/5_done/60_story_cleanup.md").exists()); } // ── bug 154: merge worktree installs frontend deps ──────────────────── diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index 12bdced..8c8da35 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -61,7 +61,7 @@ struct WorktreeListEntry { path: String, } -/// Returns true if the story file exists in `work/5_archived/`. +/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` /// response so the agents panel is not cluttered with old completed items on @@ -139,7 +139,7 @@ impl AgentsApi { /// List all agents with their status. /// - /// Agents for stories that have been archived (`work/5_archived/`) are + /// Agents for stories that have been completed (`work/5_done/` or `work/6_archived/`) are /// excluded so the agents panel is not cluttered with old completed items /// on frontend startup. #[oai(path = "/agents", method = "get")] diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 0a25639..7cb3f15 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -586,7 +586,7 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, { "name": "accept_story", - "description": "Accept a story: moves it from current/ to archived/ and auto-commits to master.", + "description": "Accept a story: moves it from current/ to done/ and auto-commits to master.", "inputSchema": { "type": "object", "properties": { @@ -693,7 +693,7 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, { "name": "merge_agent_work", - "description": "Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), archive the story from work/4_merge/ or work/2_current/ to work/5_archived/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output.", + "description": "Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), move the story from work/4_merge/ or work/2_current/ to work/5_done/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output.", "inputSchema": { "type": "object", "properties": { @@ -1301,7 +1301,7 @@ fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result { ctx.agents.remove_agents_for_story(story_id); Ok(format!( - "Story '{story_id}' accepted, moved to archived/, and committed to master." + "Story '{story_id}' accepted, moved to done/, and committed to master." )) } @@ -2251,7 +2251,7 @@ mod tests { let result = tool_close_bug(&json!({"bug_id": "1_bug_crash"}), &ctx).unwrap(); assert!(result.contains("1_bug_crash")); assert!(!bug_file.exists()); - assert!(tmp.path().join(".story_kit/work/5_archived/1_bug_crash.md").exists()); + assert!(tmp.path().join(".story_kit/work/5_done/1_bug_crash.md").exists()); } // ── Spike lifecycle tool tests ───────────────────────────────────────── diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 740c414..4a2bf36 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -505,7 +505,7 @@ fn next_item_number(root: &std::path::Path) -> Result { let work_base = root.join(".story_kit").join("work"); let mut max_num: u32 = 0; - for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_archived"] { + for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] { let dir = work_base.join(subdir); if !dir.exists() { continue; @@ -869,7 +869,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); let current = tmp.path().join(".story_kit/work/2_current"); - let archived = tmp.path().join(".story_kit/work/5_archived"); + let archived = tmp.path().join(".story_kit/work/5_done"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&archived).unwrap(); @@ -1116,7 +1116,7 @@ mod tests { fn next_item_number_scans_archived_too() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); - let archived = tmp.path().join(".story_kit/work/5_archived"); + let archived = tmp.path().join(".story_kit/work/5_done"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(&archived).unwrap(); fs::write(archived.join("5_bug_old.md"), "").unwrap(); @@ -1134,7 +1134,7 @@ mod tests { fn list_bug_files_excludes_archive_subdir() { let tmp = tempfile::tempdir().unwrap(); let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming"); - let archived_dir = tmp.path().join(".story_kit/work/5_archived"); + let archived_dir = tmp.path().join(".story_kit/work/5_done"); fs::create_dir_all(&upcoming_dir).unwrap(); fs::create_dir_all(&archived_dir).unwrap(); fs::write(upcoming_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap(); diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 8748399..b85992b 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -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 { .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 = HashMap::new(); // Whether a config file change is pending in the current debounce window. let mut config_changed_pending = false; let mut deadline: Option = 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/"); + } }