use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::gateway::kumiho_client::KumihoClient;
use crate::skills::effectiveness_cache::SkillRegressionCandidate;
use crate::skills::registration::{SkillRollback, rollback_skill_revision};
pub const DEFAULT_ROLLBACK_COOLDOWN: Duration = Duration::from_secs(60 * 60);
pub struct SkillRollbackTracker {
workspace_dir: PathBuf,
cooldown: Duration,
cooldowns: HashMap<String, Instant>,
}
impl SkillRollbackTracker {
pub fn new(workspace_dir: PathBuf) -> Self {
Self {
workspace_dir,
cooldown: DEFAULT_ROLLBACK_COOLDOWN,
cooldowns: HashMap::new(),
}
}
#[cfg(test)]
pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
self.cooldown = cooldown;
self
}
pub fn should_rollback(&self, slug: &str) -> bool {
match self.cooldowns.get(slug) {
None => true,
Some(last) => Instant::now().saturating_duration_since(*last) >= self.cooldown,
}
}
}
pub struct AutoRollbackContext {
pub workspace_dir: PathBuf,
pub kumiho_client: Arc<KumihoClient>,
pub memory_project: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRollbackOutcome {
pub slug: String,
pub restored_revision_kref: String,
pub demoted_revision_kref: String,
pub content_file: String,
}
pub async fn attempt_skill_rollback(
ctx: &AutoRollbackContext,
candidate: &SkillRegressionCandidate,
tracker: &mut SkillRollbackTracker,
) -> Result<Option<SkillRollbackOutcome>> {
if !tracker.should_rollback(&candidate.skill_name) {
return Ok(None);
}
let skill_dir = ctx.workspace_dir.join("skills").join(&candidate.skill_name);
if !skill_dir.join("SKILL.toml").exists() {
tracing::debug!(
skill = %candidate.skill_name,
workspace = %skill_dir.display(),
"auto-rollback: SKILL.toml not found on disk; skipping",
);
return Ok(None);
}
let result = rollback_skill_revision(&skill_dir, &ctx.kumiho_client, &ctx.memory_project).await;
match result {
Ok(SkillRollback {
restored_revision_kref,
demoted_revision_kref,
new_content_file,
}) => {
tracker
.cooldowns
.insert(candidate.skill_name.clone(), Instant::now());
Ok(Some(SkillRollbackOutcome {
slug: candidate.skill_name.clone(),
restored_revision_kref,
demoted_revision_kref,
content_file: new_content_file,
}))
}
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("nothing to roll back")
|| msg.contains("already the current published")
|| msg.contains(crate::skills::registration::PREVIOUS_PUBLISHED_TAG)
{
tracing::debug!(
skill = %candidate.skill_name,
error = %msg,
"auto-rollback: skill not eligible (no rollback target); skipping",
);
return Ok(None);
}
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cooldown_allows_first_attempt() {
let tracker = SkillRollbackTracker::new(PathBuf::from("/tmp/ws"));
assert!(tracker.should_rollback("any-skill"));
}
#[test]
fn cooldown_blocks_recent_attempt() {
let mut tracker = SkillRollbackTracker::new(PathBuf::from("/tmp/ws"))
.with_cooldown(Duration::from_secs(3600));
tracker
.cooldowns
.insert("recent".to_string(), Instant::now());
assert!(!tracker.should_rollback("recent"));
assert!(tracker.should_rollback("other"));
}
#[test]
fn cooldown_expires_after_window() {
let mut tracker = SkillRollbackTracker::new(PathBuf::from("/tmp/ws"))
.with_cooldown(Duration::from_millis(1));
tracker
.cooldowns
.insert("expired".to_string(), Instant::now());
std::thread::sleep(Duration::from_millis(5));
assert!(tracker.should_rollback("expired"));
}
}