mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(agents): preserve managed overlay lifecycle
Route disk-backed overlay agents through the established preflight, provider, profile, persistence, and runtime transition paths. Materialize fresh-device local records before start so stop, delete, and shutdown can manage them. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -317,99 +317,6 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn start_local_agent_with_preflight(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
allow_fresh_create_start: bool,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?
|
||||
};
|
||||
|
||||
if record_snapshot.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is not a local agent"));
|
||||
}
|
||||
|
||||
// Preflight against the same resolution spawn uses — `resolve_effective_config`
|
||||
// (definition → global fallback). A linked instance's own `provider`/`model`/
|
||||
// `relay_mesh` bytes never contribute: this reads the CURRENT definition
|
||||
// directly, so a definition edit that flips `provider` to/from relay-mesh
|
||||
// between saves is reflected here without needing a prospective re-snapshot;
|
||||
// for a global-inherited blank definition, it also folds in the global
|
||||
// default, which record-byte sniffing could never see.
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
let mesh_model_id =
|
||||
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
|
||||
&record_snapshot,
|
||||
&personas,
|
||||
&global,
|
||||
);
|
||||
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is no longer a local agent"));
|
||||
}
|
||||
// Re-snapshot the persona onto the record at every spawn so the agent always
|
||||
// starts with the current persona config (system_prompt, model, provider,
|
||||
// runtime). This clears the "out of date" drift badge without requiring a
|
||||
// delete+recreate. See `apply_persona_snapshot` for the precedence and
|
||||
// env-override self-heal rules.
|
||||
// Load personas once: used for snapshot application below and summary build
|
||||
// at the end — avoids a second disk read for the same file in the same call.
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
if let Some(persona_id) = record.persona_id.clone() {
|
||||
match personas.iter().find(|p| p.id == persona_id) {
|
||||
Some(persona) => {
|
||||
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
|
||||
record.updated_at = crate::util::now_iso();
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?;
|
||||
save_managed_agents(app, &records)?;
|
||||
if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) {
|
||||
retain_managed_agent_pending(app, state, saved_record);
|
||||
}
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
build_managed_agent_summary(
|
||||
app,
|
||||
record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
|
||||
/// spawn_blocking, and persists the result (backend_agent_id or last_error).
|
||||
///
|
||||
@@ -1039,27 +946,11 @@ pub async fn start_managed_agent(
|
||||
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
|
||||
// Read outside the records lock to keep lock ordering simple.
|
||||
let owner_hex = workspace_owner_hex(&state)?;
|
||||
let local_records = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_managed_agents(&app)?
|
||||
};
|
||||
if state
|
||||
.private_managed_agent_overlay
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.contains(&pubkey)
|
||||
{
|
||||
return crate::managed_agents::private_config_overlay::start_relay_only_agent(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
&owner_hex,
|
||||
&local_records,
|
||||
);
|
||||
}
|
||||
// A fresh-device relay record needs a durable lifecycle anchor so stop,
|
||||
// delete, runtime polling, and shutdown can find it.
|
||||
crate::managed_agents::private_config_overlay::materialize_relay_only_agent(
|
||||
&app, &state, &pubkey,
|
||||
)?;
|
||||
enum StartTarget {
|
||||
Local,
|
||||
Provider {
|
||||
@@ -1091,14 +982,18 @@ pub async fn start_managed_agent(
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
let disk_record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
let record = crate::managed_agents::private_config_overlay::resolved_local_record(
|
||||
&state,
|
||||
disk_record,
|
||||
)?;
|
||||
|
||||
// Resolve the effective harness for the avatar-fallback derivation in
|
||||
// profile reconcile (the create-time snapshot may be empty or stale for
|
||||
// a persona-inherited harness).
|
||||
let reconcile_personas = load_personas(&app).unwrap_or_default();
|
||||
let reconcile_effective_command =
|
||||
crate::managed_agents::record_agent_command(record, &reconcile_personas);
|
||||
crate::managed_agents::record_agent_command(&record, &reconcile_personas);
|
||||
|
||||
let reconcile = ProfileReconcileData {
|
||||
private_key_nsec: record.private_key_nsec.clone(),
|
||||
@@ -1117,7 +1012,7 @@ pub async fn start_managed_agent(
|
||||
StartTarget::Provider {
|
||||
backend: record.backend.clone(),
|
||||
cached_binary_path: record.provider_binary_path.clone(),
|
||||
agent_json: build_deploy_payload(&app, &state, record)?,
|
||||
agent_json: build_deploy_payload(&app, &state, &record)?,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1158,10 +1053,13 @@ pub async fn start_managed_agent(
|
||||
.iter()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let record = crate::managed_agents::private_config_overlay::resolved_local_record(
|
||||
&state, record,
|
||||
)?;
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(
|
||||
&app,
|
||||
record,
|
||||
&record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
||||
@@ -1209,6 +1107,10 @@ pub async fn stop_managed_agent(
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _transition_guard = state
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -1228,28 +1130,34 @@ pub async fn stop_managed_agent(
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
let resolved_record = {
|
||||
let disk_record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
let mut resolved =
|
||||
crate::managed_agents::private_config_overlay::resolved_local_record(
|
||||
&state,
|
||||
disk_record,
|
||||
)?;
|
||||
// Remote agents are stopped via !shutdown @mention from the frontend,
|
||||
// not via this backend command. Reject the call.
|
||||
if record.backend != BackendKind::Local {
|
||||
// not via this backend command. Reject using the relay-resolved backend.
|
||||
if resolved.backend != BackendKind::Local {
|
||||
return Err(
|
||||
"remote agents are stopped via !shutdown message, not this command".to_string(),
|
||||
);
|
||||
}
|
||||
// Pair-scoped: stops only the active workspace's pair; delete and
|
||||
// the config-restart flows still drain every pair.
|
||||
stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?;
|
||||
}
|
||||
stop_managed_agent_workspace_pair(&app, &mut resolved, &mut runtimes)?;
|
||||
crate::managed_agents::private_config_overlay::copy_lifecycle_state(
|
||||
disk_record,
|
||||
&resolved,
|
||||
);
|
||||
resolved
|
||||
};
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
build_managed_agent_summary(
|
||||
&app,
|
||||
record,
|
||||
&resolved_record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
|
||||
@@ -1270,6 +1178,10 @@ pub async fn delete_managed_agent(
|
||||
use tauri::Manager;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let _transition_guard = state
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
{
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -1298,7 +1210,19 @@ pub async fn delete_managed_agent(
|
||||
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
|
||||
// remote deployment. The frontend sends force_remote_delete: true only after
|
||||
// the user confirms the orphan warning.
|
||||
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
|
||||
let resolved_record =
|
||||
if let Some(record) = records.iter().find(|record| record.pubkey == pubkey) {
|
||||
Some(
|
||||
state
|
||||
.private_managed_agent_overlay
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.resolve_local_record(record),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(record) = resolved_record.as_ref() {
|
||||
if record.backend != BackendKind::Local
|
||||
&& record.backend_agent_id.is_some()
|
||||
&& !force_remote_delete.unwrap_or(false)
|
||||
@@ -1310,8 +1234,8 @@ pub async fn delete_managed_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
|
||||
stop_managed_agent_process(&app, record, &mut runtimes)?;
|
||||
if let Some(mut record) = resolved_record {
|
||||
stop_managed_agent_process(&app, &mut record, &mut runtimes)?;
|
||||
}
|
||||
state.clear_agent_session_caches(&pubkey);
|
||||
let initial_len = records.len();
|
||||
@@ -1320,6 +1244,11 @@ pub async fn delete_managed_agent(
|
||||
return Err(format!("agent {pubkey} not found"));
|
||||
}
|
||||
save_managed_agents(&app, &records)?;
|
||||
state
|
||||
.private_managed_agent_overlay
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.remove(&pubkey);
|
||||
// Remove the agent's nsec from the keyring after the record is gone.
|
||||
crate::managed_agents::delete_agent_key(&pubkey);
|
||||
// Tombstone-after-validation: only reached past the deployed-remote
|
||||
@@ -1344,6 +1273,10 @@ pub async fn delete_managed_agent(
|
||||
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
||||
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
|
||||
// No backend Tauri command needed. Presence IS the status.
|
||||
#[path = "agents_lifecycle.rs"]
|
||||
mod lifecycle;
|
||||
use lifecycle::start_local_agent_with_preflight;
|
||||
|
||||
#[path = "agents_deploy.rs"]
|
||||
mod deploy;
|
||||
pub(super) mod provider_access;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn start_local_agent_with_preflight(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
allow_fresh_create_start: bool,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
crate::managed_agents::private_config_overlay::resolved_local_record(state, record)?
|
||||
};
|
||||
|
||||
if record_snapshot.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is not a local agent"));
|
||||
}
|
||||
|
||||
// Preflight against the same resolution spawn uses — `resolve_effective_config`
|
||||
// (definition → global fallback). A linked instance's own `provider`/`model`/
|
||||
// `relay_mesh` bytes never contribute: this reads the CURRENT definition
|
||||
// directly, so a definition edit that flips `provider` to/from relay-mesh
|
||||
// between saves is reflected here without needing a prospective re-snapshot;
|
||||
// for a global-inherited blank definition, it also folds in the global
|
||||
// default, which record-byte sniffing could never see.
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
let mesh_model_id =
|
||||
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
|
||||
&record_snapshot,
|
||||
&personas,
|
||||
&global,
|
||||
);
|
||||
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
|
||||
|
||||
let _transition_guard = state
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if state
|
||||
.shutdown_started
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return Err("desktop shutdown has started".into());
|
||||
}
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let disk_record = find_managed_agent_mut(&mut records, pubkey)?;
|
||||
let mut resolved_record =
|
||||
crate::managed_agents::private_config_overlay::resolved_local_record(state, disk_record)?;
|
||||
if resolved_record.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is no longer a local agent"));
|
||||
}
|
||||
// Re-snapshot the persona onto the resolved spawn record at every start so
|
||||
// local persona state retains its established precedence without writing
|
||||
// relay-owned configuration into the device-local migration record.
|
||||
// Load personas once: used for snapshot application below and summary build
|
||||
// at the end — avoids a second disk read for the same file in the same call.
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
if let Some(persona_id) = resolved_record.persona_id.clone() {
|
||||
match personas.iter().find(|p| p.id == persona_id) {
|
||||
Some(persona) => {
|
||||
crate::managed_agents::persona_events::apply_persona_snapshot(
|
||||
&mut resolved_record,
|
||||
persona,
|
||||
);
|
||||
resolved_record.updated_at = crate::util::now_iso();
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
start_managed_agent_process(app, &mut resolved_record, &mut runtimes, Some(owner_hex))?;
|
||||
// Persist operational lifecycle metadata only. Relay-owned configuration
|
||||
// remains an in-memory overlay and is never copied over device-local fields.
|
||||
crate::managed_agents::private_config_overlay::copy_lifecycle_state(
|
||||
disk_record,
|
||||
&resolved_record,
|
||||
);
|
||||
save_managed_agents(app, &records)?;
|
||||
// Retain the relay-resolved configuration. The projection equality guard
|
||||
// makes a runtime-only start a no-op, while avoiding resurrection of stale
|
||||
// disk config when this device is following a newer relay snapshot.
|
||||
retain_managed_agent_pending(app, state, &resolved_record);
|
||||
build_managed_agent_summary(
|
||||
app,
|
||||
&resolved_record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,9 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use buzz_core_pkg::private_managed_agent::Payload;
|
||||
|
||||
use super::{
|
||||
build_managed_agent_summary, load_personas, start_managed_agent_process,
|
||||
validate_respond_to_allowlist, validate_user_env_keys, BackendKind, ManagedAgentRecord,
|
||||
ManagedAgentSummary, RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND,
|
||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -210,22 +209,26 @@ impl PrivateConfigOverlay {
|
||||
self.0.remove(pubkey);
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, pubkey: &str) -> bool {
|
||||
self.0.contains_key(pubkey)
|
||||
pub(crate) fn resolve_local_record(&self, record: &ManagedAgentRecord) -> ManagedAgentRecord {
|
||||
let mut resolved = record.clone();
|
||||
if let Some(patch) = self.0.get(&record.pubkey) {
|
||||
patch.apply(&mut resolved);
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_record(
|
||||
pub(crate) fn materialize_relay_only_record(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
local: &[ManagedAgentRecord],
|
||||
) -> Option<ManagedAgentRecord> {
|
||||
let patch = self.0.get(pubkey)?;
|
||||
let mut record = local
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| patch.fresh_record());
|
||||
patch.apply(&mut record);
|
||||
if local.iter().any(|record| record.pubkey == pubkey) {
|
||||
return None;
|
||||
}
|
||||
let mut record = self.0.get(pubkey)?.fresh_record();
|
||||
// Persona definitions are device-local. A fresh device can still run the
|
||||
// complete relay snapshot, but must not bind it to an absent local persona.
|
||||
record.persona_id = None;
|
||||
Some(record)
|
||||
}
|
||||
|
||||
@@ -248,40 +251,66 @@ impl PrivateConfigOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_relay_only_agent(
|
||||
pub(crate) fn resolved_local_record(
|
||||
state: &crate::app_state::AppState,
|
||||
record: &ManagedAgentRecord,
|
||||
) -> Result<ManagedAgentRecord, String> {
|
||||
state
|
||||
.private_managed_agent_overlay
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())
|
||||
.map(|overlay| overlay.resolve_local_record(record))
|
||||
}
|
||||
|
||||
pub(crate) fn copy_lifecycle_state(
|
||||
destination: &mut ManagedAgentRecord,
|
||||
source: &ManagedAgentRecord,
|
||||
) {
|
||||
destination.runtime_pid = source.runtime_pid;
|
||||
destination
|
||||
.last_started_at
|
||||
.clone_from(&source.last_started_at);
|
||||
destination
|
||||
.last_stopped_at
|
||||
.clone_from(&source.last_stopped_at);
|
||||
destination.last_exit_code = source.last_exit_code;
|
||||
destination.last_error.clone_from(&source.last_error);
|
||||
destination.last_error_code = source.last_error_code;
|
||||
}
|
||||
|
||||
pub(crate) fn materialize_relay_only_agent(
|
||||
app: &tauri::AppHandle,
|
||||
state: &crate::app_state::AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
local_records: &[ManagedAgentRecord],
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let mut record = state
|
||||
) -> Result<(), String> {
|
||||
let _transition = state
|
||||
.managed_agent_runtime_transition
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if state
|
||||
.shutdown_started
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return Err("desktop shutdown has started".into());
|
||||
}
|
||||
let _store = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = super::load_managed_agents(app)?;
|
||||
let relay_only = state
|
||||
.private_managed_agent_overlay
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.resolved_record(pubkey, local_records)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
if local_records.iter().all(|local| local.pubkey != pubkey) {
|
||||
record.persona_id = None;
|
||||
.materialize_relay_only_record(pubkey, &records);
|
||||
if let Some(record) = relay_only {
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err("relay-only provider agents cannot be started on this device".into());
|
||||
}
|
||||
records.push(record);
|
||||
super::save_managed_agents(app, &records)?;
|
||||
}
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err("relay-only provider agents cannot be started on this device".into());
|
||||
}
|
||||
let personas = load_personas(app).unwrap_or_default();
|
||||
super::try_record_agent_command(&record, &personas)
|
||||
.map_err(|error| super::user_facing_harness_error(&error))?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
start_managed_agent_process(app, &mut record, &mut runtimes, Some(owner_hex))?;
|
||||
build_managed_agent_summary(
|
||||
app,
|
||||
&record,
|
||||
&runtimes,
|
||||
&personas,
|
||||
&super::load_global_agent_config(app).unwrap_or_default(),
|
||||
)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -352,6 +381,33 @@ mod tests {
|
||||
assert_eq!(local, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_only_relay_only_record_and_preserves_disk_overlay() {
|
||||
let mut overlay = PrivateConfigOverlay::default();
|
||||
overlay.insert(payload("aa", "relay local")).unwrap();
|
||||
overlay.insert(payload("bb", "relay only")).unwrap();
|
||||
let mut local = overlay.0["aa"].fresh_record();
|
||||
local.name = "disk".into();
|
||||
local.private_key_nsec = "device-local-key".into();
|
||||
|
||||
let resolved = overlay.resolve_local_record(&local);
|
||||
assert_eq!(resolved.name, "relay local");
|
||||
assert_eq!(resolved.private_key_nsec, "nsec-test");
|
||||
assert_eq!(local.name, "disk");
|
||||
assert_eq!(local.private_key_nsec, "device-local-key");
|
||||
assert!(overlay
|
||||
.materialize_relay_only_record("aa", std::slice::from_ref(&local))
|
||||
.is_none());
|
||||
|
||||
let relay_only = overlay
|
||||
.materialize_relay_only_record("bb", &[local])
|
||||
.unwrap();
|
||||
assert_eq!(relay_only.name, "relay only");
|
||||
assert_eq!(relay_only.private_key_nsec, "nsec-test");
|
||||
assert_eq!(relay_only.backend, BackendKind::Local);
|
||||
assert!(relay_only.persona_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_patch_preserves_cached_value_and_clear_drops_scope() {
|
||||
let mut overlay = PrivateConfigOverlay::default();
|
||||
|
||||
Reference in New Issue
Block a user