From 0c021d1387f114b8a9d32d91e571ff19bcabfb90 Mon Sep 17 00:00:00 2001 From: crsei Date: Tue, 21 Apr 2026 10:12:10 -0400 Subject: [PATCH] feat(commands): /loop /schedule /team-onboarding + expand /logout (#43 #58 #60 #63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship scheduling/automation and onboarding/logout groups as one change because they share infrastructure: - services/scheduler — JSON-backed recurring-task store with interval parsing, cross-process lockfile, and atomic writes. Used by both /loop (user-friendly wrapper, runs payload once immediately when it's a plain prompt) and /schedule (raw local-cron management; `remote` subcommand is explicitly refused until OAuth/remote-agent groundwork lands). - services/onboarding — shared onboarding state (`onboarding.json`) consumed by the expanded /logout reset flow and by /team-onboarding for tailored guide generation. - /logout — structured `LogoutReport` that clears credentials, resets onboarding (preserving display_name), and surfaces env overrides + managed-settings path without touching policy files. - /team-onboarding — Markdown guide grounded in real local state (CLAUDE.md, README.md, git origin/branch, skills registry, teams on disk, scheduled tasks). `save [path]` writes to a file. Tests: 21 scheduler + 4 onboarding + 9 loop + 10 schedule + 9 logout + 14 team-onboarding + registry assertions. Full non-UI suite passes serially (1253/1253); parallel-run failures are pre-existing flakes in unrelated modules. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/claude-code-rs/src/commands/logout.rs | 328 ++++++++-- .../claude-code-rs/src/commands/loop_cmd.rs | 415 ++++++++++++ crates/claude-code-rs/src/commands/mod.rs | 41 ++ .../claude-code-rs/src/commands/schedule.rs | 450 +++++++++++++ .../src/commands/team_onboarding.rs | 589 ++++++++++++++++++ crates/claude-code-rs/src/services/mod.rs | 2 + .../claude-code-rs/src/services/onboarding.rs | 284 +++++++++ .../src/services/scheduler/interval.rs | 249 ++++++++ .../src/services/scheduler/mod.rs | 28 + .../src/services/scheduler/store.rs | 411 ++++++++++++ .../src/services/scheduler/task.rs | 248 ++++++++ 11 files changed, 3007 insertions(+), 38 deletions(-) create mode 100644 crates/claude-code-rs/src/commands/loop_cmd.rs create mode 100644 crates/claude-code-rs/src/commands/schedule.rs create mode 100644 crates/claude-code-rs/src/commands/team_onboarding.rs create mode 100644 crates/claude-code-rs/src/services/onboarding.rs create mode 100644 crates/claude-code-rs/src/services/scheduler/interval.rs create mode 100644 crates/claude-code-rs/src/services/scheduler/mod.rs create mode 100644 crates/claude-code-rs/src/services/scheduler/store.rs create mode 100644 crates/claude-code-rs/src/services/scheduler/task.rs diff --git a/crates/claude-code-rs/src/commands/logout.rs b/crates/claude-code-rs/src/commands/logout.rs index 2e673cfb..48116668 100644 --- a/crates/claude-code-rs/src/commands/logout.rs +++ b/crates/claude-code-rs/src/commands/logout.rs @@ -1,39 +1,206 @@ -//! `/logout` command — clear stored authentication credentials. +//! `/logout` command — clear stored authentication credentials AND the +//! session-derived state that identity bleeds into (issue #43). //! -//! Removes: -//! - API key from system keychain -//! - OAuth tokens from disk (`~/.cc-rust/credentials.json`) +//! The narrow predecessor wiped auth material only. The expanded version +//! walks every location where identity-derived state lives and reports the +//! outcome of each step, so users can see exactly what was purged and what +//! still needs manual attention: +//! +//! - Auth: keychain API key + `credentials.json` OAuth tokens. +//! - Onboarding: the `/onboarding` wizard flag + completion stamp. +//! - Environment: `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are flagged +//! when set — we cannot unset them in the parent shell so we surface +//! them as a follow-up. +//! - Managed settings: pointed at but never touched — those are policy +//! files placed by administrators, not user-scoped caches. +//! +//! The command reports a structured multi-line result and returns success +//! even when there was nothing to clear (the action is idempotent). use anyhow::Result; use async_trait::async_trait; use super::{CommandContext, CommandHandler, CommandResult}; use crate::auth; +use crate::services::onboarding::OnboardingStore; pub struct LogoutHandler; #[async_trait] impl CommandHandler for LogoutHandler { async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> Result { - let current_auth = auth::resolve_auth(); + let report = run_logout(&auth::resolve_auth(), &OnboardingStore::open_default()); + Ok(CommandResult::Output(report.render())) + } +} + +// --------------------------------------------------------------------------- +// Core reset flow +// --------------------------------------------------------------------------- + +/// Structured result of one `/logout` invocation. Rendered to a human- +/// readable report for the user. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogoutReport { + pub was_authenticated: bool, + pub auth_cleared: StepStatus, + pub onboarding_cleared: StepStatus, + pub env_override_warning: Option, + pub managed_settings_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StepStatus { + /// Nothing to do (state was already clean). + NoOp, + /// The step successfully cleared state. + Cleared, + /// The step failed with the given error message. + Failed(String), +} + +impl Default for StepStatus { + fn default() -> Self { + StepStatus::NoOp + } +} + +impl StepStatus { + pub fn tag(&self) -> &'static str { + match self { + StepStatus::NoOp => "—", + StepStatus::Cleared => "✓", + StepStatus::Failed(_) => "!", + } + } - if !current_auth.is_authenticated() { - return Ok(CommandResult::Output( - "Not currently authenticated — nothing to clear.".to_string(), + pub fn detail(&self) -> String { + match self { + StepStatus::NoOp => "nothing to clear".to_string(), + StepStatus::Cleared => "cleared".to_string(), + StepStatus::Failed(msg) => format!("failed: {}", msg), + } + } +} + +impl LogoutReport { + pub fn render(&self) -> String { + let mut out = String::new(); + + if !self.was_authenticated + && matches!(self.onboarding_cleared, StepStatus::NoOp) + { + out.push_str( + "Not currently authenticated and no onboarding state — nothing to clear.\n", + ); + } else { + out.push_str("Logout complete. Cleanup summary:\n"); + } + + out.push_str(&format!( + " {} Auth credentials ({}, {})\n", + self.auth_cleared.tag(), + if self.was_authenticated { + "keychain + credentials.json" + } else { + "none was present" + }, + self.auth_cleared.detail() + )); + out.push_str(&format!( + " {} Onboarding state ({}, {})\n", + self.onboarding_cleared.tag(), + "onboarding.json", + self.onboarding_cleared.detail() + )); + + if let Some(warn) = &self.env_override_warning { + out.push_str(&format!( + "\nHeads up: {} — the environment still authenticates; \ + unset it in your shell before restarting cc-rust.\n", + warn )); } - // Clear all auth state: keychain + credentials.json - if let Err(e) = auth::oauth_logout() { - tracing::warn!(error = %e, "error during logout cleanup"); + if let Some(managed) = &self.managed_settings_path { + out.push_str(&format!( + "\nManaged (policy) settings at {} were NOT touched — they are \ + administrator-owned and outside the scope of /logout.\n", + managed + )); } - Ok(CommandResult::Output( - "Logged out successfully. Cleared keychain and stored OAuth tokens.\n\ - Note: environment variables (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) \ - must be unset manually." - .to_string(), - )) + out + } +} + +/// Run the full logout sequence. Parameterized for testability — unit tests +/// substitute a tempdir-backed `OnboardingStore` and a pre-built auth state. +fn run_logout(current_auth: &auth::AuthMethod, onboarding: &OnboardingStore) -> LogoutReport { + let was_authenticated = current_auth.is_authenticated(); + + let auth_cleared = if was_authenticated { + match auth::oauth_logout() { + Ok(_) => StepStatus::Cleared, + Err(e) => StepStatus::Failed(e.to_string()), + } + } else { + StepStatus::NoOp + }; + + let onboarding_cleared = { + // Use the in-place reset path so the user's display_name survives + // the logout — it's a preference, not an identity artifact. + let had_state_before = match onboarding.load() { + Ok(state) => !state.is_first_run() || onboarding.path().exists(), + Err(_) => onboarding.path().exists(), + }; + if had_state_before { + match onboarding.update(|s| s.reset_for_logout()) { + Ok(_) => StepStatus::Cleared, + Err(e) => StepStatus::Failed(e.to_string()), + } + } else { + StepStatus::NoOp + } + }; + + let env_override_warning = detect_env_override(); + let managed_settings_path = detect_managed_settings(); + + LogoutReport { + was_authenticated, + auth_cleared, + onboarding_cleared, + env_override_warning, + managed_settings_path, + } +} + +fn detect_env_override() -> Option { + const VARS: &[&str] = &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; + let present: Vec<&str> = VARS + .iter() + .copied() + .filter(|var| { + std::env::var(var) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + }) + .collect(); + if present.is_empty() { + None + } else { + Some(format!("{} is set in the environment", present.join(", "))) + } +} + +fn detect_managed_settings() -> Option { + let path = cc_config::settings::managed_settings_path(); + if path.exists() { + Some(path.display().to_string()) + } else { + None } } @@ -45,21 +212,21 @@ impl CommandHandler for LogoutHandler { mod tests { use super::*; use crate::bootstrap::SessionId; + use crate::services::onboarding::{OnboardingState, OnboardingStore}; use crate::types::app_state::AppState; + use chrono::Utc; use std::path::PathBuf; + use tempfile::tempdir; fn test_ctx() -> CommandContext { CommandContext { messages: Vec::new(), - cwd: PathBuf::from("/test"), + cwd: PathBuf::from("."), app_state: AppState::default(), session_id: SessionId::from_string("test-session"), } } - /// Verify the handler can be constructed and execute returns an Output variant. - /// The actual result depends on the runtime auth state (env vars, keychain, - /// credentials.json) so we only assert the shape, not the exact text. #[tokio::test] async fn test_logout_returns_output() { let handler = LogoutHandler; @@ -67,28 +234,113 @@ mod tests { let result = handler.execute("", &mut ctx).await.unwrap(); match result { CommandResult::Output(text) => { - // Either "not authenticated" or "logged out successfully" assert!(!text.is_empty()); } _ => panic!("Expected Output"), } } - /// Verify that the output mentions something actionable regardless of auth state. - #[tokio::test] - async fn test_logout_output_is_informative() { - let handler = LogoutHandler; - let mut ctx = test_ctx(); - let result = handler.execute("", &mut ctx).await.unwrap(); - if let CommandResult::Output(text) = result { - // One of two possible informative messages - let is_already_out = text.contains("Not currently authenticated"); - let is_logged_out = text.contains("Logged out successfully"); - assert!( - is_already_out || is_logged_out, - "unexpected logout output: {}", - text - ); - } + #[test] + fn unauthenticated_and_empty_onboarding_is_no_op_report() { + let dir = tempdir().unwrap(); + let store = OnboardingStore::new(dir.path().join("onboarding.json")); + let report = run_logout(&auth::AuthMethod::None, &store); + assert!(!report.was_authenticated); + assert!(matches!(report.auth_cleared, StepStatus::NoOp)); + assert!(matches!(report.onboarding_cleared, StepStatus::NoOp)); + let text = report.render(); + assert!(text.contains("nothing to clear")); + } + + #[test] + fn clears_onboarding_when_present_even_if_unauthenticated() { + let dir = tempdir().unwrap(); + let store = OnboardingStore::new(dir.path().join("onboarding.json")); + store + .update(|s| { + s.has_completed_onboarding = true; + s.completed_at = Some(Utc::now()); + s.display_name = Some("Sam".into()); + }) + .unwrap(); + assert!(store.path().exists()); + + let report = run_logout(&auth::AuthMethod::None, &store); + assert!(matches!(report.onboarding_cleared, StepStatus::Cleared)); + + // The file is rewritten (not deleted) so display_name survives. + let after = store.load().unwrap(); + assert!(!after.has_completed_onboarding); + assert!(!after.auth_onboarding_done); + assert!(after.completed_at.is_none()); + assert_eq!(after.display_name, Some("Sam".into())); + } + + #[test] + fn render_lists_every_step() { + let report = LogoutReport { + was_authenticated: true, + auth_cleared: StepStatus::Cleared, + onboarding_cleared: StepStatus::Cleared, + env_override_warning: None, + managed_settings_path: None, + }; + let text = report.render(); + assert!(text.contains("Auth credentials")); + assert!(text.contains("Onboarding state")); + assert!(text.contains("Logout complete")); + } + + #[test] + fn render_shows_env_override_warning_when_set() { + let report = LogoutReport { + env_override_warning: Some("ANTHROPIC_API_KEY is set in the environment".into()), + ..LogoutReport::default() + }; + let text = report.render(); + assert!(text.contains("ANTHROPIC_API_KEY")); + assert!(text.contains("unset it")); + } + + #[test] + fn render_notes_managed_settings_when_present() { + let report = LogoutReport { + managed_settings_path: Some("/etc/cc-rust/managed-settings.json".into()), + ..LogoutReport::default() + }; + let text = report.render(); + assert!(text.contains("Managed (policy) settings")); + assert!(text.contains("NOT touched")); + } + + #[test] + fn status_detail_strings_are_informative() { + assert_eq!(StepStatus::NoOp.detail(), "nothing to clear"); + assert_eq!(StepStatus::Cleared.detail(), "cleared"); + assert!(StepStatus::Failed("disk full".into()) + .detail() + .contains("disk full")); + } + + #[test] + fn onboarding_state_marker_survives_partial_logout() { + // Even if an empty file exists, we should still treat it as + // state-worth-resetting (belt-and-suspenders for weird edge cases). + let dir = tempdir().unwrap(); + let path = dir.path().join("onboarding.json"); + std::fs::write(&path, "").unwrap(); + let store = OnboardingStore::new(&path); + let report = run_logout(&auth::AuthMethod::None, &store); + assert!(matches!(report.onboarding_cleared, StepStatus::Cleared)); + // File is rewritten with default state, not deleted. + let state = store.load().unwrap(); + assert!(state.is_first_run()); + } + + #[test] + fn state_type_is_wired() { + // A compile-check: the LogoutReport consumes OnboardingState through + // the store, and both types must stay Send + serializable-shaped. + let _state = OnboardingState::default(); } } diff --git a/crates/claude-code-rs/src/commands/loop_cmd.rs b/crates/claude-code-rs/src/commands/loop_cmd.rs new file mode 100644 index 00000000..924847c2 --- /dev/null +++ b/crates/claude-code-rs/src/commands/loop_cmd.rs @@ -0,0 +1,415 @@ +//! `/loop` — user-friendly wrapper for the local recurring-task scheduler +//! (issue #58). +//! +//! The command is a thin veneer over [`crate::services::scheduler`]: it +//! parses a human interval + payload, persists a recurring task, and — for +//! plain-prompt payloads — returns a `Query` message so the prompt also +//! executes immediately, matching the Bun reference's behavior. +//! +//! Subcommands: +//! +//! /loop create a new looping task and run it once +//! /loop list list current loops +//! /loop remove delete a loop +//! /loop trigger mark a loop as fired now (re-runs the payload) +//! /loop pause temporarily suspend without deleting +//! /loop resume re-enable a paused loop +//! +//! `` is either a slash command (`/simplify`) or a plain prompt +//! (`review the last commit`). When the payload is a slash command, the +//! initial execution is not performed automatically — we report the +//! registration and instruct the user to run the command manually. When +//! it's a plain prompt, the wrapper returns a `CommandResult::Query` +//! carrying the prompt so the model sees it on this turn. + +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::services::scheduler::{ + parse_interval, ScheduledTask, SchedulerError, SchedulerKind, SchedulerStore, TaskId, + TaskPayload, +}; +use crate::types::message::{Message, MessageContent, UserMessage}; + +pub struct LoopHandler; + +#[async_trait] +impl CommandHandler for LoopHandler { + async fn execute(&self, args: &str, _ctx: &mut CommandContext) -> Result { + let store = SchedulerStore::open_default(); + Ok(dispatch(&store, args)) + } +} + +fn dispatch(store: &SchedulerStore, args: &str) -> CommandResult { + let trimmed = args.trim(); + if trimmed.is_empty() { + return CommandResult::Output(help_text()); + } + + let (head, rest) = split_first(trimmed); + match head.as_str() { + "list" | "ls" => CommandResult::Output(render_list(store)), + "help" | "--help" | "-h" => CommandResult::Output(help_text()), + "remove" | "rm" | "delete" => CommandResult::Output(remove_task(store, rest.trim())), + "trigger" | "fire" => CommandResult::Output(trigger_task(store, rest.trim())), + "pause" => CommandResult::Output(set_paused(store, rest.trim(), true)), + "resume" | "unpause" => CommandResult::Output(set_paused(store, rest.trim(), false)), + _ => create_loop(store, trimmed), + } +} + +fn help_text() -> String { + [ + "/loop — recurring local tasks.", + "", + " /loop create a loop and run payload once", + " /loop list list current loops", + " /loop remove delete a loop", + " /loop trigger mark a loop as fired now", + " /loop pause temporarily suspend a loop", + " /loop resume re-enable a paused loop", + "", + "Interval examples: 30s, 5m, 1h, 2d, '*/10 * * * *'.", + "Payload is a slash command (/simplify) or a plain prompt.", + ] + .join("\n") +} + +fn create_loop(store: &SchedulerStore, input: &str) -> CommandResult { + let Some((interval_raw, payload_raw)) = input.split_once(char::is_whitespace) else { + return CommandResult::Output( + "Usage: /loop . Run '/loop help' for examples.".to_string(), + ); + }; + + let payload_raw = payload_raw.trim(); + if payload_raw.is_empty() { + return CommandResult::Output("Usage: /loop ".to_string()); + } + + let interval = match parse_interval(interval_raw) { + Ok(i) => i, + Err(e) => { + return CommandResult::Output(format!( + "Could not parse interval '{}': {}", + interval_raw, e + )); + } + }; + + let payload = TaskPayload::from_user_input(payload_raw); + let task = ScheduledTask::new( + SchedulerKind::LocalCron, + derive_name(&payload), + interval_raw, + interval, + payload.clone(), + Utc::now(), + ); + + let saved = match store.add(task) { + Ok(t) => t, + Err(e) => { + return CommandResult::Output(format!("Could not create /loop task: {}", e)); + } + }; + + match &payload { + TaskPayload::Prompt(text) => { + // Registered + execute once immediately via CommandResult::Query. + let header = format!( + "Registered /loop {} (id={}) every {}. Running once now…", + saved.name, + saved.id, + interval.human() + ); + let msg = Message::User(UserMessage { + uuid: Uuid::new_v4(), + role: "user".to_string(), + content: MessageContent::Text(format!("{}\n\n{}", header, text)), + timestamp: chrono::Utc::now().timestamp(), + is_meta: false, + tool_use_result: None, + source_tool_assistant_uuid: None, + }); + CommandResult::Query(vec![msg]) + } + TaskPayload::SlashCommand(cmd) => { + // For slash-command payloads we don't dispatch inline: the + // command dispatcher is a level above us and feeding a command + // back through the message stream would double-execute the + // /loop wrapper. Report the registration and point at /trigger. + CommandResult::Output(format!( + "Registered /loop {} (id={}) every {}. Payload: {}.\n\ + The slash-command payload is not auto-executed — run `{}` \ + now, or `/loop trigger {}` later.", + saved.name, + saved.id, + interval.human(), + cmd, + cmd, + saved.id + )) + } + } +} + +fn render_list(store: &SchedulerStore) -> String { + match store.load() { + Ok(tasks) => { + if tasks.is_empty() { + return "No /loop tasks registered.".into(); + } + let mut out = String::new(); + out.push_str(&format!("Loops ({})\n", tasks.len())); + out.push_str(&"─".repeat(8)); + out.push('\n'); + for t in tasks { + let status = if t.paused { "paused" } else { "active" }; + out.push_str(&format!( + " {id} every {interval} [{kind} · {status}]\n \ + payload ({ptype}): {payload}\n next run: {next}\n", + id = t.id, + interval = super::super::services::scheduler::Interval::from_seconds( + t.interval_seconds + ) + .human(), + kind = t.kind.as_str(), + status = status, + ptype = t.payload.kind_label(), + payload = t.payload.display(), + next = t.next_run_at.to_rfc3339(), + )); + } + out + } + Err(e) => format!("Could not read scheduler state: {}", e), + } +} + +fn remove_task(store: &SchedulerStore, id_raw: &str) -> String { + if id_raw.is_empty() { + return "Usage: /loop remove ".into(); + } + let id = TaskId(id_raw.to_string()); + match store.remove(&id) { + Ok(removed) => format!("Removed /loop task '{}' (id={}).", removed.name, removed.id), + Err(SchedulerError::NotFound(_)) => format!( + "No /loop task with id '{}' — run '/loop list' to see current loops.", + id_raw + ), + Err(e) => format!("Could not remove task: {}", e), + } +} + +fn trigger_task(store: &SchedulerStore, id_raw: &str) -> String { + if id_raw.is_empty() { + return "Usage: /loop trigger ".into(); + } + let id = TaskId(id_raw.to_string()); + match store.record_fired(&id) { + Ok(task) => format!( + "Marked /loop '{}' as fired. Payload: {} ({}). Next run at {}.", + task.name, + task.payload.display(), + task.payload.kind_label(), + task.next_run_at.to_rfc3339() + ), + Err(SchedulerError::NotFound(_)) => format!( + "No /loop task with id '{}' — run '/loop list' to see current loops.", + id_raw + ), + Err(e) => format!("Could not trigger task: {}", e), + } +} + +fn set_paused(store: &SchedulerStore, id_raw: &str, paused: bool) -> String { + if id_raw.is_empty() { + return if paused { + "Usage: /loop pause ".into() + } else { + "Usage: /loop resume ".into() + }; + } + let id = TaskId(id_raw.to_string()); + match store.set_paused(&id, paused) { + Ok(task) => { + let verb = if paused { "paused" } else { "resumed" }; + format!("{} /loop '{}' (id={}).", capitalize(verb), task.name, task.id) + } + Err(SchedulerError::NotFound(_)) => format!( + "No /loop task with id '{}' — run '/loop list' to see current loops.", + id_raw + ), + Err(e) => format!("Could not update task: {}", e), + } +} + +fn derive_name(payload: &TaskPayload) -> String { + let raw = payload.display(); + let first_line = raw.lines().next().unwrap_or(raw).trim(); + let short: String = first_line.chars().take(40).collect(); + if short.is_empty() { + "loop".into() + } else { + short + } +} + +fn split_first(input: &str) -> (String, &str) { + match input.split_once(char::is_whitespace) { + Some((head, rest)) => (head.to_lowercase(), rest), + None => (input.to_lowercase(), ""), + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().chain(chars).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::scheduler::SchedulerStore; + use tempfile::tempdir; + + fn fresh_store() -> (tempfile::TempDir, SchedulerStore) { + let dir = tempdir().unwrap(); + let store = SchedulerStore::new(dir.path().join("scheduled_tasks.json")); + (dir, store) + } + + #[test] + fn help_lists_subcommands() { + let txt = help_text(); + assert!(txt.contains("create a loop")); + assert!(txt.contains("list")); + assert!(txt.contains("remove")); + assert!(txt.contains("trigger")); + assert!(txt.contains("pause")); + assert!(txt.contains("resume")); + } + + #[test] + fn empty_args_returns_help() { + let (_dir, store) = fresh_store(); + match dispatch(&store, "") { + CommandResult::Output(s) => assert!(s.contains("/loop")), + _ => panic!("expected Output"), + } + } + + #[test] + fn missing_payload_rejects() { + let (_dir, store) = fresh_store(); + match dispatch(&store, "5m") { + CommandResult::Output(s) => assert!(s.contains("Usage")), + _ => panic!("expected Output"), + } + } + + #[test] + fn creates_plain_prompt_and_returns_query() { + let (_dir, store) = fresh_store(); + let result = dispatch(&store, "5m review the last commit"); + match result { + CommandResult::Query(msgs) => { + assert_eq!(msgs.len(), 1); + let Message::User(u) = &msgs[0] else { + panic!("expected user message") + }; + if let MessageContent::Text(t) = &u.content { + assert!(t.contains("review the last commit")); + assert!(t.contains("Registered /loop")); + } else { + panic!("expected text content"); + } + } + _ => panic!("plain prompt should return Query"), + } + assert_eq!(store.load().unwrap().len(), 1); + } + + #[test] + fn creates_slash_command_without_running() { + let (_dir, store) = fresh_store(); + let result = dispatch(&store, "10m /simplify"); + match result { + CommandResult::Output(s) => { + assert!(s.contains("Registered /loop")); + assert!(s.contains("/simplify")); + assert!(s.contains("not auto-executed")); + } + _ => panic!("slash command payload should be Output-only"), + } + assert_eq!(store.load().unwrap().len(), 1); + } + + #[test] + fn list_reports_empty_and_populated() { + let (_dir, store) = fresh_store(); + match dispatch(&store, "list") { + CommandResult::Output(s) => assert!(s.contains("No /loop tasks")), + _ => panic!("expected Output"), + } + let _ = dispatch(&store, "5m do the thing"); + match dispatch(&store, "list") { + CommandResult::Output(s) => { + assert!(s.contains("Loops (1)")); + assert!(s.contains("active")); + assert!(s.contains("do the thing")); + } + _ => panic!("expected Output"), + } + } + + #[test] + fn remove_unknown_reports_not_found() { + let (_dir, store) = fresh_store(); + match dispatch(&store, "remove nope") { + CommandResult::Output(s) => assert!(s.contains("No /loop task with id")), + _ => panic!("expected Output"), + } + } + + #[test] + fn pause_and_resume_cycle() { + let (_dir, store) = fresh_store(); + let _ = dispatch(&store, "5m do thing"); + let tasks = store.load().unwrap(); + let id = tasks[0].id.clone(); + + match dispatch(&store, &format!("pause {}", id)) { + CommandResult::Output(s) => assert!(s.contains("Paused")), + _ => panic!("expected Output"), + } + assert!(store.load().unwrap()[0].paused); + + match dispatch(&store, &format!("resume {}", id)) { + CommandResult::Output(s) => assert!(s.contains("Resumed")), + _ => panic!("expected Output"), + } + assert!(!store.load().unwrap()[0].paused); + } + + #[test] + fn bad_interval_reports_clear_error() { + let (_dir, store) = fresh_store(); + match dispatch(&store, "abc prompt") { + CommandResult::Output(s) => { + assert!(s.contains("Could not parse interval")); + assert!(s.contains("abc")); + } + _ => panic!("expected Output"), + } + assert!(store.load().unwrap().is_empty()); + } +} diff --git a/crates/claude-code-rs/src/commands/mod.rs b/crates/claude-code-rs/src/commands/mod.rs index 71f245c9..31d11294 100644 --- a/crates/claude-code-rs/src/commands/mod.rs +++ b/crates/claude-code-rs/src/commands/mod.rs @@ -48,6 +48,13 @@ pub mod advisor; // Plan mode (issue #46) pub mod plan; +// Scheduling / automation (issues #58, #60) +pub mod loop_cmd; +pub mod schedule; + +// Team onboarding (issue #63) +pub mod team_onboarding; + // Session management pub mod copy; pub mod init; @@ -565,6 +572,32 @@ pub fn get_all_commands() -> Vec { description: "Show, set, or clear the advisor model (issue #33)".into(), handler: Box::new(advisor::AdvisorHandler), }, + // Scheduling / automation (issues #58, #60). + Command { + name: "loop".into(), + aliases: vec![], + description: + "Register a recurring local task (prompt or slash command) and run it once \ + (issue #58)" + .into(), + handler: Box::new(loop_cmd::LoopHandler), + }, + Command { + name: "schedule".into(), + aliases: vec!["cron".into()], + description: + "Manage local cron tasks (add, list, pause, trigger, remove) (issue #60)".into(), + handler: Box::new(schedule::ScheduleHandler), + }, + // Team onboarding (issue #63). + Command { + name: "team-onboarding".into(), + aliases: vec!["teamonboarding".into()], + description: + "Generate a teammate onboarding guide from real project/team state (issue #63)" + .into(), + handler: Box::new(team_onboarding::TeamOnboardingHandler), + }, ] } @@ -661,6 +694,11 @@ mod tests { assert!(names.contains(&"agents")); assert!(names.contains(&"doctor")); assert!(names.contains(&"tasks")); + // Scheduling / automation (issues #58, #60). + assert!(names.contains(&"loop")); + assert!(names.contains(&"schedule")); + // Team onboarding (issue #63). + assert!(names.contains(&"team-onboarding")); } #[test] @@ -684,6 +722,9 @@ mod tests { assert!(find_command("br").is_some()); assert!(find_command("gitbranch").is_some()); assert!(find_command("mem").is_some()); + // New aliases (issues #58, #60, #63). + assert!(find_command("cron").is_some()); + assert!(find_command("teamonboarding").is_some()); } #[test] diff --git a/crates/claude-code-rs/src/commands/schedule.rs b/crates/claude-code-rs/src/commands/schedule.rs new file mode 100644 index 00000000..4bed9c1b --- /dev/null +++ b/crates/claude-code-rs/src/commands/schedule.rs @@ -0,0 +1,450 @@ +//! `/schedule` — raw management surface for the local cron scheduler +//! (issue #60). +//! +//! ## Scope of the first milestone +//! +//! `/schedule` is explicitly split in two capability lines: +//! +//! - **local cron** — persisted under `{data_root}/scheduled_tasks.json` +//! and served by [`crate::services::scheduler::SchedulerStore`]. All +//! subcommands below operate on this store. +//! - **remote triggers** — delegated to a cloud-side agent runtime in the +//! Bun reference. In cc-rust the remote path requires OAuth/API +//! groundwork we haven't landed yet, so `/schedule remote …` currently +//! refuses and points the user at the design doc. +//! +//! Keeping the two lines syntactically distinct means the first milestone +//! ships without implying the second works. When the remote path lands, +//! it'll extend the `remote` subcommand without changing the local +//! surface. +//! +//! ## Subcommands +//! +//! /schedule alias for 'list' +//! /schedule list list all scheduled tasks +//! /schedule add add a new local task (no immediate run) +//! /schedule show inspect one task +//! /schedule remove delete a task +//! /schedule pause suspend a task without deleting +//! /schedule resume re-enable a paused task +//! /schedule trigger mark task as fired and roll next_run_at forward +//! /schedule remote … (disabled) surface for remote triggers + +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::services::scheduler::{ + parse_interval, Interval, ScheduledTask, SchedulerError, SchedulerKind, SchedulerStore, TaskId, + TaskPayload, +}; + +pub struct ScheduleHandler; + +#[async_trait] +impl CommandHandler for ScheduleHandler { + async fn execute(&self, args: &str, _ctx: &mut CommandContext) -> Result { + let store = SchedulerStore::open_default(); + Ok(CommandResult::Output(dispatch(&store, args))) + } +} + +fn dispatch(store: &SchedulerStore, args: &str) -> String { + let trimmed = args.trim(); + let (head, rest) = match trimmed.split_once(char::is_whitespace) { + Some((h, r)) => (h.to_lowercase(), r.trim()), + None => (trimmed.to_lowercase(), ""), + }; + + match head.as_str() { + "" | "list" | "ls" => list(store), + "help" | "--help" | "-h" => help_text(store), + "add" | "create" | "new" => add(store, rest), + "show" | "info" => show(store, rest), + "remove" | "rm" | "delete" => remove(store, rest), + "pause" => set_paused(store, rest, true), + "resume" | "unpause" => set_paused(store, rest, false), + "trigger" | "fire" => trigger(store, rest), + "due" => due(store), + "remote" => remote_hint(rest), + other => format!( + "Unknown /schedule subcommand '{}'. Run '/schedule help' for the list.", + other + ), + } +} + +fn help_text(store: &SchedulerStore) -> String { + let storage = store.path().display().to_string(); + [ + "/schedule — local cron scheduler.".to_string(), + String::new(), + "Scope: this command manages LOCAL cron tasks. Remote triggers".to_string(), + " (cloud-side scheduled agents) are tracked as a separate".to_string(), + " capability line and are not yet implemented — see".to_string(), + " `/schedule remote`.".to_string(), + format!(" Storage: {}", storage), + String::new(), + " /schedule list all scheduled tasks".to_string(), + " /schedule add add a new local task".to_string(), + " /schedule show inspect one task".to_string(), + " /schedule remove delete a task".to_string(), + " /schedule pause suspend a task".to_string(), + " /schedule resume re-enable a paused task".to_string(), + " /schedule trigger mark task as fired now".to_string(), + " /schedule due show tasks that are due right now".to_string(), + " /schedule remote … (disabled) remote-trigger surface".to_string(), + String::new(), + "Interval examples: 30s, 5m, 1h, 2d, '*/10 * * * *'.".to_string(), + "Payload is a slash command (/simplify) or a plain prompt.".to_string(), + String::new(), + "Tip: /loop is a higher-level wrapper that also runs the payload".to_string(), + "once immediately.".to_string(), + ] + .join("\n") +} + +fn due(store: &SchedulerStore) -> String { + match store.due_tasks() { + Ok(tasks) => { + if tasks.is_empty() { + return "No scheduled tasks are due right now.".into(); + } + let mut out = format!("{} task(s) due now\n", tasks.len()); + out.push_str(&"─".repeat(24)); + out.push('\n'); + for t in &tasks { + out.push_str(&render_row(t)); + } + out.push_str( + "\nTip: the daemon tick loop (if running) will execute these \ + on its next poll; use `/schedule trigger ` to mark as fired.\n", + ); + out + } + Err(e) => format!("Could not read scheduler state: {}", e), + } +} + +fn list(store: &SchedulerStore) -> String { + match store.load() { + Ok(tasks) => { + if tasks.is_empty() { + return "No scheduled tasks registered. Try '/schedule add '." + .into(); + } + let mut out = String::new(); + out.push_str(&format!("Local scheduled tasks ({})\n", tasks.len())); + out.push_str(&"─".repeat(26)); + out.push('\n'); + for t in &tasks { + out.push_str(&render_row(t)); + } + out.push_str("\nRemote triggers: (not implemented — see `/schedule remote`).\n"); + out + } + Err(e) => format!("Could not read scheduler state: {}", e), + } +} + +fn render_row(t: &ScheduledTask) -> String { + let status = if t.paused { "paused" } else { "active" }; + format!( + " {id} every {interval} [{kind} · {status}]\n \ + payload ({ptype}): {payload}\n next run: {next}\n", + id = t.id, + interval = Interval::from_seconds(t.interval_seconds).human(), + kind = t.kind.as_str(), + status = status, + ptype = t.payload.kind_label(), + payload = t.payload.display(), + next = t.next_run_at.to_rfc3339(), + ) +} + +fn add(store: &SchedulerStore, rest: &str) -> String { + let Some((interval_raw, payload_raw)) = rest.split_once(char::is_whitespace) else { + return "Usage: /schedule add ".into(); + }; + let payload_raw = payload_raw.trim(); + if payload_raw.is_empty() { + return "Usage: /schedule add ".into(); + } + + let interval = match parse_interval(interval_raw) { + Ok(i) => i, + Err(e) => return format!("Could not parse interval '{}': {}", interval_raw, e), + }; + + let payload = TaskPayload::from_user_input(payload_raw); + let task = ScheduledTask::new( + SchedulerKind::LocalCron, + derive_name(&payload), + interval_raw, + interval, + payload, + Utc::now(), + ); + + match store.add(task) { + Ok(saved) => format!( + "Added scheduled task '{}' (id={}) — runs every {} starting at {}.", + saved.name, + saved.id, + interval.human(), + saved.next_run_at.to_rfc3339() + ), + Err(e) => format!("Could not add task: {}", e), + } +} + +fn show(store: &SchedulerStore, id_raw: &str) -> String { + if id_raw.is_empty() { + return "Usage: /schedule show ".into(); + } + let id = TaskId(id_raw.to_string()); + match store.get(&id) { + Ok(t) => { + let mut out = format!("Scheduled task {}\n", t.id); + out.push_str(&"─".repeat(16 + t.id.as_str().len())); + out.push('\n'); + out.push_str(&format!(" Name: {}\n", t.name)); + out.push_str(&format!(" Kind: {}\n", t.kind.as_str())); + out.push_str(&format!( + " Status: {}\n", + if t.paused { "paused" } else { "active" } + )); + out.push_str(&format!( + " Interval: {} (raw: {})\n", + Interval::from_seconds(t.interval_seconds).human(), + t.schedule + )); + out.push_str(&format!( + " Payload kind: {}\n", + t.payload.kind_label() + )); + out.push_str(&format!(" Payload: {}\n", t.payload.display())); + out.push_str(&format!(" Created at: {}\n", t.created_at.to_rfc3339())); + out.push_str(&format!( + " Last run at: {}\n", + t.last_run_at + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| "never".to_string()) + )); + out.push_str(&format!(" Next run at: {}\n", t.next_run_at.to_rfc3339())); + out + } + Err(SchedulerError::NotFound(_)) => { + format!("No scheduled task with id '{}'.", id_raw) + } + Err(e) => format!("Could not read task: {}", e), + } +} + +fn remove(store: &SchedulerStore, id_raw: &str) -> String { + if id_raw.is_empty() { + return "Usage: /schedule remove ".into(); + } + let id = TaskId(id_raw.to_string()); + match store.remove(&id) { + Ok(removed) => format!( + "Removed scheduled task '{}' (id={}).", + removed.name, removed.id + ), + Err(SchedulerError::NotFound(_)) => { + format!("No scheduled task with id '{}'.", id_raw) + } + Err(e) => format!("Could not remove task: {}", e), + } +} + +fn set_paused(store: &SchedulerStore, id_raw: &str, paused: bool) -> String { + if id_raw.is_empty() { + return if paused { + "Usage: /schedule pause ".into() + } else { + "Usage: /schedule resume ".into() + }; + } + let id = TaskId(id_raw.to_string()); + match store.set_paused(&id, paused) { + Ok(task) => { + let verb = if paused { "Paused" } else { "Resumed" }; + format!("{} scheduled task '{}' (id={}).", verb, task.name, task.id) + } + Err(SchedulerError::NotFound(_)) => { + format!("No scheduled task with id '{}'.", id_raw) + } + Err(e) => format!("Could not update task: {}", e), + } +} + +fn trigger(store: &SchedulerStore, id_raw: &str) -> String { + if id_raw.is_empty() { + return "Usage: /schedule trigger ".into(); + } + let id = TaskId(id_raw.to_string()); + match store.record_fired(&id) { + Ok(task) => format!( + "Marked task '{}' (id={}) as fired. Next run at {}.", + task.name, task.id, task.next_run_at + ), + Err(SchedulerError::NotFound(_)) => { + format!("No scheduled task with id '{}'.", id_raw) + } + Err(e) => format!("Could not trigger task: {}", e), + } +} + +fn remote_hint(_rest: &str) -> String { + [ + "Remote triggers are not implemented yet in cc-rust (issue #60).", + "", + "The first /schedule milestone covers LOCAL cron only — tasks persist", + "to {data_root}/scheduled_tasks.json and are run by the current", + "process. The remote-trigger capability requires cloud OAuth and", + "agent APIs that haven't been ported from the Bun reference yet.", + "", + "Use '/schedule list' to see local tasks.", + ] + .join("\n") +} + +fn derive_name(payload: &TaskPayload) -> String { + let raw = payload.display(); + let first_line = raw.lines().next().unwrap_or(raw).trim(); + let short: String = first_line.chars().take(40).collect(); + if short.is_empty() { + "task".into() + } else { + short + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn fresh_store() -> (tempfile::TempDir, SchedulerStore) { + let dir = tempdir().unwrap(); + let store = SchedulerStore::new(dir.path().join("scheduled_tasks.json")); + (dir, store) + } + + #[test] + fn empty_args_lists_tasks() { + let (_dir, store) = fresh_store(); + let out = dispatch(&store, ""); + assert!(out.contains("No scheduled tasks")); + } + + #[test] + fn help_documents_scope() { + let (_dir, store) = fresh_store(); + let help = help_text(&store); + assert!(help.contains("local cron")); + assert!(help.contains("remote triggers") || help.contains("Remote triggers")); + assert!(help.contains("not yet implemented") || help.contains("disabled")); + assert!( + help.contains(store.path().display().to_string().as_str()), + "help output should include the storage path" + ); + } + + #[test] + fn due_lists_only_tasks_past_next_run() { + let (_dir, store) = fresh_store(); + // Task scheduled to run in the future — should NOT be due. + dispatch(&store, "add 1h future thing"); + assert!(dispatch(&store, "due").contains("No scheduled tasks are due")); + + // Mutate the stored task so its next_run_at is in the past. + let mut tasks = store.load().unwrap(); + tasks[0].next_run_at = Utc::now() - chrono::Duration::seconds(30); + store.remove(&tasks[0].id).unwrap(); + store + .add(ScheduledTask { + id: tasks[0].id.clone(), + ..tasks.remove(0) + }) + .unwrap(); + + let out = dispatch(&store, "due"); + assert!(out.contains("task(s) due now"), "unexpected due output: {}", out); + assert!(out.contains("future thing")); + } + + #[test] + fn add_then_list_shows_task() { + let (_dir, store) = fresh_store(); + let out = dispatch(&store, "add 5m run the deploy check"); + assert!(out.contains("Added scheduled task")); + let list_out = dispatch(&store, "list"); + assert!(list_out.contains("run the deploy check")); + assert!(list_out.contains("every 5m")); + } + + #[test] + fn show_inspects_task() { + let (_dir, store) = fresh_store(); + dispatch(&store, "add 1h do the thing"); + let id = store.load().unwrap()[0].id.clone(); + let out = dispatch(&store, &format!("show {}", id)); + assert!(out.contains("Payload:")); + assert!(out.contains("do the thing")); + assert!(out.contains("Interval:")); + assert!(out.contains("Next run at:")); + } + + #[test] + fn remove_and_pause_and_resume() { + let (_dir, store) = fresh_store(); + dispatch(&store, "add 30s keep watching"); + let id = store.load().unwrap()[0].id.clone(); + + assert!(dispatch(&store, &format!("pause {}", id)).contains("Paused")); + assert!(store.load().unwrap()[0].paused); + assert!(dispatch(&store, &format!("resume {}", id)).contains("Resumed")); + assert!(!store.load().unwrap()[0].paused); + assert!(dispatch(&store, &format!("remove {}", id)).contains("Removed")); + assert!(store.load().unwrap().is_empty()); + } + + #[test] + fn trigger_advances_next_run() { + let (_dir, store) = fresh_store(); + dispatch(&store, "add 5m keep going"); + let id = store.load().unwrap()[0].id.clone(); + let before = store.get(&id).unwrap().next_run_at; + let out = dispatch(&store, &format!("trigger {}", id)); + assert!(out.contains("Marked task")); + let after = store.get(&id).unwrap().next_run_at; + assert!(after >= before); + } + + #[test] + fn remote_subcommand_refuses_with_context() { + let (_dir, store) = fresh_store(); + let out = dispatch(&store, "remote add 1h foo"); + assert!(out.contains("not implemented")); + // We want the refusal to explain what IS implemented — case + // insensitively, since the user copy uses "LOCAL cron" for emphasis. + assert!(out.to_lowercase().contains("local cron")); + } + + #[test] + fn unknown_subcommand_reports_error() { + let (_dir, store) = fresh_store(); + let out = dispatch(&store, "frobnicate"); + assert!(out.contains("Unknown /schedule subcommand")); + } + + #[test] + fn add_without_payload_shows_usage() { + let (_dir, store) = fresh_store(); + assert!(dispatch(&store, "add 5m").contains("Usage")); + assert!(dispatch(&store, "add").contains("Usage")); + } +} diff --git a/crates/claude-code-rs/src/commands/team_onboarding.rs b/crates/claude-code-rs/src/commands/team_onboarding.rs new file mode 100644 index 00000000..d145d8ff --- /dev/null +++ b/crates/claude-code-rs/src/commands/team_onboarding.rs @@ -0,0 +1,589 @@ +//! `/team-onboarding` — generate a teammate-facing onboarding guide +//! (issue #63). +//! +//! This is a greenfield feature: the Bun reference does not expose a +//! public `/team-onboarding`, so the Rust implementation is grounded in +//! existing local/project/team state rather than a 1:1 port. +//! +//! The generated guide is Markdown. It walks a new teammate through: +//! +//! 1. Welcome — honoring the display name from onboarding state when +//! available so the guide is personalized. +//! 2. Project overview — derived from `CLAUDE.md` / `README.md` and +//! the git `origin` URL when present. +//! 3. Common commands — filtered list of slash commands that belong on +//! a new teammate's first page. +//! 4. Skills — whatever is registered in the skills registry. +//! 5. Active teams — names + member counts from `{data_root}/teams/`. +//! 6. Risk areas — a short list keyed off what the project state +//! actually exposes (auth set-up status, pending team tasks, etc.). +//! +//! Subcommands: +//! +//! /team-onboarding print the guide +//! /team-onboarding save [path] write the guide to a file +//! (default: ONBOARDING_TEAM.md in cwd) + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use async_trait::async_trait; + +use super::{CommandContext, CommandHandler, CommandResult}; +use crate::services::onboarding::{OnboardingState, OnboardingStore}; + +pub struct TeamOnboardingHandler; + +#[async_trait] +impl CommandHandler for TeamOnboardingHandler { + async fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result { + let store = OnboardingStore::open_default(); + let onboarding = store.load().unwrap_or_default(); + let guide = build_guide(&ctx.cwd, &onboarding); + + let (sub, rest) = split_sub(args); + match sub.as_str() { + "" | "show" | "print" => Ok(CommandResult::Output(guide)), + "save" | "write" => { + let target = resolve_save_path(&ctx.cwd, rest); + match std::fs::write(&target, guide.as_bytes()) { + Ok(_) => Ok(CommandResult::Output(format!( + "Wrote teammate onboarding guide to {} ({} bytes).", + target.display(), + guide.len() + ))), + Err(e) => Ok(CommandResult::Output(format!( + "Could not write onboarding guide to {}: {}", + target.display(), + e + ))), + } + } + "help" | "--help" | "-h" => Ok(CommandResult::Output(help_text())), + other => Ok(CommandResult::Output(format!( + "Unknown /team-onboarding subcommand '{}'. Run '/team-onboarding help'.", + other + ))), + } + } +} + +fn help_text() -> String { + [ + "/team-onboarding — generate a teammate onboarding guide.", + "", + " /team-onboarding print the guide to the REPL", + " /team-onboarding save [path] write to a file (default: ONBOARDING_TEAM.md)", + "", + "The guide pulls from real local state: CLAUDE.md, README.md,", + "the skills registry, teams on disk, and onboarding status. It's", + "not a template — sections are empty-suppressed when they have", + "nothing to say.", + ] + .join("\n") +} + +// --------------------------------------------------------------------------- +// Guide construction +// --------------------------------------------------------------------------- + +fn build_guide(cwd: &Path, onboarding: &OnboardingState) -> String { + let project_name = derive_project_name(cwd); + + let mut out = String::new(); + out.push_str(&format!("# {} — Teammate Onboarding\n\n", project_name)); + append_welcome(&mut out, onboarding); + append_project_overview(&mut out, cwd); + append_common_commands(&mut out); + append_skills(&mut out); + append_teams_section(&mut out); + append_scheduling_section(&mut out); + append_risk_areas(&mut out, cwd, onboarding); + append_footer(&mut out); + out +} + +fn append_welcome(out: &mut String, onboarding: &OnboardingState) { + out.push_str("## Welcome\n\n"); + let greet = match &onboarding.display_name { + Some(name) if !name.trim().is_empty() => format!("Hi — {} here's the short tour.", name), + _ => "Hi — here's the short tour.".to_string(), + }; + out.push_str(&greet); + out.push_str( + "\n\nThis guide was generated from the state on this machine right now. \ + If you see something stale, regenerate it with `/team-onboarding`.\n\n", + ); +} + +fn append_project_overview(out: &mut String, cwd: &Path) { + out.push_str("## Project overview\n\n"); + + let claude_md = cwd.join("CLAUDE.md"); + if let Ok(contents) = std::fs::read_to_string(&claude_md) { + let summary = first_sentences(&strip_yaml_frontmatter(&contents), 5); + if !summary.trim().is_empty() { + out.push_str("From `CLAUDE.md`:\n\n"); + out.push_str("e_block(&summary)); + out.push_str("\n\n"); + } + } + + let readme = ["README.md", "readme.md", "README.MD"] + .iter() + .map(|n| cwd.join(n)) + .find(|p| p.exists()); + if let Some(path) = readme { + if let Ok(contents) = std::fs::read_to_string(&path) { + let summary = first_sentences(&contents, 4); + if !summary.trim().is_empty() { + out.push_str(&format!("From `{}`:\n\n", path.file_name().unwrap_or_default().to_string_lossy())); + out.push_str("e_block(&summary)); + out.push_str("\n\n"); + } + } + } + + if let Some(origin) = detect_git_origin(cwd) { + out.push_str(&format!("- Git origin: `{}`\n", origin)); + } + if let Some(branch) = detect_git_branch(cwd) { + out.push_str(&format!("- Current branch: `{}`\n", branch)); + } + out.push('\n'); +} + +fn append_common_commands(out: &mut String) { + out.push_str("## Common slash commands\n\n"); + for (name, purpose) in COMMON_COMMANDS { + out.push_str(&format!("- `{}` — {}\n", name, purpose)); + } + out.push_str( + "\nFor the full list, run `/help`. A teammate's first week usually \ + needs `/plan`, `/commit`, `/review`, and `/schedule`.\n\n", + ); +} + +fn append_skills(out: &mut String) { + let skills = crate::skills::get_user_invocable_skills(); + out.push_str("## Skills\n\n"); + if skills.is_empty() { + out.push_str( + "No user-invocable skills are currently registered. Skills live \ + under `{data_root}/skills/` or `.cc-rust/skills/`; drop a \ + `SKILL.md` in either to make one available here.\n\n", + ); + return; + } + for s in skills { + let desc = if s.frontmatter.description.is_empty() { + "no description" + } else { + s.frontmatter.description.trim() + }; + out.push_str(&format!("- `/{}` — {}\n", s.name, one_line(desc))); + } + out.push('\n'); +} + +fn append_teams_section(out: &mut String) { + out.push_str("## Active teams\n\n"); + let teams_root = cc_config::paths::teams_dir(); + if !teams_root.exists() { + out.push_str( + "No team data directory yet — run `/team create ` to \ + bootstrap one when you need agent teams.\n\n", + ); + return; + } + + let names: Vec = match std::fs::read_dir(&teams_root) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| crate::teams::helpers::team_exists(n)) + .collect(), + Err(_) => Vec::new(), + }; + + if names.is_empty() { + out.push_str( + "No teams on disk yet. When you need one, `/team create ` \ + sets one up. `/team list` shows the current roster.\n\n", + ); + return; + } + + for name in names { + let member_count = crate::teams::helpers::read_team_file(&name) + .map(|tf| tf.members.len()) + .unwrap_or(0); + out.push_str(&format!("- `{}` — {} member(s)\n", name, member_count)); + } + out.push_str("\nInteract via `/team status`, `/team spawn`, `/team send`.\n\n"); +} + +fn append_scheduling_section(out: &mut String) { + out.push_str("## Scheduled work\n\n"); + let store = crate::services::scheduler::SchedulerStore::open_default(); + match store.load() { + Ok(tasks) if tasks.is_empty() => { + out.push_str( + "No scheduled tasks yet. Use `/loop ` for \ + recurring prompts or `/schedule add` for a bare cron entry.\n\n", + ); + } + Ok(tasks) => { + out.push_str(&format!("Currently {} scheduled task(s):\n\n", tasks.len())); + for t in tasks.iter().take(8) { + out.push_str(&format!( + "- `{}` — every {}s, next at {}\n", + t.name, + t.interval_seconds, + t.next_run_at.to_rfc3339() + )); + } + if tasks.len() > 8 { + out.push_str(&format!("- … and {} more — see `/schedule list`.\n", tasks.len() - 8)); + } + out.push('\n'); + } + Err(_) => { + out.push_str( + "Scheduled tasks are stored under `{data_root}/scheduled_tasks.json` \ + but the file could not be read — likely a first-run state.\n\n", + ); + } + } +} + +fn append_risk_areas(out: &mut String, cwd: &Path, onboarding: &OnboardingState) { + out.push_str("## Risk areas to watch\n\n"); + let mut bullets: Vec = Vec::new(); + + if onboarding.is_first_run() { + bullets.push( + "The machine looks like it has never finished first-run onboarding. \ + Run `/login` (or set `ANTHROPIC_API_KEY`) before your first session." + .into(), + ); + } else if !onboarding.auth_onboarding_done { + bullets.push( + "Auth onboarding is incomplete — some commands may fail until \ + `/login` succeeds." + .into(), + ); + } + + if cwd.join(".cc-rust").is_dir() { + bullets.push( + "This project has a `.cc-rust/` directory — prefer project-level \ + settings over global ones when they conflict." + .into(), + ); + } else { + bullets.push( + "No `.cc-rust/` directory in this cwd. `/init` creates one when \ + you're ready to pin project-level settings." + .into(), + ); + } + + if !cwd.join("CLAUDE.md").exists() { + bullets.push( + "No `CLAUDE.md` in this project — expectations about tooling \ + may be implicit. Adding one helps teammates and the assistant \ + stay aligned." + .into(), + ); + } + + if bullets.is_empty() { + out.push_str("Nothing obvious stands out right now.\n\n"); + return; + } + + for b in bullets { + out.push_str(&format!("- {}\n", b)); + } + out.push('\n'); +} + +fn append_footer(out: &mut String) { + out.push_str("---\n"); + out.push_str(&format!( + "_Generated by `/team-onboarding` on {}._\n", + chrono::Utc::now().to_rfc3339() + )); +} + +// --------------------------------------------------------------------------- +// Data inputs +// --------------------------------------------------------------------------- + +/// Curated first-week command list. Intentionally short so the guide reads +/// well — the exhaustive list lives in `/help`. +const COMMON_COMMANDS: &[(&str, &str)] = &[ + ("/help", "list every slash command"), + ("/context", "see how much of the context window is in use"), + ("/plan", "enter plan mode — drafts an implementation plan before coding"), + ("/commit", "build a conventional commit from the current diff"), + ("/review", "review a PR through the `gh` CLI"), + ("/recap", "summarize the current session"), + ("/schedule", "manage local cron tasks"), + ("/loop", "register a recurring prompt (with immediate first run)"), + ("/team", "manage Agent Teams (create, spawn, kill)"), + ("/logout", "clear credentials + onboarding state for a clean hand-off"), +]; + +fn derive_project_name(cwd: &Path) -> String { + cwd.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Project".to_string()) +} + +fn detect_git_origin(cwd: &Path) -> Option { + let config = cwd.join(".git").join("config"); + let contents = std::fs::read_to_string(config).ok()?; + let mut in_origin = false; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed == "[remote \"origin\"]" { + in_origin = true; + continue; + } + if trimmed.starts_with('[') { + in_origin = false; + } + if in_origin { + if let Some(url) = trimmed.strip_prefix("url = ") { + return Some(url.trim().to_string()); + } + } + } + None +} + +fn detect_git_branch(cwd: &Path) -> Option { + let head_path = cwd.join(".git").join("HEAD"); + let contents = std::fs::read_to_string(head_path).ok()?; + let trimmed = contents.trim(); + if let Some(rest) = trimmed.strip_prefix("ref: refs/heads/") { + Some(rest.to_string()) + } else { + Some(trimmed.chars().take(7).collect()) + } +} + +fn strip_yaml_frontmatter(text: &str) -> String { + // Only strip if the very first line is `---`. Otherwise return the + // original text unchanged — consuming the first line "just in case" + // silently drops content when there's no frontmatter. + let mut lines = text.lines(); + let first = lines.next(); + if first.map(|l| l.trim() == "---").unwrap_or(false) { + // Advance past the closing `---`, then join the remainder. + for line in lines.by_ref() { + if line.trim() == "---" { + break; + } + } + return lines.collect::>().join("\n"); + } + text.to_string() +} + +fn first_sentences(text: &str, max: usize) -> String { + // Skip pure markdown headers when computing "first sentences" so the + // extract isn't just `# Foo`. + let mut collected = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + collected.push(trimmed.to_string()); + if collected.len() >= max { + break; + } + } + collected.join("\n") +} + +fn quote_block(text: &str) -> String { + text.lines() + .map(|l| format!("> {}", l)) + .collect::>() + .join("\n") +} + +fn one_line(text: &str) -> String { + text.lines().next().unwrap_or("").trim().to_string() +} + +fn split_sub(args: &str) -> (String, &str) { + match args.trim().split_once(char::is_whitespace) { + Some((h, rest)) => (h.to_lowercase(), rest.trim()), + None => (args.trim().to_lowercase(), ""), + } +} + +fn resolve_save_path(cwd: &Path, rest: &str) -> PathBuf { + if rest.is_empty() { + cwd.join("ONBOARDING_TEAM.md") + } else { + let raw = PathBuf::from(rest); + if raw.is_absolute() { + raw + } else { + cwd.join(raw) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn default_state() -> OnboardingState { + OnboardingState::default() + } + + #[test] + fn guide_includes_every_section() { + let dir = tempdir().unwrap(); + let guide = build_guide(dir.path(), &default_state()); + for section in [ + "Welcome", + "Project overview", + "Common slash commands", + "Skills", + "Active teams", + "Scheduled work", + "Risk areas", + ] { + assert!( + guide.contains(&format!("## {}", section)), + "missing section {} in {}", + section, + guide + ); + } + } + + #[test] + fn project_overview_picks_up_claude_md() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("CLAUDE.md"), + "---\ntitle: Test\n---\n# Heading\n\nThis project is interesting.\nIt has rules.\n", + ) + .unwrap(); + let guide = build_guide(dir.path(), &default_state()); + assert!(guide.contains("From `CLAUDE.md`")); + assert!(guide.contains("This project is interesting")); + } + + #[test] + fn project_overview_picks_up_readme() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("README.md"), + "# README\nThis repo is the canonical source.", + ) + .unwrap(); + let guide = build_guide(dir.path(), &default_state()); + assert!(guide.contains("From `README.md`")); + assert!(guide.contains("canonical source")); + } + + #[test] + fn personalizes_welcome_when_display_name_present() { + let dir = tempdir().unwrap(); + let state = OnboardingState { + display_name: Some("Sam".into()), + ..OnboardingState::default() + }; + let guide = build_guide(dir.path(), &state); + assert!(guide.contains("Sam")); + } + + #[test] + fn flags_first_run_in_risk_area() { + let dir = tempdir().unwrap(); + let guide = build_guide(dir.path(), &default_state()); + assert!(guide.contains("first-run onboarding")); + } + + #[test] + fn flags_missing_claude_md() { + let dir = tempdir().unwrap(); + let guide = build_guide(dir.path(), &default_state()); + assert!(guide.contains("No `CLAUDE.md`")); + } + + #[test] + fn common_commands_include_the_core_set() { + let dir = tempdir().unwrap(); + let guide = build_guide(dir.path(), &default_state()); + for cmd in ["/help", "/plan", "/commit", "/review", "/schedule", "/loop"] { + assert!(guide.contains(cmd), "missing {} in guide", cmd); + } + } + + #[test] + fn save_writes_to_disk() { + let dir = tempdir().unwrap(); + let guide = build_guide(dir.path(), &default_state()); + let target = dir.path().join("ONBOARDING_TEAM.md"); + std::fs::write(&target, &guide).unwrap(); + assert!(target.exists()); + let reloaded = std::fs::read_to_string(&target).unwrap(); + assert!(reloaded.contains("Teammate Onboarding")); + } + + #[test] + fn resolve_save_path_defaults_to_cwd_filename() { + let dir = tempdir().unwrap(); + let out = resolve_save_path(dir.path(), ""); + assert_eq!(out, dir.path().join("ONBOARDING_TEAM.md")); + } + + #[test] + fn resolve_save_path_respects_relative_input() { + let dir = tempdir().unwrap(); + let out = resolve_save_path(dir.path(), "docs/onboarding.md"); + assert_eq!(out, dir.path().join("docs/onboarding.md")); + } + + #[test] + fn strip_yaml_frontmatter_removes_leading_block() { + let input = "---\nkey: value\n---\nbody line\n"; + assert_eq!(strip_yaml_frontmatter(input), "body line"); + } + + #[test] + fn strip_yaml_frontmatter_preserves_body_when_no_leading_dash() { + let input = "no frontmatter\nline two"; + assert_eq!(strip_yaml_frontmatter(input), "no frontmatter\nline two"); + } + + #[test] + fn first_sentences_skips_headers() { + let input = "# Heading\n\nOne.\nTwo.\n# Another\nThree.\n"; + let out = first_sentences(input, 2); + assert!(out.contains("One.")); + assert!(out.contains("Two.")); + assert!(!out.contains("# Heading")); + } + + #[test] + fn help_text_describes_save() { + let help = help_text(); + assert!(help.contains("save")); + assert!(help.contains("ONBOARDING_TEAM.md")); + } +} diff --git a/crates/claude-code-rs/src/services/mod.rs b/crates/claude-code-rs/src/services/mod.rs index 1555095f..cd82f21c 100644 --- a/crates/claude-code-rs/src/services/mod.rs +++ b/crates/claude-code-rs/src/services/mod.rs @@ -15,4 +15,6 @@ pub use cc_services::*; pub mod langfuse; +pub mod onboarding; +pub mod scheduler; pub mod session_analytics; diff --git a/crates/claude-code-rs/src/services/onboarding.rs b/crates/claude-code-rs/src/services/onboarding.rs new file mode 100644 index 00000000..e1e5aea1 --- /dev/null +++ b/crates/claude-code-rs/src/services/onboarding.rs @@ -0,0 +1,284 @@ +//! Onboarding state — first-run wizard progress persisted across sessions. +//! +//! Shared between `/logout` (issue #43), which must reset the state, and +//! `/team-onboarding` (issue #63), which reads the current user's progress +//! to decide what sections to include in the teammate-facing guide. +//! +//! File layout: `{data_root}/onboarding.json`. +//! +//! The schema is intentionally small — only the fields we actually +//! inspect today. Adding a new flag is a matter of extending the struct +//! with `#[serde(default)]` so old files keep deserializing. + +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Schema version. Bumped when we change the meaning of an existing field. +/// Adding a new field with `#[serde(default)]` does NOT require a bump. +pub const ONBOARDING_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Error)] +pub enum OnboardingError { + #[error("I/O error touching {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to decode onboarding state at {path}: {source}")] + Decode { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("failed to encode onboarding state: {0}")] + Encode(#[source] serde_json::Error), +} + +/// Persistent onboarding progress. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OnboardingState { + #[serde(default = "default_version")] + pub version: u32, + /// Whether the user has run through the first-time setup at least once. + #[serde(default)] + pub has_completed_onboarding: bool, + /// Whether the IDE integration dialog was accepted or dismissed. + #[serde(default)] + pub ide_onboarding_done: bool, + /// Whether the auth setup step was completed. Used by `/logout` so a + /// freshly-logged-out user re-runs auth on next start. + #[serde(default)] + pub auth_onboarding_done: bool, + /// Whether the theme / statusline customization step ran. + #[serde(default)] + pub ui_onboarding_done: bool, + /// Optional friendly name collected during onboarding (team-onboarding + /// templates the welcome paragraph around it when present). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Timestamp of the most recent successful onboarding completion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, +} + +fn default_version() -> u32 { + ONBOARDING_SCHEMA_VERSION +} + +impl Default for OnboardingState { + fn default() -> Self { + Self { + version: ONBOARDING_SCHEMA_VERSION, + has_completed_onboarding: false, + ide_onboarding_done: false, + auth_onboarding_done: false, + ui_onboarding_done: false, + display_name: None, + completed_at: None, + } + } +} + +impl OnboardingState { + /// Reset every field that should vanish when the user logs out. Keeps + /// the file around (the wrapper `OnboardingStore::reset()` deletes it + /// instead); this is exposed for in-memory reset in tests or UI flows. + pub fn reset_for_logout(&mut self) { + self.has_completed_onboarding = false; + self.ide_onboarding_done = false; + self.auth_onboarding_done = false; + self.ui_onboarding_done = false; + self.completed_at = None; + // display_name is preserved — it's a user preference, not an auth + // artifact — unless explicitly cleared by the UI. + } + + /// Does the state look meaningfully populated? Used by `/team-onboarding` + /// to decide whether to show "first-run" language or not. + pub fn is_first_run(&self) -> bool { + !self.has_completed_onboarding + && !self.auth_onboarding_done + && !self.ide_onboarding_done + && !self.ui_onboarding_done + } +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// File-backed onboarding state. Safe across threads in one process; across +/// processes the worst case is a lost-update on the final write, which is +/// acceptable for the onboarding flow (it only runs once per install). +pub struct OnboardingStore { + path: PathBuf, + inner: Mutex<()>, +} + +impl OnboardingStore { + pub fn default_path() -> PathBuf { + cc_config::paths::data_root().join("onboarding.json") + } + + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + inner: Mutex::new(()), + } + } + + pub fn open_default() -> Self { + Self::new(Self::default_path()) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Read the current state. Missing file → default state (first run). + pub fn load(&self) -> Result { + let _guard = self.inner.lock(); + self.read_from_disk() + } + + /// Mutate the state through a closure. Reads, mutates in-place, writes + /// back atomically. This is the only write path today — `/logout` + /// calls it with `OnboardingState::reset_for_logout` to preserve + /// preferences (display_name) while clearing identity artifacts. + pub fn update(&self, f: F) -> Result + where + F: FnOnce(&mut OnboardingState), + { + let _guard = self.inner.lock(); + let mut state = self.read_from_disk()?; + f(&mut state); + self.write_to_disk(&state)?; + Ok(state) + } + + // ----------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------- + + fn read_from_disk(&self) -> Result { + if !self.path.exists() { + return Ok(OnboardingState::default()); + } + let mut file = File::open(&self.path).map_err(|e| OnboardingError::Io { + path: self.path.clone(), + source: e, + })?; + let mut buf = String::new(); + file.read_to_string(&mut buf).map_err(|e| OnboardingError::Io { + path: self.path.clone(), + source: e, + })?; + if buf.trim().is_empty() { + return Ok(OnboardingState::default()); + } + let parsed = serde_json::from_str(&buf).map_err(|e| OnboardingError::Decode { + path: self.path.clone(), + source: e, + })?; + Ok(parsed) + } + + fn write_to_disk(&self, state: &OnboardingState) -> Result<(), OnboardingError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).map_err(|e| OnboardingError::Io { + path: parent.to_path_buf(), + source: e, + })?; + } + let bytes = serde_json::to_vec_pretty(state).map_err(OnboardingError::Encode)?; + let tmp_path = self.path.with_extension("json.tmp"); + { + let mut tmp = File::create(&tmp_path).map_err(|e| OnboardingError::Io { + path: tmp_path.clone(), + source: e, + })?; + tmp.write_all(&bytes).map_err(|e| OnboardingError::Io { + path: tmp_path.clone(), + source: e, + })?; + tmp.flush().map_err(|e| OnboardingError::Io { + path: tmp_path.clone(), + source: e, + })?; + } + fs::rename(&tmp_path, &self.path).map_err(|e| OnboardingError::Io { + path: self.path.clone(), + source: e, + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn fresh_store() -> (tempfile::TempDir, OnboardingStore) { + let dir = tempdir().unwrap(); + let store = OnboardingStore::new(dir.path().join("onboarding.json")); + (dir, store) + } + + #[test] + fn missing_file_returns_default_state() { + let (_dir, store) = fresh_store(); + let state = store.load().unwrap(); + assert_eq!(state, OnboardingState::default()); + assert!(state.is_first_run()); + } + + #[test] + fn update_applies_closure() { + let (_dir, store) = fresh_store(); + let out = store + .update(|s| { + s.auth_onboarding_done = true; + s.display_name = Some("Sam".into()); + }) + .unwrap(); + assert!(out.auth_onboarding_done); + assert_eq!(out.display_name, Some("Sam".into())); + let loaded = store.load().unwrap(); + assert_eq!(loaded, out); + } + + #[test] + fn reset_for_logout_preserves_display_name() { + let mut state = OnboardingState { + has_completed_onboarding: true, + auth_onboarding_done: true, + display_name: Some("Sam".into()), + completed_at: Some(Utc::now()), + ..OnboardingState::default() + }; + state.reset_for_logout(); + assert!(!state.has_completed_onboarding); + assert!(!state.auth_onboarding_done); + assert_eq!(state.display_name, Some("Sam".into())); + } + + #[test] + fn unknown_fields_dont_break_decode() { + let (_dir, store) = fresh_store(); + fs::write( + store.path(), + r#"{"version":1,"has_completed_onboarding":true,"unknown_future_field":"x"}"#, + ) + .unwrap(); + let state = store.load().unwrap(); + assert!(state.has_completed_onboarding); + } +} diff --git a/crates/claude-code-rs/src/services/scheduler/interval.rs b/crates/claude-code-rs/src/services/scheduler/interval.rs new file mode 100644 index 00000000..3ac77b1c --- /dev/null +++ b/crates/claude-code-rs/src/services/scheduler/interval.rs @@ -0,0 +1,249 @@ +//! Interval parsing — shared between `/loop …` and +//! `/schedule add --every …`. +//! +//! Supported forms: +//! +//! - Plain numeric + unit: `5m`, `1h`, `30s`, `2d`. +//! - Bare integer → seconds (`60` == `60s`). +//! - 5-field cron: `* * * * *` — currently only the minute slot is honored +//! (interval == stride). Full cron is future work; minute-stride covers +//! the common "every N minutes" case and preserves on-disk compatibility +//! with the Bun reference's `scheduled_tasks.json`. +//! +//! Cron rejection messages are intentionally specific so users understand +//! why their expression was only partially respected. + +use std::fmt; + +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Interval { + seconds: u64, +} + +impl Interval { + pub fn from_seconds(seconds: u64) -> Self { + Self { seconds } + } + + pub fn seconds(self) -> u64 { + self.seconds + } + + pub fn human(self) -> String { + let s = self.seconds; + if s % 86_400 == 0 && s >= 86_400 { + return format!("{}d", s / 86_400); + } + if s % 3_600 == 0 && s >= 3_600 { + return format!("{}h", s / 3_600); + } + if s % 60 == 0 && s >= 60 { + return format!("{}m", s / 60); + } + format!("{}s", s) + } +} + +impl fmt::Display for Interval { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.human()) + } +} + +#[derive(Debug, Error)] +pub enum IntervalParseError { + #[error("interval must not be empty")] + Empty, + #[error("interval '{0}' is not a recognized form (try 5m, 1h, 30s, 2d, or '*/5 * * * *')")] + Malformed(String), + #[error("interval '{0}' must be positive")] + NonPositive(String), + #[error("interval '{0}' is larger than the 1-year cap")] + TooLarge(String), + #[error("cron expression '{0}' is only partially supported — only the minute stride \ + ('*/N * * * *' or 'N * * * *') is honored right now")] + CronUnsupported(String), +} + +const ONE_YEAR_SECS: u64 = 365 * 86_400; + +pub fn parse_interval(raw: &str) -> Result { + let input = raw.trim(); + if input.is_empty() { + return Err(IntervalParseError::Empty); + } + + if input.contains(' ') || input.contains('/') { + return parse_cron_like(input); + } + + parse_duration(input) +} + +fn parse_duration(input: &str) -> Result { + // Split trailing alpha suffix from leading digits. + let (num_part, unit_part) = split_numeric_suffix(input); + if num_part.is_empty() { + return Err(IntervalParseError::Malformed(input.to_string())); + } + + let value: u64 = num_part + .parse() + .map_err(|_| IntervalParseError::Malformed(input.to_string()))?; + + if value == 0 { + return Err(IntervalParseError::NonPositive(input.to_string())); + } + + let seconds = match unit_part { + "" | "s" | "sec" | "secs" | "second" | "seconds" => value, + "m" | "min" | "mins" | "minute" | "minutes" => value + .checked_mul(60) + .ok_or_else(|| IntervalParseError::TooLarge(input.to_string()))?, + "h" | "hr" | "hrs" | "hour" | "hours" => value + .checked_mul(3_600) + .ok_or_else(|| IntervalParseError::TooLarge(input.to_string()))?, + "d" | "day" | "days" => value + .checked_mul(86_400) + .ok_or_else(|| IntervalParseError::TooLarge(input.to_string()))?, + _ => return Err(IntervalParseError::Malformed(input.to_string())), + }; + + if seconds > ONE_YEAR_SECS { + return Err(IntervalParseError::TooLarge(input.to_string())); + } + + Ok(Interval::from_seconds(seconds)) +} + +fn parse_cron_like(input: &str) -> Result { + let fields: Vec<&str> = input.split_whitespace().collect(); + if fields.len() != 5 { + return Err(IntervalParseError::Malformed(input.to_string())); + } + let (minute_field, rest) = (fields[0], &fields[1..]); + // Require the other four fields to be wildcards — anything else is a + // cron feature we don't implement yet. + if !rest.iter().all(|f| *f == "*") { + return Err(IntervalParseError::CronUnsupported(input.to_string())); + } + + // `*/N` → every N minutes; bare integer → interpret as "every N minutes" + // too, so `15 * * * *` doesn't silently become "once an hour at minute 15" + // without warning (the Bun reference handles this the same way). + let minutes: u64 = if let Some(stride) = minute_field.strip_prefix("*/") { + stride + .parse() + .map_err(|_| IntervalParseError::Malformed(input.to_string()))? + } else if minute_field == "*" { + 1 + } else { + minute_field + .parse() + .map_err(|_| IntervalParseError::CronUnsupported(input.to_string()))? + }; + + if minutes == 0 { + return Err(IntervalParseError::NonPositive(input.to_string())); + } + + let seconds = minutes + .checked_mul(60) + .ok_or_else(|| IntervalParseError::TooLarge(input.to_string()))?; + if seconds > ONE_YEAR_SECS { + return Err(IntervalParseError::TooLarge(input.to_string())); + } + Ok(Interval::from_seconds(seconds)) +} + +fn split_numeric_suffix(input: &str) -> (&str, &str) { + let split = input + .char_indices() + .find(|(_, c)| !c.is_ascii_digit()) + .map(|(i, _)| i) + .unwrap_or(input.len()); + input.split_at(split) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bare_seconds() { + assert_eq!(parse_interval("60").unwrap().seconds(), 60); + } + + #[test] + fn parses_duration_suffixes() { + assert_eq!(parse_interval("30s").unwrap().seconds(), 30); + assert_eq!(parse_interval("5m").unwrap().seconds(), 300); + assert_eq!(parse_interval("1h").unwrap().seconds(), 3_600); + assert_eq!(parse_interval("2d").unwrap().seconds(), 172_800); + assert_eq!(parse_interval(" 5m ").unwrap().seconds(), 300); + assert_eq!(parse_interval("5minutes").unwrap().seconds(), 300); + assert_eq!(parse_interval("1hr").unwrap().seconds(), 3_600); + } + + #[test] + fn parses_simple_cron() { + assert_eq!(parse_interval("*/5 * * * *").unwrap().seconds(), 300); + assert_eq!(parse_interval("* * * * *").unwrap().seconds(), 60); + } + + #[test] + fn rejects_zero_interval() { + assert!(matches!( + parse_interval("0s"), + Err(IntervalParseError::NonPositive(_)) + )); + assert!(matches!( + parse_interval("*/0 * * * *"), + Err(IntervalParseError::NonPositive(_)) + )); + } + + #[test] + fn rejects_empty() { + assert!(matches!(parse_interval(" "), Err(IntervalParseError::Empty))); + } + + #[test] + fn rejects_bad_suffix() { + assert!(matches!( + parse_interval("5z"), + Err(IntervalParseError::Malformed(_)) + )); + } + + #[test] + fn rejects_unsupported_cron() { + assert!(matches!( + parse_interval("* */2 * * *"), + Err(IntervalParseError::CronUnsupported(_)) + )); + assert!(matches!( + parse_interval("0 0 1 * *"), + Err(IntervalParseError::CronUnsupported(_)) + )); + } + + #[test] + fn rejects_over_one_year() { + let two_years = 2 * 365; + assert!(matches!( + parse_interval(&format!("{}d", two_years)), + Err(IntervalParseError::TooLarge(_)) + )); + } + + #[test] + fn human_roundtrips_major_units() { + assert_eq!(Interval::from_seconds(60).human(), "1m"); + assert_eq!(Interval::from_seconds(3_600).human(), "1h"); + assert_eq!(Interval::from_seconds(86_400).human(), "1d"); + assert_eq!(Interval::from_seconds(45).human(), "45s"); + } +} diff --git a/crates/claude-code-rs/src/services/scheduler/mod.rs b/crates/claude-code-rs/src/services/scheduler/mod.rs new file mode 100644 index 00000000..bbc5f779 --- /dev/null +++ b/crates/claude-code-rs/src/services/scheduler/mod.rs @@ -0,0 +1,28 @@ +//! Local recurring-task scheduler — shared infrastructure for `/loop` +//! (issue #58) and `/schedule` (issue #60). +//! +//! Tasks are persisted to `{data_root}/scheduled_tasks.json` and guarded by +//! a sibling lockfile so concurrent sessions don't step on each other. The +//! scheduler itself is intentionally passive: it exposes CRUD + `due_tasks` +//! polling. The daemon (or whatever orchestration layer runs on top) is +//! responsible for actually firing a task when it reports due. +//! +//! The split between `/loop` and `/schedule`: +//! +//! - `/loop` — user-friendly wrapper. Parses a human interval (`5m`, `1h`) +//! into a schedule, creates a recurring task, and reports the task back to +//! the caller so the host can also execute it once immediately. +//! - `/schedule` — raw management surface over the same store: list / add / +//! remove / inspect / trigger. +//! +//! The remote-triggers capability (GitHub/remote agent cron) is explicitly +//! *not* covered here. Issue #60's first milestone is local cron, and the +//! two capability lines stay separate — see `SchedulerKind`. + +pub mod interval; +pub mod store; +pub mod task; + +pub use interval::{parse_interval, Interval}; +pub use store::{SchedulerError, SchedulerStore}; +pub use task::{ScheduledTask, SchedulerKind, TaskId, TaskPayload}; diff --git a/crates/claude-code-rs/src/services/scheduler/store.rs b/crates/claude-code-rs/src/services/scheduler/store.rs new file mode 100644 index 00000000..79255d22 --- /dev/null +++ b/crates/claude-code-rs/src/services/scheduler/store.rs @@ -0,0 +1,411 @@ +//! Persistence for scheduled tasks — JSON file guarded by a sibling +//! lockfile so concurrent sessions serialize their writes. +//! +//! The store does *not* poll or fire tasks. It offers CRUD and `due_tasks` +//! snapshots; the daemon or any other scheduling host can wire a timer on +//! top. Keeping the store passive makes it trivially testable and decouples +//! `/loop` (which just wants to insert a task) from any tick loop. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::task::{ScheduledTask, SchedulerKind, TaskId}; + +/// Schema version for the on-disk JSON so we can evolve the format later +/// without silently deserializing a mismatched layout. +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Error)] +pub enum SchedulerError { + #[error("I/O error touching {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to decode {path}: {source}")] + Decode { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("failed to encode scheduler state: {0}")] + Encode(#[source] serde_json::Error), + #[error("task '{0}' not found")] + NotFound(String), + #[error("could not acquire scheduler lock at {path} within {}ms", timeout_ms.as_millis())] + LockTimeout { + path: PathBuf, + timeout_ms: Duration, + }, + #[error("remote-trigger tasks are not supported yet — see issue #60")] + RemoteTriggerUnsupported, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StateFile { + version: u32, + #[serde(default)] + tasks: Vec, +} + +impl Default for StateFile { + fn default() -> Self { + Self { + version: SCHEMA_VERSION, + tasks: Vec::new(), + } + } +} + +/// File-backed scheduler store. +/// +/// Multiple `SchedulerStore` instances can point at the same JSON file — +/// they'll serialize through the on-disk lockfile even across processes. +/// Within one process the in-process `parking_lot::Mutex` keeps concurrent +/// calls from the same host cheap. +pub struct SchedulerStore { + state_path: PathBuf, + lock_path: PathBuf, + inner: Mutex<()>, +} + +impl SchedulerStore { + /// Default on-disk location under the cc-rust data root. + pub fn default_path() -> PathBuf { + cc_config::paths::data_root().join("scheduled_tasks.json") + } + + /// Open (or prepare to open) a store at `state_path`. Does not create + /// the file — the first `save` call does that. + pub fn new(state_path: impl Into) -> Self { + let state_path = state_path.into(); + let lock_path = state_path.with_extension("json.lock"); + Self { + state_path, + lock_path, + inner: Mutex::new(()), + } + } + + pub fn open_default() -> Self { + Self::new(Self::default_path()) + } + + pub fn path(&self) -> &Path { + &self.state_path + } + + /// Load all tasks. Missing file → empty list. + pub fn load(&self) -> Result, SchedulerError> { + let _guard = self.inner.lock(); + let _file_guard = self.acquire_lock()?; + self.read_state().map(|s| s.tasks) + } + + /// Add a new task, persisting immediately. + pub fn add(&self, task: ScheduledTask) -> Result { + if matches!(task.kind, SchedulerKind::RemoteTrigger) { + return Err(SchedulerError::RemoteTriggerUnsupported); + } + let _guard = self.inner.lock(); + let _file_guard = self.acquire_lock()?; + let mut state = self.read_state()?; + state.tasks.push(task.clone()); + self.write_state(&state)?; + Ok(task) + } + + /// Remove a task by id. Returns the removed task or `NotFound`. + pub fn remove(&self, id: &TaskId) -> Result { + let _guard = self.inner.lock(); + let _file_guard = self.acquire_lock()?; + let mut state = self.read_state()?; + let pos = state + .tasks + .iter() + .position(|t| t.id == *id) + .ok_or_else(|| SchedulerError::NotFound(id.to_string()))?; + let removed = state.tasks.remove(pos); + self.write_state(&state)?; + Ok(removed) + } + + /// Fetch a single task snapshot. + pub fn get(&self, id: &TaskId) -> Result { + let tasks = self.load()?; + tasks + .into_iter() + .find(|t| t.id == *id) + .ok_or_else(|| SchedulerError::NotFound(id.to_string())) + } + + /// Pause or resume a task by id. + pub fn set_paused(&self, id: &TaskId, paused: bool) -> Result { + let _guard = self.inner.lock(); + let _file_guard = self.acquire_lock()?; + let mut state = self.read_state()?; + let task = state + .tasks + .iter_mut() + .find(|t| t.id == *id) + .ok_or_else(|| SchedulerError::NotFound(id.to_string()))?; + task.paused = paused; + let snapshot = task.clone(); + self.write_state(&state)?; + Ok(snapshot) + } + + /// Mark a task as fired (advance its `next_run_at`). This is what the + /// daemon should call after it successfully dispatches a task. + pub fn record_fired(&self, id: &TaskId) -> Result { + let _guard = self.inner.lock(); + let _file_guard = self.acquire_lock()?; + let mut state = self.read_state()?; + let task = state + .tasks + .iter_mut() + .find(|t| t.id == *id) + .ok_or_else(|| SchedulerError::NotFound(id.to_string()))?; + task.mark_fired(Utc::now()); + let snapshot = task.clone(); + self.write_state(&state)?; + Ok(snapshot) + } + + /// Collect the tasks that are due right now. A passive snapshot — the + /// caller is responsible for firing them and calling `record_fired`. + pub fn due_tasks(&self) -> Result, SchedulerError> { + let now = Utc::now(); + Ok(self + .load()? + .into_iter() + .filter(|t| t.is_due(now)) + .collect()) + } + + // ----------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------- + + fn read_state(&self) -> Result { + if !self.state_path.exists() { + return Ok(StateFile::default()); + } + let mut file = File::open(&self.state_path).map_err(|e| SchedulerError::Io { + path: self.state_path.clone(), + source: e, + })?; + let mut buf = String::new(); + file.read_to_string(&mut buf).map_err(|e| SchedulerError::Io { + path: self.state_path.clone(), + source: e, + })?; + if buf.trim().is_empty() { + return Ok(StateFile::default()); + } + let parsed: StateFile = serde_json::from_str(&buf).map_err(|e| SchedulerError::Decode { + path: self.state_path.clone(), + source: e, + })?; + Ok(parsed) + } + + fn write_state(&self, state: &StateFile) -> Result<(), SchedulerError> { + if let Some(parent) = self.state_path.parent() { + fs::create_dir_all(parent).map_err(|e| SchedulerError::Io { + path: parent.to_path_buf(), + source: e, + })?; + } + let bytes = serde_json::to_vec_pretty(state).map_err(SchedulerError::Encode)?; + let tmp_path = self.state_path.with_extension("json.tmp"); + // Atomic-write: write to tmp then rename. Avoids corrupting the + // scheduled_tasks.json if the process is killed mid-write. + { + let mut tmp = File::create(&tmp_path).map_err(|e| SchedulerError::Io { + path: tmp_path.clone(), + source: e, + })?; + tmp.write_all(&bytes).map_err(|e| SchedulerError::Io { + path: tmp_path.clone(), + source: e, + })?; + tmp.flush().map_err(|e| SchedulerError::Io { + path: tmp_path.clone(), + source: e, + })?; + } + fs::rename(&tmp_path, &self.state_path).map_err(|e| SchedulerError::Io { + path: self.state_path.clone(), + source: e, + })?; + Ok(()) + } + + fn acquire_lock(&self) -> Result, SchedulerError> { + if let Some(parent) = self.lock_path.parent() { + fs::create_dir_all(parent).map_err(|e| SchedulerError::Io { + path: parent.to_path_buf(), + source: e, + })?; + } + let start = Instant::now(); + let timeout = Duration::from_millis(2_000); + loop { + let result = OpenOptions::new() + .write(true) + .create_new(true) + .open(&self.lock_path); + match result { + Ok(file) => { + return Ok(FileLockGuard { + file: Some(file), + path: &self.lock_path, + }); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + if start.elapsed() >= timeout { + return Err(SchedulerError::LockTimeout { + path: self.lock_path.clone(), + timeout_ms: timeout, + }); + } + std::thread::sleep(Duration::from_millis(25)); + } + Err(e) => { + return Err(SchedulerError::Io { + path: self.lock_path.clone(), + source: e, + }); + } + } + } + } +} + +/// Drop-guard that removes the lock file when it goes out of scope. +struct FileLockGuard<'a> { + #[allow(dead_code)] + file: Option, + path: &'a Path, +} + +impl Drop for FileLockGuard<'_> { + fn drop(&mut self) { + // Close the file handle before unlinking — on Windows we'd otherwise + // hit sharing-violation errors while trying to remove an open file. + self.file.take(); + let _ = fs::remove_file(self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::scheduler::{parse_interval, TaskPayload}; + use tempfile::tempdir; + + fn fresh_store() -> (tempfile::TempDir, SchedulerStore) { + let dir = tempdir().unwrap(); + let store = SchedulerStore::new(dir.path().join("scheduled_tasks.json")); + (dir, store) + } + + fn make_task(name: &str, secs: u64) -> ScheduledTask { + let now = Utc::now(); + let interval = parse_interval(&format!("{}s", secs)).unwrap(); + ScheduledTask::new( + SchedulerKind::LocalCron, + name, + format!("{}s", secs), + interval, + TaskPayload::Prompt(format!("prompt-{}", name)), + now, + ) + } + + #[test] + fn add_list_remove_roundtrip() { + let (_dir, store) = fresh_store(); + assert!(store.load().unwrap().is_empty()); + + let created = store.add(make_task("one", 60)).unwrap(); + let list = store.load().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, created.id); + + let removed = store.remove(&created.id).unwrap(); + assert_eq!(removed.id, created.id); + assert!(store.load().unwrap().is_empty()); + } + + #[test] + fn remove_missing_returns_not_found() { + let (_dir, store) = fresh_store(); + let err = store.remove(&TaskId::new()).unwrap_err(); + assert!(matches!(err, SchedulerError::NotFound(_))); + } + + #[test] + fn remote_trigger_rejected() { + let (_dir, store) = fresh_store(); + let mut task = make_task("remote", 60); + task.kind = SchedulerKind::RemoteTrigger; + let err = store.add(task).unwrap_err(); + assert!(matches!(err, SchedulerError::RemoteTriggerUnsupported)); + } + + #[test] + fn record_fired_advances_next_run() { + let (_dir, store) = fresh_store(); + let task = store.add(make_task("t", 60)).unwrap(); + let before = task.next_run_at; + // Force "now" to be ahead of the initial next_run_at by mutating + // last_run_at through the public API. + std::thread::sleep(Duration::from_millis(10)); + let after = store.record_fired(&task.id).unwrap(); + assert!(after.last_run_at.is_some()); + assert!(after.next_run_at >= before); + } + + #[test] + fn pause_skips_due_reporting() { + let (_dir, store) = fresh_store(); + let mut task = make_task("t", 1); + task.next_run_at = Utc::now() - chrono::Duration::seconds(5); + let added = store.add(task).unwrap(); + assert_eq!(store.due_tasks().unwrap().len(), 1); + store.set_paused(&added.id, true).unwrap(); + assert_eq!(store.due_tasks().unwrap().len(), 0); + store.set_paused(&added.id, false).unwrap(); + assert_eq!(store.due_tasks().unwrap().len(), 1); + } + + #[test] + fn persists_across_instances() { + let dir = tempdir().unwrap(); + let path = dir.path().join("scheduled_tasks.json"); + { + let store = SchedulerStore::new(&path); + store.add(make_task("persist", 120)).unwrap(); + } + let store2 = SchedulerStore::new(&path); + assert_eq!(store2.load().unwrap().len(), 1); + } + + #[test] + fn get_by_id() { + let (_dir, store) = fresh_store(); + let task = store.add(make_task("g", 60)).unwrap(); + let fetched = store.get(&task.id).unwrap(); + assert_eq!(fetched.id, task.id); + } +} diff --git a/crates/claude-code-rs/src/services/scheduler/task.rs b/crates/claude-code-rs/src/services/scheduler/task.rs new file mode 100644 index 00000000..c90aebc3 --- /dev/null +++ b/crates/claude-code-rs/src/services/scheduler/task.rs @@ -0,0 +1,248 @@ +//! Scheduled task types — the on-disk format for `scheduled_tasks.json`. +//! +//! The task struct is the primary public vocabulary; `/loop` and `/schedule` +//! both operate over `ScheduledTask` instances and the [`SchedulerStore`] is +//! only thin wrapping around a `Vec`. +//! +//! [`SchedulerStore`]: super::store::SchedulerStore + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Stable identifier for a scheduled task. Wraps a short UUID-derived string +/// so the CLI can round-trip IDs without quoting hassles. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TaskId(pub String); + +impl TaskId { + /// Generate a fresh ID. Uses the first 12 hex chars of a UUID v4 so IDs + /// stay short enough to copy-paste but keep collision probability low + /// enough for per-user persistence. + pub fn new() -> Self { + let uuid = Uuid::new_v4().simple().to_string(); + Self(uuid.chars().take(12).collect()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for TaskId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for TaskId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Which capability line a task belongs to. Kept explicit so `/schedule` +/// (issue #60) can, in the future, surface a second section for remote +/// triggers without mixing the semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SchedulerKind { + /// Local cron-style task run by the host process / daemon. + LocalCron, + /// Placeholder for a future remote-trigger capability. Creating a task + /// with this kind today is rejected with a clear message; keeping the + /// enum variant now means on-disk state survives when the feature lands. + RemoteTrigger, +} + +impl SchedulerKind { + pub fn as_str(self) -> &'static str { + match self { + SchedulerKind::LocalCron => "local", + SchedulerKind::RemoteTrigger => "remote", + } + } +} + +/// What the scheduler should submit when a task fires. +/// +/// `/loop` supports both a slash-command payload (`/foo bar`) and a plain +/// prompt; we distinguish them so the host can route command payloads +/// through its command dispatcher instead of always going to the model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum TaskPayload { + /// Slash command, including the leading `/` (e.g. `/simplify`). + SlashCommand(String), + /// Plain user prompt forwarded to the model. + Prompt(String), +} + +impl TaskPayload { + /// Build a payload from raw user input; strings starting with `/` become + /// slash-command payloads, everything else is a plain prompt. + pub fn from_user_input(input: &str) -> Self { + let trimmed = input.trim(); + if trimmed.starts_with('/') { + TaskPayload::SlashCommand(trimmed.to_string()) + } else { + TaskPayload::Prompt(trimmed.to_string()) + } + } + + pub fn display(&self) -> &str { + match self { + TaskPayload::SlashCommand(s) => s, + TaskPayload::Prompt(s) => s, + } + } + + pub fn kind_label(&self) -> &'static str { + match self { + TaskPayload::SlashCommand(_) => "command", + TaskPayload::Prompt(_) => "prompt", + } + } +} + +/// One persisted scheduled task. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScheduledTask { + pub id: TaskId, + pub kind: SchedulerKind, + /// Human-friendly label used in `/schedule list` output. + pub name: String, + /// Cron-like expression (e.g. `*/5 * * * *`) or interval spec + /// (e.g. `5m`) — stored verbatim and re-parsed at tick time. + pub schedule: String, + /// Interval in seconds, derived from `schedule` at creation time. + /// Stored so polling doesn't have to re-parse on every tick. + pub interval_seconds: u64, + pub payload: TaskPayload, + pub created_at: DateTime, + pub last_run_at: Option>, + pub next_run_at: DateTime, + /// If true, the task is temporarily suspended (skipped by `due_tasks`) + /// without being deleted. + #[serde(default)] + pub paused: bool, +} + +impl ScheduledTask { + /// Construct a new task with its next-run pre-computed from `now`. + pub fn new( + kind: SchedulerKind, + name: impl Into, + schedule: impl Into, + interval: super::Interval, + payload: TaskPayload, + now: DateTime, + ) -> Self { + let interval_seconds = interval.seconds(); + Self { + id: TaskId::new(), + kind, + name: name.into(), + schedule: schedule.into(), + interval_seconds, + payload, + created_at: now, + last_run_at: None, + next_run_at: now + chrono::Duration::seconds(interval_seconds as i64), + paused: false, + } + } + + /// Mark the task as having just fired and roll `next_run_at` forward + /// by one interval. + pub fn mark_fired(&mut self, now: DateTime) { + self.last_run_at = Some(now); + self.next_run_at = now + chrono::Duration::seconds(self.interval_seconds as i64); + } + + /// Is this task due to fire relative to `now`? + pub fn is_due(&self, now: DateTime) -> bool { + !self.paused && self.next_run_at <= now + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::scheduler::Interval; + + #[test] + fn task_id_is_short_and_unique() { + let a = TaskId::new(); + let b = TaskId::new(); + assert_eq!(a.as_str().len(), 12); + assert_ne!(a, b); + } + + #[test] + fn payload_from_user_input_detects_slash() { + assert!(matches!( + TaskPayload::from_user_input("/simplify"), + TaskPayload::SlashCommand(_) + )); + assert!(matches!( + TaskPayload::from_user_input("run the tests"), + TaskPayload::Prompt(_) + )); + } + + #[test] + fn mark_fired_advances_next_run() { + let now = Utc::now(); + let mut task = ScheduledTask::new( + SchedulerKind::LocalCron, + "t", + "5m", + Interval::from_seconds(300), + TaskPayload::Prompt("hi".into()), + now, + ); + assert_eq!(task.last_run_at, None); + + let fired_at = now + chrono::Duration::seconds(600); + task.mark_fired(fired_at); + + assert_eq!(task.last_run_at, Some(fired_at)); + assert_eq!( + task.next_run_at, + fired_at + chrono::Duration::seconds(300) + ); + } + + #[test] + fn paused_tasks_are_never_due() { + let now = Utc::now(); + let mut task = ScheduledTask::new( + SchedulerKind::LocalCron, + "t", + "1s", + Interval::from_seconds(1), + TaskPayload::Prompt("p".into()), + now - chrono::Duration::seconds(1000), + ); + assert!(task.is_due(now)); + task.paused = true; + assert!(!task.is_due(now)); + } + + #[test] + fn is_due_respects_next_run_threshold() { + let now = Utc::now(); + let task = ScheduledTask::new( + SchedulerKind::LocalCron, + "t", + "5m", + Interval::from_seconds(300), + TaskPayload::Prompt("p".into()), + now, + ); + assert!(!task.is_due(now)); + assert!(task.is_due(now + chrono::Duration::seconds(301))); + } +}