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 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 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 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 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 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