2026-03-28 08:59:36 +00:00
|
|
|
//! Deferred agent start via one-shot timers.
|
|
|
|
|
//!
|
|
|
|
|
//! Provides [`TimerStore`] for persisting timers to `.storkit/timers.json`,
|
|
|
|
|
//! a 30-second tick loop ([`spawn_timer_tick_loop`]) that fires due timers,
|
|
|
|
|
//! and command parsing / handling for the `timer` bot command.
|
|
|
|
|
|
|
|
|
|
use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
2026-03-28 18:33:22 +00:00
|
|
|
use crate::chat::util::strip_bot_mention;
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
// ── Data types ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// A single scheduled timer entry.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
pub struct TimerEntry {
|
|
|
|
|
/// The full story ID (filename stem, e.g. `421_story_foo`).
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
/// UTC instant at which the timer should fire.
|
|
|
|
|
pub scheduled_at: DateTime<Utc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── TimerStore ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Persistent store for pending timers, backed by a JSON file.
|
|
|
|
|
pub struct TimerStore {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
timers: Mutex<Vec<TimerEntry>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TimerStore {
|
|
|
|
|
/// Load the timer store from `path`. Returns an empty store if the file
|
|
|
|
|
/// does not exist or cannot be parsed.
|
|
|
|
|
pub fn load(path: PathBuf) -> Self {
|
|
|
|
|
let timers = if path.exists() {
|
|
|
|
|
std::fs::read_to_string(&path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| serde_json::from_str::<Vec<TimerEntry>>(&s).ok())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
};
|
|
|
|
|
Self {
|
|
|
|
|
path,
|
|
|
|
|
timers: Mutex::new(timers),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save_locked(path: &Path, timers: &[TimerEntry]) -> Result<(), String> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent)
|
|
|
|
|
.map_err(|e| format!("Failed to create directory: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
let content =
|
|
|
|
|
serde_json::to_string_pretty(timers).map_err(|e| format!("Serialize failed: {e}"))?;
|
|
|
|
|
std::fs::write(path, content).map_err(|e| format!("Failed to write timers: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add a timer and persist to disk.
|
|
|
|
|
pub fn add(&self, story_id: String, scheduled_at: DateTime<Utc>) -> Result<(), String> {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
timers.push(TimerEntry {
|
|
|
|
|
story_id,
|
|
|
|
|
scheduled_at,
|
|
|
|
|
});
|
|
|
|
|
Self::save_locked(&self.path, &timers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove the timer for `story_id`. Returns `true` if one was removed.
|
|
|
|
|
pub fn remove(&self, story_id: &str) -> bool {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
let before = timers.len();
|
|
|
|
|
timers.retain(|t| t.story_id != story_id);
|
|
|
|
|
let removed = timers.len() < before;
|
|
|
|
|
if removed {
|
|
|
|
|
let _ = Self::save_locked(&self.path, &timers);
|
|
|
|
|
}
|
|
|
|
|
removed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return all pending timers (cloned).
|
|
|
|
|
pub fn list(&self) -> Vec<TimerEntry> {
|
|
|
|
|
self.timers.lock().unwrap().clone()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 09:18:58 +00:00
|
|
|
/// Add or update a timer for `story_id`.
|
|
|
|
|
///
|
|
|
|
|
/// - If no timer exists for `story_id`, adds it.
|
|
|
|
|
/// - If a timer already exists and `scheduled_at` is **later**, updates it.
|
|
|
|
|
/// - If a timer already exists and `scheduled_at` is earlier or equal, no-op.
|
|
|
|
|
///
|
|
|
|
|
/// Use this instead of [`add`] when auto-scheduling from rate-limit events to
|
|
|
|
|
/// avoid creating duplicates and to always keep the latest reset time.
|
|
|
|
|
pub fn upsert(&self, story_id: String, scheduled_at: DateTime<Utc>) -> Result<(), String> {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
if let Some(existing) = timers.iter_mut().find(|t| t.story_id == story_id) {
|
|
|
|
|
if scheduled_at > existing.scheduled_at {
|
|
|
|
|
existing.scheduled_at = scheduled_at;
|
|
|
|
|
Self::save_locked(&self.path, &timers)?;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
timers.push(TimerEntry {
|
|
|
|
|
story_id,
|
|
|
|
|
scheduled_at,
|
|
|
|
|
});
|
|
|
|
|
Self::save_locked(&self.path, &timers)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
/// Remove and return all timers whose `scheduled_at` is ≤ `now`.
|
|
|
|
|
/// Persists the updated list to disk if any timers were removed.
|
|
|
|
|
pub fn take_due(&self, now: DateTime<Utc>) -> Vec<TimerEntry> {
|
|
|
|
|
let mut timers = self.timers.lock().unwrap();
|
|
|
|
|
let mut due = Vec::new();
|
|
|
|
|
let mut remaining = Vec::new();
|
|
|
|
|
for t in timers.drain(..) {
|
|
|
|
|
if t.scheduled_at <= now {
|
|
|
|
|
due.push(t);
|
|
|
|
|
} else {
|
|
|
|
|
remaining.push(t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
*timers = remaining;
|
|
|
|
|
if !due.is_empty() {
|
|
|
|
|
let _ = Self::save_locked(&self.path, &timers);
|
|
|
|
|
}
|
|
|
|
|
due
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tick loop ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Spawn a background tokio task that fires due timers every 30 seconds.
|
|
|
|
|
///
|
|
|
|
|
/// Same pattern as the watchdog in `agents::pool::auto_assign`.
|
|
|
|
|
/// When a timer fires, `start_agent` is called for the story. If all coders
|
|
|
|
|
/// are busy the story remains in `2_current/` and auto-assign will pick it up.
|
|
|
|
|
pub fn spawn_timer_tick_loop(
|
|
|
|
|
store: Arc<TimerStore>,
|
|
|
|
|
agents: Arc<crate::agents::AgentPool>,
|
|
|
|
|
project_root: PathBuf,
|
|
|
|
|
) {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
|
|
|
|
loop {
|
|
|
|
|
interval.tick().await;
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
let due = store.take_due(now);
|
|
|
|
|
for entry in due {
|
|
|
|
|
crate::slog!(
|
2026-04-03 11:50:30 +00:00
|
|
|
"[timer] Timer fired for story {}",
|
2026-03-28 08:59:36 +00:00
|
|
|
entry.story_id
|
|
|
|
|
);
|
2026-04-03 11:50:30 +00:00
|
|
|
|
|
|
|
|
// Move from backlog to current if needed — the auto-assign
|
|
|
|
|
// watcher will then start an agent automatically.
|
|
|
|
|
if let Err(e) = crate::agents::lifecycle::move_story_to_current(
|
|
|
|
|
&project_root,
|
|
|
|
|
&entry.story_id,
|
|
|
|
|
) {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Failed to move story {} to current: {e}",
|
|
|
|
|
entry.story_id
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
match agents
|
|
|
|
|
.start_agent(&project_root, &entry.story_id, None, None)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(info) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Started agent {} for story {}",
|
|
|
|
|
info.agent_name,
|
|
|
|
|
entry.story_id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!(
|
2026-04-03 11:50:30 +00:00
|
|
|
"[timer] Failed to start agent for story {}: {e} \
|
|
|
|
|
(auto-assign may pick it up)",
|
2026-03-28 08:59:36 +00:00
|
|
|
entry.story_id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 09:18:58 +00:00
|
|
|
/// Spawn a background task that listens for [`WatcherEvent::RateLimitHardBlock`]
|
|
|
|
|
/// events and auto-schedules a timer for the blocked story.
|
|
|
|
|
///
|
|
|
|
|
/// If a timer already exists for the story, it is updated to the later reset time
|
|
|
|
|
/// rather than creating a duplicate (via [`TimerStore::upsert`]).
|
|
|
|
|
pub fn spawn_rate_limit_auto_scheduler(
|
|
|
|
|
store: Arc<TimerStore>,
|
|
|
|
|
mut watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
|
|
|
|
) {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
loop {
|
|
|
|
|
match watcher_rx.recv().await {
|
|
|
|
|
Ok(crate::io::watcher::WatcherEvent::RateLimitHardBlock {
|
|
|
|
|
story_id,
|
|
|
|
|
agent_name,
|
|
|
|
|
reset_at,
|
|
|
|
|
}) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Auto-scheduling timer for story {story_id} \
|
|
|
|
|
(agent {agent_name}) to resume at {reset_at}"
|
|
|
|
|
);
|
|
|
|
|
match store.upsert(story_id.clone(), reset_at) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Timer upserted for story {story_id}; \
|
|
|
|
|
scheduled at {reset_at}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Failed to upsert timer for story {story_id}: {e}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Rate-limit auto-scheduler lagged, skipped {n} events"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[timer] Watcher channel closed, stopping rate-limit auto-scheduler"
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
// ── Command types ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// A parsed `timer` command.
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
|
|
|
pub enum TimerCommand {
|
|
|
|
|
/// `timer <story_id_or_number> <HH:MM>` — schedule a deferred start.
|
|
|
|
|
Schedule {
|
|
|
|
|
story_number_or_id: String,
|
|
|
|
|
hhmm: String,
|
|
|
|
|
},
|
|
|
|
|
/// `timer list` — list all pending timers.
|
|
|
|
|
List,
|
|
|
|
|
/// `timer cancel <story_id_or_number>` — remove a pending timer.
|
|
|
|
|
Cancel { story_number_or_id: String },
|
|
|
|
|
/// Malformed arguments.
|
|
|
|
|
BadArgs,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Command extraction ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Parse a `timer` command from a raw message body.
|
|
|
|
|
///
|
|
|
|
|
/// Strips the bot mention prefix and matches the `timer` keyword.
|
|
|
|
|
/// Returns `None` when the message is not a timer command at all.
|
|
|
|
|
pub fn extract_timer_command(
|
|
|
|
|
message: &str,
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
bot_user_id: &str,
|
|
|
|
|
) -> Option<TimerCommand> {
|
2026-03-28 18:33:22 +00:00
|
|
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
2026-03-28 08:59:36 +00:00
|
|
|
let trimmed = stripped
|
|
|
|
|
.trim()
|
|
|
|
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
|
|
|
|
|
|
|
|
|
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
|
|
|
|
Some((c, a)) => (c, a.trim()),
|
|
|
|
|
None => (trimmed, ""),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !cmd.eq_ignore_ascii_case("timer") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `timer` with no args or `timer list`
|
|
|
|
|
if args.is_empty() || args.eq_ignore_ascii_case("list") {
|
|
|
|
|
return Some(TimerCommand::List);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (sub, rest) = match args.split_once(char::is_whitespace) {
|
|
|
|
|
Some((s, r)) => (s, r.trim()),
|
|
|
|
|
None => (args, ""),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// `timer cancel <id>`
|
|
|
|
|
if sub.eq_ignore_ascii_case("cancel") {
|
|
|
|
|
if rest.is_empty() {
|
|
|
|
|
return Some(TimerCommand::BadArgs);
|
|
|
|
|
}
|
|
|
|
|
return Some(TimerCommand::Cancel {
|
|
|
|
|
story_number_or_id: rest.to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `timer <id> <HH:MM>`
|
|
|
|
|
if rest.is_empty() {
|
|
|
|
|
return Some(TimerCommand::BadArgs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: sub.to_string(),
|
|
|
|
|
hhmm: rest.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 {
|
|
|
|
|
match cmd {
|
|
|
|
|
TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id,
|
|
|
|
|
hhmm,
|
|
|
|
|
} => {
|
|
|
|
|
let story_id = match 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."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-03 11:50:30 +00:00
|
|
|
// The story must be in backlog or current. When the timer fires,
|
|
|
|
|
// backlog stories are moved to current automatically.
|
|
|
|
|
let work_dir = project_root.join(".storkit").join("work");
|
|
|
|
|
let in_backlog = work_dir.join("1_backlog").join(format!("{story_id}.md")).exists();
|
|
|
|
|
let in_current = work_dir.join("2_current").join(format!("{story_id}.md")).exists();
|
|
|
|
|
if !in_backlog && !in_current {
|
2026-03-28 08:59:36 +00:00
|
|
|
return format!(
|
2026-04-03 11:50:30 +00:00
|
|
|
"Story **{story_id}** is not in backlog or current."
|
2026-03-28 08:59:36 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let scheduled_at = match next_occurrence_of_hhmm(&hhmm) {
|
|
|
|
|
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 local_time = scheduled_at.with_timezone(&Local);
|
|
|
|
|
format!(
|
|
|
|
|
"Timer set for **{story_id}** at **{}** (server local time).",
|
|
|
|
|
local_time.format("%Y-%m-%d %H:%M")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
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 local_time = t.scheduled_at.with_timezone(&Local);
|
|
|
|
|
lines.push(format!(
|
|
|
|
|
"- **{}** → {}",
|
|
|
|
|
t.story_id,
|
|
|
|
|
local_time.format("%Y-%m-%d %H:%M")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
lines.join("\n")
|
|
|
|
|
}
|
|
|
|
|
TimerCommand::Cancel { story_number_or_id } => {
|
|
|
|
|
let story_id = 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Parse `HH:MM` and return the next UTC instant at which the server-local
|
|
|
|
|
/// clock will read that time. If the time has already passed today, returns
|
|
|
|
|
/// tomorrow's occurrence.
|
|
|
|
|
pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
|
|
|
|
|
let (hh, mm) = hhmm.split_once(':')?;
|
|
|
|
|
let hours: u32 = hh.parse().ok()?;
|
|
|
|
|
let minutes: u32 = mm.parse().ok()?;
|
|
|
|
|
if hours > 23 || minutes > 59 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?;
|
|
|
|
|
let now_local = Local::now();
|
|
|
|
|
let today = now_local.date_naive();
|
|
|
|
|
let candidate = today.and_time(target_time);
|
|
|
|
|
let candidate_local = Local.from_local_datetime(&candidate).single()?;
|
|
|
|
|
if candidate_local > now_local {
|
|
|
|
|
Some(candidate_local.to_utc())
|
|
|
|
|
} else {
|
|
|
|
|
let tomorrow = today + Duration::days(1);
|
|
|
|
|
let tomorrow_candidate = tomorrow.and_time(target_time);
|
|
|
|
|
let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?;
|
|
|
|
|
Some(tomorrow_local.to_utc())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve a story ID from a numeric story number or a full ID string.
|
|
|
|
|
///
|
|
|
|
|
/// Searches all pipeline stages. Returns `None` only when the input is
|
|
|
|
|
/// numeric but no matching file is found.
|
|
|
|
|
fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
|
|
|
|
|
const STAGES: &[&str] = &[
|
|
|
|
|
"1_backlog",
|
|
|
|
|
"2_current",
|
|
|
|
|
"3_qa",
|
|
|
|
|
"4_merge",
|
|
|
|
|
"5_done",
|
|
|
|
|
"6_archived",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Full ID (contains underscores) — return as-is; validation happens at file-check time.
|
|
|
|
|
if number_or_id.contains('_') {
|
|
|
|
|
return Some(number_or_id.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Numeric lookup.
|
|
|
|
|
if !number_or_id.chars().all(|c| c.is_ascii_digit()) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for stage in STAGES {
|
|
|
|
|
let dir = project_root.join(".storkit").join("work").join(stage);
|
|
|
|
|
if !dir.exists() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
|
|
|
let file_num = stem
|
|
|
|
|
.split('_')
|
|
|
|
|
.next()
|
|
|
|
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
if file_num == number_or_id {
|
|
|
|
|
return Some(stem.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
// ── next_occurrence_of_hhmm ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn valid_hhmm_returns_some() {
|
|
|
|
|
let result = next_occurrence_of_hhmm("14:30");
|
|
|
|
|
assert!(result.is_some(), "valid HH:MM should return Some");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_hhmm_missing_colon_returns_none() {
|
|
|
|
|
assert!(next_occurrence_of_hhmm("1430").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_hhmm_bad_hours_returns_none() {
|
|
|
|
|
assert!(next_occurrence_of_hhmm("25:00").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn invalid_hhmm_bad_minutes_returns_none() {
|
|
|
|
|
assert!(next_occurrence_of_hhmm("12:60").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn next_occurrence_is_in_the_future() {
|
|
|
|
|
let result = next_occurrence_of_hhmm("14:30").unwrap();
|
|
|
|
|
assert!(result > Utc::now(), "next occurrence must be in the future");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── extract_timer_command ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn non_timer_command_returns_none() {
|
|
|
|
|
assert!(extract_timer_command("Timmy help", "Timmy", "@bot:home").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_list_no_args() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command("Timmy timer", "Timmy", "@bot:home"),
|
|
|
|
|
Some(TimerCommand::List)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_list_explicit() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command("Timmy timer list", "Timmy", "@bot:home"),
|
|
|
|
|
Some(TimerCommand::List)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_cancel_story_id() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command(
|
|
|
|
|
"Timmy timer cancel 421_story_foo",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@bot:home"
|
|
|
|
|
),
|
|
|
|
|
Some(TimerCommand::Cancel {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_cancel_no_arg_is_bad_args() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command("Timmy timer cancel", "Timmy", "@bot:home"),
|
|
|
|
|
Some(TimerCommand::BadArgs)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_schedule_with_story_id() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command(
|
|
|
|
|
"Timmy timer 421_story_foo 14:30",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@bot:home"
|
|
|
|
|
),
|
|
|
|
|
Some(TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "421_story_foo".to_string(),
|
|
|
|
|
hhmm: "14:30".to_string(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_schedule_with_number() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command("Timmy timer 421 14:30", "Timmy", "@bot:home"),
|
|
|
|
|
Some(TimerCommand::Schedule {
|
|
|
|
|
story_number_or_id: "421".to_string(),
|
|
|
|
|
hhmm: "14:30".to_string(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_schedule_missing_time_is_bad_args() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
extract_timer_command(
|
|
|
|
|
"Timmy timer 421_story_foo",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@bot:home"
|
|
|
|
|
),
|
|
|
|
|
Some(TimerCommand::BadArgs)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── TimerStore ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_store_empty_on_missing_file() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_store_add_and_list() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("story_1".to_string(), t).unwrap();
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].story_id, "story_1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_store_remove() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("story_1".to_string(), t).unwrap();
|
|
|
|
|
assert!(store.remove("story_1"));
|
|
|
|
|
assert!(!store.remove("story_1")); // already gone
|
|
|
|
|
assert!(store.list().is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn timer_store_persists_and_reloads() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("timers.json");
|
|
|
|
|
let t = Utc::now() + Duration::hours(2);
|
|
|
|
|
{
|
|
|
|
|
let store = TimerStore::load(path.clone());
|
|
|
|
|
store.add("421_story_foo".to_string(), t).unwrap();
|
|
|
|
|
}
|
|
|
|
|
// Reload from disk.
|
|
|
|
|
let store2 = TimerStore::load(path);
|
|
|
|
|
let list = store2.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].story_id, "421_story_foo");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn take_due_returns_only_past_entries() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let past = Utc::now() - Duration::minutes(1);
|
|
|
|
|
let future = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.add("past_story".to_string(), past).unwrap();
|
|
|
|
|
store.add("future_story".to_string(), future).unwrap();
|
|
|
|
|
|
|
|
|
|
let due = store.take_due(Utc::now());
|
|
|
|
|
assert_eq!(due.len(), 1);
|
|
|
|
|
assert_eq!(due[0].story_id, "past_story");
|
|
|
|
|
assert_eq!(store.list().len(), 1);
|
|
|
|
|
assert_eq!(store.list()[0].story_id, "future_story");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 09:18:58 +00:00
|
|
|
// ── AC3: upsert ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn upsert_adds_new_timer_when_none_exists() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let t = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.upsert("story_1".to_string(), t).unwrap();
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].story_id, "story_1");
|
|
|
|
|
assert_eq!(list[0].scheduled_at, t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn upsert_updates_to_later_time() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let early = Utc::now() + Duration::hours(1);
|
|
|
|
|
let later = Utc::now() + Duration::hours(2);
|
|
|
|
|
store.upsert("story_1".to_string(), early).unwrap();
|
|
|
|
|
store.upsert("story_1".to_string(), later).unwrap();
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1, "should not create duplicate");
|
|
|
|
|
assert_eq!(list[0].scheduled_at, later, "should update to later time");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn upsert_does_not_downgrade_to_earlier_time() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let later = Utc::now() + Duration::hours(2);
|
|
|
|
|
let earlier = Utc::now() + Duration::hours(1);
|
|
|
|
|
store.upsert("story_1".to_string(), later).unwrap();
|
|
|
|
|
store.upsert("story_1".to_string(), earlier).unwrap();
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
list[0].scheduled_at, later,
|
|
|
|
|
"should keep the later time, not downgrade"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── AC2: spawn_rate_limit_auto_scheduler ────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// AC2: a RateLimitHardBlock event causes the auto-scheduler to add a timer.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn rate_limit_auto_scheduler_adds_timer_on_hard_block() {
|
|
|
|
|
use crate::io::watcher::WatcherEvent;
|
|
|
|
|
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
|
|
|
|
|
let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
|
|
|
|
|
|
|
|
|
|
spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);
|
|
|
|
|
|
|
|
|
|
let reset_at = Utc::now() + Duration::hours(1);
|
|
|
|
|
watcher_tx
|
|
|
|
|
.send(WatcherEvent::RateLimitHardBlock {
|
|
|
|
|
story_id: "423_story_test".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
reset_at,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Give the spawned task time to process the event.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
|
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1, "expected one timer after hard block");
|
|
|
|
|
assert_eq!(list[0].story_id, "423_story_test");
|
|
|
|
|
assert_eq!(list[0].scheduled_at, reset_at);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// AC3 integration: a second hard block with a later reset_at updates the
|
|
|
|
|
/// existing timer rather than creating a duplicate.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn rate_limit_auto_scheduler_upserts_on_repeated_hard_block() {
|
|
|
|
|
use crate::io::watcher::WatcherEvent;
|
|
|
|
|
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
|
|
|
|
|
let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
|
|
|
|
|
|
|
|
|
|
spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);
|
|
|
|
|
|
|
|
|
|
let first = Utc::now() + Duration::hours(1);
|
|
|
|
|
let second = Utc::now() + Duration::hours(2);
|
|
|
|
|
|
|
|
|
|
watcher_tx
|
|
|
|
|
.send(WatcherEvent::RateLimitHardBlock {
|
|
|
|
|
story_id: "423_story_test".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
reset_at: first,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
|
|
|
watcher_tx
|
|
|
|
|
.send(WatcherEvent::RateLimitHardBlock {
|
|
|
|
|
story_id: "423_story_test".to_string(),
|
|
|
|
|
agent_name: "coder-1".to_string(),
|
|
|
|
|
reset_at: second,
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
|
|
|
|
|
|
let list = store.list();
|
|
|
|
|
assert_eq!(list.len(), 1, "should not create a duplicate timer");
|
|
|
|
|
assert_eq!(list[0].scheduled_at, second, "should update to later time");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
#[test]
|
|
|
|
|
fn multiple_timers_same_time_all_returned() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = TimerStore::load(dir.path().join("timers.json"));
|
|
|
|
|
let past = Utc::now() - Duration::minutes(1);
|
|
|
|
|
store.add("story_a".to_string(), past).unwrap();
|
|
|
|
|
store.add("story_b".to_string(), past).unwrap();
|
|
|
|
|
|
|
|
|
|
let due = store.take_due(Utc::now());
|
|
|
|
|
assert_eq!(due.len(), 2, "both timers at same time must fire");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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]
|
2026-04-03 11:50:30 +00:00
|
|
|
async fn handle_schedule_story_not_in_backlog_or_current() {
|
2026-03-28 08:59:36 +00:00
|
|
|
let dir = TempDir::new().unwrap();
|
2026-04-03 11:50:30 +00:00
|
|
|
// Set up directory structure with no story in backlog or current
|
|
|
|
|
std::fs::create_dir_all(dir.path().join(".storkit/work/1_backlog")).unwrap();
|
2026-03-28 08:59:36 +00:00
|
|
|
std::fs::create_dir_all(dir.path().join(".storkit/work/2_current")).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!(
|
2026-04-03 11:50:30 +00:00
|
|
|
result.contains("not in backlog or current"),
|
2026-03-28 08:59:36 +00:00
|
|
|
"unexpected: {result}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:50:30 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_accepts_backlog_story() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let backlog_dir = dir.path().join(".storkit/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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 08:59:36 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_schedule_success() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let current_dir = dir.path().join(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t_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(".storkit/work/2_current");
|
|
|
|
|
std::fs::create_dir_all(¤t_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}");
|
|
|
|
|
}
|
|
|
|
|
}
|