use std::path::Path;
use super::settings::deploy_output_style;
use crate::core::agent_deployer::{DeployResult, deploy_agents_filtered, retract_framework_agents};
use crate::core::agent_skill_codeploy::co_deploy_skill_set;
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deployer::DeployStats;
use crate::core::skill_tiers::deploy_all_skill_tiers;
#[derive(Debug, thiserror::Error)]
pub enum SyncAssetsError {
#[error("agent redeploy failed: {0}")]
AgentDeploy(String),
#[error("skill redeploy failed: {0}")]
SkillDeploy(String),
}
#[derive(Debug, Default)]
pub struct SyncAssetsReport {
pub agents_deployed: Vec<String>,
pub agents_skipped: Vec<String>,
pub skills_deployed: Vec<String>,
pub skills_skipped: Vec<String>,
pub output_style_synced: bool,
pub retraction_error: Option<String>,
pub quarantine_summary: Option<String>,
}
pub fn sync_session_assets(
fw: &FrameworkPaths,
project_dir: &Path,
) -> Result<SyncAssetsReport, SyncAssetsError> {
let catalog_root = crate::content::catalog_root_for(&fw.root);
let sources = crate::core::manifest::ManifestSources::resolve(project_dir, &catalog_root);
let manifest = crate::core::manifest::resolve_manifest(&sources);
let plan = crate::core::manifest::HarnessPlan::from_manifest(&manifest, fw, &catalog_root);
let deploy: DeployResult =
deploy_agents_filtered(&plan.agent_source, &fw.agent_deploy_dir(), |name| {
plan.agent_selected(name)
})
.map_err(|e| SyncAssetsError::AgentDeploy(e.to_string()))?;
let retraction_error =
match retract_framework_agents(&project_dir.join(".claude").join("agents")) {
Ok(_) => None,
Err(err) => {
tracing::warn!(
project_dir = %project_dir.display(),
"workspace bundled-agent retraction failed during sync-assets: {err}"
);
Some(err.to_string())
}
};
let quarantine_summary =
match super::quarantine_shadows::quarantine_workspace_shadows(fw, project_dir) {
Ok(report) => super::quarantine_shadows::summarize(&report),
Err(err) => {
tracing::warn!(
project_dir = %project_dir.display(),
"shadowing-agent quarantine refused to run during sync-assets: {err}"
);
Some(format!("agent quarantine refused: {err}"))
}
};
let co_deploy_skills = co_deploy_skill_set(&deploy.declared_skills);
let skill_deploy: DeployStats = deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.claude_skills_dir(),
|name| plan.skill_selected(name) || co_deploy_skills.contains(name),
)
.map_err(|e| SyncAssetsError::SkillDeploy(e.to_string()))?
.stats;
let output_style_synced = deploy_output_style(project_dir).is_ok();
Ok(SyncAssetsReport {
agents_deployed: deploy.deployed,
agents_skipped: deploy.skipped,
skills_deployed: skill_deploy.deployed,
skills_skipped: skill_deploy.skipped,
output_style_synced,
retraction_error,
quarantine_summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_assets_redeploys_new_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let first = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert_eq!(first.agents_deployed, vec!["rust-engineer.md".to_string()]);
assert!(!ws_fw.agent_deploy_dir().join("qa.md").exists());
std::fs::write(bundled.join("qa.md"), "---\nname: qa\n---\nv1").unwrap();
let second = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(second.agents_deployed.contains(&"qa.md".to_string()));
assert!(ws_fw.agent_deploy_dir().join("qa.md").exists());
}
#[test]
fn sync_assets_refreshes_stale_skill() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.skill_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("tm-doctor.md"), "v1.0.0").unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
sync_session_assets(&ws_fw, &project_dir).unwrap();
let deployed_path = ws_fw.claude_skills_dir().join("tm-doctor").join("SKILL.md");
assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.0.0");
std::fs::write(bundled.join("tm-doctor.md"), "v1.1.0").unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(report.skills_deployed.contains(&"tm-doctor".to_string()));
assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.1.0");
}
#[test]
fn sync_assets_refreshes_project_output_style() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(report.output_style_synced);
assert!(
project_dir
.join(".claude")
.join("output-styles")
.join("trusty-mpm.md")
.exists()
);
}
#[test]
fn sync_assets_never_touches_hand_placed_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
std::fs::create_dir_all(ws_fw.agent_deploy_dir()).unwrap();
let hand_placed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
let content = "---\nname: rust-engineer\n---\noperator's own agent";
std::fs::write(&hand_placed, content).unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.agents_skipped
.contains(&"rust-engineer.md".to_string())
);
assert_eq!(std::fs::read_to_string(&hand_placed).unwrap(), content);
}
#[test]
fn sync_assets_repairs_drifted_bundled_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
sync_session_assets(&ws_fw, &project_dir).unwrap();
let deployed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
std::fs::write(&deployed, "---\nname: rust-engineer\n---\n\nv1\n").unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv2",
)
.unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.agents_deployed
.contains(&"rust-engineer.md".to_string()),
"drifted bundled agent must be repaired, got: {report:?}"
);
assert!(
!report
.agents_skipped
.contains(&"rust-engineer.md".to_string())
);
assert!(
std::fs::read_to_string(&deployed).unwrap().contains("v2"),
"the current bundled composition must be restored"
);
}
#[test]
fn sync_assets_retracts_workspace_bundled_agents() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let legacy = ws_fw.claude_agents_dir();
deploy_agents_filtered(&bundled, &legacy, |_| true).unwrap();
assert!(legacy.join("rust-engineer.md").exists());
let hand_placed = legacy.join("my-own-agent.md");
let mine = "---\nname: my-own-agent\ndescription: mine\n---\n\nMine.\n";
std::fs::write(&hand_placed, mine).unwrap();
sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
!legacy.join("rust-engineer.md").exists(),
"the legacy per-workspace bundled copy must be retracted"
);
assert_eq!(
std::fs::read_to_string(&hand_placed).unwrap(),
mine,
"a hand-placed agent must survive retraction byte-identical"
);
assert!(
ws_fw.agent_deploy_dir().join("rust-engineer.md").exists(),
"the canonical config-dir tier must carry the roster instead"
);
}
#[test]
fn sync_assets_reports_retraction_failure_on_corrupt_manifest() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let legacy = project_dir.join(".claude").join("agents");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(
legacy.join(crate::core::agent_manifest::MANIFEST_FILE),
b"not valid json{{{",
)
.unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.retraction_error
.as_deref()
.is_some_and(|e| e.contains("corrupt")),
"the refusal must be reported, got: {:?}",
report.retraction_error
);
assert!(
report
.agents_deployed
.contains(&"rust-engineer.md".to_string())
);
}
}