Files
buzz/desktop/src-tauri/src/migration/detach.rs
T
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7andWill Pfleger db225c63dd fix(desktop): repair clippy and doc-comment issues from pass-3 corrections
- backfill.rs: remove blank line between two consecutive doc comment blocks
- detach.rs: merge orphaned step-list doc comment into function doc comment
- migration.rs: remove blank line after doc comment before private fn
- migration_tests.rs, migration_command_tests.rs, migration_avatar_tests.rs,
  migration_databricks_tests.rs: add .unwrap() to calls that now return
  Result after C5 migration fallibility changes

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-03 04:30:47 -04:00

144 lines
6.0 KiB
Rust

//! T4 migration: lift pack-level instructions into `TeamRecord.instructions`
//! and detach all directory-backed teams from their file-layer plumbing.
use std::collections::HashMap;
use std::path::Path;
use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
/// Lift pack instructions into `TeamRecord.instructions` and detach
/// directory-backed teams from their source directories.
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
/// Returns the number of teams detached (0 = nothing to do).
///
/// Steps (written last so the idempotency gate stays open until both files
/// are committed):
///
/// 1. Build a persona-key → team ID map from the directory-backed teams.
/// 2. Backfill `team_id` and clear `persona_team_dir`/`persona_name_in_team`
/// on every `ManagedAgentRecord`.
/// 3. Read `instructions.md` from each team's `source_dir`; populate
/// `instructions` if the field is not already set.
/// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each
/// directory-backed `TeamRecord`.
pub(crate) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result<usize, String> {
let teams_path = base_dir.join("teams.json");
let agents_path = base_dir.join("managed-agents.json");
if !teams_path.exists() {
return Ok(0);
}
let teams_content = std::fs::read_to_string(&teams_path)
.map_err(|e| format!("failed to read teams.json: {e}"))?;
let mut teams: Vec<TeamRecord> = serde_json::from_str(&teams_content)
.map_err(|e| format!("failed to parse teams.json: {e}"))?;
if !teams.iter().any(|t| t.source_dir.is_some()) {
return Ok(0);
}
// Build persona-key → team ID map before clearing source_dirs.
// persona-key = the directory name (pack manifest ID) or, for teams without
// a source_dir, the team UUID. This matches the value stored in
// AgentDefinition.source_team / ManagedAgentRecord.source_team.
let persona_key_to_team_id: HashMap<String, String> = teams
.iter()
.filter(|t| t.source_dir.is_some())
.map(|t| {
let key = t
.source_dir
.as_deref()
.and_then(|d| d.file_name())
.and_then(|n| n.to_str())
.unwrap_or(&t.id)
.to_string();
(key, t.id.clone())
})
.collect();
// Step 1 (agents first so the idempotency gate on teams.json stays open
// until both files are successfully written):
if agents_path.exists() {
let agents_content = std::fs::read_to_string(&agents_path)
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
let mut agents: Vec<ManagedAgentRecord> = serde_json::from_str(&agents_content)
.map_err(|e| format!("failed to parse managed-agents.json: {e}"))?;
let mut agents_changed = false;
for agent in agents.iter_mut() {
// Backfill team_id from source_team (absorbed from the definition,
// equals the pack directory name / persona key).
if agent.team_id.is_none() {
if let Some(source_team) = agent.source_team.as_deref() {
if let Some(team_id) = persona_key_to_team_id.get(source_team) {
agent.team_id = Some(team_id.clone());
agents_changed = true;
}
}
}
// F1 (Thufir): clear instance-side pack plumbing. These fields
// drove BUZZ_ACP_PERSONA_PACK / BUZZ_ACP_PERSONA_NAME env vars
// at spawn, which T3 already removed. Clearing removes the dead
// data so T6 can safely delete the consuming code.
if agent.persona_team_dir.is_some() || agent.persona_name_in_team.is_some() {
agent.persona_team_dir = None;
agent.persona_name_in_team = None;
agents_changed = true;
}
}
if agents_changed {
let payload = serde_json::to_vec_pretty(&agents)
.map_err(|e| format!("failed to serialize managed-agents.json: {e}"))?;
crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?;
}
}
// Step 2: lift instructions + detach each directory-backed team.
// Written last — clearing source_dir closes the idempotency gate.
let now = chrono::Utc::now().to_rfc3339();
let mut detached = 0usize;
for team in teams.iter_mut() {
let Some(source_dir) = team.source_dir.take() else {
continue;
};
// Lift instructions.md content if the field is not already populated.
// If reading fails, log and leave instructions as-is (never corrupt).
if team.instructions.is_none() {
let instructions_path = source_dir.join("instructions.md");
if instructions_path.exists() {
match std::fs::read_to_string(&instructions_path) {
Ok(content) => {
let trimmed = content.trim().to_string();
if !trimmed.is_empty() {
team.instructions = Some(trimmed);
}
}
Err(e) => eprintln!(
"buzz-desktop: detach-dir-teams: team {}: \
failed to read instructions.md (preserving existing value): {e}",
team.id
),
}
}
}
// F3 (Thufir): clear all file-layer fields. The UI badges off
// isSymlink — leaving it set without a source_dir is inconsistent.
team.is_symlink = false;
team.symlink_target = None;
team.version = None;
team.updated_at = now.clone();
detached += 1;
}
let payload = serde_json::to_vec_pretty(&teams)
.map_err(|e| format!("failed to serialize teams.json: {e}"))?;
crate::managed_agents::atomic_write_json(&teams_path, &payload)?;
Ok(detached)
}