返回 CodeWhale
skill_state.rs
根目录 / crates / tui / src / skill_state.rs
1 //! Persistent enable/disable state shared by runtime/API Skill catalogs.
2 //!
3 //! Backs `GET /v1/skills` (`enabled` field per skill) and
4 //! `POST /v1/skills/{name}` (toggle). Discovery tells us which Skills exist;
5 //! this store is the final exact-name activation filter shared by prompts,
6 //! tools, TUI surfaces, sub-agents, and the API. Plugin trust/enablement stays
7 //! a separate bundle lifecycle gate.
8 //!
9 //! Storage shape (TOML at `~/.codewhale/skills_state.toml`, legacy `~/.deepseek/skills_state.toml`):
10 //!
11 //! ```toml
12 //! disabled = ["skill-name-1", "skill-name-2"]
13 //! ```
14 //!
15 //! Default state when the file does not exist: empty list (everything enabled).
16 //! A present but unreadable or malformed file is an error. Callers may keep
17 //! native Skills available for recovery, but reviewed plugin Skills must stay
18 //! hidden until their exact activation state can be read authoritatively.
19
20 use std::collections::BTreeSet;
21 use std::fs::{self, OpenOptions};
22 use std::path::{Path, PathBuf};
23
24 use anyhow::{Context, Result};
25 use serde::{Deserialize, Serialize};
26
27 const STATE_FILE_NAME: &str = "skills_state.toml";
28
29 #[derive(Debug, Clone)]
30 pub struct SkillStateStore {
31 path: PathBuf,
32 disabled: BTreeSet<String>,
33 }
34
35 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
36 struct OnDiskState {
37 #[serde(default)]
38 disabled: Vec<String>,
39 }
40
41 impl SkillStateStore {
42 pub fn load_default() -> Result<Self> {
43 let path = default_state_path()?;
44 Self::load_from(path)
45 }
46
47 pub fn load_from(path: PathBuf) -> Result<Self> {
48 let disabled = load_disabled(&path)?;
49 Ok(Self { path, disabled })
50 }
51
52 pub fn is_enabled(&self, skill_name: &str) -> bool {
53 !self.disabled.contains(skill_name)
54 }
55
56 pub fn set_enabled(&mut self, skill_name: &str, enabled: bool) -> Result<()> {
57 self.set_enabled_with_persist(skill_name, enabled, persist_disabled)
58 }
59
60 /// Refresh the in-memory snapshot under the same shared lock used by
61 /// other Codewhale processes. Long-running Runtime API servers call this
62 /// before listing Skills so an external toggle becomes visible without a
63 /// restart.
64 pub fn refresh(&mut self) -> Result<()> {
65 let disabled = load_disabled(&self.path)?;
66 self.disabled = disabled;
67 Ok(())
68 }
69
70 #[allow(dead_code)]
71 pub fn disabled(&self) -> Vec<String> {
72 self.disabled.iter().cloned().collect()
73 }
74
75 fn set_enabled_with_persist(
76 &mut self,
77 skill_name: &str,
78 enabled: bool,
79 persist: impl FnOnce(&Path, &BTreeSet<String>) -> Result<()>,
80 ) -> Result<()> {
81 if let Some(parent) = self
82 .path
83 .parent()
84 .filter(|path| !path.as_os_str().is_empty())
85 {
86 fs::create_dir_all(parent)
87 .with_context(|| format!("create parent dir for {}", self.path.display()))?;
88 }
89 let lock_path = state_lock_path(&self.path);
90 let lock_file = open_state_lock(&lock_path, true)?;
91 let mut lock = fd_lock::RwLock::new(lock_file);
92 let _guard = lock
93 .write()
94 .with_context(|| format!("write-lock skill state at {}", self.path.display()))?;
95
96 // Reload while holding the cross-process writer lock. Applying the
97 // requested exact-name change to this latest snapshot merges updates
98 // from other Runtime API/TUI processes instead of replacing them with
99 // the caller's possibly stale in-memory view.
100 let mut next = load_disabled_unlocked(&self.path)?;
101 let changed = if enabled {
102 next.remove(skill_name)
103 } else {
104 next.insert(skill_name.to_string())
105 };
106 if changed {
107 // Disk is authoritative. Publish to memory only after the atomic
108 // write succeeds so a failed persistence attempt cannot make this
109 // process report a toggle that no other process can observe.
110 persist(&self.path, &next)?;
111 }
112 self.disabled = next;
113 Ok(())
114 }
115 }
116
117 fn default_state_path() -> Result<PathBuf> {
118 // Listing, prompt construction, and doctor are read-only. The explicit
119 // mutation path creates the parent from `persist` when needed.
120 Ok(codewhale_config::codewhale_home()
121 .context("could not resolve Codewhale state directory")?
122 .join(STATE_FILE_NAME))
123 }
124
125 fn load_disabled(path: &Path) -> Result<BTreeSet<String>> {
126 let lock_path = state_lock_path(path);
127 if path_entry_exists(&lock_path)? {
128 let lock_file = open_state_lock(&lock_path, false)?;
129 let lock = fd_lock::RwLock::new(lock_file);
130 let _guard = lock
131 .read()
132 .with_context(|| format!("read-lock skill state at {}", path.display()))?;
133 return load_disabled_unlocked(path);
134 }
135 load_disabled_unlocked(path)
136 }
137
138 fn load_disabled_unlocked(path: &Path) -> Result<BTreeSet<String>> {
139 let raw = match fs::read_to_string(path) {
140 Ok(raw) => raw,
141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
142 return Ok(BTreeSet::new());
143 }
144 Err(error) => {
145 return Err(error).with_context(|| format!("read skill state at {}", path.display()));
146 }
147 };
148 let parsed: OnDiskState =
149 toml::from_str(&raw).with_context(|| format!("parse skill state at {}", path.display()))?;
150 Ok(parsed.disabled.into_iter().collect())
151 }
152
153 fn persist_disabled(path: &Path, disabled: &BTreeSet<String>) -> Result<()> {
154 let on_disk = OnDiskState {
155 disabled: disabled.iter().cloned().collect(),
156 };
157 let body = toml::to_string_pretty(&on_disk).context("serialize skill state")?;
158 codewhale_config::persistence::atomic_write(path, body.as_bytes())
159 .with_context(|| format!("atomically persist skill state at {}", path.display()))
160 }
161
162 fn state_lock_path(path: &Path) -> PathBuf {
163 let mut name = path
164 .file_name()
165 .map(|name| name.to_os_string())
166 .unwrap_or_else(|| STATE_FILE_NAME.into());
167 name.push(".lock");
168 path.with_file_name(name)
169 }
170
171 fn path_entry_exists(path: &Path) -> Result<bool> {
172 match fs::symlink_metadata(path) {
173 Ok(_) => Ok(true),
174 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
175 Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())),
176 }
177 }
178
179 fn open_state_lock(path: &Path, create: bool) -> Result<fs::File> {
180 let mut options = OpenOptions::new();
181 options
182 .read(true)
183 .write(true)
184 .create(create)
185 .truncate(false);
186 #[cfg(unix)]
187 {
188 use std::os::unix::fs::OpenOptionsExt as _;
189 options
190 .mode(0o600)
191 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
192 }
193 #[cfg(windows)]
194 {
195 use std::os::windows::fs::OpenOptionsExt as _;
196 options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
197 }
198 let file = options
199 .open(path)
200 .with_context(|| format!("open skill state lock at {}", path.display()))?;
201 validate_state_lock(path, &file)?;
202 Ok(file)
203 }
204
205 #[cfg(unix)]
206 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
207 use std::os::unix::fs::MetadataExt as _;
208
209 let metadata = file
210 .metadata()
211 .with_context(|| format!("inspect skill state lock at {}", path.display()))?;
212 anyhow::ensure!(
213 metadata.is_file() && metadata.nlink() == 1,
214 "skill state lock at {} must be one regular, non-hard-linked file",
215 path.display()
216 );
217 Ok(())
218 }
219
220 #[cfg(windows)]
221 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
222 use std::os::windows::fs::MetadataExt as _;
223
224 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
225 let metadata = file
226 .metadata()
227 .with_context(|| format!("inspect skill state lock at {}", path.display()))?;
228 anyhow::ensure!(
229 metadata.is_file() && metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
230 "skill state lock at {} must be a regular, non-reparse file",
231 path.display()
232 );
233 Ok(())
234 }
235
236 #[cfg(all(not(unix), not(windows)))]
237 fn validate_state_lock(path: &Path, file: &fs::File) -> Result<()> {
238 anyhow::ensure!(
239 file.metadata()
240 .with_context(|| format!("inspect skill state lock at {}", path.display()))?
241 .is_file(),
242 "skill state lock at {} must be a regular file",
243 path.display()
244 );
245 Ok(())
246 }
247
248 #[cfg(test)]
249 mod tests {
250 use super::*;
251 use tempfile::TempDir;
252
253 fn fresh() -> (TempDir, SkillStateStore) {
254 let dir = TempDir::new().unwrap();
255 let path = dir.path().join(STATE_FILE_NAME);
256 let store = SkillStateStore::load_from(path).unwrap();
257 (dir, store)
258 }
259
260 #[test]
261 fn missing_file_defaults_to_everything_enabled() {
262 let (_dir, store) = fresh();
263 assert!(store.is_enabled("anything"));
264 assert!(store.disabled().is_empty());
265 }
266
267 #[test]
268 fn disable_then_reload_persists() {
269 let (dir, mut store) = fresh();
270 store.set_enabled("foo", false).unwrap();
271 assert!(!store.is_enabled("foo"));
272
273 let reloaded = SkillStateStore::load_from(dir.path().join(STATE_FILE_NAME)).unwrap();
274 assert!(!reloaded.is_enabled("foo"));
275 assert!(reloaded.is_enabled("bar"));
276 }
277
278 #[test]
279 fn enable_removes_from_disabled_list() {
280 let (_dir, mut store) = fresh();
281 store.set_enabled("foo", false).unwrap();
282 store.set_enabled("foo", true).unwrap();
283 assert!(store.is_enabled("foo"));
284 assert!(store.disabled().is_empty());
285 }
286
287 #[test]
288 fn redundant_toggle_is_noop() {
289 let (_dir, mut store) = fresh();
290 store.set_enabled("foo", true).unwrap();
291 assert!(store.disabled().is_empty());
292 }
293
294 #[test]
295 fn malformed_file_fails_closed() {
296 let dir = TempDir::new().unwrap();
297 let path = dir.path().join(STATE_FILE_NAME);
298 fs::write(&path, b"this is not toml = { broken").unwrap();
299 let error = SkillStateStore::load_from(path.clone()).unwrap_err();
300 assert!(error.to_string().contains("parse skill state"));
301 assert_eq!(
302 fs::read(&path).unwrap(),
303 b"this is not toml = { broken",
304 "a malformed authority file must remain untouched for recovery"
305 );
306 }
307
308 #[test]
309 fn disabled_list_is_deterministic_order() {
310 let (_dir, mut store) = fresh();
311 store.set_enabled("zeta", false).unwrap();
312 store.set_enabled("alpha", false).unwrap();
313 store.set_enabled("mu", false).unwrap();
314 assert_eq!(
315 store.disabled(),
316 vec!["alpha".to_string(), "mu".to_string(), "zeta".to_string()]
317 );
318 }
319
320 #[test]
321 fn stale_stores_merge_independent_toggles() {
322 let dir = TempDir::new().unwrap();
323 let path = dir.path().join(STATE_FILE_NAME);
324 let mut first = SkillStateStore::load_from(path.clone()).unwrap();
325 let mut second = SkillStateStore::load_from(path.clone()).unwrap();
326
327 first.set_enabled("alpha", false).unwrap();
328 second.set_enabled("beta", false).unwrap();
329
330 let persisted = SkillStateStore::load_from(path).unwrap();
331 assert!(!persisted.is_enabled("alpha"));
332 assert!(!persisted.is_enabled("beta"));
333 }
334
335 #[test]
336 fn stale_enable_request_reloads_before_noop_decision() {
337 let dir = TempDir::new().unwrap();
338 let path = dir.path().join(STATE_FILE_NAME);
339 let mut disabler = SkillStateStore::load_from(path.clone()).unwrap();
340 let mut stale_enabler = SkillStateStore::load_from(path.clone()).unwrap();
341
342 disabler.set_enabled("alpha", false).unwrap();
343 stale_enabler.set_enabled("alpha", true).unwrap();
344
345 assert!(stale_enabler.is_enabled("alpha"));
346 assert!(
347 SkillStateStore::load_from(path)
348 .unwrap()
349 .is_enabled("alpha")
350 );
351 }
352
353 #[test]
354 fn refresh_observes_external_process_snapshot() {
355 let dir = TempDir::new().unwrap();
356 let path = dir.path().join(STATE_FILE_NAME);
357 let mut writer = SkillStateStore::load_from(path.clone()).unwrap();
358 let mut reader = SkillStateStore::load_from(path).unwrap();
359
360 writer.set_enabled("alpha", false).unwrap();
361 assert!(reader.is_enabled("alpha"));
362 reader.refresh().unwrap();
363 assert!(!reader.is_enabled("alpha"));
364 }
365
366 #[test]
367 fn failed_persist_does_not_advance_in_memory_state() {
368 let (_dir, mut store) = fresh();
369 store.set_enabled("alpha", false).unwrap();
370
371 let error = store
372 .set_enabled_with_persist("beta", false, |_, _| {
373 anyhow::bail!("injected persistence failure")
374 })
375 .unwrap_err();
376
377 assert!(error.to_string().contains("injected persistence failure"));
378 assert!(!store.is_enabled("alpha"));
379 assert!(store.is_enabled("beta"));
380 }
381
382 #[test]
383 fn cross_process_toggles_serialize_and_merge() {
384 const CHILD_PATH: &str = "CODEWHALE_TEST_SKILL_STATE_PATH";
385 const CHILD_NAME: &str = "CODEWHALE_TEST_SKILL_STATE_NAME";
386 const TEST_NAME: &str = "skill_state::tests::cross_process_toggles_serialize_and_merge";
387
388 if let (Some(path), Some(name)) =
389 (std::env::var_os(CHILD_PATH), std::env::var_os(CHILD_NAME))
390 {
391 let path = PathBuf::from(path);
392 let name = name.to_string_lossy().into_owned();
393 let mut store = SkillStateStore::load_from(path.clone()).unwrap();
394 fs::write(path.with_file_name(format!("{name}.ready")), b"ready").unwrap();
395 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
396 while ["alpha", "beta"]
397 .iter()
398 .any(|peer| !path.with_file_name(format!("{peer}.ready")).exists())
399 {
400 assert!(
401 std::time::Instant::now() < deadline,
402 "peer skill-state process did not reach the mutation barrier"
403 );
404 std::thread::sleep(std::time::Duration::from_millis(10));
405 }
406 store.set_enabled(&name, false).unwrap();
407 return;
408 }
409
410 use std::process::{Command, Stdio};
411 use wait_timeout::ChildExt as _;
412
413 let dir = TempDir::new().unwrap();
414 let path = dir.path().join(STATE_FILE_NAME);
415 let executable = std::env::current_exe().expect("current test executable");
416 let mut children = ["alpha", "beta"].map(|name| {
417 Command::new(&executable)
418 .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"])
419 .env(CHILD_PATH, &path)
420 .env(CHILD_NAME, name)
421 .stdin(Stdio::null())
422 .stdout(Stdio::null())
423 .stderr(Stdio::null())
424 .spawn()
425 .expect("spawn isolated skill-state writer")
426 });
427 for child in &mut children {
428 let status = match child
429 .wait_timeout(std::time::Duration::from_secs(15))
430 .expect("wait for isolated skill-state writer")
431 {
432 Some(status) => status,
433 None => {
434 let _ = child.kill();
435 let _ = child.wait();
436 panic!("isolated skill-state writer timed out");
437 }
438 };
439 assert!(status.success(), "isolated skill-state writer failed");
440 }
441
442 let persisted = SkillStateStore::load_from(path).unwrap();
443 assert!(!persisted.is_enabled("alpha"));
444 assert!(!persisted.is_enabled("beta"));
445 }
446 }
447
447 lines RUST