From 6df6e2577ae287d62f8e292f55b27998a85e9e1f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 10:51:04 -0400 Subject: [PATCH 1/2] feat(desktop): show private per-Desktop last-heard observations Signed-off-by: Logan Johnson --- crates/buzz-core/src/desktop_observation.rs | 123 ++++++++++++++++++ crates/buzz-core/src/desktop_profile.rs | 9 +- crates/buzz-core/src/kind.rs | 5 + crates/buzz-core/src/lib.rs | 1 + crates/buzz-db/src/runtime/migration.rs | 30 ++++- .../src/api/desktop_profile_postgres_tests.rs | 28 +++- crates/buzz-relay/src/handlers/event.rs | 5 + crates/buzz-relay/src/handlers/ingest.rs | 10 +- .../src/commands/desktop_profiles.rs | 56 +++++++- desktop/src-tauri/src/lib.rs | 2 + .../src/features/agents/desktopList.test.mjs | 50 ++++++- .../agents/desktopObservations.test.mjs | 103 +++++++++++++++ .../features/agents/desktopObservations.ts | 82 ++++++++++++ .../src/features/agents/ui/KnownDesktops.tsx | 77 +++++++++-- migrations/0046_desktop_observation_fts.sql | 26 ++++ schema/schema.sql | 2 +- 16 files changed, 580 insertions(+), 29 deletions(-) create mode 100644 crates/buzz-core/src/desktop_observation.rs create mode 100644 desktop/src/features/agents/desktopObservations.test.mjs create mode 100644 desktop/src/features/agents/desktopObservations.ts create mode 100644 migrations/0046_desktop_observation_fts.sql diff --git a/crates/buzz-core/src/desktop_observation.rs b/crates/buzz-core/src/desktop_observation.rs new file mode 100644 index 00000000000..21ba206280a --- /dev/null +++ b/crates/buzz-core/src/desktop_observation.rs @@ -0,0 +1,123 @@ +//! Owner-private, advisory Desktop observations; never agent readiness or placement. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_OBSERVATION}; + +/// A pulse for one local profile. The signed event timestamp is the observed time. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopObservation { + /// Format version. + pub v: u8, + /// Canonical community, encrypted along with the coordinate. + pub community: String, + /// Stable Desktop profile coordinate, not an execution credential. + pub id: String, +} + +/// 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_OBSERVATION) +} + +impl DesktopObservation { + /// Observe a profile belonging to this local Desktop. + pub fn new(profile: DesktopProfile) -> Self { + Self { + v: 1, + community: profile.community, + id: profile.id, + } + } + + /// Encrypt and sign a fresh observation, without rewriting the durable profile. + pub fn sign(&self, keys: &Keys) -> Result { + 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())?; + EventBuilder::new(Kind::Custom(KIND_DESKTOP_OBSERVATION as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) + } + + /// Authenticate and decrypt an observation before displaying its timestamp. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop observation signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop observation".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop observation decryption failed")?; + let observation: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop observation")?; + let expected = Self::new(DesktopProfile::new( + community.to_owned(), + event.tags.identifier().unwrap_or_default().to_owned(), + )?); + if observation != expected { + return Err("Desktop observation scope mismatch".into()); + } + Ok(observation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn observation_is_private_scoped_and_distinct_from_profile() { + let keys = Keys::generate(); + let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(); + let saved = profile.sign(&keys).unwrap(); + let observation = DesktopObservation::new(profile); + let event = observation.sign(&keys).unwrap(); + assert_eq!( + DesktopObservation::read(&event, &keys, &observation.community).unwrap(), + observation + ); + assert!(DesktopObservation::read(&event, &keys, "wss://two.example").is_err()); + assert!( + DesktopObservation::read(&event, &Keys::generate(), &observation.community).is_err() + ); + assert!(DesktopObservation::read(&saved, &keys, &observation.community).is_err()); + assert!(DesktopProfile::read(&event, &keys, &observation.community).is_err()); + let forged_author = EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(DesktopObservation::read(&forged_author, &keys, &observation.community).is_err()); + let mut tampered = event.clone(); + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopObservation::read(&tampered, &keys, &observation.community).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_OBSERVATION)); + for field in ["v", "community", "id", "extra"] { + let mut payload = serde_json::to_value(&observation).unwrap(); + payload[field] = serde_json::json!("invalid"); + 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!( + DesktopObservation::read(&invalid, &keys, &observation.community).is_err(), + "{field}" + ); + } + } +} diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs index 697808c4677..5b08a438b3a 100644 --- a/crates/buzz-core/src/desktop_profile.rs +++ b/crates/buzz-core/src/desktop_profile.rs @@ -20,8 +20,15 @@ pub struct DesktopProfile { /// Validate the public envelope without decrypting private content. pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + validate_private_desktop_envelope(event, KIND_DESKTOP_PROFILE) +} + +pub(crate) fn validate_private_desktop_envelope( + event: &Event, + kind: u32, +) -> Result<(), &'static str> { let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect(); - if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE + if event.kind.as_u16() as u32 != kind || event.created_at.as_secs() > 253_402_300_799 || !(132..=2048).contains(&event.content.len()) || tags.len() != 1 diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index bb8be1a2819..1e5bfd43d6e 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -120,6 +120,9 @@ pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; /// Owner-private encrypted Desktop identity/name, keyed by installation coordinate. 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; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -134,6 +137,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_PUSH_LEASE, KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -664,6 +668,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, 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 fe94c0f356a..2105e4a15a7 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_observation; /// Owner-private Desktop display profiles. pub mod desktop_profile; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 8cc2b3cd08d..5a0d2a64150 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(), 45); + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -910,8 +910,9 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); 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, 30300, 30350, 30622, 44100, 44101, 44200)")); + assert!(include_str!("../../../../schema/schema.sql").contains( + "kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)" + )); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -2391,6 +2392,7 @@ mod postgres_tests { (2_u8, 30_350_i32), (3_u8, 30_179_i32), (4_u8, 30_180_i32), + (5_u8, 30_181_i32), ] { sqlx::query( "INSERT INTO events \ @@ -2420,7 +2422,13 @@ mod postgres_tests { .expect("read pre-push search behavior"); assert_eq!( before, - vec![(1, true), (30_179, true), (30_180, true), (30_350, true)] + vec![ + (1, true), + (30_179, true), + (30_180, true), + (30_181, true), + (30_350, true) + ] ); // 0014 fixes 30350 only. A brownfield database that stopped here still @@ -2442,6 +2450,7 @@ mod postgres_tests { (1, Some(true)), (30_179, Some(true)), (30_180, Some(true)), + (30_181, Some(true)), (30_350, None) ] ); @@ -2457,6 +2466,18 @@ mod postgres_tests { .await .expect("read pre-0045 Desktop search behavior"); assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS"); + run_migrations_through(&pool, 45) + .await + .expect("apply through 45"); + let observation_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30181") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + observation_indexed, + "0046 must change brownfield observation FTS" + ); run_migrations(&pool) .await @@ -2474,6 +2495,7 @@ mod postgres_tests { (1, Some(true)), (30_179, None), (30_180, None), + (30_181, 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 922c4bb0226..5d863666832 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_PROFILE; +use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; use tower::ServiceExt; @@ -67,6 +67,16 @@ fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Ve #[tokio::test] #[ignore = "requires Postgres"] async fn desktop_profile_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_PROFILE).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_observation_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_OBSERVATION).await; +} + +async fn assert_private_desktop(kind: u32) { let mut state = bridge_handler_test_state() .await .expect("test infrastructure"); @@ -86,13 +96,21 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { uuid::Uuid::new_v4().simple().to_string(), ) .unwrap(); - let event = profile.sign(&owner).unwrap(); + let id = profile.id.clone(); + let event = if kind == KIND_DESKTOP_PROFILE { + profile.sign(&owner).unwrap() + } else { + buzz_core::desktop_observation::DesktopObservation::new(profile) + .sign(&owner) + .unwrap() + }; let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await; assert_eq!(status, StatusCode::OK, "{result}"); assert_eq!(result["accepted"], true, "{result}"); // Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe. - let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]); - let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]); + let own = json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "limit":100}]); + let exact = + json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "#d":[id], "limit":1}]); for filters in [&own, &exact] { let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await; assert_eq!(status, StatusCode::OK, "{rows}"); @@ -105,7 +123,7 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() { assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}"); } // Known IDs cannot grant an authenticated outsider read access either. - let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]); + let known = json!([{"ids":[event.id.to_hex()], "kinds":[kind,1]}]); let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await; assert_eq!(status, StatusCode::OK, "{rows}"); assert_eq!(rows, json!([])); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 5ed5f1037e2..b2f86ec357c 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2215,6 +2215,11 @@ mod tests { assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await; } + #[tokio::test] + async fn desktop_observation_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).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 13c781613fb..f60a76fd4a8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -10,7 +10,6 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::Scope; -use buzz_core::kind::KIND_DESKTOP_PROFILE; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, @@ -37,6 +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::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 => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => 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 @@ -659,6 +659,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT | KIND_DESKTOP_PROFILE + | KIND_DESKTOP_OBSERVATION | 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). @@ -2792,6 +2793,11 @@ async fn ingest_event_inner( } } + if kind_u32 == KIND_DESKTOP_OBSERVATION { + buzz_core::desktop_observation::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_DESKTOP_PROFILE { buzz_core::desktop_profile::validate_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs index 3b3017da71b..d3438d71c51 100644 --- a/desktop/src-tauri/src/commands/desktop_profiles.rs +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -1,5 +1,5 @@ //! Durable read-only Desktop profiles, separate from persona publication queues. -use buzz_core_pkg::desktop_profile::DesktopProfile; +use buzz_core_pkg::{desktop_observation::DesktopObservation, desktop_profile::DesktopProfile}; use nostr::{Event, JsonUtil}; use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; use serde_json::{json, Value}; @@ -97,6 +97,50 @@ pub fn read_desktop_profiles( Ok(json!(rows?)) } +/// Sign a fresh pulse for the persisted local profile, never an arbitrary host ID. +#[tauri::command] +pub fn prepare_desktop_observation( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + Ok(json!({ "event": prepare_observation(&mut open_retention_db(&scope.db_path)?, &scope)? })) +} + +fn prepare_observation(conn: &mut Connection, scope: &RetentionScope) -> Result { + let saved = prepare(conn, scope)?; + let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let profile = DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )?; + DesktopObservation::new(profile).sign(&scope.owner_keys) +} + +/// Verify bounded owner-private observations; the UI treats their timestamps as advisory. +#[tauri::command] +pub fn read_desktop_observations( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + if events.len() > 100 { + return Err("too many Desktop observations".into()); + } + let mut rows = Vec::with_capacity(events.len()); + for event in &events { + let observation = DesktopObservation::read(event, &scope.owner_keys, &community)?; + rows.push(json!({ "id": observation.id, "heard": event.created_at.as_secs() })); + } + Ok(json!(rows)) +} + #[cfg(test)] mod tests { use super::*; @@ -119,6 +163,16 @@ mod tests { let accepted = prepare(&mut reopened, &scope).unwrap(); assert_eq!(accepted, first); assert_eq!(reopened.total_changes(), 0, "no repeated native writes"); + let pulse = prepare_observation(&mut reopened, &scope).unwrap(); + let observation = + DesktopObservation::read(&pulse, &scope.owner_keys, &scope.relay_url).unwrap(); + assert_eq!(first["event"]["tags"][0][1], observation.id); + assert_eq!( + prepare(&mut reopened, &scope).unwrap(), + first, + "pulses never rewrite profiles" + ); + assert_eq!(reopened.total_changes(), 0); let other = RetentionScope { db_path: dir.path().join("two.db"), relay_url: scope.relay_url.clone(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 65fe1aaa4d7..edffed5e462 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -554,6 +554,8 @@ pub fn run() { get_identity, prepare_desktop_profile, read_desktop_profiles, + prepare_desktop_observation, + read_desktop_observations, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs index 4ffa141e0d2..6f7330d978c 100644 --- a/desktop/src/features/agents/desktopList.test.mjs +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -155,10 +155,11 @@ test("rendered list distinguishes current, partial, unavailable and empty withou render({ ...list, rows: [], partial: false }), /No Desktop profiles found/, ); - assert.doesNotMatch(html, /Last heard|Online|Offline/); + assert.match(html, /Last heard: Unknown/); + assert.doesNotMatch(html, /Online|Offline/); }); -test("mounted cache clears both scopes, fences late reads and retains rows on failure", async () => { +test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => { const { JSDOM } = await import("jsdom"); const dom = new JSDOM("
", { url: "https://desktop.test", @@ -198,8 +199,26 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa let current; const originalFetch = relayClient.fetchEvents; const originalPublish = relayClient.publishEvent; + const originalReconnect = relayClient.subscribeToReconnects; + let reconnect; + let pulses = 0; + relayClient.subscribeToReconnects = (callback) => { + reconnect = callback; + return () => { + reconnect = undefined; + }; + }; window.__TAURI_INTERNALS__ = { invoke: async (command, args) => { + if (command === "prepare_desktop_observation") + return { event: { ...first, kind: 30181 } }; + if (command === "read_desktop_observations") + return [ + { + id: `${args.owner}-${args.community}`, + heard: Math.floor(Date.now() / 1000), + }, + ]; if (command === "prepare_desktop_profile") { current = { ...first, @@ -218,6 +237,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 (filter["#d"]) return [current]; const rows = [current]; if (hold) { @@ -228,8 +248,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa } return rows; }; - relayClient.publishEvent = async () => { - throw Error("unexpected rewrite"); + relayClient.publishEvent = async (event) => { + assert.equal(event.kind, 30181, "no profile heartbeat rewrite"); + pulses++; }; function Screen() { controls = useCommunities(); @@ -246,6 +267,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa await new Promise((resolve) => setTimeout(resolve, 20)); }); const text = () => document.body.textContent; + t.mock.timers.enable({ apis: ["setInterval"] }); try { await React.act(async () => root.render( @@ -262,6 +284,15 @@ 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/); + const beforeReconnect = pulses; + await React.act(async () => reconnect()); + await settle(); + assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse"); + const beforeTimer = pulses; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.ok(pulses > beforeTimer, "bounded periodic publisher runs"); hold = true; await React.act(async () => { void client.refetchQueries({ queryKey: ["desktop-profiles"] }); @@ -283,8 +314,11 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa fail = true; await React.act(async () => { await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); }); await settle(); + assert.match(text(), /Last-heard refresh unavailable/); + assert.match(text(), /Last heard: Recent/); assert.match(text(), /unavailable/); assert.match(text(), /owner-b-wss:\/\/a.example/); await React.act(async () => controls.switchCommunity("b")); @@ -293,6 +327,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa fail = false; await React.act(async () => { await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); }); await settle(); assert.match(text(), /owner-b-wss:\/\/b.example/); @@ -301,6 +336,13 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa client.clear(); relayClient.fetchEvents = originalFetch; relayClient.publishEvent = originalPublish; + relayClient.subscribeToReconnects = originalReconnect; + assert.equal( + reconnect, + undefined, + "reconnect producer unsubscribed on unmount", + ); + t.mock.timers.reset(); dom.window.close(); } }); diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs new file mode 100644 index 00000000000..af9418365f9 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + refreshDesktopObservations, + desktopFreshness, +} from "./desktopObservations.ts"; + +const scope = { owner: "owner-a", community: "wss://a.example" }; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const event = { id: "pulse", kind: 30181 }; + const rows = [ + { id: "a", heard: 100 }, + { id: "b", heard: 10 }, + ]; + const finish = (name, value) => { + if (name === boundary) epoch++; + return value; + }; + const f = { + calls, + rows, + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_observation") + return finish(command, { event }); + assert.equal(command, "read_desktop_observations"); + return finish(command, rows); + }, + relay: { + getSessionEpoch: () => epoch, + publishEvent: async (value) => { + assert.equal(value, event); + calls.push("publish"); + finish("publishEvent"); + }, + fetchEvents: async (filter) => { + assert.deepEqual(filter, { + kinds: [30181], + authors: [scope.owner], + limit: 100, + }); + calls.push("fetch"); + return finish("fetchEvents", [event]); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopObservations(scope, active, f.ipc, f.relay); + return f; +} + +test("every async completion is fenced, including decrypt and late ACK", async () => { + for (const boundary of [ + "prepare_desktop_observation", + "publishEvent", + "fetchEvents", + "read_desktop_observations", + ]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary === "prepare_desktop_observation") + assert.ok(!f.calls.includes("publish")); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); +}); + +test("bounded/invalid reads are not silently authoritative, clocks and staleness stay advisory", async () => { + const f = fixture(); + const good = await f.refresh(); + assert.deepEqual(good.rows, f.rows); + assert.deepEqual(f.calls, [ + "prepare_desktop_observation", + "publish", + "fetch", + "read_desktop_observations", + ]); + f.relay.publishEvent = async () => { + throw Error("offline"); + }; + assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => Array(100).fill({}); + assert.equal((await f.refresh()).partial, true); + const read = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + throw Error("invalid signature"); + return read(command, args); + }; + await assert.rejects(f.refresh(), /invalid signature/); + for (const [heard, now, expected] of [ + [undefined, 100, "Unknown"], + [100, 280, "Recent"], + [100, 281, "Stale"], + [101, 100, "Unknown (Desktop clock ahead)"], + ]) + assert.ok(desktopFreshness(heard, now).startsWith(expected)); +}); diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts new file mode 100644 index 00000000000..5de30fe8139 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.ts @@ -0,0 +1,82 @@ +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 DesktopObservation = { id: string; heard: number }; +export const DESKTOP_PULSE_MS = 60_000; + +/** A failed pulse does not hide other Desktops; failed reads retain cached observations. */ +export async function refreshDesktopObservations( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop observation scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_observation", scope), + ); + await wait( + relay.publishEvent( + event, + "Desktop pulse timed out", + "Desktop pulse failed", + ), + ); + } catch { + check(); + // The next bounded interval retries with a new observation, not an old heartbeat. + warning = "This Desktop could not report its last-heard time. Will retry."; + } + const events = await wait( + relay.fetchEvents({ kinds: [30181], authors: [scope.owner], limit: 100 }), + ); + const rows = await wait( + ipc("read_desktop_observations", { + ...scope, + events, + }), + ); + return { rows, warning, partial: events.length === 100 }; +} + +export function useDesktopObservations(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-observations", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopObservations(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: DESKTOP_PULSE_MS / 2, + retry: false, + refetchOnWindowFocus: false, + }); +} + +/** Signed sender time is advisory, including clock skew; it never establishes agent death. */ +export function desktopFreshness(heard: number | undefined, now: number) { + if (heard === undefined) return "Unknown"; + const state = + heard > now + ? "Unknown (Desktop clock ahead)" + : now - heard <= 180 + ? "Recent" + : "Stale"; + return `${state} · ${new Date(heard * 1000).toLocaleString()}`; +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx index 1d986d7bfd2..68eeacc1bcb 100644 --- a/desktop/src/features/agents/ui/KnownDesktops.tsx +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -1,25 +1,40 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; import { relayClient } from "@/shared/api/relayClient"; import { Button } from "@/shared/ui/button"; import { refreshDesktopList, type DesktopList } from "../desktopList"; +import { + DESKTOP_PULSE_MS, + desktopFreshness, + useDesktopObservations, + type DesktopObservation, +} from "../desktopObservations"; type View = { list: DesktopList | null; error: boolean; loading: boolean; refresh: () => void; + observations?: DesktopObservation[]; + observationWarning?: string; + now?: number; }; -function useDesktopList() { +function useDesktopScope() { const owner = useIdentityQuery().data?.pubkey; const { activeCommunity } = useCommunities(); const community = activeCommunity?.relayUrl .trim() .replace(/^http/, "ws") .replace(/\/+$/, ""); + return owner && community ? { owner, community } : null; +} + +function useDesktopList() { + const scope = useDesktopScope(); + const { owner, community } = scope ?? {}; return useQuery({ queryKey: ["desktop-profiles", owner, community], enabled: !!owner && !!community, @@ -38,31 +53,62 @@ function useDesktopList() { /** Startup and Agents share the existing owner/community query cache. */ export function DesktopListStartup() { const { refetch } = useDesktopList(); - useEffect( - () => - relayClient.subscribeToReconnects(() => { - void refetch(); - }), - [refetch], - ); + const { refetch: pulse } = useDesktopObservations(useDesktopScope()); + useEffect(() => { + const timer = setInterval(() => { + void pulse(); + }, DESKTOP_PULSE_MS); + const unsubscribe = relayClient.subscribeToReconnects(() => { + void refetch(); + void pulse(); + }); + return () => { + clearInterval(timer); + unsubscribe(); + }; + }, [refetch, pulse]); return null; } export function KnownDesktops() { const query = useDesktopList(); + const observations = useDesktopObservations(useDesktopScope()); + const [now, setNow] = useState(() => Date.now() / 1000); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now() / 1000), 30_000); + return () => clearInterval(timer); + }, []); return ( { void query.refetch(); + void observations.refetch(); }} /> ); } -export function DesktopListView({ list, loading, error, refresh }: View) { +export function DesktopListView({ + list, + loading, + error, + refresh, + observations, + observationWarning, + now = Date.now() / 1000, +}: View) { return (

