huskies: merge 615_story_extract_timer_service

This commit is contained in:
dave
2026-04-24 17:39:42 +00:00
parent 62bfaf20f4
commit eca0ef792c
13 changed files with 1603 additions and 1153 deletions
+468
View File
@@ -0,0 +1,468 @@
//! Timer service — deferred agent start via one-shot timers.
//!
//! Provides [`TimerStore`] for persisting timers to `.huskies/timers.json`
//! and command parsing / handling for the `timer` bot command.
//! Due timers are fired by the unified background tick loop in `main`.
//!
//! Follows service-module conventions:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem, clock, spawn)
//! - `parse.rs` — pure: command parsing, time display formatting
//! - `persist.rs` — pure: serialisation/deserialisation of `timers.json`
//! - `schedule.rs` — pure: next-fire-time calculation given a reference instant
pub(super) mod io;
pub(super) mod parse;
pub(super) mod persist;
pub(super) mod schedule;
pub use io::{TimerStore, next_occurrence_of_hhmm, spawn_rate_limit_auto_scheduler, tick_once};
pub use parse::{TimerCommand, extract_timer_command};
pub use persist::TimerEntry;
use std::path::Path;
// ── Error type ────────────────────────────────────────────────────────────────
/// Typed errors returned by `service::timer` operations.
///
/// HTTP handlers and bot commands may map these to user-facing messages:
/// - [`Error::Parse`] → "Invalid time format"
/// - [`Error::DuplicateSchedule`] → "Timer already scheduled"
/// - [`Error::NoSuchSchedule`] → "No timer found"
/// - [`Error::Io`] → "Internal error saving timer"
#[derive(Debug)]
#[allow(dead_code)]
pub enum Error {
/// The supplied `HH:MM` string could not be parsed or is out of range.
Parse(String),
/// A timer already exists for the given story ID.
DuplicateSchedule(String),
/// No timer exists for the given story ID.
NoSuchSchedule(String),
/// A filesystem read or write operation failed.
Io(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(msg) => write!(f, "Parse error: {msg}"),
Self::DuplicateSchedule(id) => {
write!(f, "Timer already exists for story '{id}'")
}
Self::NoSuchSchedule(id) => write!(f, "No timer found for story '{id}'"),
Self::Io(msg) => write!(f, "I/O error: {msg}"),
}
}
}
// ── Typed public API ──────────────────────────────────────────────────────────
/// Schedule a new timer for `story_id` to fire at the next `HH:MM` occurrence.
#[allow(dead_code)]
///
/// Returns the scheduled UTC instant on success.
///
/// # Errors
/// - [`Error::DuplicateSchedule`] if a timer already exists for `story_id`.
/// - [`Error::Parse`] if `hhmm` is not a valid `HH:MM` string.
/// - [`Error::Io`] if persisting the timer to disk fails.
pub fn schedule_timer(
store: &TimerStore,
story_id: &str,
hhmm: &str,
timezone: Option<&str>,
) -> Result<chrono::DateTime<chrono::Utc>, Error> {
if store.list().iter().any(|t| t.story_id == story_id) {
return Err(Error::DuplicateSchedule(story_id.to_string()));
}
let scheduled_at = next_occurrence_of_hhmm(hhmm, timezone)
.ok_or_else(|| Error::Parse(format!("invalid HH:MM: '{hhmm}'")))?;
store
.add(story_id.to_string(), scheduled_at)
.map_err(Error::Io)?;
Ok(scheduled_at)
}
/// Cancel an existing timer for `story_id`.
#[allow(dead_code)]
///
/// # Errors
/// - [`Error::NoSuchSchedule`] if no timer exists for `story_id`.
pub fn cancel_timer(store: &TimerStore, story_id: &str) -> Result<(), Error> {
if store.remove(story_id) {
Ok(())
} else {
Err(Error::NoSuchSchedule(story_id.to_string()))
}
}
// ── Command handler ────────────────────────────────────────────────────────────
/// Handle a parsed `timer` command. Returns a markdown-formatted response.
pub async fn handle_timer_command(
cmd: TimerCommand,
store: &TimerStore,
project_root: &Path,
) -> String {
// Load the configured timezone (if any) from project.toml.
let config_tz: Option<String> = crate::config::ProjectConfig::load(project_root)
.ok()
.and_then(|c| c.timezone);
let tz_str: Option<&str> = config_tz.as_deref();
match cmd {
TimerCommand::Schedule {
story_number_or_id,
hhmm,
} => {
let story_id = match io::resolve_story_id(&story_number_or_id, project_root) {
Some(id) => id,
None => {
return format!("No story with number or ID **{story_number_or_id}** found.");
}
};
// The story must be in backlog or current. When the timer fires,
// backlog stories are moved to current automatically.
// Check CRDT state first, then fall back to filesystem.
let in_valid_stage =
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
use crate::pipeline_state::Stage;
matches!(item.stage, Stage::Backlog | Stage::Coding)
} else {
let work_dir = project_root.join(".huskies").join("work");
work_dir
.join("1_backlog")
.join(format!("{story_id}.md"))
.exists()
|| work_dir
.join("2_current")
.join(format!("{story_id}.md"))
.exists()
};
if !in_valid_stage {
return format!("Story **{story_id}** is not in backlog or current.");
}
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
Some(t) => t,
None => {
return format!("Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`).");
}
};
match store.add(story_id.clone(), scheduled_at) {
Ok(()) => {
let (display_time, tz_label) = parse::format_in_timezone(scheduled_at, tz_str);
format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).")
}
Err(e) => format!("Failed to save timer: {e}"),
}
}
TimerCommand::List => {
let timers = store.list();
if timers.is_empty() {
return "No pending timers.".to_string();
}
let mut lines = vec!["**Pending timers:**".to_string()];
for t in &timers {
let (display_time, _) = parse::format_in_timezone(t.scheduled_at, tz_str);
lines.push(format!("- **{}** → {}", t.story_id, display_time));
}
lines.join("\n")
}
TimerCommand::Cancel { story_number_or_id } => {
let story_id = io::resolve_story_id(&story_number_or_id, project_root)
.unwrap_or(story_number_or_id.clone());
if store.remove(&story_id) {
format!("Timer for **{story_id}** cancelled.")
} else {
format!("No timer found for **{story_id}**.")
}
}
TimerCommand::BadArgs => "Usage:\n\
- `timer <story_id> <HH:MM>` — schedule deferred start\n\
- `timer list` — show pending timers\n\
- `timer cancel <story_id>` — remove a timer"
.to_string(),
}
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use tempfile::TempDir;
// ── Error Display ─────────────────────────────────────────────────────────
#[test]
fn error_parse_display() {
let e = Error::Parse("bad value".to_string());
assert!(e.to_string().contains("Parse error"));
assert!(e.to_string().contains("bad value"));
}
#[test]
fn error_duplicate_schedule_display() {
let e = Error::DuplicateSchedule("421_story_foo".to_string());
assert!(e.to_string().contains("already exists"));
assert!(e.to_string().contains("421_story_foo"));
}
#[test]
fn error_no_such_schedule_display() {
let e = Error::NoSuchSchedule("421_story_foo".to_string());
assert!(e.to_string().contains("No timer found"));
assert!(e.to_string().contains("421_story_foo"));
}
#[test]
fn error_io_display() {
let e = Error::Io("disk full".to_string());
assert!(e.to_string().contains("I/O error"));
assert!(e.to_string().contains("disk full"));
}
// ── schedule_timer ─────────────────────────────────────────────────────
#[test]
fn schedule_timer_returns_duplicate_when_already_exists() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let t = Utc::now() + Duration::hours(1);
store.add("421_story_foo".to_string(), t).unwrap();
let result = schedule_timer(&store, "421_story_foo", "14:30", None);
assert!(
matches!(result, Err(Error::DuplicateSchedule(_))),
"expected DuplicateSchedule: {result:?}"
);
}
#[test]
fn schedule_timer_returns_parse_error_for_bad_hhmm() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = schedule_timer(&store, "421_story_foo", "99:99", None);
assert!(
matches!(result, Err(Error::Parse(_))),
"expected Parse error: {result:?}"
);
}
// ── cancel_timer ───────────────────────────────────────────────────────
#[test]
fn cancel_timer_returns_no_such_when_missing() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = cancel_timer(&store, "421_story_foo");
assert!(
matches!(result, Err(Error::NoSuchSchedule(_))),
"expected NoSuchSchedule: {result:?}"
);
}
#[test]
fn cancel_timer_succeeds_when_exists() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let t = Utc::now() + Duration::hours(1);
store.add("421_story_foo".to_string(), t).unwrap();
assert!(cancel_timer(&store, "421_story_foo").is_ok());
assert!(store.list().is_empty());
}
// ── handle_timer_command ────────────────────────────────────────────
#[tokio::test]
async fn handle_list_empty() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
assert!(result.contains("No pending timers"), "unexpected: {result}");
}
#[tokio::test]
async fn handle_cancel_not_found() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(
TimerCommand::Cancel {
story_number_or_id: "421_story_foo".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(result.contains("No timer found"), "unexpected: {result}");
}
#[tokio::test]
async fn handle_schedule_story_not_in_backlog_or_current() {
let dir = TempDir::new().unwrap();
// Ensure CRDT content store is initialised so the DB-first lookup works.
crate::db::ensure_content_store();
// No story written — "9950_story_timer_neg" should not be found.
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(
TimerCommand::Schedule {
story_number_or_id: "9950_story_timer_neg".to_string(),
hhmm: "14:30".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(
result.contains("not in backlog or current"),
"unexpected: {result}"
);
}
#[tokio::test]
async fn handle_schedule_accepts_backlog_story() {
let dir = TempDir::new().unwrap();
let backlog_dir = dir.path().join(".huskies/work/1_backlog");
std::fs::create_dir_all(&backlog_dir).unwrap();
std::fs::write(
backlog_dir.join("421_story_foo.md"),
"---\nname: Foo\n---\n",
)
.unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(
TimerCommand::Schedule {
story_number_or_id: "421_story_foo".to_string(),
hhmm: "14:30".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(
result.contains("Timer set"),
"backlog story should be accepted: {result}"
);
}
#[tokio::test]
async fn handle_schedule_success() {
let dir = TempDir::new().unwrap();
let current_dir = dir.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current_dir).unwrap();
std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(
TimerCommand::Schedule {
story_number_or_id: "421_story_foo".to_string(),
hhmm: "23:59".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(result.contains("Timer set for"), "unexpected: {result}");
assert_eq!(store.list().len(), 1);
}
#[tokio::test]
async fn handle_schedule_invalid_time() {
let dir = TempDir::new().unwrap();
let current_dir = dir.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current_dir).unwrap();
std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let result = handle_timer_command(
TimerCommand::Schedule {
story_number_or_id: "421_story_foo".to_string(),
hhmm: "99:00".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(result.contains("Invalid time"), "unexpected: {result}");
}
#[tokio::test]
async fn handle_cancel_existing_timer() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let future = Utc::now() + Duration::hours(1);
store.add("421_story_foo".to_string(), future).unwrap();
let result = handle_timer_command(
TimerCommand::Cancel {
story_number_or_id: "421_story_foo".to_string(),
},
&store,
dir.path(),
)
.await;
assert!(result.contains("cancelled"), "unexpected: {result}");
assert!(store.list().is_empty());
}
#[tokio::test]
async fn handle_list_with_entries() {
let dir = TempDir::new().unwrap();
let store = TimerStore::load(dir.path().join("timers.json"));
let future = Utc::now() + Duration::hours(1);
store.add("421_story_foo".to_string(), future).unwrap();
let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
assert!(result.contains("421_story_foo"), "unexpected: {result}");
assert!(result.contains("Pending timers"), "unexpected: {result}");
}
// ── firing a timer for a backlog story moves it to current ───────────
/// When a timer fires for a story in backlog, the tick loop calls
/// `move_story_to_current` before `start_agent`. This test exercises
/// that exact sequence (minus the agent pool) to prove the story ends
/// up in `2_current/` after firing.
#[test]
fn fired_timer_for_backlog_story_moves_to_current() {
use std::fs;
let dir = TempDir::new().unwrap();
let root = dir.path();
let backlog = root.join(".huskies/work/1_backlog");
let current = root.join(".huskies/work/2_current");
fs::create_dir_all(&backlog).unwrap();
fs::create_dir_all(&current).unwrap();
let content = "---\nname: Foo\n---\n";
fs::write(backlog.join("421_story_foo.md"), content).unwrap();
crate::db::ensure_content_store();
crate::db::write_content("421_story_foo", content);
// Add a past timer so take_due returns it immediately.
let store = TimerStore::load(root.join("timers.json"));
let past = Utc::now() - Duration::seconds(1);
store.add("421_story_foo".to_string(), past).unwrap();
// Drain due timers — same as the tick loop does.
let due = store.take_due(Utc::now());
assert_eq!(due.len(), 1, "expected one fired timer");
// Apply the move-to-current step the tick loop performs.
for entry in &due {
crate::agents::lifecycle::move_story_to_current(root, &entry.story_id)
.expect("move_story_to_current should succeed for backlog story");
}
// Story must still be accessible in the content store after the move.
assert!(
crate::db::read_content("421_story_foo").is_some(),
"story should be in the content store after timer fires"
);
// Timer was consumed.
assert!(
store.list().is_empty(),
"fired timer should be removed from store"
);
}
}