mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): pass-3 corrections C1–C7 (workspace-scoped agent store)
C1 — Production agents-root contract: - scope_init.rs already had the correct base_dir contract (no extra 'agents' join); added production-shaped adoption test that mirrors the exact managed_agents_base_dir semantics to prevent regression. C2/C3 — Deadlock removal + store lock: - workspace.rs: drop rt_transition + store lock BEFORE compensate_drain on all three commit-guard failure paths; hold store lock from drain through commit so concurrent store writes cannot interleave. - identity.rs: drain returns Err((stopped, msg)); compensate uses the real stopped slice, not []; locks dropped before compensate_drain. C4 — Captured-scope completion: - confirm_team_snapshot_import and confirm_agent_snapshot_import: both now capture scope at entry, use _at() APIs throughout, validate generation before first write, resolve RetentionScope from captured. - Mesh recovery (recovery.rs): capture full WorkspaceAgentScope at entry; validate generation before each write to definitions_dir. - Restore missing-record stale-child: when find_managed_agent_mut fails for a spawned child (record deleted between Phase B and C), terminate the child and remove its receipt instead of leaking the process. C5 — Pre-Ready family in scope initializer: - ensure_scope_ready gains owner_pubkey parameter. - New run_pre_ready_family: runs legacy retention migration and persona snapshot backfill before writing _ready so a crash leaves the scope in a retryable state, not permanently marked Ready with incomplete data. - workspace.rs guards remain for pre-existing Ready scopes (idempotent). C6 — Delete dead boot-migration wrappers: - Deleted backfill_standalone_agents, detach_directory_backed_teams, and strip_baked_team_instructions (the #[allow(dead_code)]-suppressed app-level wrappers); their _in_dir equivalents are the authoritative scoped pipeline entry points. - Removed tests for the deleted functions from migration_command_tests.rs. C7 — Structured degradation reporting: - spawn_event_sync return type changed from Result<(), String> to (): the dispatch cannot fail; the false Result contract is removed. - workspace.rs restore spawn now emits workspace-degraded Tauri event when restore_managed_agents_on_launch returns Err, making restore failures observable to the UI instead of silently logged. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
d26c15ae15
commit
b0cc726d3f
@@ -339,18 +339,24 @@ pub async fn save_ncryptsec_copy(
|
||||
/// `managed_agent_runtime_transition` before calling this function.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(())` when all runtimes were stopped (or there were none).
|
||||
/// - `Err(msg)` when the drain failed; the caller must NOT proceed with
|
||||
/// the identity persist and MUST compensate by restarting the stopped set.
|
||||
/// - `Ok(stopped)` when all runtimes were stopped (or there were none).
|
||||
/// - `Err((stopped, msg))` when the drain failed; `stopped` contains the
|
||||
/// entries that were successfully killed before the failure — the caller
|
||||
/// MUST compensate these and then drop `managed_agent_runtime_transition`
|
||||
/// BEFORE calling `compensate_drain` (which re-acquires that lock via
|
||||
/// `start_pair`).
|
||||
fn drain_managed_agent_runtimes_for_import(
|
||||
app: &tauri::AppHandle,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<crate::managed_agents::DrainJournalEntry>, String> {
|
||||
) -> Result<
|
||||
Vec<crate::managed_agents::DrainJournalEntry>,
|
||||
(Vec<crate::managed_agents::DrainJournalEntry>, String),
|
||||
> {
|
||||
let (stopped, _remaining, drain_error) =
|
||||
crate::managed_agents::drain_scope_runtimes(app, state);
|
||||
match drain_error {
|
||||
None => Ok(stopped),
|
||||
Some(e) => Err(e),
|
||||
Some(e) => Err((stopped, e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +389,35 @@ pub async fn import_identity(
|
||||
None
|
||||
};
|
||||
|
||||
// ── Layer 1 async: drain the Mesh client when switching away from an active
|
||||
// scope — mirrors the pre-spawn_blocking Mesh drain in apply_workspace so
|
||||
// the Mesh client is not left running against a scope that no longer exists
|
||||
// after the identity import clears the active scope. Best-effort: a drain
|
||||
// failure is logged and the import proceeds.
|
||||
if has_active_scope {
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
{
|
||||
// Drain using the current active relay — the import clears the scope
|
||||
// entirely, so any live Mesh client becomes stale regardless of relay.
|
||||
let active_relay = lock_state
|
||||
.capture_active_scope()
|
||||
.map(|s| s.relay_url.clone())
|
||||
.unwrap_or_default();
|
||||
if let Err(error) = crate::commands::mesh_llm::scope_impl::drain_mesh_client_if_stale(
|
||||
&app_handle,
|
||||
// Pass an empty string so any client (any relay) is treated
|
||||
// as stale and drained; identity import invalidates all scopes.
|
||||
"",
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-desktop: Mesh client drain before identity import failed: {error} (active_relay={active_relay})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
|
||||
// Raw nsec/hex input follows the existing parser path unchanged.
|
||||
@@ -407,13 +442,17 @@ pub async fn import_identity(
|
||||
// new identity. This is the same drain-journal protocol used by
|
||||
// `apply_workspace`.
|
||||
//
|
||||
// `managed_agents_store_lock` is acquired at Layer 2 start (same as
|
||||
// apply_workspace) so a concurrent save_managed_agents cannot interleave
|
||||
// with the drain or the scope clear.
|
||||
//
|
||||
// On drain failure: compensate by restarting what was stopped, then
|
||||
// return Err — do NOT proceed with the identity persist. The caller
|
||||
// (frontend membership-denied flow) must handle the Err and retry.
|
||||
//
|
||||
// The transition lock is held through the identity persist and scope
|
||||
// clear so no concurrent start/reconcile can insert a runtime between
|
||||
// drain and scope invalidation.
|
||||
// The transition lock (and store lock) must be DROPPED before calling
|
||||
// compensate_drain — compensate_drain calls start_pair which re-acquires
|
||||
// both managed_agent_runtime_transition and managed_agents_store_lock.
|
||||
let _rt_transition_guard = if has_active_scope {
|
||||
Some(
|
||||
state
|
||||
@@ -425,13 +464,30 @@ pub async fn import_identity(
|
||||
None
|
||||
};
|
||||
|
||||
let _store_guard = if has_active_scope {
|
||||
Some(
|
||||
state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| format!("managed_agents_store_lock poisoned: {e}"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let stopped_entries = if has_active_scope {
|
||||
match drain_managed_agent_runtimes_for_import(&app_handle, &state) {
|
||||
Ok(stopped) => stopped,
|
||||
Err(drain_err) => {
|
||||
// Drain failed — compensate and abort.
|
||||
Err((stopped, drain_err)) => {
|
||||
// Drain failed — drop the transition lock AND store lock BEFORE
|
||||
// compensating: compensate_drain calls start_pair which
|
||||
// re-acquires both managed_agent_runtime_transition and
|
||||
// managed_agents_store_lock. Holding either here deadlocks.
|
||||
// Also restore the partial stopped set, not an empty slice.
|
||||
drop(_store_guard);
|
||||
drop(_rt_transition_guard);
|
||||
let comp_err =
|
||||
crate::managed_agents::compensate_drain(&app_handle, &[]);
|
||||
crate::managed_agents::compensate_drain(&app_handle, &stopped);
|
||||
let msg = match comp_err {
|
||||
Some(comp) => format!(
|
||||
"identity import drain failed: {drain_err}; compensation failed: {comp}"
|
||||
@@ -455,10 +511,14 @@ pub async fn import_identity(
|
||||
});
|
||||
|
||||
// If identity persist failed after a successful drain, compensate.
|
||||
// Drop the transition lock AND store lock BEFORE calling compensate_drain
|
||||
// for the same reason: start_pair re-acquires both.
|
||||
let (pubkey, storage) = match commit_result {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
if !stopped_entries.is_empty() {
|
||||
drop(_store_guard);
|
||||
drop(_rt_transition_guard);
|
||||
if let Some(comp_err) =
|
||||
crate::managed_agents::compensate_drain(&app_handle, &stopped_entries)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ fn trim_optional(value: Option<String>) -> Option<String> {
|
||||
|
||||
mod pending;
|
||||
pub(in crate::commands) use pending::retain_persona_pending;
|
||||
pub(in crate::commands) use pending::retain_persona_pending_in_scope;
|
||||
pub(super) use pending::tombstone_persona_pending;
|
||||
mod create;
|
||||
pub use create::create_persona;
|
||||
|
||||
@@ -46,6 +46,21 @@ pub(in crate::commands) fn retain_persona_pending(
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain a persona event using a pre-resolved [`RetentionScope`].
|
||||
///
|
||||
/// For snapshot-import callers that already hold a captured scope inside the
|
||||
/// `managed_agents_store_lock` — avoids re-reading live state at a point where
|
||||
/// the lock already prevents any workspace switch from succeeding.
|
||||
pub(in crate::commands) fn retain_persona_pending_in_scope(
|
||||
scope: &crate::managed_agents::retention::RetentionScope,
|
||||
persona: &AgentDefinition,
|
||||
) {
|
||||
if let Err(e) = prepare_persona_publication_at(&scope.db_path, &scope.owner_keys, persona, None)
|
||||
{
|
||||
eprintln!("buzz-desktop: persona-retain: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, sign, and durably retain a persona event in the active relay+owner
|
||||
/// scope.
|
||||
///
|
||||
|
||||
@@ -18,8 +18,7 @@ use crate::{
|
||||
decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload,
|
||||
LOCKED_CARD_REFUSAL,
|
||||
},
|
||||
load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition,
|
||||
ManagedAgentRecord, RespondTo,
|
||||
load_managed_agents, AgentDefinition, ManagedAgentRecord, RespondTo,
|
||||
},
|
||||
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -124,33 +123,12 @@ pub struct AgentSnapshotImportResult {
|
||||
|
||||
/// Resolve the behavioral defaults for an incoming agent snapshot.
|
||||
///
|
||||
/// This is the single authoritative selection path for all import-time
|
||||
/// allowlist and behavioral decisions. It is extracted as a pure, testable
|
||||
/// function so that unit tests exercise the exact production logic rather
|
||||
/// than a reconstruction of it.
|
||||
/// Single authoritative selection path for all import-time allowlist and
|
||||
/// behavioral decisions. Extracted as a pure function for testability.
|
||||
///
|
||||
/// # UI contract
|
||||
///
|
||||
/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true
|
||||
/// (i.e. the raw allowlist is non-empty), regardless of the source mode.
|
||||
/// The mode (`respond_to` wire string) and the list are independent axes.
|
||||
///
|
||||
/// # Decision table
|
||||
///
|
||||
/// | Source mode | Non-empty list | keep=true | keep=false |
|
||||
/// |--------------|----------------|----------------------|-------------------------|
|
||||
/// | allowlist | yes | preserve mode + list | owner-only + empty |
|
||||
/// | allowlist | no | **Err** (reject) | **Err** (reject) |
|
||||
/// | non-allowlist| yes | preserve mode + list | preserve mode + empty |
|
||||
/// | non-allowlist| no | preserve mode | preserve mode |
|
||||
///
|
||||
/// Allowlist-mode + empty list is always rejected: the UI showed no choice
|
||||
/// and there is no coherent value to write.
|
||||
///
|
||||
/// Non-allowlist + non-empty + Clear: preserve the source mode but empty the
|
||||
/// list. Only allowlist-mode requires a mode downgrade on Clear, because
|
||||
/// `allowlist` without entries is an invalid state. Non-allowlist modes
|
||||
/// remain valid with an empty list.
|
||||
/// Decision: `allowlist` mode + empty list is rejected (invalid state).
|
||||
/// On `keep_allowlist=false` with `allowlist` mode, downgrades to owner-only.
|
||||
/// On `keep_allowlist=false` with other modes, preserves mode, clears list.
|
||||
pub(crate) fn resolve_snapshot_import_behavior(
|
||||
raw_respond_to: Option<&str>,
|
||||
raw_allowlist: &[String],
|
||||
@@ -458,6 +436,13 @@ pub async fn confirm_agent_snapshot_import(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AgentSnapshotImportResult, String> {
|
||||
// Capture the active scope at entry — all definition I/O targets this
|
||||
// scope; the generation is re-validated before the first write in Phase 3a.
|
||||
let captured_scope = state
|
||||
.capture_active_scope()
|
||||
.ok_or("confirm_agent_snapshot_import: no active workspace scope")?;
|
||||
let definitions_dir = captured_scope.definitions_dir.clone();
|
||||
|
||||
// ── Phase 1: validate (no writes) ────────────────────────────────────────
|
||||
// Locked cards unlock only via this machine's exact key endpoints;
|
||||
// anything else fails closed here, before key generation.
|
||||
@@ -468,7 +453,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
load_managed_agents(&app)?
|
||||
crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?
|
||||
};
|
||||
decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0
|
||||
};
|
||||
@@ -547,8 +532,21 @@ pub async fn confirm_agent_snapshot_import(
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut personas = load_personas(&app)?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
// Re-validate the captured scope's generation before any write; abort
|
||||
// if the workspace switched since Phase 1.
|
||||
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
|
||||
.map_err(|e| format!("confirm_agent_snapshot_import: {e}"))?;
|
||||
|
||||
// Resolve retention scope from the captured scope so retain calls
|
||||
// write to the correct scope's DB even if live state diverged.
|
||||
let owner_keys_for_retention = state.signing_keys()?;
|
||||
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
|
||||
&captured_scope,
|
||||
owner_keys_for_retention,
|
||||
)?;
|
||||
|
||||
let mut personas = crate::managed_agents::load_personas_at(&definitions_dir)?;
|
||||
let mut records = crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?;
|
||||
|
||||
// Guard against duplicate pubkey (astronomically unlikely but safe).
|
||||
if records.iter().any(|r| r.pubkey == pubkey) {
|
||||
@@ -587,10 +585,10 @@ pub async fn confirm_agent_snapshot_import(
|
||||
};
|
||||
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
crate::managed_agents::save_personas_at(&definitions_dir, &personas)?;
|
||||
|
||||
// Enqueue the kind:30175 persona event via the retention path.
|
||||
super::super::pending::retain_persona_pending(&app, &state, &persona);
|
||||
super::super::pending::retain_persona_pending_in_scope(&retention_scope, &persona);
|
||||
|
||||
// Build the managed agent record — no machine-local commands, no
|
||||
// secrets, no lineage from the snapshot.
|
||||
@@ -657,12 +655,12 @@ pub async fn confirm_agent_snapshot_import(
|
||||
};
|
||||
|
||||
records.push(record.clone());
|
||||
save_managed_agents(&app, &records)?;
|
||||
crate::managed_agents::storage::save_managed_agents_at(&definitions_dir, &records)?;
|
||||
|
||||
// Enqueue the kind:30177 managed-agent event via retention.
|
||||
// (Uses the same pattern as agents.rs::retain_managed_agent_pending
|
||||
// inlined here to avoid cross-module private-fn access.)
|
||||
retain_agent_pending(&app, &state, &record);
|
||||
retain_agent_pending(&retention_scope, &record);
|
||||
|
||||
crate::managed_agents::try_regenerate_nest(&app).ok();
|
||||
|
||||
@@ -752,7 +750,10 @@ pub async fn confirm_agent_snapshot_import(
|
||||
/// Inline retention for the managed-agent kind:30177 event — mirrors
|
||||
/// `agents::retain_managed_agent_pending` without requiring cross-module
|
||||
/// private function access.
|
||||
fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) {
|
||||
fn retain_agent_pending(
|
||||
scope: &crate::managed_agents::retention::RetentionScope,
|
||||
record: &ManagedAgentRecord,
|
||||
) {
|
||||
use crate::managed_agents::{
|
||||
agent_events::{agent_event_content, build_agent_event},
|
||||
persona_events::monotonic_created_at,
|
||||
@@ -762,7 +763,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
|
||||
use nostr::JsonUtil;
|
||||
|
||||
let result = (|| -> Result<(), String> {
|
||||
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
||||
let conn = open_retention_db(&scope.db_path)?;
|
||||
let content = serde_json::to_string(&agent_event_content(record))
|
||||
.map_err(|e| format!("failed to serialize agent content: {e}"))?;
|
||||
|
||||
@@ -17,8 +17,10 @@ use crate::{
|
||||
},
|
||||
managed_agents::{
|
||||
agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel},
|
||||
load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents,
|
||||
save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord,
|
||||
load_managed_agents, load_managed_agents_at, load_personas, load_personas_at, load_teams,
|
||||
load_teams_readonly, managed_agents_store_path_at, save_managed_agents_at,
|
||||
save_personas_at, save_teams_at, teams_store_path_at, AgentDefinition, ManagedAgentRecord,
|
||||
TeamRecord,
|
||||
},
|
||||
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -502,6 +504,16 @@ pub async fn confirm_team_snapshot_import(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TeamSnapshotImportResult, String> {
|
||||
// ── Scope capture: snapshot the active workspace at entry ─────────────────
|
||||
// All store reads and writes in Phase 3 use `definitions_dir` derived from
|
||||
// this captured scope so a concurrent workspace switch cannot redirect them
|
||||
// to the wrong scope. The generation is re-validated inside the store lock
|
||||
// before the first write (Phase 3).
|
||||
let captured_scope = state
|
||||
.capture_active_scope()
|
||||
.ok_or("confirm_team_snapshot_import: no active workspace scope — cannot import")?;
|
||||
let definitions_dir = captured_scope.definitions_dir.clone();
|
||||
|
||||
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
|
||||
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
|
||||
let now = now_iso();
|
||||
@@ -631,8 +643,22 @@ pub async fn confirm_team_snapshot_import(
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Re-validate the captured scope's generation before any write.
|
||||
// If the workspace switched since Phase 1, abort — writing to the
|
||||
// new scope would import into the wrong workspace.
|
||||
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
|
||||
.map_err(|e| format!("confirm_team_snapshot_import: {e}"))?;
|
||||
|
||||
// Resolve retention scope once from the captured workspace scope so
|
||||
// all retain calls below write to the correct scope's database.
|
||||
let owner_keys_for_retention = state.signing_keys()?;
|
||||
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
|
||||
&captured_scope,
|
||||
owner_keys_for_retention,
|
||||
)?;
|
||||
|
||||
// Guard against duplicate pubkeys (astronomically unlikely).
|
||||
let existing_records = load_managed_agents(&app)?;
|
||||
let existing_records = load_managed_agents_at(&definitions_dir)?;
|
||||
for m in &minted {
|
||||
if existing_records.iter().any(|r| r.pubkey == m.pubkey) {
|
||||
return Err(format!(
|
||||
@@ -646,13 +672,13 @@ pub async fn confirm_team_snapshot_import(
|
||||
// Distinguish "file exists with content" from "file absent" so rollback
|
||||
// can delete a file created by the import rather than leaving orphaned
|
||||
// records.
|
||||
let agents_store_path = crate::managed_agents::storage::managed_agents_store_path(&app)?;
|
||||
let agents_store_path = managed_agents_store_path_at(&definitions_dir);
|
||||
let agents_store_snapshot = match std::fs::read(&agents_store_path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(format!("failed to snapshot agent store: {e}")),
|
||||
};
|
||||
let teams_store_path = crate::managed_agents::teams_store_path(&app)?;
|
||||
let teams_store_path = teams_store_path_at(&definitions_dir);
|
||||
let teams_store_snapshot = match std::fs::read(&teams_store_path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -700,11 +726,11 @@ pub async fn confirm_team_snapshot_import(
|
||||
};
|
||||
|
||||
// Write all definitions.
|
||||
let mut personas = load_personas(&app)?;
|
||||
let mut personas = load_personas_at(&definitions_dir)?;
|
||||
for m in &minted {
|
||||
personas.push(m.definition.clone());
|
||||
}
|
||||
if let Err(e) = save_personas(&app, &personas) {
|
||||
if let Err(e) = save_personas_at(&definitions_dir, &personas) {
|
||||
return Err(rollback_agents(e));
|
||||
}
|
||||
|
||||
@@ -713,15 +739,15 @@ pub async fn confirm_team_snapshot_import(
|
||||
for m in &minted {
|
||||
records.push(m.record.clone());
|
||||
}
|
||||
if let Err(e) = save_managed_agents(&app, &records) {
|
||||
if let Err(e) = save_managed_agents_at(&definitions_dir, &records) {
|
||||
return Err(rollback_agents(e));
|
||||
}
|
||||
|
||||
// Write the team record. `teams` was pre-loaded via the read-only
|
||||
// loader before any agent commits, so a read/parse failure already
|
||||
// aborted before any phase-3 write. save_teams sorts and persists.
|
||||
// aborted before any phase-3 write. save_teams_at sorts and persists.
|
||||
teams.push(imported_team.clone());
|
||||
if let Err(e) = save_teams(&app, &teams) {
|
||||
if let Err(e) = save_teams_at(&definitions_dir, &teams) {
|
||||
let err = rollback_agents(e);
|
||||
// Also restore teams store.
|
||||
let teams_restore = match &teams_store_snapshot {
|
||||
@@ -741,10 +767,13 @@ pub async fn confirm_team_snapshot_import(
|
||||
|
||||
// All writes committed — safe to update in-memory state.
|
||||
for m in &minted {
|
||||
crate::commands::personas::retain_persona_pending(&app, &state, &m.definition);
|
||||
crate::commands::personas::retain_persona_pending_in_scope(
|
||||
&retention_scope,
|
||||
&m.definition,
|
||||
);
|
||||
}
|
||||
for m in &minted {
|
||||
retain_agent_pending(&app, &state, &m.record);
|
||||
retain_agent_pending(&retention_scope, &m.record);
|
||||
}
|
||||
crate::commands::teams::retain_team_pending(&app, &state, &imported_team);
|
||||
|
||||
@@ -847,7 +876,10 @@ pub async fn confirm_team_snapshot_import(
|
||||
|
||||
/// Inline retention for the managed-agent kind:30177 event — mirrors
|
||||
/// `commands::personas::snapshot::import::retain_agent_pending`.
|
||||
fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) {
|
||||
fn retain_agent_pending(
|
||||
scope: &crate::managed_agents::retention::RetentionScope,
|
||||
record: &ManagedAgentRecord,
|
||||
) {
|
||||
use crate::managed_agents::{
|
||||
agent_events::{agent_event_content, build_agent_event},
|
||||
persona_events::monotonic_created_at,
|
||||
@@ -857,7 +889,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
|
||||
use nostr::JsonUtil;
|
||||
|
||||
let result = (|| -> Result<(), String> {
|
||||
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
|
||||
let conn = open_retention_db(&scope.db_path)?;
|
||||
let content = serde_json::to_string(&agent_event_content(record))
|
||||
.map_err(|e| format!("failed to serialize agent content: {e}"))?;
|
||||
|
||||
@@ -186,34 +186,23 @@ pub async fn apply_workspace(
|
||||
&target_scope_id,
|
||||
&scope_dir,
|
||||
&base_dir,
|
||||
&effective_owner_pubkey,
|
||||
)?;
|
||||
|
||||
// ── Persona snapshot backfill (still in prepare stage) ────────────
|
||||
// Run before commit so auto-start agents in the newly-ready scope
|
||||
// have valid persona snapshots available at the first restore pass.
|
||||
// Best-effort: a failure is logged but does not block the transition.
|
||||
// ── Legacy retention migration and persona snapshot backfill ─────────
|
||||
// Both now run inside `ensure_scope_ready` above (as `run_pre_ready_family`),
|
||||
// before the `_ready` marker is written. They remain here as
|
||||
// best-effort guards for any pre-existing Ready scope that was
|
||||
// initialized before these steps were added to the pipeline.
|
||||
if let Err(e) = crate::managed_agents::backfill_persona_snapshots_at(
|
||||
&scope_dir,
|
||||
&state,
|
||||
) {
|
||||
eprintln!("buzz-desktop: persona-snapshot backfill failed during prepare: {e}");
|
||||
eprintln!("buzz-desktop: persona-snapshot backfill guard failed: {e}");
|
||||
}
|
||||
|
||||
// ── Legacy retention migration (still in prepare stage) ───────────
|
||||
// Migrate legacy retained events into this scope's retention DB BEFORE
|
||||
// committing the scope as active. Both the retention and definition
|
||||
// stores must be migrated together so event-sync sees consistent data.
|
||||
// Best-effort: a failure is logged and does not block the transition.
|
||||
{
|
||||
let effective_owner_pubkey_for_retention = match &parsed_keys {
|
||||
Some(keys) => keys.public_key().to_hex(),
|
||||
None => state
|
||||
.keys
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.public_key()
|
||||
.to_hex(),
|
||||
};
|
||||
let effective_owner_pubkey_for_retention = effective_owner_pubkey.clone();
|
||||
let retention_db_path = crate::managed_agents::retention::scoped_retention_db_path(
|
||||
&base_dir,
|
||||
&relay_url,
|
||||
@@ -232,7 +221,7 @@ pub async fn apply_workspace(
|
||||
"buzz-desktop: adopted {copied} legacy retained event(s) into this community"
|
||||
),
|
||||
Err(error) => eprintln!(
|
||||
"buzz-desktop: legacy retention migration failed during prepare: {error}"
|
||||
"buzz-desktop: legacy retention migration guard failed: {error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -242,6 +231,11 @@ pub async fn apply_workspace(
|
||||
// through the end of the commit swap so no start/reconcile can insert
|
||||
// a new runtime into the gap between drain and scope publication.
|
||||
//
|
||||
// `managed_agents_store_lock` is acquired immediately after
|
||||
// `managed_agent_runtime_transition` and held through commit so that
|
||||
// a concurrent save_managed_agents (e.g., a runtime status flush)
|
||||
// cannot interleave with the drain or the scope swap.
|
||||
//
|
||||
// All fallible guards (relay_url_override, keys, active_agent_scope)
|
||||
// are acquired BEFORE any field is mutated so a poison or other lock
|
||||
// failure cannot leave us half-committed with old processes drained.
|
||||
@@ -250,16 +244,24 @@ pub async fn apply_workspace(
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _store = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Build the journal and drain under the held transition lock.
|
||||
let (stopped_entries, _remaining, drain_error) =
|
||||
crate::managed_agents::drain_scope_runtimes(&app, &state);
|
||||
|
||||
if let Some(drain_err) = drain_error {
|
||||
// Drain failed — compensate by restarting what we stopped.
|
||||
// The rt_transition lock stays held during compensation so the
|
||||
// runtime map is still protected.
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
// Drop BOTH the transition lock AND the store lock BEFORE calling
|
||||
// compensate_drain: start_pair re-acquires both
|
||||
// managed_agent_runtime_transition and managed_agents_store_lock
|
||||
// — holding either here would deadlock on the non-reentrant Mutex.
|
||||
drop(_store);
|
||||
drop(rt_transition);
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
let degraded_msg = match comp_err {
|
||||
Some(comp) => {
|
||||
format!("drain failed ({drain_err}); compensation also failed: {comp}")
|
||||
@@ -274,8 +276,11 @@ pub async fn apply_workspace(
|
||||
let mut override_guard = match state.relay_url_override.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
// Drop _store and rt_transition BEFORE compensate_drain:
|
||||
// start_pair re-acquires both; holding either here deadlocks.
|
||||
drop(_store);
|
||||
drop(rt_transition);
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
let msg = format!(
|
||||
"commit failed (relay lock poisoned: {e}){}",
|
||||
comp_err
|
||||
@@ -287,9 +292,10 @@ pub async fn apply_workspace(
|
||||
let mut keys_guard = match state.keys.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
drop(override_guard);
|
||||
drop(_store);
|
||||
drop(rt_transition);
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
let msg = format!(
|
||||
"commit failed (keys lock poisoned: {e}){}",
|
||||
comp_err
|
||||
@@ -301,10 +307,11 @@ pub async fn apply_workspace(
|
||||
let mut scope_guard = match state.active_agent_scope.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
drop(keys_guard);
|
||||
drop(override_guard);
|
||||
drop(_store);
|
||||
drop(rt_transition);
|
||||
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
|
||||
let msg = format!(
|
||||
"commit failed (scope lock poisoned: {e}){}",
|
||||
comp_err
|
||||
@@ -360,6 +367,25 @@ pub async fn apply_workspace(
|
||||
// If blocking returned a drain-failed result, surface it now.
|
||||
let apply_result = blocking_result?;
|
||||
if !apply_result.applied {
|
||||
// The workspace switch failed (drain or commit error). The Mesh client
|
||||
// may have been drained in the Layer-1 async stage before spawn_blocking
|
||||
// was entered. Re-arm it so the old scope's sharing state is restored.
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
{
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) =
|
||||
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-desktop: failed to re-arm Mesh after failed workspace switch: {error}"
|
||||
);
|
||||
}
|
||||
crate::mesh_llm::publish_current_status_once(&app, "workspace switch rollback")
|
||||
.await;
|
||||
});
|
||||
}
|
||||
return Ok(apply_result);
|
||||
}
|
||||
|
||||
@@ -378,14 +404,12 @@ pub async fn apply_workspace(
|
||||
match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) {
|
||||
Ok(scope) => {
|
||||
if let Some(agent_scope) = state.capture_active_scope() {
|
||||
if let Err(error) = crate::event_sync::spawn_event_sync(
|
||||
crate::event_sync::spawn_event_sync(
|
||||
restore_app.clone(),
|
||||
scope.owner_keys,
|
||||
scope.db_path,
|
||||
agent_scope.definitions_dir,
|
||||
) {
|
||||
degraded.push(format!("event-sync dispatch failed: {error}"));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
degraded.push(
|
||||
"active agent scope unavailable after workspace apply — event sync skipped"
|
||||
@@ -402,7 +426,8 @@ pub async fn apply_workspace(
|
||||
|
||||
// Per-transition restore: always restore the new scope's auto-start agents
|
||||
// (replaces the launch-only `managed_agent_restore_pending.swap` one-shot).
|
||||
// Fire-and-forget spawn so the command returns promptly; failures are logged.
|
||||
// Fire-and-forget spawn so the command returns promptly; restore failures
|
||||
// are surfaced as a structured `workspace-degraded` event consumed by the UI.
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
{
|
||||
let app = restore_app.clone();
|
||||
@@ -418,7 +443,9 @@ pub async fn apply_workspace(
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
|
||||
let msg = format!("agent restore failed: {error}");
|
||||
eprintln!("buzz-desktop: {msg}");
|
||||
let _ = app.emit("workspace-degraded", &msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -431,7 +458,9 @@ pub async fn apply_workspace(
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
|
||||
let msg = format!("agent restore failed: {error}");
|
||||
eprintln!("buzz-desktop: {msg}");
|
||||
let _ = app.emit("workspace-degraded", &msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,16 +39,15 @@ pub fn run_event_sync(
|
||||
/// SQLite, and signing work, so it runs on the blocking pool rather than an
|
||||
/// async worker.
|
||||
///
|
||||
/// Returns `Err` when the `spawn_blocking` call itself fails to enqueue (the
|
||||
/// thread pool is exhausted or the runtime is shutting down). Completion
|
||||
/// failures from the reconcile task are logged internally and do not reach
|
||||
/// the caller — only dispatch failure reaches here.
|
||||
/// The dispatch always succeeds (fire-and-forget); completion failures are
|
||||
/// logged internally. Callers that need observable failure should emit a
|
||||
/// workspace degradation event via `emit_workspace_degradation`.
|
||||
pub fn spawn_event_sync(
|
||||
app: tauri::AppHandle,
|
||||
owner_keys: nostr::Keys,
|
||||
db_path: std::path::PathBuf,
|
||||
definitions_dir: std::path::PathBuf,
|
||||
) -> Result<(), String> {
|
||||
) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = tauri::async_runtime::spawn_blocking(move || {
|
||||
run_event_sync(&app, &owner_keys, &db_path, &definitions_dir);
|
||||
@@ -58,7 +57,6 @@ pub fn spawn_event_sync(
|
||||
eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile `personas.json` into the persona-event retention store.
|
||||
|
||||
@@ -66,6 +66,18 @@ pub fn backfill_persona_snapshots_at(
|
||||
backfill_persona_snapshots_in_dir(definitions_dir, state)
|
||||
}
|
||||
|
||||
/// Backfill persona snapshots without acquiring the store lock.
|
||||
///
|
||||
/// For use during scope initialization (inside `ensure_scope_ready`), where the
|
||||
/// scope directory is not yet published as `_ready` and no concurrent reader or
|
||||
/// writer can legally access it. The lock-taking variant (`backfill_persona_snapshots_at`)
|
||||
/// must be used in all other contexts.
|
||||
pub(crate) fn backfill_persona_snapshots_pre_ready(
|
||||
definitions_dir: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
backfill_persona_snapshots_inner(definitions_dir)
|
||||
}
|
||||
|
||||
fn backfill_persona_snapshots_in_dir(
|
||||
definitions_dir: &std::path::Path,
|
||||
state: &AppState,
|
||||
@@ -75,6 +87,10 @@ fn backfill_persona_snapshots_in_dir(
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
backfill_persona_snapshots_inner(definitions_dir)
|
||||
}
|
||||
|
||||
fn backfill_persona_snapshots_inner(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
let mut records = load_managed_agents_at(definitions_dir)?;
|
||||
let needs_backfill = records
|
||||
.iter()
|
||||
@@ -440,6 +456,16 @@ pub async fn restore_managed_agents_on_launch(
|
||||
SpawnOutcome::Skipped => continue,
|
||||
SpawnOutcome::Spawned(key, mut process) => {
|
||||
let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else {
|
||||
// Record was deleted between Phase B and Phase C — terminate
|
||||
// the spawned child and remove its receipt to avoid a leaked
|
||||
// process with no record to track it.
|
||||
eprintln!(
|
||||
"buzz-desktop: restore: record for {} was deleted during spawn; \
|
||||
terminating stale child",
|
||||
pubkey
|
||||
);
|
||||
let _ = super::terminate_process(process.child.id());
|
||||
super::remove_agent_runtime_receipt(app, &key);
|
||||
continue;
|
||||
};
|
||||
let now = util::now_iso();
|
||||
|
||||
@@ -115,6 +115,32 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result<Reten
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `RetentionScope` from a captured [`WorkspaceAgentScope`] and keys.
|
||||
/// Use instead of [`active_retention_scope`] when a captured scope is held.
|
||||
/// Derives `base_dir` two levels up from `definitions_dir`.
|
||||
pub(crate) fn retention_scope_from_captured(
|
||||
captured: &crate::managed_agents::scope::WorkspaceAgentScope,
|
||||
owner_keys: nostr::Keys,
|
||||
) -> Result<RetentionScope, String> {
|
||||
let base_dir = captured
|
||||
.definitions_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or("retention_scope_from_captured: definitions_dir has fewer than two parent levels")?;
|
||||
let db_path = scoped_retention_db_path(base_dir, &captured.relay_url, &captured.owner_pubkey);
|
||||
std::fs::create_dir_all(
|
||||
db_path
|
||||
.parent()
|
||||
.ok_or("retention scope path has no parent")?,
|
||||
)
|
||||
.map_err(|e| format!("failed to create retention scope directory: {e}"))?;
|
||||
Ok(RetentionScope {
|
||||
db_path,
|
||||
relay_url: captured.relay_url.clone(),
|
||||
owner_keys,
|
||||
})
|
||||
}
|
||||
|
||||
/// Snapshot the active relay + owner, but only when it is the scope that owns
|
||||
/// events delivered by `arrival_relay_url` from `arrival_owner_pubkey`.
|
||||
///
|
||||
|
||||
@@ -91,7 +91,16 @@ pub fn scope_is_ready(scope_dir: &Path) -> bool {
|
||||
///
|
||||
/// Idempotent: safe to call on every `apply_workspace`, even if the scope was
|
||||
/// already initialized. Returns `Ok(())` when the scope is ready to use.
|
||||
pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) -> Result<(), String> {
|
||||
///
|
||||
/// `owner_pubkey` is used for the legacy retention DB migration (copying
|
||||
/// retained events for this owner from the legacy global DB into the scoped
|
||||
/// DB). Pass the authenticated owner's hex pubkey.
|
||||
pub fn ensure_scope_ready(
|
||||
scope_id: &str,
|
||||
scope_dir: &Path,
|
||||
base_dir: &Path,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<(), String> {
|
||||
if scope_is_ready(scope_dir) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -121,6 +130,7 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) ->
|
||||
// have landed in the target after the rename.
|
||||
if scope_dir.exists() && scope_dir.join(MANIFEST_FILE).exists() {
|
||||
run_scoped_migrations(scope_dir)?;
|
||||
run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?;
|
||||
write_ready_marker(scope_dir)?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -131,11 +141,14 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) ->
|
||||
// Build the staged directory.
|
||||
install_staged(scope_id, scope_dir, base_dir, &init_kind)?;
|
||||
|
||||
// Run idempotent scoped migrations (currently a no-op placeholder — the
|
||||
// boot-time migrations in migration.rs still run pre-scope; per-scope
|
||||
// migration scheduling is a follow-on once the legacy data is stable).
|
||||
// Run idempotent scoped migrations.
|
||||
run_scoped_migrations(scope_dir)?;
|
||||
|
||||
// Run pre-Ready family steps: legacy retention migration + persona backfill.
|
||||
// These must complete before _ready is written so a crash leaves the scope
|
||||
// in a retry-able state rather than permanently marking incomplete data Ready.
|
||||
run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?;
|
||||
|
||||
// Write the ready marker.
|
||||
write_ready_marker(scope_dir)?;
|
||||
|
||||
@@ -184,7 +197,9 @@ fn read_or_create_canonical_claim(
|
||||
}
|
||||
|
||||
// No retention DB yet — use the fallback JSON claim file.
|
||||
let claim_path = base_dir.join("agents").join(FALLBACK_CLAIM_FILE);
|
||||
// `base_dir` is already `<app-data>/agents`; the claim file lives
|
||||
// at `<app-data>/agents/legacy-claim.json` (no extra "agents" join).
|
||||
let claim_path = base_dir.join(FALLBACK_CLAIM_FILE);
|
||||
read_or_create_fallback_claim(&claim_path, scope_id)
|
||||
}
|
||||
|
||||
@@ -254,14 +269,17 @@ fn read_or_create_fallback_claim(
|
||||
}
|
||||
|
||||
/// Check whether legacy (unscoped) definition files exist that need adoption.
|
||||
///
|
||||
/// `base_dir` is `<app-data>/agents` (not `<app-data>`). Legacy files live
|
||||
/// directly under `base_dir`; the new scoped layout puts them under
|
||||
/// `base_dir/scopes/<scope_id>/`. There is no extra "agents" join here.
|
||||
fn legacy_definitions_exist(base_dir: &Path) -> bool {
|
||||
let agents_dir = base_dir.join("agents");
|
||||
// Legacy layout: `agents/managed-agents.json` at the unscoped root.
|
||||
// New layout puts files under `agents/scopes/<id>/`.
|
||||
agents_dir.join("managed-agents.json").exists()
|
||||
|| agents_dir.join("teams.json").exists()
|
||||
|| agents_dir.join("global-agent-config.json").exists()
|
||||
|| agents_dir.join("personas.json").exists()
|
||||
// Legacy layout: files live directly in `<app-data>/agents/`.
|
||||
// New layout puts files under `<app-data>/agents/scopes/<id>/`.
|
||||
base_dir.join("managed-agents.json").exists()
|
||||
|| base_dir.join("teams.json").exists()
|
||||
|| base_dir.join("global-agent-config.json").exists()
|
||||
|| base_dir.join("personas.json").exists()
|
||||
}
|
||||
|
||||
/// Build and atomically install the staged scope directory.
|
||||
@@ -283,14 +301,14 @@ fn install_staged(
|
||||
|
||||
// For AdoptedLegacy: copy legacy files into staging.
|
||||
if matches!(init_kind, ScopeInitKind::AdoptedLegacy) {
|
||||
let agents_dir = base_dir.join("agents");
|
||||
// `base_dir` is `<app-data>/agents`; legacy files live directly in it.
|
||||
for filename in &[
|
||||
"managed-agents.json",
|
||||
"teams.json",
|
||||
"global-agent-config.json",
|
||||
"personas.json",
|
||||
] {
|
||||
let src = agents_dir.join(filename);
|
||||
let src = base_dir.join(filename);
|
||||
if src.exists() {
|
||||
let dst = staging.join(filename);
|
||||
std::fs::copy(&src, &dst)
|
||||
@@ -380,7 +398,8 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Step 3: refresh legacy builtin agent avatars.
|
||||
crate::migration::refresh_builtin_agent_avatars_at(scope_dir);
|
||||
crate::migration::refresh_builtin_agent_avatars_at(scope_dir)
|
||||
.map_err(|e| format!("scope-init-avatars: {e}"))?;
|
||||
|
||||
// Step 4: backfill standalone agents into definition-linked records.
|
||||
match crate::migration::backfill_standalone_agents_in_dir(scope_dir) {
|
||||
@@ -397,21 +416,24 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Step 6: reconcile legacy command names.
|
||||
crate::migration::reconcile_legacy_command_names_at(scope_dir);
|
||||
crate::migration::reconcile_legacy_command_names_at(scope_dir)
|
||||
.map_err(|e| format!("scope-init-cmd-names: {e}"))?;
|
||||
|
||||
// Step 7: reconcile provider mcp_command values.
|
||||
crate::migration::reconcile_provider_mcp_commands_at(scope_dir);
|
||||
crate::migration::reconcile_provider_mcp_commands_at(scope_dir)
|
||||
.map_err(|e| format!("scope-init-mcp-cmds: {e}"))?;
|
||||
|
||||
// Step 8: Databricks V1 → V2 provider migration.
|
||||
crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir);
|
||||
crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir)
|
||||
.map_err(|e| format!("scope-init-databricks: {e}"))?;
|
||||
|
||||
// Step 9: materialize runtime onto each record.
|
||||
crate::migration::materialize_agent_runtimes_at(scope_dir);
|
||||
crate::migration::materialize_agent_runtimes_at(scope_dir)
|
||||
.map_err(|e| format!("scope-init-materialize: {e}"))?;
|
||||
|
||||
// Step 10: validate the final managed-agents.json is parseable JSON before
|
||||
// writing the Ready marker. Steps 6-9 use `patch_json_records` which logs
|
||||
// and swallows parse failures internally; this final check ensures we never
|
||||
// mark a scope Ready when its primary store is corrupt.
|
||||
// writing the Ready marker. The step-10 backstop ensures the file is still
|
||||
// valid JSON even after all migrations have run successfully.
|
||||
let agents_path = scope_dir.join("managed-agents.json");
|
||||
if agents_path.exists() {
|
||||
let content = std::fs::read_to_string(&agents_path)
|
||||
@@ -426,6 +448,52 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the pre-Ready family steps: legacy retention migration and persona
|
||||
/// snapshot backfill. These must complete before the `_ready` marker is
|
||||
/// written so a crash between migration and marker leaves the scope in a
|
||||
/// retry-able state rather than permanently marking incomplete data Ready.
|
||||
///
|
||||
/// Both steps are idempotent: a second run after a crash is safe.
|
||||
/// A failure aborts the pre-Ready sequence and propagates to `ensure_scope_ready`,
|
||||
/// which withholds the `_ready` marker, enabling a clean retry on next launch.
|
||||
fn run_pre_ready_family(
|
||||
scope_dir: &Path,
|
||||
base_dir: &Path,
|
||||
scope_id: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<(), String> {
|
||||
// Step A: legacy retention migration — copy owned retained events from
|
||||
// the legacy global retention.db into this scope's scoped DB.
|
||||
let scope_db_path = base_dir.join("retention").join(format!("{scope_id}.db"));
|
||||
if let Some(parent) = scope_db_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("scope-init-retention: failed to create retention dir: {e}"))?;
|
||||
}
|
||||
match crate::managed_agents::retention::migrate_legacy_retention_db(
|
||||
base_dir,
|
||||
&scope_db_path,
|
||||
owner_pubkey,
|
||||
) {
|
||||
Ok(0) => {}
|
||||
Ok(copied) => eprintln!(
|
||||
"buzz-desktop: scope-init-retention: adopted {copied} legacy retained event(s)"
|
||||
),
|
||||
Err(e) => return Err(format!("scope-init-retention: {e}")),
|
||||
}
|
||||
|
||||
// Step B: persona snapshot backfill — pre-populate `persona_source_version`
|
||||
// on instances that link a persona but have no version pinned yet, so
|
||||
// auto-start agents boot from a valid snapshot even on first activation.
|
||||
// Runs without the store lock because the scope is not yet published as
|
||||
// _ready and no concurrent reader or writer can legally access it.
|
||||
if let Err(e) = crate::managed_agents::restore::backfill_persona_snapshots_pre_ready(scope_dir)
|
||||
{
|
||||
return Err(format!("scope-init-backfill: {e}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the `_ready` marker file inside the scope directory, signaling that
|
||||
/// all migrations are complete and the scope is available for use.
|
||||
fn write_ready_marker(scope_dir: &Path) -> Result<(), String> {
|
||||
@@ -488,22 +556,41 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a temporary directory and return it. The agents base dir
|
||||
/// (`base_dir`) must be `tmp.path().join("agents")` — matching the
|
||||
/// production layout where `managed_agents_base_dir` returns
|
||||
/// `<app-data>/agents`. Callers use `make_base_dir_pair` to get both.
|
||||
#[allow(dead_code)] // Kept as documentation for the pair-based helpers.
|
||||
fn make_base_dir() -> TempDir {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
/// Returns `(TempDir, base_dir)` where `base_dir = tmp.path().join("agents")`.
|
||||
///
|
||||
/// Production: `managed_agents_base_dir` returns `<app-data>/agents`.
|
||||
/// Tests must use that same layout so `legacy_definitions_exist`,
|
||||
/// `install_staged`, and `read_or_create_canonical_claim` all see files
|
||||
/// at the correct level.
|
||||
fn make_base_dir_pair() -> (TempDir, std::path::PathBuf) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let base_dir = tmp.path().join("agents");
|
||||
std::fs::create_dir_all(&base_dir).unwrap();
|
||||
(tmp, base_dir)
|
||||
}
|
||||
|
||||
/// Write the legacy definition files directly into `base_dir`
|
||||
/// (i.e. `<app-data>/agents/managed-agents.json` etc.).
|
||||
fn make_legacy_files(base_dir: &Path) {
|
||||
let agents_dir = base_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).unwrap();
|
||||
std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap();
|
||||
std::fs::write(agents_dir.join("teams.json"), b"[]").unwrap();
|
||||
std::fs::create_dir_all(base_dir).unwrap();
|
||||
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
|
||||
std::fs::write(base_dir.join("teams.json"), b"[]").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fresh_no_legacy_scope_initializes_ready() {
|
||||
let tmp = make_base_dir();
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join("testscope");
|
||||
ensure_scope_ready("testscope", &scope_dir, tmp.path()).unwrap();
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
let scope_dir = base_dir.join("scopes").join("testscope");
|
||||
ensure_scope_ready("testscope", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
assert!(scope_is_ready(&scope_dir), "scope should be Ready");
|
||||
// Manifest should indicate FreshNoLegacy.
|
||||
let manifest: ScopeManifest =
|
||||
@@ -513,11 +600,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_adopted_legacy_scope_copies_files() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
let scope_id = "firstscope";
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id);
|
||||
ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap();
|
||||
let scope_dir = base_dir.join("scopes").join(scope_id);
|
||||
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
assert!(scope_is_ready(&scope_dir));
|
||||
assert!(
|
||||
scope_dir.join("managed-agents.json").exists(),
|
||||
@@ -530,16 +617,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_second_scope_legacy_claimed_by_other() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
// First scope claims.
|
||||
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
|
||||
let scope_a = base_dir.join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
|
||||
|
||||
// Second scope should see LegacyClaimedByOther.
|
||||
let scope_b = tmp.path().join("agents").join("scopes").join("scope_b");
|
||||
ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap();
|
||||
let scope_b = base_dir.join("scopes").join("scope_b");
|
||||
ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap();
|
||||
assert!(scope_is_ready(&scope_b));
|
||||
let manifest: ScopeManifest =
|
||||
serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap();
|
||||
@@ -559,20 +646,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_idempotent_double_initialize() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join("idempotent");
|
||||
ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap();
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
let scope_dir = base_dir.join("scopes").join("idempotent");
|
||||
ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
// Second call should be a fast no-op.
|
||||
ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
assert!(scope_is_ready(&scope_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_staging_cleanup_on_retry() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join("retry");
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
let scope_dir = base_dir.join("scopes").join("retry");
|
||||
let staging = staging_dir_for(&scope_dir);
|
||||
|
||||
// Simulate an interrupted staging directory.
|
||||
@@ -580,18 +667,19 @@ mod tests {
|
||||
std::fs::write(staging.join("partial.json"), b"garbage").unwrap();
|
||||
|
||||
// ensure_scope_ready should clean it up and succeed.
|
||||
ensure_scope_ready("retry", &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready("retry", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
assert!(scope_is_ready(&scope_dir));
|
||||
assert!(!staging.exists(), "staging dir should be cleaned up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retention_db_claim_takes_precedence() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
// Pre-plant a retention.db with scope_a's claim.
|
||||
let retention_db_path = tmp.path().join("retention.db");
|
||||
// retention.db lives at `base_dir/retention.db` (i.e. `<app-data>/agents/retention.db`).
|
||||
let retention_db_path = base_dir.join("retention.db");
|
||||
let conn = Connection::open(&retention_db_path).unwrap();
|
||||
ensure_migration_table(&conn).unwrap();
|
||||
conn.execute(
|
||||
@@ -602,8 +690,8 @@ mod tests {
|
||||
drop(conn);
|
||||
|
||||
// scope_b activates first — retention.db says scope_a owns legacy.
|
||||
let scope_b = tmp.path().join("agents").join("scopes").join("scope_b");
|
||||
ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap();
|
||||
let scope_b = base_dir.join("scopes").join("scope_b");
|
||||
ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap();
|
||||
let manifest: ScopeManifest =
|
||||
serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap();
|
||||
assert!(
|
||||
@@ -617,8 +705,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// scope_a now activates — should adopt legacy.
|
||||
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
|
||||
let scope_a = base_dir.join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
|
||||
let manifest_a: ScopeManifest =
|
||||
serde_json::from_slice(&std::fs::read(scope_a.join(MANIFEST_FILE)).unwrap()).unwrap();
|
||||
assert!(matches!(manifest_a.init_kind, ScopeInitKind::AdoptedLegacy));
|
||||
@@ -631,19 +719,19 @@ mod tests {
|
||||
/// IGNORE is idempotent) and legacy files are copied correctly.
|
||||
#[test]
|
||||
fn test_crash_after_claim_before_staging_resumes_correctly() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
// Simulate: claim was written into the fallback file but no staging dir exists yet.
|
||||
let agents_dir = tmp.path().join("agents");
|
||||
let claim_path = agents_dir.join(FALLBACK_CLAIM_FILE);
|
||||
std::fs::create_dir_all(&agents_dir).unwrap();
|
||||
// The fallback claim file lives at `base_dir/legacy-claim.json`
|
||||
// (no extra "agents" join — base_dir is already `<app-data>/agents`).
|
||||
let claim_path = base_dir.join(FALLBACK_CLAIM_FILE);
|
||||
let claim = serde_json::json!({"scope_id": "scope_a"});
|
||||
std::fs::write(&claim_path, serde_json::to_vec(&claim).unwrap()).unwrap();
|
||||
|
||||
// No staging dir exists — retry runs the full staged install from the claim.
|
||||
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
|
||||
let scope_a = base_dir.join("scopes").join("scope_a");
|
||||
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
|
||||
|
||||
assert!(scope_is_ready(&scope_a), "scope must be Ready after retry");
|
||||
let manifest: ScopeManifest =
|
||||
@@ -667,10 +755,10 @@ mod tests {
|
||||
/// overwrite — staging is the only artifact).
|
||||
#[test]
|
||||
fn test_crash_during_staging_copy_is_cleaned_on_retry() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join("scope_retry");
|
||||
let scope_dir = base_dir.join("scopes").join("scope_retry");
|
||||
let staging = staging_dir_for(&scope_dir);
|
||||
|
||||
// Simulate interrupted staging: directory exists with partial content.
|
||||
@@ -678,7 +766,7 @@ mod tests {
|
||||
std::fs::write(staging.join("managed-agents.json"), b"[\"partial\"]").unwrap();
|
||||
// No manifest inside staging (write didn't complete).
|
||||
|
||||
ensure_scope_ready("scope_retry", &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready("scope_retry", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
|
||||
assert!(scope_is_ready(&scope_dir));
|
||||
assert!(
|
||||
@@ -698,14 +786,10 @@ mod tests {
|
||||
/// re-run; the claim is idempotent so the same scope adopts legacy again.
|
||||
#[test]
|
||||
fn test_crash_after_staging_manifest_before_rename_resumes_correctly() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
let scope_dir = tmp
|
||||
.path()
|
||||
.join("agents")
|
||||
.join("scopes")
|
||||
.join("scope_rename");
|
||||
let scope_dir = base_dir.join("scopes").join("scope_rename");
|
||||
let staging = staging_dir_for(&scope_dir);
|
||||
|
||||
// Simulate: staging complete with manifest, but rename never fired.
|
||||
@@ -723,7 +807,7 @@ mod tests {
|
||||
// Scope dir itself does not exist (rename didn't fire).
|
||||
assert!(!scope_dir.exists());
|
||||
|
||||
ensure_scope_ready("scope_rename", &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready("scope_rename", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
|
||||
assert!(scope_is_ready(&scope_dir));
|
||||
assert!(!staging.exists(), "staging must be cleaned after retry");
|
||||
@@ -746,14 +830,10 @@ mod tests {
|
||||
/// outside the scope of the crash-resume path.
|
||||
#[test]
|
||||
fn test_crash_after_rename_before_ready_resumes_migrations() {
|
||||
let tmp = make_base_dir();
|
||||
make_legacy_files(tmp.path());
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
make_legacy_files(&base_dir);
|
||||
|
||||
let scope_dir = tmp
|
||||
.path()
|
||||
.join("agents")
|
||||
.join("scopes")
|
||||
.join("scope_pre_ready");
|
||||
let scope_dir = base_dir.join("scopes").join("scope_pre_ready");
|
||||
|
||||
// Simulate: rename already happened — target has manifest + files but no
|
||||
// _ready marker. Content is valid JSON so migrations can complete.
|
||||
@@ -772,7 +852,7 @@ mod tests {
|
||||
// No _ready marker.
|
||||
assert!(!scope_is_ready(&scope_dir));
|
||||
|
||||
ensure_scope_ready("scope_pre_ready", &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready("scope_pre_ready", &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
|
||||
assert!(
|
||||
scope_is_ready(&scope_dir),
|
||||
@@ -794,21 +874,20 @@ mod tests {
|
||||
/// the defect corrected, migrations complete and `_ready` IS written.
|
||||
#[test]
|
||||
fn test_migration_failure_withholds_ready_and_retry_succeeds() {
|
||||
let tmp = make_base_dir();
|
||||
let (_tmp, base_dir) = make_base_dir_pair();
|
||||
|
||||
// Create the legacy directory with a CORRUPT personas.json.
|
||||
let agents_dir = tmp.path().join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).unwrap();
|
||||
std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap();
|
||||
std::fs::create_dir_all(&base_dir).unwrap();
|
||||
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
|
||||
// Corrupt personas.json: `fold_personas_in_dir` tries to parse it and
|
||||
// returns Err, which run_scoped_migrations propagates.
|
||||
std::fs::write(agents_dir.join("personas.json"), b"not valid json").unwrap();
|
||||
std::fs::write(base_dir.join("personas.json"), b"not valid json").unwrap();
|
||||
|
||||
let scope_id = "scope_fail_retry";
|
||||
let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id);
|
||||
let scope_dir = base_dir.join("scopes").join(scope_id);
|
||||
|
||||
// First attempt: migrations fail, _ready must NOT be written.
|
||||
let result = ensure_scope_ready(scope_id, &scope_dir, tmp.path());
|
||||
let result = ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner");
|
||||
assert!(result.is_err(), "corrupt personas.json must cause Err");
|
||||
assert!(
|
||||
!scope_is_ready(&scope_dir),
|
||||
@@ -816,7 +895,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Repair the corrupt file.
|
||||
std::fs::write(agents_dir.join("personas.json"), b"[]").unwrap();
|
||||
std::fs::write(base_dir.join("personas.json"), b"[]").unwrap();
|
||||
// Also repair the scope_dir since ensure_scope_ready may have left it in
|
||||
// a partial state — remove it so the state machine reruns from staging.
|
||||
if scope_dir.exists() {
|
||||
@@ -824,10 +903,66 @@ mod tests {
|
||||
}
|
||||
|
||||
// Second attempt: migrations succeed, _ready IS written.
|
||||
ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap();
|
||||
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
assert!(
|
||||
scope_is_ready(&scope_dir),
|
||||
"_ready must be written on successful retry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Production-contract coverage: `base_dir` is `<app-data>/agents`
|
||||
/// (the real shape from `managed_agents_base_dir`). Legacy files live
|
||||
/// at `base_dir/{managed-agents,teams}.json`; the scope dir lives at
|
||||
/// `base_dir/scopes/<scope_id>/`; the fallback claim file lives at
|
||||
/// `base_dir/legacy-claim.json`. This test verifies the full adoption
|
||||
/// path using the production layout so any future double-join regresses
|
||||
/// visibly here rather than silently succeeding on a synthetic tree.
|
||||
#[test]
|
||||
fn test_production_shaped_adoption_finds_legacy_files() {
|
||||
// `app_data_dir` is the synthetic `<app-data>` root.
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let app_data_dir = tmp.path();
|
||||
|
||||
// Production: `managed_agents_base_dir` returns `<app-data>/agents`.
|
||||
let base_dir = app_data_dir.join("agents");
|
||||
std::fs::create_dir_all(&base_dir).unwrap();
|
||||
|
||||
// Legacy files sit directly under `base_dir`.
|
||||
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
|
||||
std::fs::write(base_dir.join("teams.json"), b"[]").unwrap();
|
||||
|
||||
// Scope dir is `base_dir/scopes/<scope_id>/`.
|
||||
let scope_id = "prod-shape-scope";
|
||||
let scope_dir = base_dir.join("scopes").join(scope_id);
|
||||
|
||||
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
|
||||
|
||||
assert!(scope_is_ready(&scope_dir), "scope must be Ready");
|
||||
|
||||
// With the correct layout the scope must adopt legacy (not start fresh).
|
||||
let manifest: ScopeManifest =
|
||||
serde_json::from_slice(&std::fs::read(scope_dir.join(MANIFEST_FILE)).unwrap()).unwrap();
|
||||
assert!(
|
||||
matches!(manifest.init_kind, ScopeInitKind::AdoptedLegacy),
|
||||
"production-layout scope must adopt legacy, got {:?}",
|
||||
manifest.init_kind
|
||||
);
|
||||
// Legacy files were copied into the scope.
|
||||
assert!(
|
||||
scope_dir.join("managed-agents.json").exists(),
|
||||
"managed-agents.json must be present in adopted scope"
|
||||
);
|
||||
|
||||
// Fallback claim file lives at `base_dir/legacy-claim.json`, NOT at
|
||||
// `base_dir/agents/legacy-claim.json` (which would be the double-join
|
||||
// path). Verify the correct location was used.
|
||||
assert!(
|
||||
base_dir.join(FALLBACK_CLAIM_FILE).exists(),
|
||||
"fallback claim must be at base_dir/legacy-claim.json, not at a nested path"
|
||||
);
|
||||
assert!(
|
||||
!base_dir.join("agents").join(FALLBACK_CLAIM_FILE).exists(),
|
||||
"double-join claim path must NOT exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,11 +278,15 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
// that commits after this point is handled on the next watchdog cycle.
|
||||
// Both relay and definitions_dir come from the single captured scope so
|
||||
// all store reads below target the same workspace as the relay check.
|
||||
let (scope_relay, scope_definitions_dir) = {
|
||||
// The generation is captured alongside so writes to definitions_dir can
|
||||
// detect a mid-pass workspace switch via validate_scope_generation before
|
||||
// persisting any error/clear record.
|
||||
let (scope_relay, scope_definitions_dir, captured_scope) = {
|
||||
let s = state.capture_active_scope();
|
||||
(
|
||||
s.as_ref().map(|scope| scope.relay_url.clone()),
|
||||
s.map(|scope| scope.definitions_dir.clone()),
|
||||
s.as_ref().map(|scope| scope.definitions_dir.clone()),
|
||||
s,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -408,8 +412,17 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Some(dir) = scope_definitions_dir.as_deref() {
|
||||
if let Err(error) = clear_mesh_last_error_if_set_at(app, dir, &record.pubkey) {
|
||||
eprintln!("buzz-mesh: failed to clear recovery error: {error}");
|
||||
// Validate generation before writing to the captured scope's
|
||||
// directory — if the workspace switched during this await,
|
||||
// do not write the stale success into the new scope's store.
|
||||
if captured_scope.as_ref().map_or(false, |s| {
|
||||
crate::managed_agents::scope::validate_scope_generation(s).is_ok()
|
||||
}) {
|
||||
if let Err(error) =
|
||||
clear_mesh_last_error_if_set_at(app, dir, &record.pubkey)
|
||||
{
|
||||
eprintln!("buzz-mesh: failed to clear recovery error: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,10 +431,18 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
"{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}"
|
||||
);
|
||||
if let Some(dir) = scope_definitions_dir.as_deref() {
|
||||
if let Err(persist_error) =
|
||||
persist_mesh_last_error_at(app, dir, &record.pubkey, &message)
|
||||
{
|
||||
eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}");
|
||||
// Validate generation before persisting the error — if the
|
||||
// workspace switched during this await, skip the write.
|
||||
if captured_scope.as_ref().map_or(false, |s| {
|
||||
crate::managed_agents::scope::validate_scope_generation(s).is_ok()
|
||||
}) {
|
||||
if let Err(persist_error) =
|
||||
persist_mesh_last_error_at(app, dir, &record.pubkey, &message)
|
||||
{
|
||||
eprintln!(
|
||||
"buzz-mesh: failed to persist recovery error: {persist_error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.get_or_insert(message);
|
||||
|
||||
@@ -471,17 +471,23 @@ fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<(
|
||||
fn patch_json_records(
|
||||
path: &Path,
|
||||
mut f: impl FnMut(&mut serde_json::Map<String, serde_json::Value>) -> bool,
|
||||
) {
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
) -> Result<(), String> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"patch-json-records: failed to read {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
|
||||
eprintln!(
|
||||
"buzz-desktop: patch-json-records: failed to parse {}",
|
||||
let mut records = serde_json::from_str::<Vec<serde_json::Value>>(&content).map_err(|e| {
|
||||
format!(
|
||||
"patch-json-records: failed to parse {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
)
|
||||
})?;
|
||||
let mut changed = false;
|
||||
for record in &mut records {
|
||||
if let Some(obj) = record.as_object_mut() {
|
||||
@@ -489,12 +495,15 @@ fn patch_json_records(
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
|
||||
if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) {
|
||||
eprintln!("buzz-desktop: patch-json-records: {e}");
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&records).map_err(|e| {
|
||||
format!(
|
||||
"patch-json-records: failed to serialize {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
crate::managed_agents::atomic_write_json_restricted(path, &bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct LegacyBuiltInAvatar<'a> {
|
||||
@@ -533,41 +542,27 @@ struct LegacyAvatarMatch<'a> {
|
||||
was_uploaded: bool,
|
||||
}
|
||||
|
||||
/// Refresh the prior seeded avatar on built-in definitions and linked agent
|
||||
/// instances while preserving any avatar the user customized. Matching by the
|
||||
/// exact data URL or content-addressed upload digest makes the migration
|
||||
/// idempotent and avoids relying on timestamps or other persona fields the
|
||||
/// user may also have edited.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses refresh_builtin_agent_avatars_at.
|
||||
fn refresh_builtin_agent_avatars(app: &tauri::AppHandle) {
|
||||
let Ok(dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
refresh_builtin_agent_avatars_in_file(
|
||||
&path,
|
||||
LEGACY_BUILTIN_AVATARS,
|
||||
&crate::util::now_iso(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_builtin_agent_avatars_in_file(
|
||||
path: &Path,
|
||||
legacy_avatars: &[LegacyBuiltInAvatar<'_>],
|
||||
now: &str,
|
||||
) {
|
||||
let Ok(contents) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
) -> Result<(), String> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"refresh-builtin-agent-avatars: failed to read {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) else {
|
||||
eprintln!(
|
||||
"buzz-desktop: refresh-builtin-agent-avatars: invalid JSON in {}",
|
||||
let mut records = serde_json::from_str::<Vec<serde_json::Value>>(&contents).map_err(|e| {
|
||||
format!(
|
||||
"refresh-builtin-agent-avatars: invalid JSON in {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
)
|
||||
})?;
|
||||
|
||||
// Definitions must be migrated first so linked instances can advance from
|
||||
// the exact old persona hash to the exact new one. Only advance an instance
|
||||
@@ -641,12 +636,15 @@ fn refresh_builtin_agent_avatars_in_file(
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
|
||||
if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) {
|
||||
eprintln!("buzz-desktop: refresh-builtin-agent-avatars: {e}");
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&records).map_err(|e| {
|
||||
format!(
|
||||
"refresh-builtin-agent-avatars: failed to serialize {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
crate::managed_agents::atomic_write_json_restricted(path, &bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn legacy_avatar_match<'a>(
|
||||
@@ -952,7 +950,7 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_mcp_commands_in_file(path: &Path) {
|
||||
fn reconcile_mcp_commands_in_file(path: &Path) -> Result<(), String> {
|
||||
// Resolve each record's EFFECTIVE harness (persona-wins, override-honored)
|
||||
// before deriving its mcp_command, so a persona-inherited harness switch
|
||||
// doesn't leave a stale persisted mcp_command. The persona runtime is read
|
||||
@@ -1005,7 +1003,8 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
|
||||
serde_json::Value::String(expected.to_string()),
|
||||
);
|
||||
true
|
||||
});
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_command_field(
|
||||
@@ -1029,7 +1028,7 @@ fn replace_command_field(
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_legacy_command_names_in_file(path: &Path) {
|
||||
fn reconcile_legacy_command_names_in_file(path: &Path) -> Result<(), String> {
|
||||
patch_json_records(path, |obj| {
|
||||
let mut changed = false;
|
||||
|
||||
@@ -1076,151 +1075,13 @@ fn reconcile_legacy_command_names_in_file(path: &Path) {
|
||||
}
|
||||
|
||||
changed
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
|
||||
fn reconcile_legacy_persona_runtimes_in_file(path: &Path) {
|
||||
patch_json_records(path, |obj| {
|
||||
let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
if runtime != "sprout-agent" {
|
||||
return false;
|
||||
}
|
||||
eprintln!(
|
||||
"buzz-desktop: command-rename-reconcile: persona {:?}: runtime {:?} → {:?}",
|
||||
obj.get("display_name")
|
||||
.or_else(|| obj.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?"),
|
||||
runtime,
|
||||
"buzz-agent",
|
||||
);
|
||||
obj.insert(
|
||||
"runtime".to_string(),
|
||||
serde_json::Value::String("buzz-agent".to_string()),
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
|
||||
fn rewrite_legacy_persona_md_runtime(content: &str) -> Option<String> {
|
||||
let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?;
|
||||
let mut value = serde_yaml::from_str::<serde_yaml::Value>(frontmatter).ok()?;
|
||||
let mapping = value.as_mapping_mut()?;
|
||||
let runtime = mapping.get_mut(serde_yaml::Value::String("runtime".to_string()))?;
|
||||
if runtime.as_str()? != "sprout-agent" {
|
||||
return None;
|
||||
}
|
||||
*runtime = serde_yaml::Value::String("buzz-agent".to_string());
|
||||
let frontmatter = serde_yaml::to_string(&value).ok()?;
|
||||
Some(format!("---\n{frontmatter}---\n{body}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
|
||||
fn reconcile_legacy_team_persona_runtime_files(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
reconcile_legacy_team_persona_runtime_files(&path);
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.ends_with(".persona.md") {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(updated) = rewrite_legacy_persona_md_runtime(&content) else {
|
||||
continue;
|
||||
};
|
||||
if updated == content {
|
||||
continue;
|
||||
}
|
||||
match std::fs::write(&path, updated) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: command-rename-reconcile: updated {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: command-rename-reconcile: failed to update {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile exact built-in command values persisted before the Sprout→Buzz
|
||||
/// rename. Custom commands and explicit paths are left untouched.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_legacy_command_names_at.
|
||||
pub fn reconcile_legacy_command_names(app: &tauri::AppHandle) {
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut dirs = vec![current_dir.clone()];
|
||||
if let Some(canonical) = canonical_dev_data_dir(¤t_dir) {
|
||||
if canonical.exists() && canonical != current_dir {
|
||||
dirs.push(canonical);
|
||||
}
|
||||
}
|
||||
for dir in dirs {
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_legacy_command_names_in_file(&path);
|
||||
}
|
||||
let personas_path = dir.join("agents/personas.json");
|
||||
if personas_path.exists() {
|
||||
reconcile_legacy_persona_runtimes_in_file(&personas_path);
|
||||
}
|
||||
let teams_dir = dir.join("agents/teams");
|
||||
if teams_dir.exists() && !teams_dir.is_symlink() {
|
||||
reconcile_legacy_team_persona_runtime_files(&teams_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile `mcp_command` values in managed-agents.json against the
|
||||
/// discovery table. Known runtimes get their canonical mcp_command;
|
||||
/// unknown/custom agents are left untouched. Covers both the current
|
||||
/// app data dir and the canonical dev data dir (for worktree instances).
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_provider_mcp_commands_at.
|
||||
pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) {
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut dirs = vec![current_dir.clone()];
|
||||
if let Some(canonical) = canonical_dev_data_dir(¤t_dir) {
|
||||
if canonical.exists() && canonical != current_dir {
|
||||
dirs.push(canonical);
|
||||
}
|
||||
}
|
||||
for dir in dirs {
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_mcp_commands_in_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) {
|
||||
fn reconcile_databricks_v1_to_v2_in_file(
|
||||
path: &Path,
|
||||
rewrite_v1_provider: bool,
|
||||
) -> Result<(), String> {
|
||||
use crate::managed_agents::is_derived_provider_model_key;
|
||||
patch_json_records(path, |obj| {
|
||||
let mut changed = false;
|
||||
@@ -1277,7 +1138,7 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool)
|
||||
}
|
||||
|
||||
changed
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip stale derived provider/model keys from `env_vars` in all
|
||||
@@ -1301,36 +1162,9 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool)
|
||||
/// Covers both the current app data dir and the canonical dev data dir
|
||||
/// (for worktree instances) — same dual-dir pattern as
|
||||
/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_databricks_v1_to_v2_at.
|
||||
pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) {
|
||||
use crate::managed_agents::baked_build_env;
|
||||
// On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2.
|
||||
// Use that as a reliable signal that this is a Block build and the V1
|
||||
// provider should be migrated. OSS builds have an empty baked env, so
|
||||
// rewrite_v1_provider is false and the structured provider is preserved.
|
||||
let rewrite_v1_provider = baked_build_env()
|
||||
.get("BUZZ_AGENT_PROVIDER")
|
||||
.map(|v| v == "databricks_v2")
|
||||
.unwrap_or(false);
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut dirs = vec![current_dir.clone()];
|
||||
if let Some(canonical) = canonical_dev_data_dir(¤t_dir) {
|
||||
if canonical.exists() && canonical != current_dir {
|
||||
dirs.push(canonical);
|
||||
}
|
||||
}
|
||||
for dir in dirs {
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_provider_to_runtime_in_personas(path: &Path) {
|
||||
patch_json_records(path, |obj| {
|
||||
if let Err(e) = patch_json_records(path, |obj| {
|
||||
if obj.contains_key("runtime") {
|
||||
return false;
|
||||
}
|
||||
@@ -1340,7 +1174,9 @@ fn rename_provider_to_runtime_in_personas(path: &Path) {
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}) {
|
||||
eprintln!("buzz-desktop: rename-provider-to-runtime: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
|
||||
|
||||
@@ -31,21 +31,6 @@ use crate::managed_agents::{
|
||||
/// The manufactured definition's slug is the agent's pubkey: 64-hex passes
|
||||
/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys
|
||||
/// are unique, so the coordinate is collision-free by construction.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses backfill_standalone_agents_in_dir.
|
||||
pub fn backfill_standalone_agents(app: &tauri::AppHandle) {
|
||||
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
|
||||
return;
|
||||
};
|
||||
match backfill_standalone_agents_in_dir(&base_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(backfilled) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions"
|
||||
);
|
||||
}
|
||||
Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing.
|
||||
/// Returns the number of records backfilled (0 = nothing to do).
|
||||
|
||||
@@ -24,18 +24,6 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
|
||||
/// `instructions` if the field is not already set.
|
||||
/// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each
|
||||
/// directory-backed `TeamRecord`.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses detach_directory_backed_teams_in_dir.
|
||||
pub fn detach_directory_backed_teams(app: &tauri::AppHandle) {
|
||||
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
|
||||
return;
|
||||
};
|
||||
match detach_directory_backed_teams_in_dir(&base_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"),
|
||||
Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
|
||||
///
|
||||
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
|
||||
|
||||
@@ -3,38 +3,6 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Fold `personas.json` into the unified agent store (Phase 1A.2).
|
||||
///
|
||||
/// One-way, versioned by presence: runs only while `personas.json` exists.
|
||||
/// Each persona becomes a key-less definition record
|
||||
/// ([`AgentDefinition::into_agent_record`]) appended to `managed-agents.json`
|
||||
/// via the definition-preserving save; the old file is renamed to
|
||||
/// `personas.json.bak` so a second boot is a no-op and the data survives for
|
||||
/// manual recovery. Built-ins are skipped — `merge_personas` regenerates them
|
||||
/// from code on every load, exactly as before.
|
||||
///
|
||||
/// Ordering (see `run_boot_migrations`): runs after the JSON-level
|
||||
/// `personas.json` migrations (which must see the legacy file) and BEFORE
|
||||
/// every consumer of the `load/save_personas` shims — `sync_team_personas`,
|
||||
/// `reconcile_provider_mcp_commands`, and `materialize_agent_runtimes` all
|
||||
/// read definitions post-fold via [`load_persona_runtimes`]'s unified-store
|
||||
/// branch.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses fold_personas_in_dir.
|
||||
pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) {
|
||||
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
|
||||
return;
|
||||
};
|
||||
match fold_personas_in_dir(&base_dir) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(folded)) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: persona-store-fold: {folded} definitions folded into the unified store"
|
||||
);
|
||||
}
|
||||
Err(e) => eprintln!("buzz-desktop: persona-store-fold: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core fold logic, decoupled from the Tauri `AppHandle` for testing.
|
||||
/// Operates on the raw JSON files — no keyring interaction: instance records
|
||||
/// are passed through byte-identical, and folded definitions carry no keys.
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
//! Phase 1A (unified agent model): boot-time materialization of each
|
||||
//! persona-linked agent record's `runtime` onto the record itself.
|
||||
//!
|
||||
//! Child module of `migration` so it reuses the parent's private JSON-patch
|
||||
//! helpers (`patch_json_records`, `load_persona_runtimes`,
|
||||
//! `canonical_dev_data_dir`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tauri::Manager as _;
|
||||
|
||||
use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
|
||||
use super::{load_persona_runtimes, patch_json_records};
|
||||
|
||||
/// Materialize each persona-linked agent record's `runtime` from its linked
|
||||
/// persona (unified agent model, Phase 1A). After this, spawn resolution reads
|
||||
@@ -21,29 +15,10 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
|
||||
/// Idempotent: records that already carry `runtime` are untouched, as are
|
||||
/// records with no linked persona or a persona without a runtime (both keep
|
||||
/// resolving through the legacy fallback path unchanged).
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses materialize_agent_runtimes_at.
|
||||
pub fn materialize_agent_runtimes(app: &tauri::AppHandle) {
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut dirs = vec![current_dir.clone()];
|
||||
if let Some(canonical) = canonical_dev_data_dir(¤t_dir) {
|
||||
if canonical.exists() && canonical != current_dir {
|
||||
dirs.push(canonical);
|
||||
}
|
||||
}
|
||||
for dir in dirs {
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
materialize_runtimes_in_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn materialize_runtimes_in_file(path: &Path) {
|
||||
pub(crate) fn materialize_runtimes_in_file(path: &Path) -> Result<(), String> {
|
||||
let persona_runtimes = load_persona_runtimes(path);
|
||||
if persona_runtimes.is_empty() {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
patch_json_records(path, |obj| {
|
||||
if obj.contains_key("runtime") {
|
||||
@@ -61,7 +36,7 @@ pub(crate) fn materialize_runtimes_in_file(path: &Path) {
|
||||
serde_json::Value::String(runtime.clone()),
|
||||
);
|
||||
true
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -82,7 +57,7 @@ mod tests {
|
||||
dir.path(),
|
||||
&serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]),
|
||||
);
|
||||
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json")).unwrap();
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["runtime"], "goose");
|
||||
}
|
||||
@@ -104,7 +79,7 @@ mod tests {
|
||||
]),
|
||||
);
|
||||
let agents_path = dir.path().join("agents/managed-agents.json");
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
materialize_runtimes_in_file(&agents_path).unwrap();
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(
|
||||
records[0]["runtime"], "claude",
|
||||
@@ -113,7 +88,7 @@ mod tests {
|
||||
assert_eq!(records[1]["runtime"], "goose");
|
||||
|
||||
let before = std::fs::read_to_string(&agents_path).unwrap();
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
materialize_runtimes_in_file(&agents_path).unwrap();
|
||||
let after = std::fs::read_to_string(&agents_path).unwrap();
|
||||
assert_eq!(before, after, "second run must be a no-op");
|
||||
}
|
||||
@@ -134,7 +109,7 @@ mod tests {
|
||||
);
|
||||
let agents_path = dir.path().join("agents/managed-agents.json");
|
||||
let before = std::fs::read_to_string(&agents_path).unwrap();
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
materialize_runtimes_in_file(&agents_path).unwrap();
|
||||
let after = std::fs::read_to_string(&agents_path).unwrap();
|
||||
assert_eq!(before, after, "no linked runtime → untouched file");
|
||||
}
|
||||
|
||||
@@ -45,21 +45,6 @@ const TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n";
|
||||
/// `personas.json` are cleaned in the same boot, and BEFORE
|
||||
/// `backfill_standalone_agents` so a manufactured definition never snapshots
|
||||
/// a suffix this migration is about to remove.
|
||||
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses strip_baked_team_instructions_in_dir.
|
||||
pub fn strip_baked_team_instructions(app: &tauri::AppHandle) {
|
||||
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
|
||||
return;
|
||||
};
|
||||
match strip_baked_team_instructions_in_dir(&base_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(stripped) => eprintln!(
|
||||
"buzz-desktop: team-suffix-strip: removed the baked team-instructions suffix from \
|
||||
{stripped} record(s)"
|
||||
),
|
||||
Err(e) => eprintln!("buzz-desktop: team-suffix-strip: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
|
||||
///
|
||||
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
|
||||
|
||||
@@ -82,104 +82,8 @@ fn reconcile_legacy_command_names_preserves_custom_commands() {
|
||||
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_legacy_command_names_rewrites_persona_runtime() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"id": "persona-1",
|
||||
"display_name": "Brain",
|
||||
"runtime": "sprout-agent"
|
||||
}]),
|
||||
);
|
||||
|
||||
reconcile_legacy_persona_runtimes_in_file(&dir.path().join("agents/personas.json"));
|
||||
|
||||
let records = read_personas_json(dir.path());
|
||||
assert_eq!(records[0]["runtime"], "buzz-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_legacy_command_names_rewrites_runtime_after_provider_migration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"id": "persona-1",
|
||||
"display_name": "Brain",
|
||||
"provider": "sprout-agent"
|
||||
}]),
|
||||
);
|
||||
let path = dir.path().join("agents/personas.json");
|
||||
|
||||
rename_provider_to_runtime_in_personas(&path);
|
||||
reconcile_legacy_persona_runtimes_in_file(&path);
|
||||
|
||||
let records = read_personas_json(dir.path());
|
||||
assert_eq!(records[0]["runtime"], "buzz-agent");
|
||||
assert!(records[0].get("provider").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_legacy_command_names_preserves_non_legacy_persona_runtime() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{
|
||||
"id": "persona-1",
|
||||
"display_name": "Solo",
|
||||
"runtime": "goose"
|
||||
}]),
|
||||
);
|
||||
let path = dir.path().join("agents/personas.json");
|
||||
let before = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
reconcile_legacy_persona_runtimes_in_file(&path);
|
||||
|
||||
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_legacy_persona_md_runtime_rewrites_frontmatter_only() {
|
||||
let content = concat!(
|
||||
"---\n",
|
||||
"name: brain\n",
|
||||
"display_name: Brain\n",
|
||||
"description: Test persona\n",
|
||||
"runtime: sprout-agent\n",
|
||||
"---\n",
|
||||
"Body mentions runtime: sprout-agent.\n",
|
||||
);
|
||||
|
||||
let updated = rewrite_legacy_persona_md_runtime(content).unwrap();
|
||||
|
||||
assert!(updated.contains("runtime: buzz-agent\n"));
|
||||
assert!(updated.contains("Body mentions runtime: sprout-agent.\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_legacy_team_persona_runtime_files_rewrites_persona_md() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let teams_dir = dir.path().join("agents/teams/com.example.team/agents");
|
||||
std::fs::create_dir_all(&teams_dir).unwrap();
|
||||
let persona_path = teams_dir.join("brain.persona.md");
|
||||
std::fs::write(
|
||||
&persona_path,
|
||||
concat!(
|
||||
"---\n",
|
||||
"name: brain\n",
|
||||
"display_name: Brain\n",
|
||||
"description: Test persona\n",
|
||||
"runtime: sprout-agent\n",
|
||||
"---\n",
|
||||
"Prompt\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
reconcile_legacy_team_persona_runtime_files(&dir.path().join("agents/teams"));
|
||||
|
||||
let updated = std::fs::read_to_string(persona_path).unwrap();
|
||||
assert!(updated.contains("runtime: buzz-agent\n"));
|
||||
}
|
||||
// Tests for `reconcile_legacy_persona_runtimes_in_file`,
|
||||
// `rewrite_legacy_persona_md_runtime`, and
|
||||
// `reconcile_legacy_team_persona_runtime_files` were removed alongside those
|
||||
// deleted functions. The scoped pipeline's `_in_dir` variants carry the
|
||||
// equivalent coverage.
|
||||
|
||||
@@ -5,15 +5,16 @@ pub(crate) use materialize::materialize_runtimes_in_file;
|
||||
pub(crate) use team_suffix::strip_baked_team_instructions_in_dir;
|
||||
|
||||
/// Reconcile `mcp_command` values in a scoped `definitions_dir`.
|
||||
pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) {
|
||||
pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
let path = definitions_dir.join("managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_mcp_commands_in_file(&path);
|
||||
reconcile_mcp_commands_in_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile Databricks V1 → V2 provider entries in a scoped `definitions_dir`.
|
||||
pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) {
|
||||
pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
use crate::managed_agents::baked_build_env;
|
||||
let rewrite_v1_provider = baked_build_env()
|
||||
.get("BUZZ_AGENT_PROVIDER")
|
||||
@@ -21,30 +22,34 @@ pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path
|
||||
.unwrap_or(false);
|
||||
let path = definitions_dir.join("managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
|
||||
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh legacy built-in agent avatars in a scoped `definitions_dir`.
|
||||
pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) {
|
||||
pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
let path = definitions_dir.join("managed-agents.json");
|
||||
if path.exists() {
|
||||
refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso());
|
||||
refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile legacy command names in a scoped `definitions_dir`.
|
||||
pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) {
|
||||
pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
let path = definitions_dir.join("managed-agents.json");
|
||||
if path.exists() {
|
||||
reconcile_legacy_command_names_in_file(&path);
|
||||
reconcile_legacy_command_names_in_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Materialize per-record runtimes in a scoped `definitions_dir`.
|
||||
pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) {
|
||||
pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) -> Result<(), String> {
|
||||
let path = definitions_dir.join("managed-agents.json");
|
||||
if path.exists() {
|
||||
materialize_runtimes_in_file(&path);
|
||||
materialize_runtimes_in_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user