Skip to main content

claw10_skill/
lib.rs

1use std::sync::Arc;
2
3use chrono::Utc;
4
5use claw10_domain::{
6    Permission, Skill, SkillCostProfile, SkillId, SkillState, SkillVersion,
7};
8use claw10_store::{Store, StoreError, StoreExt};
9
10const KEY_PREFIX: &str = "skill:";
11const VERSION_PREFIX: &str = "skill:ver:";
12
13#[derive(Debug, thiserror::Error)]
14pub enum SkillError {
15    #[error("skill not found: {0}")]
16    NotFound(String),
17    #[error("invalid state transition: {from:?} -> {to:?}")]
18    InvalidTransition { from: SkillState, to: SkillState },
19    #[error("unsigned skills cannot be made active")]
20    Unsigned,
21    #[error("{0}")]
22    Store(#[from] StoreError),
23}
24
25pub struct SkillService {
26    store: Arc<dyn Store>,
27}
28
29impl SkillService {
30    #[must_use]
31    pub fn new(store: Arc<dyn Store>) -> Self {
32        Self { store }
33    }
34
35    /// Create a new skill in Candidate state.
36    pub async fn create_skill(
37        &self,
38        name: String,
39        purpose: String,
40        version: String,
41        input_schema: serde_json::Value,
42        output_schema: serde_json::Value,
43        steps: Vec<String>,
44        required_tools: Vec<String>,
45        required_permissions: Vec<Permission>,
46        cost_profile: SkillCostProfile,
47    ) -> Result<Skill, SkillError> {
48        let now = Utc::now();
49        let skill = Skill {
50            id: SkillId(uuid::Uuid::now_v7()),
51            name,
52            purpose,
53            version,
54            input_schema,
55            output_schema,
56            steps,
57            required_tools,
58            required_permissions,
59            state: SkillState::Candidate,
60            signature: None,
61            cost_profile,
62            created_at: now,
63            updated_at: now,
64        };
65        let key = format!("{KEY_PREFIX}{}", skill.id.0);
66        self.store.set(&key, &skill).await?;
67        Ok(skill)
68    }
69
70    /// Transition a skill through its lifecycle states.
71    ///
72    /// Valid transitions:
73    /// Candidate -> Scanning
74    /// Scanning -> Testing | Rejected
75    /// Testing -> Review | Failed
76    /// Review -> Approved | Rejected
77    /// Approved -> Staged
78    /// Staged -> Active (requires signature)
79    /// Active -> Deprecated | Quarantined
80    /// Deprecated -> Retired | Active
81    /// Quarantined -> Scanning | Retired
82    pub async fn transition_state(
83        &self,
84        id: &SkillId,
85        to: SkillState,
86    ) -> Result<Skill, SkillError> {
87        let key = format!("{KEY_PREFIX}{}", id.0);
88        let mut skill = self
89            .store
90            .get::<Skill>(&key)
91            .await?
92            .ok_or_else(|| SkillError::NotFound(id.0.to_string()))?;
93
94        if !is_valid_transition(&skill.state, &to) {
95            return Err(SkillError::InvalidTransition {
96                from: skill.state,
97                to: to.clone(),
98            });
99        }
100
101        // Active requires signature (FR-064)
102        if to == SkillState::Active && skill.signature.is_none() {
103            return Err(SkillError::Unsigned);
104        }
105
106        skill.state = to;
107        skill.updated_at = Utc::now();
108        self.store.set(&key, &skill).await?;
109        Ok(skill)
110    }
111
112    /// Sign a skill so it can be activated.
113    pub async fn sign_skill(
114        &self,
115        id: &SkillId,
116        signature: String,
117    ) -> Result<Skill, SkillError> {
118        let key = format!("{KEY_PREFIX}{}", id.0);
119        let mut skill = self
120            .store
121            .get::<Skill>(&key)
122            .await?
123            .ok_or_else(|| SkillError::NotFound(id.0.to_string()))?;
124
125        skill.signature = Some(signature);
126        skill.updated_at = Utc::now();
127        self.store.set(&key, &skill).await?;
128        Ok(skill)
129    }
130
131    /// Record a new version entry for this skill.
132    pub async fn create_version(
133        &self,
134        skill_id: &SkillId,
135        version: String,
136        previous_version: Option<String>,
137        change_log: String,
138    ) -> Result<SkillVersion, SkillError> {
139        let skill = self
140            .get_skill(skill_id)
141            .await?
142            .ok_or_else(|| SkillError::NotFound(skill_id.0.to_string()))?;
143
144        let sv = SkillVersion {
145            skill_id: skill.id,
146            version,
147            previous_version,
148            change_log,
149            is_deprecated: false,
150            created_at: Utc::now(),
151        };
152        let key = format!("{VERSION_PREFIX}{}:{}", sv.skill_id.0, sv.version);
153        self.store.set(&key, &sv).await?;
154        Ok(sv)
155    }
156
157    pub async fn get_skill(&self, id: &SkillId) -> Result<Option<Skill>, SkillError> {
158        let key = format!("{KEY_PREFIX}{}", id.0);
159        Ok(self.store.get::<Skill>(&key).await?)
160    }
161
162    pub async fn list_skills(
163        &self,
164        state_filter: Option<SkillState>,
165    ) -> Result<Vec<Skill>, SkillError> {
166        let all: Vec<(String, Skill)> = self.store.scan_prefix(KEY_PREFIX).await?;
167        Ok(all
168            .into_iter()
169            .map(|(_, s)| s)
170            .filter(|s| match &state_filter {
171                Some(state) => &s.state == state,
172                None => true,
173            })
174            .collect())
175    }
176
177    pub async fn list_by_required_tool(&self, tool_name: &str) -> Result<Vec<Skill>, SkillError> {
178        let all: Vec<(String, Skill)> = self.store.scan_prefix(KEY_PREFIX).await?;
179        Ok(all
180            .into_iter()
181            .map(|(_, s)| s)
182            .filter(|s| s.required_tools.iter().any(|t| t == tool_name))
183            .collect())
184    }
185
186    pub async fn count_by_state(
187        &self,
188    ) -> Result<std::collections::HashMap<String, usize>, SkillError> {
189        let all: Vec<(String, Skill)> = self.store.scan_prefix(KEY_PREFIX).await?;
190        let mut counts = std::collections::HashMap::new();
191        for (_, skill) in all {
192            *counts.entry(format!("{:?}", skill.state)).or_insert(0) += 1;
193        }
194        Ok(counts)
195    }
196}
197
198fn is_valid_transition(from: &SkillState, to: &SkillState) -> bool {
199    use SkillState::*;
200    matches!(
201        (from, to),
202        (Candidate, Scanning)
203            | (Scanning, Testing)
204            | (Scanning, Rejected)
205            | (Testing, Review)
206            | (Testing, Failed)
207            | (Review, Approved)
208            | (Review, Rejected)
209            | (Approved, Staged)
210            | (Staged, Active)
211            | (Active, Deprecated)
212            | (Active, Quarantined)
213            | (Deprecated, Retired)
214            | (Deprecated, Active)
215            | (Quarantined, Scanning)
216            | (Quarantined, Retired)
217    )
218}
219
220#[cfg(test)]
221#[path = "lib_test.rs"]
222mod tests;
223