mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): avoid updater panic in dev and enforce tauri rustfmt (#167)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user