Private to you. Saved profiles do not indicate whether a Desktop is - online or ready to run agents. + online or ready to run agents. Last heard is a Desktop observation, not + proof that its agents are running or stopped.

{loading &&

Loading Desktop profiles…

} {error && ( @@ -89,6 +136,7 @@ export function DesktopListView({ list, loading, error, refresh }: View) { Desktop profiles unavailable. Previously loaded profiles are retained.

)} + {observationWarning &&

{observationWarning}

} {list?.warning &&

{list.warning}

} {list?.partial && (

Partial list: showing up to 100 profiles.

@@ -105,6 +153,13 @@ export function DesktopListView({ list, loading, error, refresh }: View) { {new Date(row.updated * 1000).toLocaleString()} +
+ Last heard:{" "} + {desktopFreshness( + observations?.find((item) => item.id === row.id)?.heard, + now, + )} +
))} diff --git a/migrations/0046_desktop_observation_fts.sql b/migrations/0046_desktop_observation_fts.sql new file mode 100644 index 00000000000..ba4776fd040 --- /dev/null +++ b/migrations/0046_desktop_observation_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop observations 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 = 30181 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 59147e4204e..16613480710 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, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 7284ffb2adb20156cb72e9538c5d9ddabd68209f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 11:27:37 -0400 Subject: [PATCH 2/2] fix(desktop): fence delayed pulses and order host observations Signed-off-by: Logan Johnson --- .../agents/desktopObservations.test.mjs | 22 +++++++++ .../features/agents/desktopObservations.ts | 9 +++- .../api/relayClientPublishRejection.test.mjs | 49 +++++++++++++++++++ desktop/src/shared/api/relayClientSession.ts | 2 + desktop/src/shared/api/relayEventPublisher.ts | 14 +++++- 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs index af9418365f9..b3166ba25d8 100644 --- a/desktop/src/features/agents/desktopObservations.test.mjs +++ b/desktop/src/features/agents/desktopObservations.test.mjs @@ -101,3 +101,25 @@ test("bounded/invalid reads are not silently authoritative, clocks and staleness ]) assert.ok(desktopFreshness(heard, now).startsWith(expected)); }); + +test("history/live replacement races select newest signed time and lower ID, not arrival", async () => { + const f = fixture(); + const events = [ + { id: "old", created_at: 10 }, + { id: "b", created_at: 20 }, + { id: "a", created_at: 20 }, + ]; + const ipc = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + assert.deepEqual( + args.events.map((e) => e.id), + ["a", "b", "old"], + ); + return ipc(command, args); + }; + for (const batch of [events, [...events].reverse()]) { + f.relay.fetchEvents = async () => batch; + await f.refresh(); + } +}); diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts index 5de30fe8139..d548fa42433 100644 --- a/desktop/src/features/agents/desktopObservations.ts +++ b/desktop/src/features/agents/desktopObservations.ts @@ -35,6 +35,7 @@ export async function refreshDesktopObservations( event, "Desktop pulse timed out", "Desktop pulse failed", + check, ), ); } catch { @@ -48,7 +49,13 @@ export async function refreshDesktopObservations( const rows = await wait( ipc("read_desktop_observations", { ...scope, - events, + // History is chronological and may include a live replacement before EOSE. + // First matching host wins in the view: newest signed time, then lower ID. + events: [...events].sort( + (a, b) => + b.created_at - a.created_at || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ), }), ); return { rows, warning, partial: events.length === 100 }; diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs index 7875b8679ab..f323623ef6f 100644 --- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -293,3 +293,52 @@ test("a community switch after send failure cannot retry through its replacement ); assert.equal(eventFrames().length, 0); }); + +test("Desktop pulse cancellation fences rate-limit and reconnect continuations", async () => { + const { refreshDesktopObservations } = await import( + "../../features/agents/desktopObservations.ts" + ); + for (const boundary of ["rate-limit", "reconnect"]) { + reset(); + const client = connectedClient(); + const scope = { owner: "owner", community: "wss://a.example" }; + let active = true; + const reconnect = deferred(); + let reconnecting = false; + client.ensureConnected = async () => { + reconnecting = true; + await reconnect.promise; + client.wsId = 8; + return client.connectionGeneration; + }; + if (boundary === "rate-limit") activateRateLimit(4); + else + sendTransport = async () => { + throw Error("socket failed"); + }; + const result = refreshDesktopObservations( + scope, + () => active, + async (command) => { + assert.equal(command, "prepare_desktop_observation"); + return { event: { id: "pulse", kind: 30181 } }; + }, + client, + ); + const rejected = assert.rejects(result, /scope changed/); + if (boundary === "reconnect") await flushUntil(() => reconnecting); + else await Promise.resolve(); + active = false; + const generation = client.connectionGeneration; + resetRateLimitGate(); + reconnect.resolve(); + await rejected; + assert.equal(eventFrames().length, 0, boundary); + assert.equal(client.pendingEvents.size, 0); + assert.equal( + client.connectionGeneration, + generation, + "cancellation is not socket failure", + ); + } +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 93384a40741..3839d01c7e5 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -725,6 +725,7 @@ export class RelayClient { event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive?: () => void, ) { return publishSessionEvent( { @@ -742,6 +743,7 @@ export class RelayClient { event, timeoutMessage, sendErrorMessage, + assertActive, ); } diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff719926700..6f6b6b7c824 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -19,9 +19,11 @@ export async function publishSessionEvent( event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive: () => void = () => {}, ): Promise { const publishOwnership = session.ownership(); await waitForRateLimit(); + assertActive(); if (publishOwnership !== session.ownership()) { throw new Error("Relay disconnected for community switch."); } @@ -48,6 +50,14 @@ export async function publishSessionEvent( return; } + try { + assertActive(); + } catch (error) { + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject(error); + return; + } // Expected socket recovery must not reject the operation being retried. session.pendingEvents.delete(event.id); const sendError = session.recoverSocketFailure(error, sendErrorMessage); @@ -55,7 +65,9 @@ export async function publishSessionEvent( let retryGeneration: number | null = null; try { - retryGeneration = await session.reconnect(); + const reconnected = await session.reconnect(); + assertActive(); + retryGeneration = reconnected; if ( publishOwnership !== session.ownership() || session.generation() !== retryGeneration ||