fix(desktop): avoid updater panic in dev and enforce tauri rustfmt (#167)

This commit is contained in:
Wes
2026-03-24 14:30:10 -07:00
committed by GitHub
parent 52bd65a759
commit 491a5160e5
19 changed files with 216 additions and 138 deletions
+2
View File
@@ -20,6 +20,8 @@ jobs:
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Format check
run: just fmt-check
- name: Desktop Tauri format check
run: just desktop-tauri-fmt-check
- name: Clippy
run: just clippy
+2 -2
View File
@@ -30,7 +30,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/persona_card.rs", 760], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests)
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 775],
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
@@ -40,7 +40,7 @@ const overrides = new Map([
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement
["src-tauri/src/commands/agents.rs", 845], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
+5 -6
View File
@@ -55,7 +55,6 @@ pub fn build_app_state() -> AppState {
session_token: Mutex::new(None),
managed_agents_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
}
}
@@ -80,8 +79,7 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("create app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
// Try to load an existing key.
@@ -103,7 +101,9 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
.map(|d| d.as_secs())
.unwrap_or(0);
let bad_name = format!("identity.key.bad.{ts}");
eprintln!("sprout-desktop: corrupt identity.key ({error}), quarantining to {bad_name}");
eprintln!(
"sprout-desktop: corrupt identity.key ({error}), quarantining to {bad_name}"
);
let bad_path = data_dir.join(bad_name);
if std::fs::rename(&key_path, &bad_path).is_err() {
let _ = std::fs::remove_file(&key_path);
@@ -125,8 +125,7 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
}
fn load_key_file(path: &std::path::Path) -> Result<Keys, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?;
let content = std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("empty identity.key".to_string());
@@ -6,7 +6,7 @@ use crate::{
managed_agents::{
command_availability, discover_local_acp_providers, AcpProviderInfo,
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
DEFAULT_ADMIN_COMMAND, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
DEFAULT_ACP_COMMAND, DEFAULT_ADMIN_COMMAND, DEFAULT_MCP_COMMAND,
},
relay::{build_authed_request, send_json_request},
};
+65 -23
View File
@@ -11,8 +11,8 @@ use crate::{
stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config,
BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary,
MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_AGENT_ARG,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{relay_ws_url, sync_managed_agent_profile},
@@ -61,18 +61,17 @@ async fn deploy_to_provider(
.filter(|p| p.exists())
.map(|p| p.canonicalize().unwrap_or(p))
.filter(|canonical| {
discover_provider_candidates()
.iter()
.any(|(id, cp)| id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical))
discover_provider_candidates().iter().any(|(id, cp)| {
id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical)
})
})
.map_or_else(|| resolve_provider_binary(provider_id), Ok)?;
let config_clone = config.clone();
let deploy_result = tokio::task::spawn_blocking(move || {
provider_deploy(&bin_path, &agent_json, &config_clone)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
let deploy_result =
tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone))
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
// Persist result under lock.
let _store_guard = state
@@ -152,7 +151,16 @@ pub async fn create_managed_agent(
// ── Phase 1: generate keys and collect mint parameters (sync lock) ────────
// We do NOT mint here — minting is async and must happen outside the lock.
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, token_scopes, token_name, mint_token, input) = {
let (
agent_keys,
private_key_nsec,
pubkey,
resolved_relay_url,
token_scopes,
token_name,
mint_token,
input,
) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -215,7 +223,16 @@ pub async fn create_managed_agent(
.unwrap_or_else(relay_ws_url);
let mint_token = input.mint_token;
(keys, private_key_nsec, pubkey, resolved_relay_url, token_scopes, token_name, mint_token, input)
(
keys,
private_key_nsec,
pubkey,
resolved_relay_url,
token_scopes,
token_name,
mint_token,
input,
)
};
// ── Pre-Phase 2: validate provider config BEFORE any side effects ────────
@@ -231,8 +248,12 @@ pub async fn create_managed_agent(
}
// Enforce minimum scopes for remote agents. The harness needs users:read
// to query its owner (for !shutdown). Without it, the agent is unstoppable.
const REQUIRED_PROVIDER_SCOPES: &[&str] =
&["messages:read", "messages:write", "channels:read", "users:read"];
const REQUIRED_PROVIDER_SCOPES: &[&str] = &[
"messages:read",
"messages:write",
"channels:read",
"users:read",
];
for required in REQUIRED_PROVIDER_SCOPES {
if !token_scopes.iter().any(|s| s == required) {
return Err(format!(
@@ -295,7 +316,9 @@ pub async fn create_managed_agent(
// Cache the discovered binary path for deploy_to_provider.
let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend {
// Use resolve_provider_binary (discovered candidates only).
resolve_provider_binary(id).ok().map(|p| p.display().to_string())
resolve_provider_binary(id)
.ok()
.map(|p| p.display().to_string())
} else {
None
};
@@ -429,9 +452,14 @@ pub async fn create_managed_agent(
// Read the saved record to build the deploy payload (record has the
// canonical field values after Phase 3 normalization).
let agent_json = {
let _g = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?;
let _g = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let rec = records.iter().find(|r| r.pubkey == pubkey)
let rec = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
build_deploy_payload(rec)
};
@@ -448,10 +476,18 @@ pub async fn create_managed_agent(
// Rebuild summary if provider deploy may have updated backend_agent_id.
let final_agent = if input.backend != BackendKind::Local && spawn_error.is_none() {
let _store_guard = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?;
let record = records.iter().find(|r| r.pubkey == pubkey)
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
build_managed_agent_summary(&app, record, &runtimes)?
} else {
@@ -690,7 +726,8 @@ pub async fn mint_managed_agent_token(
// Re-minting: do NOT send owner_pubkey. Ownership was established during
// the first mint (create flow). Sending it again would be rejected by the
// relay if the owner is already set to a different pubkey.
let minted_token = mint_token_via_api(&state, &agent_keys, &relay_url, &token_name, &scopes, None).await?;
let minted_token =
mint_token_via_api(&state, &agent_keys, &relay_url, &token_name, &scopes, None).await?;
// ── Phase 3: persist new token to agent record (sync lock) ───────────────
let (agent, api_token) = {
@@ -724,7 +761,10 @@ pub async fn mint_managed_agent_token(
(agent, minted_token)
};
Ok(MintManagedAgentTokenResponse { agent, token: api_token })
Ok(MintManagedAgentTokenResponse {
agent,
token: api_token,
})
}
#[tauri::command]
@@ -739,7 +779,9 @@ pub fn get_managed_agent_log(
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
let record = records.iter().find(|record| record.pubkey == pubkey)
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
if record.backend != BackendKind::Local {
return Err("logs are not available for remote agents".to_string());
+1 -4
View File
@@ -22,10 +22,7 @@ pub async fn open_dm(
}
#[tauri::command]
pub async fn hide_dm(
channel_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
pub async fn hide_dm(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
let path = format!("/api/dms/{channel_id}/hide");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
send_empty_request(request).await
+8 -9
View File
@@ -52,12 +52,13 @@ pub async fn get_forum_posts(
state: State<'_, AppState>,
) -> Result<ForumPostsResponse, String> {
let path = format!("/api/channels/{channel_id}/messages");
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?
.query(&GetForumPostsQuery {
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query(
&GetForumPostsQuery {
limit,
before,
with_threads: true,
});
},
);
send_json_request(request).await
}
@@ -108,8 +109,8 @@ async fn resolve_thread_ref(
parent_event_id: &str,
state: &AppState,
) -> Result<events::ThreadRef, String> {
let parent_eid = EventId::from_hex(parent_event_id)
.map_err(|e| format!("invalid parent event ID: {e}"))?;
let parent_eid =
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
let path = format!("/api/events/{parent_event_id}");
let request = build_authed_request(&state.http_client, Method::GET, &path, state)?;
@@ -243,8 +244,7 @@ pub async fn add_reaction(
emoji: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let target_eid = EventId::from_hex(&event_id)
.map_err(|e| format!("invalid event ID: {e}"))?;
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let builder = events::build_reaction(target_eid, emoji.trim())?;
submit_event(builder, &state).await?;
Ok(())
@@ -296,8 +296,7 @@ pub async fn remove_reaction(
#[tauri::command]
pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> {
let target_eid = EventId::from_hex(&event_id)
.map_err(|e| format!("invalid event ID: {e}"))?;
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let builder = events::build_delete_compat(target_eid)?;
submit_event(builder, &state).await?;
Ok(())
+4 -1
View File
@@ -28,7 +28,10 @@ fn trim_optional(value: Option<String>) -> Option<String> {
}
#[tauri::command]
pub fn list_personas(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<PersonaRecord>, String> {
pub fn list_personas(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<PersonaRecord>, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
+7 -10
View File
@@ -94,10 +94,11 @@ pub async fn get_users_batch(
state: State<'_, AppState>,
) -> Result<UsersBatchResponse, String> {
let request =
build_authed_request(&state.http_client, Method::POST, "/api/users/batch", &state)?
.json(&GetUsersBatchBody {
build_authed_request(&state.http_client, Method::POST, "/api/users/batch", &state)?.json(
&GetUsersBatchBody {
pubkeys: pubkeys.as_slice(),
});
},
);
send_json_request(request).await
}
@@ -109,13 +110,9 @@ pub async fn search_users(
) -> Result<SearchUsersResponse, String> {
let limit = limit.unwrap_or(8);
let limit_param = limit.to_string();
let request = build_authed_request(
&state.http_client,
Method::GET,
"/api/users/search",
&state,
)?
.query(&[("q", query.as_str()), ("limit", limit_param.as_str())]);
let request =
build_authed_request(&state.http_client, Method::GET, "/api/users/search", &state)?
.query(&[("q", query.as_str()), ("limit", limit_param.as_str())]);
send_json_request(request).await
}
+1 -5
View File
@@ -93,11 +93,7 @@ pub fn update_team(
}
#[tauri::command]
pub fn delete_team(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
+11 -4
View File
@@ -158,7 +158,7 @@ fn error_response(status: u16, msg: &str) -> http::Response<Vec<u8>> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
let builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(
@@ -168,8 +168,16 @@ pub fn run() {
)
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_process::init());
// The updater config is only generated for signed release builds.
let builder = if cfg!(debug_assertions) {
builder
} else {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
let app = builder
.register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
tauri::async_runtime::spawn(async move {
@@ -256,7 +264,6 @@ pub fn run() {
update_managed_agent,
discover_backend_providers,
probe_backend_provider,
list_personas,
create_persona,
update_persona,
@@ -21,8 +21,10 @@ pub fn invoke_provider(
request: &serde_json::Value,
timeout: Duration,
) -> Result<serde_json::Value, String> {
let request_bytes =
format!("{}\n", serde_json::to_string(request).map_err(|e| e.to_string())?);
let request_bytes = format!(
"{}\n",
serde_json::to_string(request).map_err(|e| e.to_string())?
);
let mut child = std::process::Command::new(binary)
.stdin(std::process::Stdio::piped())
@@ -241,7 +243,8 @@ fn split_config_key(key: &str) -> Vec<String> {
} else if ch.is_uppercase() {
// Start a new word on: (a) transition from lowercase to uppercase, or
// (b) uppercase followed by lowercase (end of acronym run, e.g. "APIKey" → "API" + "Key").
let prev_lower = !current.is_empty() && current.chars().last().map_or(false, |c| c.is_lowercase());
let prev_lower =
!current.is_empty() && current.chars().last().map_or(false, |c| c.is_lowercase());
let acronym_end = !current.is_empty()
&& current.chars().last().map_or(false, |c| c.is_uppercase())
&& chars.get(i + 1).map_or(false, |c| c.is_lowercase());
@@ -92,7 +92,10 @@ fn normalize_command_identity(command: &str) -> String {
let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string();
if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') {
return lower.strip_suffix(&format!(".{suffix}")).unwrap_or(&lower).to_string();
return lower
.strip_suffix(&format!(".{suffix}"))
.unwrap_or(&lower)
.to_string();
}
if !std::env::consts::EXE_SUFFIX.is_empty() {
@@ -309,7 +312,11 @@ pub async fn mint_token_via_api(
// Build NIP-98 auth header signed by the AGENT's keys (not the desktop user's).
let payload_hash = format!("{:x}", Sha256::digest(&body_bytes));
let forwarded_proto = if url.starts_with("http://") { "http" } else { "https" };
let forwarded_proto = if url.starts_with("http://") {
"http"
} else {
"https"
};
let tags = vec![
Tag::parse(vec!["u", &url]).map_err(|e| format!("url tag failed: {e}"))?,
Tag::parse(vec!["method", "POST"]).map_err(|e| format!("method tag failed: {e}"))?,
@@ -343,8 +350,7 @@ mod tests {
#[test]
fn resolves_known_avatar_for_bare_command() {
let avatar_url =
managed_agent_avatar_url("goose").expect("goose avatar should resolve");
let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve");
assert_eq!(avatar_url, GOOSE_AVATAR_URL);
}
@@ -43,7 +43,9 @@ const MAX_ZIP_DECOMPRESSED: usize = 100 * 1024 * 1024;
pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, String> {
let decoder = Decoder::new(Cursor::new(png_bytes));
let reader = decoder.read_info().map_err(|e| format!("Invalid PNG: {e}"))?;
let reader = decoder
.read_info()
.map_err(|e| format!("Invalid PNG: {e}"))?;
let info = reader.info();
let mut sprout_text: Option<&str> = None;
@@ -67,7 +69,10 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
// For PNG persona cards, the avatar is the image itself — override
// whatever avatarUrl the embedded JSON metadata might contain.
let avatar_data_url = Some(format!("data:image/png;base64,{}", STANDARD.encode(png_bytes)));
let avatar_data_url = Some(format!(
"data:image/png;base64,{}",
STANDARD.encode(png_bytes)
));
Ok(ParsedPersonaPreview {
display_name: fields.display_name,
@@ -195,8 +200,7 @@ fn parse_chara_payload(b64: &str) -> Result<SproutPersonaFields, String> {
// ---------------------------------------------------------------------------
pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, String> {
let v: Value =
serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?;
let v: Value = serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?;
let fields = extract_sprout_fields(&v)?;
Ok(ParsedPersonaPreview {
@@ -218,14 +222,8 @@ pub fn encode_persona_json(
) -> Result<Vec<u8>, String> {
let mut map = serde_json::Map::new();
map.insert("version".to_string(), serde_json::json!(1));
map.insert(
"displayName".to_string(),
serde_json::json!(display_name),
);
map.insert(
"systemPrompt".to_string(),
serde_json::json!(system_prompt),
);
map.insert("displayName".to_string(), serde_json::json!(display_name));
map.insert("systemPrompt".to_string(), serde_json::json!(system_prompt));
if let Some(url) = avatar_url {
map.insert("avatarUrl".to_string(), serde_json::json!(url));
}
@@ -300,7 +298,9 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
let mut data = Vec::new();
loop {
let mut chunk = [0u8; 8192];
let n = entry.read(&mut chunk).map_err(|e| format!("Read error: {e}"))?;
let n = entry
.read(&mut chunk)
.map_err(|e| format!("Read error: {e}"))?;
if n == 0 {
break;
}
@@ -356,7 +356,8 @@ mod tests {
let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1);
enc.set_color(ColorType::Rgba);
enc.set_depth(BitDepth::Eight);
enc.add_text_chunk(keyword.to_string(), text.to_string()).unwrap();
enc.add_text_chunk(keyword.to_string(), text.to_string())
.unwrap();
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0, 0, 0, 255]).unwrap();
}
@@ -406,7 +407,10 @@ mod tests {
let result = parse_png_persona(&png).unwrap();
assert_eq!(result.display_name, "George Costanza");
assert_eq!(result.system_prompt, "You are George.");
assert!(result.avatar_data_url.unwrap().starts_with("data:image/png;base64,"));
assert!(result
.avatar_data_url
.unwrap()
.starts_with("data:image/png;base64,"));
}
#[test]
@@ -483,7 +487,8 @@ mod tests {
let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1);
enc.set_color(ColorType::Rgba);
enc.set_depth(BitDepth::Eight);
enc.add_text_chunk("sprout_persona".to_string(), sprout_b64).unwrap();
enc.add_text_chunk("sprout_persona".to_string(), sprout_b64)
.unwrap();
enc.add_text_chunk("chara".to_string(), chara_b64).unwrap();
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0, 0, 0, 255]).unwrap();
@@ -532,7 +537,9 @@ mod tests {
#[test]
fn parse_zip_exceeds_entry_limit() {
let png = make_test_persona_png("X", "Y");
let entries: Vec<(String, &[u8])> = (0..51).map(|i| (format!("{i}.png"), png.as_slice())).collect();
let entries: Vec<(String, &[u8])> = (0..51)
.map(|i| (format!("{i}.png"), png.as_slice()))
.collect();
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect();
let zip = make_test_zip(&refs);
let err = parse_zip_personas(&zip).unwrap_err();
@@ -553,8 +560,10 @@ mod tests {
#[test]
fn parse_png_duplicate_chunks() {
// Two sprout_persona chunks — should use the first and ignore the second.
let payload1 = serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"});
let payload2 = serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"});
let payload1 =
serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"});
let payload2 =
serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"});
let b64_1 = STANDARD.encode(payload1.to_string().as_bytes());
let b64_2 = STANDARD.encode(payload2.to_string().as_bytes());
@@ -563,8 +572,10 @@ mod tests {
let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1);
enc.set_color(ColorType::Rgba);
enc.set_depth(BitDepth::Eight);
enc.add_text_chunk("sprout_persona".to_string(), b64_1).unwrap();
enc.add_text_chunk("sprout_persona".to_string(), b64_2).unwrap();
enc.add_text_chunk("sprout_persona".to_string(), b64_1)
.unwrap();
enc.add_text_chunk("sprout_persona".to_string(), b64_2)
.unwrap();
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0, 0, 0, 255]).unwrap();
}
@@ -597,9 +608,14 @@ mod tests {
#[test]
fn parse_json_round_trip() {
let bytes =
encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"), None, None)
.unwrap();
let bytes = encode_persona_json(
"Ada Lovelace",
"You are Ada.",
Some("https://example.com/ada.png"),
None,
None,
)
.unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Ada Lovelace");
assert_eq!(result.system_prompt, "You are Ada.");
@@ -622,7 +638,8 @@ mod tests {
#[test]
fn parse_json_round_trip_data_uri_avatar() {
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap();
let bytes =
encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Carol");
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
@@ -698,8 +715,7 @@ mod tests {
"displayName": "X",
"systemPrompt": ""
});
let err =
parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err();
let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err();
assert!(err.contains("systemPrompt is empty"));
}
@@ -68,7 +68,9 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
}
fn built_in_order(id: &str) -> Option<usize> {
BUILT_IN_PERSONAS.iter().position(|persona| persona.id == id)
BUILT_IN_PERSONAS
.iter()
.position(|persona| persona.id == id)
}
fn sort_personas(records: &mut [PersonaRecord]) {
@@ -78,10 +80,12 @@ fn sort_personas(records: &mut [PersonaRecord]) {
left_builtin
.cmp(&right_builtin)
.then_with(|| match (built_in_order(&left.id), built_in_order(&right.id)) {
(Some(left_order), Some(right_order)) => left_order.cmp(&right_order),
_ => std::cmp::Ordering::Equal,
})
.then_with(
|| match (built_in_order(&left.id), built_in_order(&right.id)) {
(Some(left_order), Some(right_order)) => left_order.cmp(&right_order),
_ => std::cmp::Ordering::Equal,
},
)
.then_with(|| {
left.display_name
.to_lowercase()
@@ -179,10 +183,20 @@ mod tests {
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
let display_names: Vec<&str> = records.iter().map(|record| record.display_name.as_str()).collect();
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(
display_names,
vec!["Orchestrator", "Researcher", "Planner", "Builder", "Refactor", "Reviewer"]
vec![
"Orchestrator",
"Researcher",
"Planner",
"Builder",
"Refactor",
"Reviewer"
]
);
}
@@ -230,12 +244,12 @@ mod tests {
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
assert!(changed);
assert!(
records
.iter()
.any(|record| record.id == "builtin:implementer" && record.display_name == "Builder")
);
assert!(records.iter().any(|record| record.id == "builtin:orchestrator"));
assert!(records
.iter()
.any(|record| record.id == "builtin:implementer" && record.display_name == "Builder"));
assert!(records
.iter()
.any(|record| record.id == "builtin:orchestrator"));
assert!(records.iter().any(|record| record.id == "builtin:refactor"));
assert!(records.iter().any(|record| record.id == "builtin:reviewer"));
}
@@ -35,7 +35,9 @@ fn terminate_process(pid: u32) -> Result<(), String> {
.status()
.map_err(|error| format!("failed to terminate process {pid}: {error}"))?;
if !status.success() && process_is_running(pid) {
return Err(format!("failed to terminate process {pid}: signal was rejected"));
return Err(format!(
"failed to terminate process {pid}: signal was rejected"
));
}
for _ in 0..10 {
+5 -15
View File
@@ -41,8 +41,8 @@ pub fn load_teams(app: &AppHandle) -> Result<Vec<TeamRecord>, String> {
return Ok(Vec::new());
}
let content =
fs::read_to_string(&path).map_err(|error| format!("failed to read teams store: {error}"))?;
let content = fs::read_to_string(&path)
.map_err(|error| format!("failed to read teams store: {error}"))?;
let mut records: Vec<TeamRecord> = serde_json::from_str(&content)
.map_err(|error| format!("failed to parse teams store: {error}"))?;
sort_teams(&mut records);
@@ -66,10 +66,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
/// Encode a team as a JSON blob for export. The format includes the team's
/// name, description, and the full persona data for each member (so the
/// import side can recreate personas that don't exist locally).
pub fn encode_team_json(
team: &TeamRecord,
personas: &[PersonaRecord],
) -> Result<Vec<u8>, String> {
pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result<Vec<u8>, String> {
let resolved_personas: Vec<serde_json::Value> = team
.persona_ids
.iter()
@@ -185,11 +182,7 @@ mod tests {
#[test]
fn sort_teams_alphabetical_case_insensitive() {
let mut teams = vec![
team("3", "Zulu"),
team("1", "alpha"),
team("2", "Bravo"),
];
let mut teams = vec![team("3", "Zulu"), team("1", "alpha"), team("2", "Bravo")];
sort_teams(&mut teams);
let names: Vec<&str> = teams.iter().map(|t| t.name.as_str()).collect();
@@ -198,10 +191,7 @@ mod tests {
#[test]
fn sort_teams_breaks_ties_by_id() {
let mut teams = vec![
team("b", "same"),
team("a", "same"),
];
let mut teams = vec![team("b", "same"), team("a", "same")];
sort_teams(&mut teams);
let ids: Vec<&str> = teams.iter().map(|t| t.id.as_str()).collect();
+5 -8
View File
@@ -69,13 +69,7 @@ pub async fn sync_managed_agent_profile(
avatar_url: Option<&str>,
) -> Result<(), String> {
// Build a kind:0 profile event signed by the agent's keys.
let builder = crate::events::build_profile(
Some(display_name),
None,
avatar_url,
None,
None,
)?;
let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?;
// Sign with the agent's keys (not the desktop user's).
let event = builder
@@ -268,7 +262,10 @@ pub async fn submit_event(
let url = format!("{}/api/events", relay_api_base_url());
let request = if auth_header.starts_with("Bearer ") {
state.http_client.post(&url).header("Authorization", &auth_header)
state
.http_client
.post(&url)
.header("Authorization", &auth_header)
} else {
let pubkey = auth_header.strip_prefix("X-Pubkey ").unwrap_or("");
state.http_client.post(&url).header("X-Pubkey", pubkey)
+10 -2
View File
@@ -43,7 +43,7 @@ build-release:
cargo build --workspace --release
# Run repo lint and formatting checks
check: fmt-check clippy desktop-check
check: fmt-check clippy desktop-check desktop-tauri-fmt-check
# Format all Rust code
fmt:
@@ -77,12 +77,20 @@ desktop-typecheck:
desktop-build:
cd {{desktop_dir}} && pnpm build
# Format desktop Tauri Rust code
desktop-tauri-fmt:
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all
# Check desktop Tauri Rust formatting
desktop-tauri-fmt-check:
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all -- --check
# Check the desktop Tauri Rust crate compiles
desktop-tauri-check:
cargo check --manifest-path {{desktop_tauri_manifest}}
# Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-build desktop-tauri-check
desktop-ci: desktop-check desktop-tauri-fmt-check desktop-build desktop-tauri-check
# Seed deterministic channel data for desktop Playwright tests
desktop-e2e-seed: