feat: let install commands remove files that have been removed from bundles, both internal and external
This commit is contained in:
3 files changed
+401
-13
No files matched your search
@@ -31,6 +31,15 @@ use std::{env, ffi::OsStr, path::Path};
|
|||||||
|
|
||||||
const DEFAULT_AGENT_NAME: &str = "rag";
|
const DEFAULT_AGENT_NAME: &str = "rag";
|
||||||
|
|
||||||
|
const AGENT_DEFINITION_FILES: [&str; 6] = [
|
||||||
|
"config.yaml",
|
||||||
|
"graph.yaml",
|
||||||
|
"tools.sh",
|
||||||
|
"tools.py",
|
||||||
|
"tools.ts",
|
||||||
|
"tools.js",
|
||||||
|
];
|
||||||
|
|
||||||
pub type AgentVariables = IndexMap<String, String>;
|
pub type AgentVariables = IndexMap<String, String>;
|
||||||
|
|
||||||
#[derive(Embed)]
|
#[derive(Embed)]
|
||||||
@@ -95,6 +104,36 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut bundled_files: HashMap<String, HashSet<String>> = HashMap::new();
|
||||||
|
for file in AgentAssets::iter() {
|
||||||
|
if let Some((agent, rest)) = file.as_ref().split_once('/') {
|
||||||
|
bundled_files
|
||||||
|
.entry(agent.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (agent, files) in &bundled_files {
|
||||||
|
for candidate in AGENT_DEFINITION_FILES {
|
||||||
|
if files.contains(candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stale_path = paths::agents_data_dir().join(agent).join(candidate);
|
||||||
|
if stale_path.exists() {
|
||||||
|
info!(
|
||||||
|
"Removing stale file no longer shipped by built-in agent '{agent}': {}",
|
||||||
|
stale_path.display()
|
||||||
|
);
|
||||||
|
if let Err(err) = std::fs::remove_file(&stale_path) {
|
||||||
|
warn!(
|
||||||
|
"Failed to remove stale agent file {}: {err}",
|
||||||
|
stale_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+279
-13
@@ -464,10 +464,9 @@ fn owned_unmodified_mcp_keys(
|
|||||||
enum ObsoleteAction {
|
enum ObsoleteAction {
|
||||||
Keep,
|
Keep,
|
||||||
Delete,
|
Delete,
|
||||||
|
Backup,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kept files stay in the record, so a later uninstall still offers to
|
|
||||||
/// remove them.
|
|
||||||
fn handle_obsolete_files(
|
fn handle_obsolete_files(
|
||||||
store: &mut BundleStore,
|
store: &mut BundleStore,
|
||||||
bundle: &str,
|
bundle: &str,
|
||||||
@@ -479,21 +478,21 @@ fn handle_obsolete_files(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|planned| provenance_path(&planned.dst))
|
.map(|planned| provenance_path(&planned.dst))
|
||||||
.collect();
|
.collect();
|
||||||
let obsolete: Vec<String> = store
|
let obsolete: Vec<(String, String)> = store
|
||||||
.get(bundle)
|
.get(bundle)
|
||||||
.map(|record| {
|
.map(|record| {
|
||||||
record
|
record
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|file| file.path.clone())
|
.filter(|file| !planned.contains(&file.path))
|
||||||
.filter(|path| !planned.contains(path))
|
.map(|file| (file.path.clone(), file.sha256.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let config_dir = paths::config_dir();
|
let config_dir = paths::config_dir();
|
||||||
let mut sticky: Option<ObsoleteAction> = assume_yes.then_some(ObsoleteAction::Keep);
|
let mut sticky: Option<ObsoleteAction> = assume_yes.then_some(ObsoleteAction::Keep);
|
||||||
for path in obsolete {
|
for (path, recorded_sha) in obsolete {
|
||||||
if !is_safe_relative_path(&path) {
|
if !is_safe_relative_path(&path) {
|
||||||
eprintln!("skipping suspicious recorded path {path}; keeping its record");
|
eprintln!("skipping suspicious recorded path {path}; keeping its record");
|
||||||
continue;
|
continue;
|
||||||
@@ -506,13 +505,33 @@ fn handle_obsolete_files(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = resolve_obsolete(&path, &mut sticky)?;
|
let modified = hash_file(&full)? != recorded_sha;
|
||||||
apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?;
|
let action = match (modified, agent_definition_conflict(&path, &planned)) {
|
||||||
|
(false, _) => ObsoleteAction::Delete,
|
||||||
|
(true, true) => ObsoleteAction::Backup,
|
||||||
|
(true, false) => resolve_obsolete(&path, &mut sticky)?,
|
||||||
|
};
|
||||||
|
apply_obsolete_action(store, bundle, &path, &full, &config_dir, action, modified)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn agent_definition_conflict(path: &str, planned: &HashSet<String>) -> bool {
|
||||||
|
let Some(rest) = path.strip_prefix("agents/") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((agent, file)) = rest.split_once('/') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let counterpart = match file {
|
||||||
|
"config.yaml" => "graph.yaml",
|
||||||
|
"graph.yaml" => "config.yaml",
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
planned.contains(&format!("agents/{agent}/{counterpart}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_obsolete(path: &str, sticky: &mut Option<ObsoleteAction>) -> Result<ObsoleteAction> {
|
fn resolve_obsolete(path: &str, sticky: &mut Option<ObsoleteAction>) -> Result<ObsoleteAction> {
|
||||||
if let Some(action) = *sticky {
|
if let Some(action) = *sticky {
|
||||||
return Ok(action);
|
return Ok(action);
|
||||||
@@ -522,7 +541,9 @@ fn resolve_obsolete(path: &str, sticky: &mut Option<ObsoleteAction>) -> Result<O
|
|||||||
return Ok(ObsoleteAction::Keep);
|
return Ok(ObsoleteAction::Keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
let prompt = format!("Obsolete file {path} is no longer shipped by the bundle");
|
let prompt = format!(
|
||||||
|
"Obsolete file {path} is no longer shipped by the bundle and has local modifications"
|
||||||
|
);
|
||||||
let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"])
|
let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"])
|
||||||
.prompt()
|
.prompt()
|
||||||
.with_context(|| "failed to read obsolete-file choice")?;
|
.with_context(|| "failed to read obsolete-file choice")?;
|
||||||
@@ -549,6 +570,7 @@ fn apply_obsolete_action(
|
|||||||
full: &Path,
|
full: &Path,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
action: ObsoleteAction,
|
action: ObsoleteAction,
|
||||||
|
modified: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match action {
|
match action {
|
||||||
ObsoleteAction::Keep => {
|
ObsoleteAction::Keep => {
|
||||||
@@ -559,7 +581,29 @@ fn apply_obsolete_action(
|
|||||||
.with_context(|| format!("failed to delete obsolete file {}", full.display()))?;
|
.with_context(|| format!("failed to delete obsolete file {}", full.display()))?;
|
||||||
store.remove_file_record(bundle, path)?;
|
store.remove_file_record(bundle, path)?;
|
||||||
prune_empty_dirs(full, config_dir);
|
prune_empty_dirs(full, config_dir);
|
||||||
|
if modified {
|
||||||
println!("deleted obsolete file {path}");
|
println!("deleted obsolete file {path}");
|
||||||
|
} else {
|
||||||
|
println!("deleted obsolete file {path} (unchanged since install)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ObsoleteAction::Backup => {
|
||||||
|
let mut backup_name = full.as_os_str().to_os_string();
|
||||||
|
backup_name.push(".bak");
|
||||||
|
let backup = PathBuf::from(backup_name);
|
||||||
|
if backup.exists() {
|
||||||
|
fs::remove_file(&backup).with_context(|| {
|
||||||
|
format!("failed to remove stale backup {}", backup.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::rename(full, &backup)
|
||||||
|
.with_context(|| format!("failed to back up obsolete file {}", full.display()))?;
|
||||||
|
store.remove_file_record(bundle, path)?;
|
||||||
|
println!(
|
||||||
|
"moved obsolete file {path} to {path}.bak: keeping it would break the agent \
|
||||||
|
(both config.yaml and graph.yaml present); your local modifications are \
|
||||||
|
preserved in the backup"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,6 +891,9 @@ fn apply_uninstall_file_action(
|
|||||||
ObsoleteAction::Delete => {
|
ObsoleteAction::Delete => {
|
||||||
delete_owned_file(store, bundle, path, full, config_dir, summary)?;
|
delete_owned_file(store, bundle, path, full, config_dir, summary)?;
|
||||||
}
|
}
|
||||||
|
ObsoleteAction::Backup => {
|
||||||
|
unreachable!("uninstall never resolves to a backup action")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1018,6 +1065,9 @@ fn uninstall_mcp_entries(
|
|||||||
released.push(key.clone());
|
released.push(key.clone());
|
||||||
summary.removed.push(key);
|
summary.removed.push(key);
|
||||||
}
|
}
|
||||||
|
ObsoleteAction::Backup => {
|
||||||
|
unreachable!("uninstall never resolves to a backup action")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3892,10 +3942,34 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn update_keeps_obsolete_files_non_interactively() {
|
fn update_deletes_unmodified_obsolete_files() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-del");
|
||||||
|
let src_root = fresh_temp_dir("upd-obsolete-del-src-");
|
||||||
|
let repo = src_root.join("bundle");
|
||||||
|
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: obs-del\n");
|
||||||
|
write_src(&repo, "macros/keep.yaml", "k\n");
|
||||||
|
write_src(&repo, "macros/gone.yaml", "g\n");
|
||||||
|
init_bundle_repo(&repo);
|
||||||
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
fs::remove_file(repo.join("macros/gone.yaml")).unwrap();
|
||||||
|
commit_file(&repo, "macros/keep.yaml", "k2\n");
|
||||||
|
|
||||||
|
update_bundle("obs-del", false).unwrap();
|
||||||
|
|
||||||
|
assert!(!paths::macros_dir().join("gone.yaml").exists());
|
||||||
|
let store = BundleStore::load().unwrap();
|
||||||
|
let record = store.get("obs-del").unwrap();
|
||||||
|
assert!(record.files.iter().all(|f| f.path != "macros/gone.yaml"));
|
||||||
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_keeps_modified_obsolete_files_non_interactively() {
|
||||||
if *IS_STDOUT_TERMINAL {
|
if *IS_STDOUT_TERMINAL {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Skipping update_keeps_obsolete_files_non_interactively: requires non-TTY stdout"
|
"Skipping update_keeps_modified_obsolete_files_non_interactively: \
|
||||||
|
requires non-TTY stdout"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3907,6 +3981,7 @@ mod tests {
|
|||||||
write_src(&repo, "macros/gone.yaml", "g\n");
|
write_src(&repo, "macros/gone.yaml", "g\n");
|
||||||
init_bundle_repo(&repo);
|
init_bundle_repo(&repo);
|
||||||
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||||
|
fs::write(paths::macros_dir().join("gone.yaml"), "g-local\n").unwrap();
|
||||||
fs::remove_file(repo.join("macros/gone.yaml")).unwrap();
|
fs::remove_file(repo.join("macros/gone.yaml")).unwrap();
|
||||||
commit_file(&repo, "macros/keep.yaml", "k2\n");
|
commit_file(&repo, "macros/keep.yaml", "k2\n");
|
||||||
|
|
||||||
@@ -3914,7 +3989,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(),
|
fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(),
|
||||||
"g\n"
|
"g-local\n"
|
||||||
);
|
);
|
||||||
let store = BundleStore::load().unwrap();
|
let store = BundleStore::load().unwrap();
|
||||||
let record = store.get("obs-keep").unwrap();
|
let record = store.get("obs-keep").unwrap();
|
||||||
@@ -3944,7 +4019,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
apply_obsolete_action(&mut store, "omc", &path, &dst, &dir, ObsoleteAction::Delete)
|
apply_obsolete_action(
|
||||||
|
&mut store,
|
||||||
|
"omc",
|
||||||
|
&path,
|
||||||
|
&dst,
|
||||||
|
&dir,
|
||||||
|
ObsoleteAction::Delete,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!dst.exists());
|
assert!(!dst.exists());
|
||||||
@@ -4013,6 +4096,189 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&dir);
|
let _ = fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handle_obsolete_deletes_unmodified_files_without_prompting() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-unmod");
|
||||||
|
let dir = paths::config_dir();
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/gone.yaml");
|
||||||
|
write_src(&dir, "macros/gone.yaml", "g");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: "macros/gone.yaml".to_string(),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: hash_file(&dst).unwrap(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: Vec::new(),
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, "omc", &plan, false).unwrap();
|
||||||
|
|
||||||
|
assert!(!dst.exists());
|
||||||
|
assert!(store.get("omc").unwrap().files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handle_obsolete_keeps_modified_files_non_interactively() {
|
||||||
|
if *IS_STDOUT_TERMINAL {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping handle_obsolete_keeps_modified_files_non_interactively: \
|
||||||
|
requires non-TTY stdout"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-mod");
|
||||||
|
let dir = paths::config_dir();
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("macros/gone.yaml");
|
||||||
|
write_src(&dir, "macros/gone.yaml", "local edits");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: "macros/gone.yaml".to_string(),
|
||||||
|
category: "macros".to_string(),
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: Vec::new(),
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, "omc", &plan, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read_to_string(&dst).unwrap(), "local edits");
|
||||||
|
assert_eq!(store.get("omc").unwrap().files.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handle_obsolete_backs_up_modified_agent_definition_conflicts() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-agent-bak");
|
||||||
|
let dir = paths::config_dir();
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("agents/foo/config.yaml");
|
||||||
|
write_src(&dir, "agents/foo/config.yaml", "local edits");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: "agents/foo/config.yaml".to_string(),
|
||||||
|
category: "agents".to_string(),
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![PlannedFile {
|
||||||
|
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
||||||
|
dst: dir.join("agents/foo/graph.yaml"),
|
||||||
|
kind: PlannedKind::New,
|
||||||
|
top_category: TopCategory::Agents,
|
||||||
|
}],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, "omc", &plan, false).unwrap();
|
||||||
|
|
||||||
|
assert!(!dst.exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dir.join("agents/foo/config.yaml.bak")).unwrap(),
|
||||||
|
"local edits"
|
||||||
|
);
|
||||||
|
assert!(store.get("omc").unwrap().files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn handle_obsolete_deletes_unmodified_agent_definition_conflicts() {
|
||||||
|
let _guard = TestVaultConfigGuard::new("upd-obsolete-agent-del");
|
||||||
|
let dir = paths::config_dir();
|
||||||
|
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||||
|
.unwrap();
|
||||||
|
let dst = dir.join("agents/foo/config.yaml");
|
||||||
|
write_src(&dir, "agents/foo/config.yaml", "shipped content");
|
||||||
|
store
|
||||||
|
.record_file(
|
||||||
|
"omc",
|
||||||
|
FileRecord {
|
||||||
|
path: "agents/foo/config.yaml".to_string(),
|
||||||
|
category: "agents".to_string(),
|
||||||
|
sha256: hash_file(&dst).unwrap(),
|
||||||
|
action: FileAction::New,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = InstallPlan {
|
||||||
|
files: vec![PlannedFile {
|
||||||
|
src: dir.join("bundle-src/agents/foo/graph.yaml"),
|
||||||
|
dst: dir.join("agents/foo/graph.yaml"),
|
||||||
|
kind: PlannedKind::New,
|
||||||
|
top_category: TopCategory::Agents,
|
||||||
|
}],
|
||||||
|
mcp_json: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_obsolete_files(&mut store, "omc", &plan, false).unwrap();
|
||||||
|
|
||||||
|
assert!(!dst.exists());
|
||||||
|
assert!(!dir.join("agents/foo/config.yaml.bak").exists());
|
||||||
|
assert!(store.get("omc").unwrap().files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_definition_conflict_matches_only_planned_counterparts() {
|
||||||
|
let planned: HashSet<String> = [
|
||||||
|
"agents/foo/graph.yaml".to_string(),
|
||||||
|
"agents/bar/config.yaml".to_string(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(agent_definition_conflict(
|
||||||
|
"agents/foo/config.yaml",
|
||||||
|
&planned
|
||||||
|
));
|
||||||
|
assert!(agent_definition_conflict("agents/bar/graph.yaml", &planned));
|
||||||
|
assert!(!agent_definition_conflict(
|
||||||
|
"agents/foo/graph.yaml",
|
||||||
|
&planned
|
||||||
|
));
|
||||||
|
assert!(!agent_definition_conflict(
|
||||||
|
"agents/baz/config.yaml",
|
||||||
|
&planned
|
||||||
|
));
|
||||||
|
assert!(!agent_definition_conflict(
|
||||||
|
"agents/foo/scripts/config.yaml",
|
||||||
|
&planned
|
||||||
|
));
|
||||||
|
assert!(!agent_definition_conflict("macros/config.yaml", &planned));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn update_refreshes_record_metadata_and_stamps_updated_at() {
|
fn update_refreshes_record_metadata_and_stamps_updated_at() {
|
||||||
|
|||||||
@@ -8038,6 +8038,86 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn install_builtin_agents_removes_stale_definition_files() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
|
||||||
|
Agent::install_builtin_agents(false).unwrap();
|
||||||
|
|
||||||
|
let agents_dir = paths::agents_data_dir();
|
||||||
|
// Simulate an upgrade from an older install: coder used to ship
|
||||||
|
// config.yaml + tools.py (now graph.yaml + tools.sh) and demo used
|
||||||
|
// to ship tools.sh (now tools.py + tools.sh.bak).
|
||||||
|
write(agents_dir.join("coder").join("config.yaml"), "stale").unwrap();
|
||||||
|
write(agents_dir.join("coder").join("tools.py"), "stale").unwrap();
|
||||||
|
write(agents_dir.join("demo").join("tools.sh"), "stale").unwrap();
|
||||||
|
// Reconciliation must never touch subdirectories, non-definition
|
||||||
|
// root files, or agents that aren't part of the bundle.
|
||||||
|
create_dir_all(agents_dir.join("coder").join("sessions")).unwrap();
|
||||||
|
write(
|
||||||
|
agents_dir.join("coder").join("sessions").join("keep.yaml"),
|
||||||
|
"keep",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write(agents_dir.join("coder").join(".env"), "keep").unwrap();
|
||||||
|
create_dir_all(agents_dir.join("myagent")).unwrap();
|
||||||
|
write(agents_dir.join("myagent").join("config.yaml"), "keep").unwrap();
|
||||||
|
|
||||||
|
Agent::install_builtin_agents(false).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!agents_dir.join("coder").join("config.yaml").exists(),
|
||||||
|
"stale config.yaml must be removed for a graph-based bundled agent"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!agents_dir.join("coder").join("tools.py").exists(),
|
||||||
|
"stale tools script must be removed when the bundle no longer ships it"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!agents_dir.join("demo").join("tools.sh").exists(),
|
||||||
|
"stale tools.sh must be removed when the bundle only ships tools.py"
|
||||||
|
);
|
||||||
|
assert!(agents_dir.join("coder").join("graph.yaml").exists());
|
||||||
|
assert!(agents_dir.join("coder").join("tools.sh").exists());
|
||||||
|
assert!(agents_dir.join("demo").join("tools.py").exists());
|
||||||
|
assert!(
|
||||||
|
agents_dir
|
||||||
|
.join("coder")
|
||||||
|
.join("sessions")
|
||||||
|
.join("keep.yaml")
|
||||||
|
.exists(),
|
||||||
|
"files in agent subdirectories must survive reconciliation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
agents_dir.join("coder").join(".env").exists(),
|
||||||
|
"non-definition root files must survive reconciliation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
agents_dir.join("myagent").join("config.yaml").exists(),
|
||||||
|
"custom agents outside the bundle must survive reconciliation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn install_builtin_agents_removes_stale_graph_for_config_agent() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
|
||||||
|
Agent::install_builtin_agents(false).unwrap();
|
||||||
|
|
||||||
|
let agents_dir = paths::agents_data_dir();
|
||||||
|
write(agents_dir.join("architect").join("graph.yaml"), "stale").unwrap();
|
||||||
|
|
||||||
|
Agent::install_builtin_agents(false).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!agents_dir.join("architect").join("graph.yaml").exists(),
|
||||||
|
"stale graph.yaml must be removed for a config-based bundled agent"
|
||||||
|
);
|
||||||
|
assert!(agents_dir.join("architect").join("config.yaml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn install_builtin_skills_force_overwrites_only_with_force() {
|
fn install_builtin_skills_force_overwrites_only_with_force() {
|
||||||
@@ -8141,6 +8221,8 @@ mod tests {
|
|||||||
// rule that fires on a shipped asset (or a new bundled graph) must be
|
// rule that fires on a shipped asset (or a new bundled graph) must be
|
||||||
// recorded here deliberately.
|
// recorded here deliberately.
|
||||||
let expected_warnings = BTreeMap::from([
|
let expected_warnings = BTreeMap::from([
|
||||||
|
("adversary".to_string(), Vec::new()),
|
||||||
|
("code-reviewer".to_string(), Vec::new()),
|
||||||
(
|
(
|
||||||
"coder".to_string(),
|
"coder".to_string(),
|
||||||
unreachable(&[
|
unreachable(&[
|
||||||
@@ -8160,6 +8242,7 @@ mod tests {
|
|||||||
("deep-research".to_string(), unreachable(&["ask_topic"])),
|
("deep-research".to_string(), unreachable(&["ask_topic"])),
|
||||||
("finding-verifier".to_string(), Vec::new()),
|
("finding-verifier".to_string(), Vec::new()),
|
||||||
("librarian".to_string(), Vec::new()),
|
("librarian".to_string(), Vec::new()),
|
||||||
|
("review-gauntlet".to_string(), Vec::new()),
|
||||||
(
|
(
|
||||||
"step-runner".to_string(),
|
"step-runner".to_string(),
|
||||||
unreachable(&[
|
unreachable(&[
|
||||||
|
|||||||
Reference in new issue
Block a user