Skip to content

Commit 89cd4b3

Browse files
fix: prune stale install_meta skill/plugin rows whose files were deleted (#78)
sync_extensions and sync_extensions_for_agent exempted every install_meta row from stale cleanup, so a skill or plugin whose files the user deleted (e.g. rm -rf ~/.claude) lingered forever as a ghost row in the Extensions list. Narrow the exemption via a shared stale_row_should_prune predicate used by both sync paths: CLI binaries (flaky detection) stay exempt, and file-backed rows with install_meta are kept only while their source_path still exists on disk — a genuine delete is pruned, a transient scan gap is not. Scanned MCP/hook entries carry no install_meta and were already pruned by the existing path, so this concretely targets skill (SKILL.md) and plugin (dir) rows. Add regression tests: decision matrix, plus ghost pruning through both the global and per-agent sync paths.
1 parent 40489d5 commit 89cd4b3

1 file changed

Lines changed: 228 additions & 16 deletions

File tree

crates/hk-core/src/store.rs

Lines changed: 228 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,41 @@ impl Store {
903903
Ok(())
904904
}
905905

906+
/// Decide whether a stale extension row (one absent from the latest scan)
907+
/// should be pruned from the store.
908+
///
909+
/// Kept (returns false):
910+
/// - disabled rows — intentionally absent from scan results;
911+
/// - CLI extensions with install_meta — their binary can transiently fail
912+
/// detection on startup, so one missing scan isn't proof of removal;
913+
/// - file-backed install_meta rows whose `source_path` still exists on disk
914+
/// (or is unknown) — a momentary scan gap, not a real uninstall.
915+
///
916+
/// Pruned (returns true): everything else that is enabled and gone,
917+
/// including skill and plugin rows with install_meta whose files the user
918+
/// deleted (e.g. `rm -rf ~/.claude`) — otherwise they linger forever as
919+
/// ghost rows. Scanned MCP/hook entries carry no install_meta, so they take
920+
/// the normal no-meta prune path rather than this `has_install_meta` branch.
921+
fn stale_row_should_prune(
922+
enabled: bool,
923+
has_install_meta: bool,
924+
kind: &str,
925+
source_path: Option<&str>,
926+
) -> bool {
927+
if !enabled {
928+
return false;
929+
}
930+
if has_install_meta {
931+
if kind == ExtensionKind::Cli.as_str() {
932+
return false;
933+
}
934+
if source_path.is_none_or(|p| Path::new(p).exists()) {
935+
return false;
936+
}
937+
}
938+
true
939+
}
940+
906941
/// Sync all scanned extensions in a single transaction.
907942
/// Upserts every extension and removes stale entries that no longer exist on disk.
908943
/// Much faster than individual insert_extension calls (one fsync instead of N).
@@ -953,24 +988,38 @@ impl Store {
953988
[],
954989
)?;
955990

956-
// Remove stale extensions no longer on disk — but keep:
957-
// - Disabled ones (intentionally absent from scan results)
958-
// - Extensions with install_meta (user explicitly installed, e.g. CLI via install_cli)
959-
// These may temporarily disappear from scan if binary detection fails on startup.
991+
// Remove stale extensions no longer on disk. The keep/prune decision
992+
// lives in `stale_row_should_prune`: disabled rows and CLI binaries with
993+
// install_meta are always kept; file-backed install_meta rows are kept
994+
// only while their source_path still exists, so a manual delete (e.g.
995+
// `rm -rf ~/.claude`) no longer leaves ghost rows behind.
960996
let scanned_ids: std::collections::HashSet<&str> =
961997
extensions.iter().map(|e| e.id.as_str()).collect();
962-
let stale_ids: Vec<(String, bool, bool)> = {
998+
let stale_ids: Vec<(String, bool, bool, String, Option<String>)> = {
963999
let mut stmt = tx.prepare(
964-
"SELECT id, enabled, (install_type IS NOT NULL) as has_meta FROM extensions"
1000+
"SELECT id, enabled, (install_type IS NOT NULL) as has_meta, kind, source_path FROM extensions"
9651001
)?;
9661002
stmt.query_map([], |row| {
967-
Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?, row.get::<_, bool>(2)?))
1003+
Ok((
1004+
row.get::<_, String>(0)?,
1005+
row.get::<_, bool>(1)?,
1006+
row.get::<_, bool>(2)?,
1007+
row.get::<_, String>(3)?,
1008+
row.get::<_, Option<String>>(4)?,
1009+
))
9681010
})?
9691011
.filter_map(|r| r.map_err(|e| eprintln!("[hk] row error: {e}")).ok())
9701012
.collect()
9711013
};
972-
for (id, enabled, has_install_meta) in &stale_ids {
973-
if !scanned_ids.contains(id.as_str()) && *enabled && !has_install_meta {
1014+
for (id, enabled, has_install_meta, kind, source_path) in &stale_ids {
1015+
if !scanned_ids.contains(id.as_str())
1016+
&& Self::stale_row_should_prune(
1017+
*enabled,
1018+
*has_install_meta,
1019+
kind,
1020+
source_path.as_deref(),
1021+
)
1022+
{
9741023
tx.execute("DELETE FROM extensions WHERE id = ?1", params![id])?;
9751024
}
9761025
}
@@ -1044,25 +1093,41 @@ impl Store {
10441093
Self::sync_extension_agents(&tx, &ext.id, &ext.agents)?;
10451094
}
10461095

1047-
// Remove stale extensions for THIS agent only — keep disabled ones
1048-
// and extensions with install_meta (user-installed, may temporarily not scan)
1096+
// Remove stale extensions for THIS agent only, using the same keep/prune
1097+
// rule as sync_extensions (see `stale_row_should_prune`): disabled rows
1098+
// and CLI binaries with install_meta stay; file-backed install_meta rows
1099+
// stay only while their source_path still exists on disk.
10491100
let scanned_ids: std::collections::HashSet<&str> =
10501101
extensions.iter().map(|e| e.id.as_str()).collect();
1051-
let stale_ids: Vec<(String, bool, bool)> = {
1102+
let stale_ids: Vec<(String, bool, bool, String, Option<String>)> = {
10521103
let mut stmt = tx.prepare(
1053-
"SELECT DISTINCT e.id, e.enabled, (e.install_type IS NOT NULL) as has_meta
1104+
"SELECT DISTINCT e.id, e.enabled, (e.install_type IS NOT NULL) as has_meta,
1105+
e.kind, e.source_path
10541106
FROM extensions e
10551107
INNER JOIN extension_agents ea ON e.id = ea.extension_id
10561108
WHERE ea.agent_name = ?1"
10571109
)?;
10581110
stmt.query_map(params![agent], |row| {
1059-
Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?, row.get::<_, bool>(2)?))
1111+
Ok((
1112+
row.get::<_, String>(0)?,
1113+
row.get::<_, bool>(1)?,
1114+
row.get::<_, bool>(2)?,
1115+
row.get::<_, String>(3)?,
1116+
row.get::<_, Option<String>>(4)?,
1117+
))
10601118
})?
10611119
.filter_map(|r| r.ok())
10621120
.collect()
10631121
};
1064-
for (id, enabled, has_install_meta) in &stale_ids {
1065-
if !scanned_ids.contains(id.as_str()) && *enabled && !has_install_meta {
1122+
for (id, enabled, has_install_meta, kind, source_path) in &stale_ids {
1123+
if !scanned_ids.contains(id.as_str())
1124+
&& Self::stale_row_should_prune(
1125+
*enabled,
1126+
*has_install_meta,
1127+
kind,
1128+
source_path.as_deref(),
1129+
)
1130+
{
10661131
tx.execute("DELETE FROM extensions WHERE id = ?1", params![id])?;
10671132
}
10681133
}
@@ -2424,6 +2489,99 @@ mod tests {
24242489
assert_eq!(im.remote_revision.as_deref(), Some("def456"));
24252490
}
24262491

2492+
#[test]
2493+
fn test_stale_row_should_prune_decision() {
2494+
let cli = ExtensionKind::Cli.as_str();
2495+
let skill = ExtensionKind::Skill.as_str();
2496+
let exists = env!("CARGO_MANIFEST_DIR"); // guaranteed to exist
2497+
let missing = "/nonexistent/harnesskit/ghost/SKILL.md";
2498+
2499+
// Disabled rows are intentionally absent from scans — always kept.
2500+
assert!(!Store::stale_row_should_prune(false, true, skill, Some(missing)));
2501+
// Sourceless rows (no install_meta) are pruned when gone — prior behavior.
2502+
assert!(Store::stale_row_should_prune(true, false, skill, None));
2503+
// CLI with install_meta is kept even when absent (flaky binary detection).
2504+
assert!(!Store::stale_row_should_prune(true, true, cli, Some(missing)));
2505+
// File-backed install_meta row whose file is gone → pruned (the ghost fix).
2506+
assert!(Store::stale_row_should_prune(true, true, skill, Some(missing)));
2507+
// File-backed install_meta row whose file still exists → kept (scan gap).
2508+
assert!(!Store::stale_row_should_prune(true, true, skill, Some(exists)));
2509+
// Unknown source_path → kept (can't prove removal).
2510+
assert!(!Store::stale_row_should_prune(true, true, skill, None));
2511+
}
2512+
2513+
#[test]
2514+
fn test_sync_prunes_ghost_skill_with_install_meta_when_files_deleted() {
2515+
// Regression: a marketplace/git-installed skill whose files the user
2516+
// deleted (e.g. `rm -rf ~/.claude`) used to linger forever because any
2517+
// install_meta row was exempt from stale cleanup. It should now be
2518+
// pruned once its source_path is gone, while a CLI with install_meta and
2519+
// a skill whose file still exists are both kept.
2520+
let (store, dir) = test_store();
2521+
let meta = || {
2522+
Some(InstallMeta {
2523+
install_type: "marketplace".into(),
2524+
url: Some("https://github.com/tw93/waza".into()),
2525+
url_resolved: None,
2526+
branch: None,
2527+
subpath: None,
2528+
revision: None,
2529+
remote_revision: None,
2530+
checked_at: None,
2531+
check_error: None,
2532+
})
2533+
};
2534+
2535+
// Skill installed from marketplace, but its file no longer exists.
2536+
let mut ghost = sample_extension();
2537+
ghost.id = "ghost-skill".into();
2538+
ghost.name = "ghost-skill".into();
2539+
ghost.source_path = Some("/nonexistent/harnesskit/ghost/SKILL.md".into());
2540+
ghost.install_meta = meta();
2541+
store.insert_extension(&ghost).unwrap();
2542+
2543+
// Skill whose file still exists on disk (simulate a transient scan gap).
2544+
let live_path = dir.path().join("live-SKILL.md");
2545+
std::fs::write(&live_path, "x").unwrap();
2546+
let mut live = sample_extension();
2547+
live.id = "live-skill".into();
2548+
live.name = "live-skill".into();
2549+
live.source_path = Some(live_path.to_string_lossy().into_owned());
2550+
live.install_meta = meta();
2551+
store.insert_extension(&live).unwrap();
2552+
2553+
// CLI with install_meta — binary detection is flaky, must stay.
2554+
let mut cli = sample_extension();
2555+
cli.id = "cli-tool".into();
2556+
cli.name = "cli-tool".into();
2557+
cli.kind = ExtensionKind::Cli;
2558+
cli.source_path = Some("/nonexistent/harnesskit/cli-bin".into());
2559+
cli.install_meta = meta();
2560+
store.insert_extension(&cli).unwrap();
2561+
2562+
// Empty scan = nothing found on disk this round.
2563+
store.sync_extensions(&[]).unwrap();
2564+
2565+
let ids: Vec<String> = store
2566+
.list_extensions(None, None)
2567+
.unwrap()
2568+
.into_iter()
2569+
.map(|e| e.id)
2570+
.collect();
2571+
assert!(
2572+
!ids.contains(&"ghost-skill".to_string()),
2573+
"ghost skill with deleted files should be pruned"
2574+
);
2575+
assert!(
2576+
ids.contains(&"live-skill".to_string()),
2577+
"skill whose file still exists should be kept"
2578+
);
2579+
assert!(
2580+
ids.contains(&"cli-tool".to_string()),
2581+
"CLI with install_meta should be kept"
2582+
);
2583+
}
2584+
24272585
#[test]
24282586
fn test_sync_backfills_install_meta_from_git_source() {
24292587
let (store, _dir) = test_store();
@@ -2758,6 +2916,60 @@ mod tests {
27582916
assert_eq!(cursor.len(), 1);
27592917
}
27602918

2919+
#[test]
2920+
fn test_sync_for_agent_prunes_ghost_skill_with_install_meta() {
2921+
// Same ghost-pruning rule as sync_extensions, exercised through the
2922+
// per-agent path (which has its own JOIN query) to guard the column
2923+
// mapping there.
2924+
let (store, dir) = test_store();
2925+
let meta = || {
2926+
Some(InstallMeta {
2927+
install_type: "marketplace".into(),
2928+
url: Some("https://github.com/tw93/waza".into()),
2929+
url_resolved: None,
2930+
branch: None,
2931+
subpath: None,
2932+
revision: None,
2933+
remote_revision: None,
2934+
checked_at: None,
2935+
check_error: None,
2936+
})
2937+
};
2938+
2939+
let mut ghost = sample_extension();
2940+
ghost.id = "agent-ghost".into();
2941+
ghost.agents = vec!["claude".into()];
2942+
ghost.source_path = Some("/nonexistent/harnesskit/ghost/SKILL.md".into());
2943+
ghost.install_meta = meta();
2944+
store.insert_extension(&ghost).unwrap();
2945+
2946+
let live_path = dir.path().join("agent-live-SKILL.md");
2947+
std::fs::write(&live_path, "x").unwrap();
2948+
let mut live = sample_extension();
2949+
live.id = "agent-live".into();
2950+
live.agents = vec!["claude".into()];
2951+
live.source_path = Some(live_path.to_string_lossy().into_owned());
2952+
live.install_meta = meta();
2953+
store.insert_extension(&live).unwrap();
2954+
2955+
store.sync_extensions_for_agent("claude", &[]).unwrap();
2956+
2957+
let ids: Vec<String> = store
2958+
.list_extensions(None, Some("claude"))
2959+
.unwrap()
2960+
.into_iter()
2961+
.map(|e| e.id)
2962+
.collect();
2963+
assert!(
2964+
!ids.contains(&"agent-ghost".to_string()),
2965+
"ghost skill should be pruned via the per-agent path"
2966+
);
2967+
assert!(
2968+
ids.contains(&"agent-live".to_string()),
2969+
"skill whose file still exists should be kept"
2970+
);
2971+
}
2972+
27612973
#[test]
27622974
fn test_count_latest_findings_by_severity() {
27632975
let (store, _dir) = test_store();

0 commit comments

Comments
 (0)