diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs
new file mode 100644
index 00000000000..ef1c4d8f63a
--- /dev/null
+++ b/crates/buzz-core/src/desktop_capabilities.rs
@@ -0,0 +1,209 @@
+//! 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.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(),
+ &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))
+ .custom_created_at(observed)
+ .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..f5efd5c953b
--- /dev/null
+++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs
@@ -0,0 +1,224 @@
+//! 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, 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 =
+ 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,
+ 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()],
+ )
+ .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 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"),
+ 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![],
+ || nostr::Timestamp::from(1000),
+ )
+ .unwrap();
+ let mut reopened = open_retention_db(&scope.db_path).unwrap();
+ assert_eq!(
+ prepare_report(&mut reopened, &scope, vec![], || panic!(
+ "unchanged must not sign"
+ ))
+ .unwrap(),
+ first
+ );
+ assert_eq!(reopened.total_changes(), 0);
+ let mut facts = vec![RuntimeFact {
+ id: "goose".into(),
+ availability: "available".into(),
+ requires_external_cli: true,
+ max_parallelism: None,
+ }];
+ 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![], nostr::Timestamp::now).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..da09f6bb4a8
--- /dev/null
+++ b/desktop/src/features/agents/desktopCapabilities.test.mjs
@@ -0,0 +1,160 @@
+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("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);
+ 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/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"] });
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,