From b5738696d02ab50628e2797327246686ceac331f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:52:30 -0400 Subject: [PATCH 1/2] feat(desktop): report private per-Desktop runtime capabilities Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_capabilities.rs | 203 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 4 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 19 +- .../src/api/desktop_profile_postgres_tests.rs | 12 +- crates/buzz-relay/src/handlers/event.rs | 5 + crates/buzz-relay/src/handlers/ingest.rs | 17 +- .../src/commands/desktop_capabilities.rs | 159 ++++++++++++++ .../src/commands/desktop_profiles.rs | 4 +- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + .../agents/desktopCapabilities.test.mjs | 132 ++++++++++++ .../features/agents/desktopCapabilities.ts | 85 ++++++++ .../agents/ui/DesktopCapabilityDetails.tsx | 46 ++++ .../src/features/agents/ui/KnownDesktops.tsx | 30 ++- migrations/0047_desktop_capabilities_fts.sql | 26 +++ schema/schema.sql | 2 +- 17 files changed, 737 insertions(+), 12 deletions(-) create mode 100644 crates/buzz-core/src/desktop_capabilities.rs create mode 100644 desktop/src-tauri/src/commands/desktop_capabilities.rs create mode 100644 desktop/src/features/agents/desktopCapabilities.test.mjs create mode 100644 desktop/src/features/agents/desktopCapabilities.ts create mode 100644 desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx create mode 100644 migrations/0047_desktop_capabilities_fts.sql diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs new file mode 100644 index 00000000000..c621cca9776 --- /dev/null +++ b/crates/buzz-core/src/desktop_capabilities.rs @@ -0,0 +1,203 @@ +//! Bounded, owner-private runtime facts, not signing access or agent readiness. +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_CAPABILITIES}; +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +/// Allowlisted projection of a built-in runtime; never catalog paths or auth data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeFact { + /// Built-in catalog identifier. + pub id: String, + /// Discovery's installation/adapter availability, not authentication. + pub availability: String, + /// Whether a separate vendor CLI is required. + pub requires_external_cli: bool, + /// Spawn policy cap; None means no configured cap, not infinite capacity. + pub max_parallelism: Option, +} + +/// Facts at the signed event time, changed only when the projection changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopCapabilities { + /// Format version. + pub v: u8, + /// Encrypted canonical community. + pub community: String, + /// Local Desktop coordinate. + pub id: String, + /// Sorted, unique built-in runtime facts. + pub runtimes: Vec, +} + +/// Validate the bounded public envelope without decrypting it. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_CAPABILITIES) +} + +impl DesktopCapabilities { + /// Project onto the persisted Desktop coordinate, not a caller-selected host. + pub fn new(profile: DesktopProfile, mut runtimes: Vec) -> Self { + runtimes.sort_by(|a, b| a.id.cmp(&b.id)); + Self { + v: 1, + community: profile.community, + id: profile.id, + runtimes, + } + } + + fn validate(&self) -> Result<(), String> { + DesktopProfile::new(self.community.clone(), self.id.clone())?; + if self.v != 1 + || self.runtimes.len() > 8 + || self.runtimes.windows(2).any(|r| r[0].id >= r[1].id) + || self.runtimes.iter().any(|r| { + r.id.is_empty() + || r.id.len() > 32 + || !r.id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + || !matches!( + r.availability.as_str(), + "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed" + ) + || r.max_parallelism == Some(0) + }) + { + return Err("invalid Desktop runtime facts".into()); + } + Ok(()) + } + + /// Encrypt/sign once, then persist these exact bytes for retries. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate()?; + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + validate_envelope(&event)?; + Ok(event) + } + + /// Bounded history/live merge: newest signed time, lower event ID on ties. + pub fn read_latest( + mut events: Vec, + keys: &Keys, + community: &str, + ) -> Result, String> { + if events.len() > 100 { + return Err("too many Desktop reports".into()); + } + events.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id))); + let mut seen = std::collections::HashSet::new(); + let mut rows = Vec::new(); + for event in events { + let report = Self::read(&event, keys, community)?; + if seen.insert(report.id.clone()) { + rows.push((report, event.created_at.as_secs())); + } + } + Ok(rows) + } + + /// Authenticate, decrypt and scope-check before exposing any fact. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop report signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop report".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop report decryption failed")?; + let report: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop report")?; + report.validate()?; + if report.community != community || Some(report.id.as_str()) != event.tags.identifier() { + return Err("Desktop report scope mismatch".into()); + } + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_scoped_bounded_facts() { + let keys = Keys::generate(); + let mut report = DesktopCapabilities::new( + DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(), + vec![], + ); + let event = report.sign(&keys).unwrap(); + assert_eq!( + DesktopCapabilities::read(&event, &keys, &report.community).unwrap(), + report + ); + assert!(DesktopCapabilities::read(&event, &keys, "wss://two.example").is_err()); + assert!(DesktopCapabilities::read(&event, &Keys::generate(), &report.community).is_err()); + let mut payload = serde_json::to_value(&report).unwrap(); + payload["auth"] = serde_json::json!("must not appear"); + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, ciphertext) + .tags(event.tags.clone()) + .sign_with_keys(&keys) + .unwrap(); + assert!(DesktopCapabilities::read(&invalid, &keys, &report.community).is_err()); + let mut tampered = event; + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopCapabilities::read(&tampered, &keys, &report.community).is_err()); + report.runtimes.push(RuntimeFact { + id: "/private/path".into(), + availability: "available".into(), + requires_external_cli: false, + max_parallelism: None, + }); + assert!(report.sign(&keys).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_CAPABILITIES)); + report.runtimes[0].id = "goose".into(); + let old = report.sign(&keys).unwrap(); + report.runtimes[0].availability = "cli_missing".into(); + let new = report.sign(&keys).unwrap(); + let signed = |event: &Event, time| { + EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .custom_created_at(nostr::Timestamp::from(time)) + .sign_with_keys(&keys) + .unwrap() + }; + let a = signed(&old, 20); + let b = signed(&new, 20); + let winner = if a.id < b.id { &a } else { &b }; + let expected = DesktopCapabilities::read(winner, &keys, &report.community).unwrap(); + for events in [vec![signed(&old, 10), a.clone(), b.clone()], vec![b, a]] { + assert_eq!( + DesktopCapabilities::read_latest(events, &keys, &report.community).unwrap(), + vec![(expected.clone(), 20)] + ); + } + assert!( + DesktopCapabilities::read_latest(vec![old; 101], &keys, &report.community).is_err() + ); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 1e5bfd43d6e..4d309c3b609 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -122,6 +122,8 @@ pub const KIND_DESKTOP_PROFILE: u32 = 30180; /// Owner-private, per-Desktop last-heard observation; not online or readiness. pub const KIND_DESKTOP_OBSERVATION: u32 = 30181; +/// Owner-private built-in runtime facts per Desktop, not agent readiness. +pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; /// Kinds whose stored events are readable only by their author. /// @@ -138,6 +140,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -669,6 +672,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 2105e4a15a7..ee4bb5df789 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +pub mod desktop_capabilities; pub mod desktop_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 5a0d2a64150..7da5d0d0c68 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 46); + assert_eq!(migrations.len(), 47); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -911,7 +911,7 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); assert!(include_str!("../../../../schema/schema.sql").contains( - "kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)" + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)" )); // Public push-gateway authority is intentionally deployment-global and @@ -2393,6 +2393,7 @@ mod postgres_tests { (3_u8, 30_179_i32), (4_u8, 30_180_i32), (5_u8, 30_181_i32), + (6_u8, 30_182_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2427,6 +2428,7 @@ mod postgres_tests { (30_179, true), (30_180, true), (30_181, true), + (30_182, true), (30_350, true) ] ); @@ -2451,6 +2453,7 @@ mod postgres_tests { (30_179, Some(true)), (30_180, Some(true)), (30_181, Some(true)), + (30_182, Some(true)), (30_350, None) ] ); @@ -2479,6 +2482,17 @@ mod postgres_tests { "0046 must change brownfield observation FTS" ); + run_migrations_through(&pool, 46).await.unwrap(); + let capability_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30182") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + capability_indexed, + "0047 must change brownfield capability FTS" + ); + run_migrations(&pool) .await .expect("apply remaining migrations to populated database"); @@ -2496,6 +2510,7 @@ mod postgres_tests { (30_179, None), (30_180, None), (30_181, None), + (30_182, None), (30_350, None) ] ); diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs index 5d863666832..80f4619c583 100644 --- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -2,7 +2,7 @@ use super::postgres_tests::bridge_handler_test_state; use super::*; use axum::{body::Body, http::Request}; -use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; use tower::ServiceExt; @@ -76,6 +76,12 @@ async fn desktop_observation_authenticated_owner_query_and_private_storage() { assert_private_desktop(KIND_DESKTOP_OBSERVATION).await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_capabilities_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await; +} + async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await @@ -99,6 +105,10 @@ async fn assert_private_desktop(kind: u32) { let id = profile.id.clone(); let event = if kind == KIND_DESKTOP_PROFILE { profile.sign(&owner).unwrap() + } else if kind == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![]) + .sign(&owner) + .unwrap() } else { buzz_core::desktop_observation::DesktopObservation::new(profile) .sign(&owner) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b2f86ec357c..832b5678c9d 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2220,6 +2220,11 @@ mod tests { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await; } + #[tokio::test] + async fn desktop_capabilities_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await; + } + async fn assert_author_only_fanout(kind: u32) { let state = test_state().await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f60a76fd4a8..9d4e9ad5dc4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -36,7 +36,7 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; -use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; +use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -437,7 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { - KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT @@ -660,6 +660,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_PRIVATE_MANAGED_AGENT | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION + | KIND_DESKTOP_CAPABILITIES | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2170,14 +2171,14 @@ pub async fn ingest_event( result } -// Profiles are durable display records, not freshness signals. A Desktop may +// Profiles and capability facts are durable records, not freshness signals. A Desktop may // first publish its immutable signed record long after an offline startup. // Only their past-age bound is waived; future drift and all other admission // checks still apply. Observation/presence kinds must retain their own window. fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) - && (kind == KIND_DESKTOP_PROFILE + && (matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) } @@ -2793,6 +2794,11 @@ async fn ingest_event_inner( } } + if kind_u32 == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_DESKTOP_OBSERVATION { buzz_core::desktop_observation::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3338,6 +3344,7 @@ mod postgres_tests { // Include the next observation kind explicitly: freshness is not profile age. for kind in [ KIND_DESKTOP_PROFILE, + KIND_DESKTOP_CAPABILITIES, 30181, KIND_PROFILE, KIND_EVENT_REMINDER, @@ -3355,7 +3362,7 @@ mod postgres_tests { ] { assert_eq!( timestamp_within_ingest_window(kind, timestamp, now), - if kind == KIND_DESKTOP_PROFILE { + if matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) { profile } else { ordinary diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs new file mode 100644 index 00000000000..bdab02274eb --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs @@ -0,0 +1,159 @@ +//! Private Desktop reports reuse the local catalog authority and retention scope. +use super::desktop_profiles::{prepare, scope}; +use crate::{ + app_state::AppState, + managed_agents::{ + retention::{open_retention_db, RetentionScope}, + AcpRuntimeCatalogEntry, HarnessSource, + }, +}; +use buzz_core_pkg::{ + desktop_capabilities::{DesktopCapabilities, RuntimeFact}, + desktop_profile::DesktopProfile, +}; +use nostr::{Event, JsonUtil}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +fn project(catalog: Vec) -> Result, String> { + catalog + .into_iter() + .filter(|r| r.source == HarnessSource::Builtin) + .map(|r| { + Ok(RuntimeFact { + id: r.id, + availability: serde_json::from_value( + serde_json::to_value(r.availability).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?, + requires_external_cli: r.requires_external_cli, + max_parallelism: r.max_parallelism, + }) + }) + .collect() +} + +/// Cached discovery only; Settings → Agents remains the local setup/check-again UI. +#[tauri::command] +pub async fn prepare_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + // Serialize discovery + persistence so an older native completion cannot + // overwrite a newer projection when observers cancel/restart. + static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _guard = SERIAL.lock().await; + scope(&app, &state, &owner, &community)?; + let facts = + project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?; + let scope = scope(&app, &state, &owner, &community)?; + Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? })) +} + +fn prepare_report( + conn: &mut Connection, + scope: &RetentionScope, + facts: Vec, +) -> Result { + let saved = prepare(conn, scope)?; + let profile: Event = + serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let community = scope.relay_url.trim_end_matches('/'); + let report = DesktopCapabilities::new( + DesktopProfile::read(&profile, &scope.owner_keys, community)?, + facts, + ); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch("CREATE TABLE IF NOT EXISTS desktop_capabilities (slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);").map_err(|e| e.to_string())?; + let raw: Option = tx + .query_row( + "SELECT raw FROM desktop_capabilities WHERE slot = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let previous = raw + .map(|raw| Event::from_json(raw).map_err(|e| e.to_string())) + .transpose()?; + let unchanged = previous + .as_ref() + .map(|e| { + DesktopCapabilities::read(e, &scope.owner_keys, community).map(|old| old == report) + }) + .transpose()? + .unwrap_or(false); + let event = match previous { + Some(event) if unchanged => event, + _ => { + let event = report.sign(&scope.owner_keys)?; + tx.execute( + "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)", + [event.as_json()], + ) + .map_err(|e| e.to_string())?; + event + } + }; + tx.commit().map_err(|e| e.to_string())?; + Ok(event) +} + +/// Read only verified owner/community reports, newest signed time then lower ID. +#[tauri::command] +pub fn read_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + let rows: Vec<_> = DesktopCapabilities::read_latest(events, &scope.owner_keys, &community)?.into_iter() + .map(|(report, reported)| json!({ "id": report.id, "reported": reported, "runtimes": report.runtimes })).collect(); + Ok(json!(rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() { + let dir = tempfile::tempdir().unwrap(); + let scope = RetentionScope { + db_path: dir.path().join("report.db"), + relay_url: "wss://one.example".into(), + owner_keys: nostr::Keys::generate(), + }; + let first = prepare_report( + &mut open_retention_db(&scope.db_path).unwrap(), + &scope, + vec![], + ) + .unwrap(); + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + assert_eq!( + prepare_report(&mut reopened, &scope, vec![]).unwrap(), + first + ); + assert_eq!(reopened.total_changes(), 0); + let facts = vec![RuntimeFact { + id: "goose".into(), + availability: "available".into(), + requires_external_cli: true, + max_parallelism: None, + }]; + let changed = prepare_report(&mut reopened, &scope, facts).unwrap(); + assert_ne!(changed.id, first.id); + assert_eq!(changed.tags, first.tags); + reopened + .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", []) + .unwrap(); + assert!(prepare_report(&mut reopened, &scope, vec![]).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs index d3438d71c51..caec1f9af88 100644 --- a/desktop/src-tauri/src/commands/desktop_profiles.rs +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, State}; use crate::app_state::AppState; use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope}; -fn scope( +pub(super) fn scope( app: &AppHandle, state: &AppState, owner: &str, @@ -23,7 +23,7 @@ fn scope( Ok(scope) } -fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { +pub(super) fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { // SQLite serializes concurrent startup/open requests across processes. The ID // and exact ciphertext/signature commit together, before any network write. let tx = conn diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 3e3c38a3e8c..0cb37bf7e02 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod desktop_capabilities; mod desktop_profiles; mod dms; mod engrams; @@ -93,6 +94,7 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use desktop_capabilities::*; pub use desktop_profiles::*; pub use dms::*; pub use engrams::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index edffed5e462..f794243354d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -556,6 +556,8 @@ pub fn run() { read_desktop_profiles, prepare_desktop_observation, read_desktop_observations, + prepare_desktop_capabilities, + read_desktop_capabilities, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs new file mode 100644 index 00000000000..9def2ec43d8 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { refreshDesktopCapabilities } from "./desktopCapabilities.ts"; +import { DesktopListView } from "./ui/KnownDesktops.tsx"; + +const scope = { owner: "owner-a", community: "wss://one.example" }; +const event = { id: "signed", created_at: 100, kind: 30182 }; +const row = { + id: "desktop-a", + reported: 100, + runtimes: [ + { + id: "goose", + availability: "cli_missing", + requires_external_cli: true, + max_parallelism: null, + }, + ], +}; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const finish = (name, result) => { + if (name === boundary) epoch++; + return result; + }; + const f = { + calls, + head: [], + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_capabilities") + return finish("prepare", { event }); + assert.equal(command, "read_desktop_capabilities"); + return finish( + "read", + args.events.map(() => row), + ); + }, + relay: { + getSessionEpoch: () => epoch, + fetchEvents: async (filter) => { + assert.deepEqual(filter.authors, [scope.owner]); + assert.deepEqual(filter.kinds, [30182]); + assert.ok(filter.limit <= 100); + return finish("fetch", filter["#d"] ? f.head : [event]); + }, + publishEvent: async (value, _timeout, _failure, check) => { + finish("transport"); + check(); // Delayed transport must invoke the production cancellation guard. + calls.push(value); + f.head = [value]; + finish("ack"); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopCapabilities(scope, active, f.ipc, f.relay); + return f; +} + +test("unchanged accepted report does not republish; failed publish retries exact bytes", async () => { + const f = fixture(); + assert.deepEqual((await f.refresh()).rows, [row]); + await f.refresh(); + assert.equal(f.calls.filter((c) => c === event).length, 1); + f.head = []; + f.relay.publishEvent = async (value) => { + assert.equal(value, event); + throw Error("offline"); + }; + for (let i = 0; i < 2; i++) assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => { + throw Error("unavailable"); + }; + await assert.rejects(f.refresh(), /unavailable/); +}); + +test("all async boundaries fence cancellation, account/community switches and late ACK", async () => { + for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary !== "ack") assert.ok(!f.calls.includes(event)); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); + f.relay.fetchEvents = async () => Array(100).fill(event); + assert.equal((await f.refresh()).partial, true); + f.ipc = async () => { + throw Error("invalid signature"); + }; + await assert.rejects(f.refresh(), /invalid signature/); +}); + +test("mounted Desktop rows show exact remote facts and unknowns, not readiness", () => { + const html = renderToStaticMarkup( + React.createElement(DesktopListView, { + list: { + rows: ["desktop-a", "desktop-b"].map((id) => ({ + id, + name: id, + updated: 1, + })), + local: "desktop-b", + }, + capabilities: [row], + now: 99, + refresh() {}, + loading: false, + error: false, + }), + ); + assert.equal( + (html.match(/Capability details<\/summary>/g) ?? []).length, + 2, + ); + for (const text of [ + "goose", + "cli missing", + "not configured", + "Desktop clock ahead", + "No capability report received", + "not agent readiness", + "Settings", + ]) + assert.ok(html.includes(text), text); +}); diff --git a/desktop/src/features/agents/desktopCapabilities.ts b/desktop/src/features/agents/desktopCapabilities.ts new file mode 100644 index 00000000000..566ef2b8117 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.ts @@ -0,0 +1,85 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQuery } from "@tanstack/react-query"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export type DesktopCapabilities = { + id: string; + reported: number; + runtimes: { + id: string; + availability: string; + requires_external_cli: boolean; + max_parallelism: number | null; + }[]; +}; + +/** Exact signed reports are persisted natively; only changed facts create new bytes. */ +export async function refreshDesktopCapabilities( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop capability scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + const read = (events: RelayEvent[]) => + wait( + ipc("read_desktop_capabilities", { + ...scope, + events, + }), + ); + const filter = { kinds: [30182], authors: [scope.owner] }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_capabilities", scope), + ); + const [local] = await read([event]); + const head = await wait( + relay.fetchEvents({ ...filter, "#d": [local.id], limit: 1 }), + ); + await read(head); + if (!head.some((e) => e.id === event.id)) + await wait( + relay.publishEvent( + event, + "Desktop report timed out", + "Desktop report failed", + check, + ), + ); + } catch { + check(); + warning = + "This Desktop could not synchronize capability facts. Will retry."; + } + const events = await wait(relay.fetchEvents({ ...filter, limit: 100 })); + return { rows: await read(events), partial: events.length === 100, warning }; +} + +export function useDesktopCapabilities(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-capabilities", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopCapabilities(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }); +} diff --git a/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx new file mode 100644 index 00000000000..aa18aed7a3d --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx @@ -0,0 +1,46 @@ +import type { DesktopCapabilities } from "../desktopCapabilities"; + +/** Read-only remote projection; local setup remains in Settings → Agents. */ +export function DesktopCapabilityDetails({ + report, + now, +}: { + report?: DesktopCapabilities; + now: number; +}) { + return ( +
+ Capability details + {!report ? ( +

No capability report received.

+ ) : ( + <> +

+ Facts reported {new Date(report.reported * 1000).toLocaleString()} + {report.reported > now && + " (Desktop clock ahead; report time uncertain)"} + . Unchanged facts keep their original report time. +

+
    + {report.runtimes.map((runtime) => ( +
  • + {runtime.id}: {runtime.availability.replaceAll("_", " ")} · + external CLI{" "} + {runtime.requires_external_cli ? "required" : "not required"} · + parallelism cap {runtime.max_parallelism ?? "not configured"}. +
  • + ))} +
+ {!report.runtimes.length && ( +

No built-in runtime facts reported.

+ )} + + )} +

+ Cached installation facts only, not agent readiness or access to an + agent’s signing key. Stable agent keys must be provisioned separately by + you. For local setup and Check again, use Settings → Agents. +

+
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 68eeacc1bcb..16d872c2d8b 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -12,7 +12,15 @@ import { type DesktopObservation, } from "../desktopObservations"; +import { + useDesktopCapabilities, + type DesktopCapabilities, +} from "../desktopCapabilities"; +import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails"; + type View = { + capabilities?: DesktopCapabilities[]; + capabilityWarning?: string; list: DesktopList | null; error: boolean; loading: boolean; @@ -54,25 +62,29 @@ function useDesktopList() { export function DesktopListStartup() { const { refetch } = useDesktopList(); const { refetch: pulse } = useDesktopObservations(useDesktopScope()); + const { refetch: report } = useDesktopCapabilities(useDesktopScope()); useEffect(() => { const timer = setInterval(() => { void pulse(); + void report(); }, DESKTOP_PULSE_MS); const unsubscribe = relayClient.subscribeToReconnects(() => { void refetch(); void pulse(); + void report(); }); return () => { clearInterval(timer); unsubscribe(); }; - }, [refetch, pulse]); + }, [refetch, pulse, report]); return null; } export function KnownDesktops() { const query = useDesktopList(); const observations = useDesktopObservations(useDesktopScope()); + const capabilities = useDesktopCapabilities(useDesktopScope()); const [now, setNow] = useState(() => Date.now() / 1000); useEffect(() => { const timer = setInterval(() => setNow(Date.now() / 1000), 30_000); @@ -80,6 +92,14 @@ export function KnownDesktops() { }, []); return ( { void query.refetch(); void observations.refetch(); + void capabilities.refetch(); }} /> ); @@ -107,6 +128,8 @@ export function DesktopListView({ refresh, observations, observationWarning, + capabilities, + capabilityWarning, now = Date.now() / 1000, }: View) { return ( @@ -136,6 +159,7 @@ export function DesktopListView({ Desktop profiles unavailable. Previously loaded profiles are retained.

)} + {capabilityWarning &&

{capabilityWarning}

} {observationWarning &&

{observationWarning}

} {list?.warning &&

{list.warning}

} {list?.partial && ( @@ -160,6 +184,10 @@ export function DesktopListView({ now, )} + item.id === row.id)} + now={now} + /> ))} diff --git a/migrations/0047_desktop_capabilities_fts.sql b/migrations/0047_desktop_capabilities_fts.sql new file mode 100644 index 00000000000..425d1eee92b --- /dev/null +++ b/migrations/0047_desktop_capabilities_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop capability reports must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30182 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 16613480710..d78b3036d97 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 08fbf9c95bbd9107fcf196d14d5e677368879db6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 12:24:55 -0400 Subject: [PATCH 2/2] fix(desktop): defer capability changes until real time advances Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_capabilities.rs | 6 ++ .../src/commands/desktop_capabilities.rs | 83 +++++++++++++++++-- .../agents/desktopCapabilities.test.mjs | 28 +++++++ .../src/features/agents/desktopList.test.mjs | 34 +++++++- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs index c621cca9776..ef1c4d8f63a 100644 --- a/crates/buzz-core/src/desktop_capabilities.rs +++ b/crates/buzz-core/src/desktop_capabilities.rs @@ -75,6 +75,11 @@ impl DesktopCapabilities { /// Encrypt/sign once, then persist these exact bytes for retries. pub fn sign(&self, keys: &Keys) -> Result { + self.sign_at(keys, nostr::Timestamp::now()) + } + + /// Sign at an observed wall-clock second, never a synthesized logical time. + pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result { self.validate()?; let content = nip44::encrypt( keys.secret_key(), @@ -85,6 +90,7 @@ impl DesktopCapabilities { .map_err(|e| e.to_string())?; let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content) .tag(Tag::identifier(&self.id)) + .custom_created_at(observed) .sign_with_keys(keys) .map_err(|e| e.to_string())?; validate_envelope(&event)?; diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs index bdab02274eb..f5efd5c953b 100644 --- a/desktop/src-tauri/src/commands/desktop_capabilities.rs +++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs @@ -50,13 +50,16 @@ pub async fn prepare_desktop_capabilities( let facts = project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?; let scope = scope(&app, &state, &owner, &community)?; - Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? })) + Ok( + json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts, nostr::Timestamp::now)? }), + ) } fn prepare_report( conn: &mut Connection, scope: &RetentionScope, facts: Vec, + clock: impl FnOnce() -> nostr::Timestamp, ) -> Result { let saved = prepare(conn, scope)?; let profile: Event = @@ -90,8 +93,16 @@ fn prepare_report( .unwrap_or(false); let event = match previous { Some(event) if unchanged => event, - _ => { - let event = report.sign(&scope.owner_keys)?; + previous => { + let now = clock(); + // Keep the prior retry record until real time advances. Signing tied + // ciphertext can lose NIP-33's lower-ID tie; never cache that loss or + // future-date a replacement. The existing pulse/reconnect/Refresh + // retries discovery, not a captured projection, without waiting here. + if previous.as_ref().is_some_and(|e| now <= e.created_at) { + return Err("Desktop capability facts deferred until the clock advances".into()); + } + let event = report.sign_at(&scope.owner_keys, now)?; tx.execute( "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)", [event.as_json()], @@ -123,7 +134,7 @@ pub fn read_desktop_capabilities( mod tests { use super::*; #[test] - fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() { + fn changed_facts_defer_until_real_clock_advances_then_win_signed_order() { let dir = tempfile::tempdir().unwrap(); let scope = RetentionScope { db_path: dir.path().join("report.db"), @@ -134,26 +145,80 @@ mod tests { &mut open_retention_db(&scope.db_path).unwrap(), &scope, vec![], + || nostr::Timestamp::from(1000), ) .unwrap(); let mut reopened = open_retention_db(&scope.db_path).unwrap(); assert_eq!( - prepare_report(&mut reopened, &scope, vec![]).unwrap(), + prepare_report(&mut reopened, &scope, vec![], || panic!( + "unchanged must not sign" + )) + .unwrap(), first ); assert_eq!(reopened.total_changes(), 0); - let facts = vec![RuntimeFact { + let mut facts = vec![RuntimeFact { id: "goose".into(), availability: "available".into(), requires_external_cli: true, max_parallelism: None, }]; - let changed = prepare_report(&mut reopened, &scope, facts).unwrap(); - assert_ne!(changed.id, first.id); + for now in [1000, 990, 999, 1000] { + let error = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(now) + }) + .unwrap_err(); + assert!(error.contains("clock advances")); + assert_eq!(reopened.total_changes(), 0, "deferral must not persist"); + // Returning to old facts cancels the proposed change, even after a + // restart/rollback: no deferred payload or timestamp renewal survives. + assert_eq!( + prepare_report(&mut reopened, &scope, vec![], || panic!("exact retry")).unwrap(), + first + ); + reopened = open_retention_db(&scope.db_path).unwrap(); + } + // The retry observes today's facts, not the projection first deferred. + facts[0].availability = "cli_missing".into(); + let changed = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(1001) + }) + .unwrap(); + first.verify().unwrap(); + changed.verify().unwrap(); + assert_eq!(changed.created_at.as_secs(), 1001, "no future timestamp"); + assert!(changed.created_at > first.created_at); assert_eq!(changed.tags, first.tags); + for events in [ + vec![first.clone(), changed.clone()], + vec![changed.clone(), first], + ] { + let rows = + DesktopCapabilities::read_latest(events, &scope.owner_keys, &scope.relay_url) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0.runtimes, facts); + assert_eq!(rows[0].1, 1001); + } + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + let mut invalid = facts.clone(); + invalid[0].max_parallelism = Some(0); + assert!(prepare_report(&mut reopened, &scope, invalid, || { + nostr::Timestamp::from(1002) + }) + .is_err()); + assert_eq!( + prepare_report(&mut reopened, &scope, facts, || panic!("exact retry")).unwrap(), + changed + ); + assert_eq!( + reopened.total_changes(), + 0, + "failed signing must not persist" + ); reopened .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", []) .unwrap(); - assert!(prepare_report(&mut reopened, &scope, vec![]).is_err()); + assert!(prepare_report(&mut reopened, &scope, vec![], nostr::Timestamp::now).is_err()); } } diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs index 9def2ec43d8..da09f6bb4a8 100644 --- a/desktop/src/features/agents/desktopCapabilities.test.mjs +++ b/desktop/src/features/agents/desktopCapabilities.test.mjs @@ -80,6 +80,34 @@ test("unchanged accepted report does not republish; failed publish retries exact await assert.rejects(f.refresh(), /unavailable/); }); +test("deferred preparation settles with prior relay facts, never publishes, and honors cancellation", async () => { + const f = fixture(); + const ipc = f.ipc; + let active = true; + let cancel = false; + f.ipc = async (command, args) => { + if (command === "prepare_desktop_capabilities") { + if (cancel) active = false; + throw Error("Desktop capability facts deferred until the clock advances"); + } + return ipc(command, args); + }; + const deferred = await f.refresh(() => active); + assert.deepEqual(deferred.rows, [row]); + assert.match(deferred.warning, /Will retry/); + assert.ok(!f.calls.includes(event)); + cancel = true; + await assert.rejects( + f.refresh(() => active), + /scope changed/, + ); + assert.ok(!f.calls.includes(event)); + // A later, active attempt prepares afresh; no held promise or queued event. + f.ipc = ipc; + assert.equal((await f.refresh()).warning, ""); + assert.equal(f.calls.filter((c) => c === event).length, 1); +}); + test("all async boundaries fence cancellation, account/community switches and late ACK", async () => { for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) { const f = fixture(boundary); diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 6f7330d978c..5e21adbb316 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -202,6 +202,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa const originalReconnect = relayClient.subscribeToReconnects; let reconnect; let pulses = 0; + let reports = 0; + let publishedReports = 0; + let deferReport = true; relayClient.subscribeToReconnects = (callback) => { reconnect = callback; return () => { @@ -210,6 +213,17 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa }; window.__TAURI_INTERNALS__ = { invoke: async (command, args) => { + if (command === "prepare_desktop_capabilities") { + reports++; + if (deferReport) throw Error("clock has not advanced"); + return { event: { ...first, kind: 30182 } }; + } + if (command === "read_desktop_capabilities") + return args.events.map(() => ({ + id: "desktop-a", + reported: 100, + runtimes: [], + })); if (command === "prepare_desktop_observation") return { event: { ...first, kind: 30181 } }; if (command === "read_desktop_observations") @@ -237,7 +251,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa }; relayClient.fetchEvents = async (filter) => { if (fail) throw Error("unavailable"); - if (filter.kinds[0] === 30181) return []; + if ([30181, 30182].includes(filter.kinds[0])) return []; if (filter["#d"]) return [current]; const rows = [current]; if (hold) { @@ -249,6 +263,10 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa return rows; }; relayClient.publishEvent = async (event) => { + if (event.kind === 30182) { + publishedReports++; + return; + } assert.equal(event.kind, 30181, "no profile heartbeat rewrite"); pulses++; }; @@ -285,14 +303,28 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa await settle(); assert.match(text(), /owner-a-wss:\/\/a.example/); assert.match(text(), /Last heard: Recent/); + assert.match(text(), /could not synchronize capability facts/); const beforeReconnect = pulses; + const reportsBeforeReconnect = reports; await React.act(async () => reconnect()); await settle(); assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse"); + assert.ok( + reports > reportsBeforeReconnect, + "reconnect retries deferred facts", + ); const beforeTimer = pulses; + const reportsBeforeTimer = reports; await React.act(async () => t.mock.timers.tick(60_000)); await settle(); assert.ok(pulses > beforeTimer, "bounded periodic publisher runs"); + assert.ok(reports > reportsBeforeTimer, "periodic retry survives deferral"); + assert.equal(publishedReports, 0, "deferred facts are not published"); + deferReport = false; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.equal(publishedReports, 1, "later preparation is published"); + assert.doesNotMatch(text(), /could not synchronize capability facts/); hold = true; await React.act(async () => { void client.refetchQueries({ queryKey: ["desktop-profiles"] });