mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): instance-level agents nav — fix round 2 corrections
Addresses all 6 blocking findings from Thufir pass 2:
1. [CRITICAL] Persona-grouped view model: get_owned_agent_inventory now
parses kind:30177 content for persona_id via managed_agent_content_from_event,
returns {byPersonaId, unknown} grouped view model. OwnedAgentInstance gains
personaId: Option<String> field. TypeScript OwnedAgentInventorySnapshot updated
to {byPersonaId: Record<string, ...>, unknown: [...]}.
2. [IMPORTANT] Pure reducer with raw-tail cursor: extracted reduce_page() pure
function. Cursor advances from raw page tail only (last event before dedup).
Transport errors propagate — partial inventory never silently returned.
Three new unit tests for cursor behavior, tiebreak, and full-page detection.
3. [IMPORTANT] Archive snapshot uses scoped keys: load_archive_snapshot now
calls query_relay_at_with_keys(&scope.keys) for NIP-98 auth. Returns
(false, empty) for ALL failure conditions including query transport error.
4. [IMPORTANT] Co-generational recovery flags: flag stores moved inside epoch
guard in both resolve_persisted_identity (app_state.rs) and
commit_imported_identity (commands/identity.rs). SeqCst ordering throughout.
New test capture_archive_scope_rejects_after_recovery_transition verifies
ephemeral key + flag set co-generationally is rejected by capture.
5. [IMPORTANT] Expired-bound test hardened: result.expect(...) now asserts relay
accepted the archive. Added kind:8002 delta query and consent == 'owner'
assertion proving the owner path succeeded end-to-end.
6. [IMPORTANT] Playwright spec mounted + UI simplified: spec added to smoke
project in playwright.config.ts. nip01_verification_rejects_tampered_event
now actually tampers the event JSON. UnifiedAgentsSection simplified:
openInstancesSheet takes only persona. InstancesSheet takes inventory
snapshot directly and uses byPersonaId[persona.id] for filtering.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -140,6 +140,7 @@ export default defineConfig({
|
||||
"**/huddle-transcription.spec.ts",
|
||||
"**/agent-numeric-tuning.spec.ts",
|
||||
"**/needs-restart-screenshots.spec.ts",
|
||||
"**/agent-instances-sheet.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
@@ -458,22 +458,23 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
|
||||
|
||||
let resolved = load_or_create_identity(&data_dir)?;
|
||||
// Write keys and storage before setting the recovery flags (Release) so
|
||||
// any thread that reads a flag as false with Acquire sees consistent data.
|
||||
// Write keys, storage, AND recovery flags inside the same epoch guard so
|
||||
// they are co-generational: a `capture_archive_scope` that reads an even
|
||||
// epoch after the guard exits sees a consistent (keys, flags) tuple.
|
||||
{
|
||||
let _epoch_guard = state.begin_workspace_write()?;
|
||||
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
*active_keys = resolved.keys;
|
||||
state.set_identity_storage(resolved.storage);
|
||||
state.identity_lost.store(
|
||||
resolved.recovery == RecoveryState::Lost,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
);
|
||||
state.keyring_locked.store(
|
||||
resolved.recovery == RecoveryState::KeyringLocked,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
);
|
||||
}
|
||||
state.identity_lost.store(
|
||||
resolved.recovery == RecoveryState::Lost,
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
state.keyring_locked.store(
|
||||
resolved.recovery == RecoveryState::KeyringLocked,
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -316,3 +316,47 @@ fn capture_archive_scope_rejects_when_keyring_locked() {
|
||||
"error must mention recovery mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// Finding 4 (fix round 2): recovery key + true flag must never yield a
|
||||
/// signable scope. This is the inverse-transition test:
|
||||
/// 1. Start with a normal (non-recovery) key — capture succeeds.
|
||||
/// 2. Simulate a recovery transition: write a new ephemeral key inside the
|
||||
/// epoch guard AND set identity_lost = true (co-generational).
|
||||
/// 3. A subsequent capture_archive_scope call must reject — the flag and key
|
||||
/// are from the same generation, so there is no window where the
|
||||
/// ephemeral key is visible with flags=false.
|
||||
#[test]
|
||||
fn capture_archive_scope_rejects_after_recovery_transition() {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let normal_keys = Keys::generate();
|
||||
let state = make_epoch_test_state(normal_keys);
|
||||
|
||||
// Pre-condition: capture succeeds with normal keys.
|
||||
assert!(
|
||||
state.capture_archive_scope(8).is_ok(),
|
||||
"pre-condition: capture must succeed with normal keys"
|
||||
);
|
||||
|
||||
// Simulate a recovery transition — write ephemeral recovery key AND set
|
||||
// identity_lost = true, both inside the epoch guard (co-generational).
|
||||
{
|
||||
let _guard = state
|
||||
.begin_workspace_write()
|
||||
.expect("begin_workspace_write");
|
||||
let ephemeral = Keys::generate();
|
||||
*state.keys.lock().unwrap() = ephemeral;
|
||||
state.identity_lost.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// Post-condition: capture must reject the ephemeral recovery key.
|
||||
let result = state.capture_archive_scope(8);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"capture must reject ephemeral recovery key after co-generational transition"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("recovery mode"),
|
||||
"error must mention recovery mode"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,29 +415,24 @@ fn commit_imported_identity(
|
||||
|
||||
let storage = persist(&keys)?;
|
||||
|
||||
// Update in-memory keys BEFORE clearing recovery flags. The Release
|
||||
// stores below pair with Acquire loads in get_identity: a reader
|
||||
// observing false is guaranteed to see the updated keys.
|
||||
// Update in-memory keys AND clear recovery flags inside the same epoch guard
|
||||
// so they are co-generational: a concurrent `capture_archive_scope` that
|
||||
// reads an even epoch after the guard exits sees the new key with flags=false.
|
||||
let pubkey = keys.public_key();
|
||||
{
|
||||
let _epoch_guard = state.begin_workspace_write()?;
|
||||
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
*active_keys = keys;
|
||||
state.set_identity_storage(storage);
|
||||
// Clear both recovery flags — an import resolves lost and locked.
|
||||
state
|
||||
.identity_lost
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
state
|
||||
.keyring_locked
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// Clear both recovery flags — an import is valid in either lost or
|
||||
// keyring-locked state and resolves both. In the locked case the
|
||||
// keyring is unreachable, so the persist step already fell back to
|
||||
// identity.key; on the next Unreachable boot the file is loaded
|
||||
// directly and when the keyring returns the adoption path picks it up.
|
||||
state
|
||||
.identity_lost
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
state
|
||||
.keyring_locked
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
|
||||
// Importing a different identity invalidates the app-managed backup: it
|
||||
// encrypts the previous key and must not linger mislabeled. Best-effort
|
||||
// per the ordering contract above.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Owned-agent relay inventory: exhaustive keyset-paged `kind:30177` query,
|
||||
//! `d`-tag agent extraction, `kind:0` fetch + NIP-01 verification, and
|
||||
//! `NipIaOwnerProof` classification joined with the archive snapshot.
|
||||
//! `d`-tag agent extraction + content parsing, `kind:0` fetch + NIP-01
|
||||
//! verification, `NipIaOwnerProof` classification joined with the archive
|
||||
//! snapshot, and persona-grouped view model.
|
||||
//!
|
||||
//! All state is captured atomically via `capture_archive_scope` before any I/O.
|
||||
|
||||
@@ -10,9 +11,9 @@ use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
app_state::{AppState, ArchiveScope},
|
||||
managed_agents::agent_events::managed_agent_content_from_event,
|
||||
relay::{
|
||||
classify_request_error, query_relay_at, query_relay_at_with_keys, relay_api_base_url,
|
||||
relay_http_base_url,
|
||||
classify_request_error, query_relay_at_with_keys, relay_api_base_url, relay_http_base_url,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,16 +48,24 @@ pub struct OwnedAgentInstance {
|
||||
pub nip_ia_owner_proof: NipIaOwnerProof,
|
||||
/// Archive tri-state joined from the `kind:13535` snapshot.
|
||||
pub archive_state: OwnedAgentArchiveState,
|
||||
/// Persona ID parsed from `kind:30177` content, if present.
|
||||
/// `None` for standalone (definition-less) agents or malformed content.
|
||||
pub persona_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot returned by `get_owned_agent_inventory`.
|
||||
/// Complete merged view model returned by `get_owned_agent_inventory`.
|
||||
///
|
||||
/// Instances are grouped by persona ID. The `unknown` bucket holds instances
|
||||
/// whose `kind:30177` content is missing or has no `persona_id`.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OwnedAgentInventorySnapshot {
|
||||
/// Whether the archive snapshot was loaded and trusted.
|
||||
pub archive_state_trusted: bool,
|
||||
/// All owned agent instances, sorted `(created_at DESC, id ASC)`.
|
||||
pub instances: Vec<OwnedAgentInstance>,
|
||||
/// Instances keyed by their persona ID — drives per-card counts and Sheet.
|
||||
pub by_persona_id: HashMap<String, Vec<OwnedAgentInstance>>,
|
||||
/// Instances with no parseable persona ID (standalone agents).
|
||||
pub unknown: Vec<OwnedAgentInstance>,
|
||||
}
|
||||
|
||||
// ── Page-to-exhaustion fetch ──────────────────────────────────────────────
|
||||
@@ -70,22 +79,83 @@ fn is_valid_agent_pubkey(s: &str) -> bool {
|
||||
lower.len() == 64 && lower.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// State returned by `reduce_page`.
|
||||
struct PageResult {
|
||||
/// Canonical (latest) event per agent pubkey accumulated across all pages.
|
||||
canonical: HashMap<String, (u64, String, nostr::Event)>,
|
||||
/// Cursor for the next request: `(created_at, id)` of the LAST raw event
|
||||
/// on this page. Advances from the raw page tail ONLY — dedup never
|
||||
/// influences cursor progression.
|
||||
next_until: u64,
|
||||
next_before_id: String,
|
||||
/// Whether the relay returned a full page (more data may follow).
|
||||
full_page: bool,
|
||||
}
|
||||
|
||||
/// Pure reducer: merge one raw relay page into `canonical` and compute the
|
||||
/// next cursor from the raw page tail.
|
||||
///
|
||||
/// Malformed `d` tags are skipped and DO NOT affect the cursor.
|
||||
fn reduce_page(
|
||||
mut canonical: HashMap<String, (u64, String, nostr::Event)>,
|
||||
page: Vec<nostr::Event>,
|
||||
) -> PageResult {
|
||||
let full_page = page.len() as u64 == PAGE_SIZE;
|
||||
|
||||
// Cursor from raw page tail — the relay sorts (created_at DESC, id ASC),
|
||||
// so the last event is the oldest on this page.
|
||||
let (next_until, next_before_id) = page
|
||||
.last()
|
||||
.map(|ev| (ev.created_at.as_secs(), ev.id.to_hex()))
|
||||
.unwrap_or((0, String::new()));
|
||||
|
||||
for ev in page {
|
||||
let d_raw = ev
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.as_slice().first().map(String::as_str) == Some("d"))
|
||||
.and_then(|t| t.as_slice().get(1).cloned())
|
||||
.unwrap_or_default();
|
||||
let agent_pubkey = d_raw.to_ascii_lowercase();
|
||||
if !is_valid_agent_pubkey(&agent_pubkey) {
|
||||
continue; // malformed d tag — skip, cursor unaffected
|
||||
}
|
||||
|
||||
let ts = ev.created_at.as_secs();
|
||||
let id = ev.id.to_hex();
|
||||
|
||||
let supersedes = canonical
|
||||
.get(&agent_pubkey)
|
||||
.map(|(ets, eid, _)| ts > *ets || (ts == *ets && id < *eid))
|
||||
.unwrap_or(true);
|
||||
|
||||
if supersedes {
|
||||
canonical.insert(agent_pubkey, (ts, id, ev));
|
||||
}
|
||||
}
|
||||
|
||||
PageResult {
|
||||
canonical,
|
||||
next_until,
|
||||
next_before_id,
|
||||
full_page,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all `kind:30177` events authored by `scope.actor`, paging to
|
||||
/// exhaustion via composite `(until, before_id)` cursor.
|
||||
///
|
||||
/// Returns the canonical latest event per NIP-33 `d` tag (agent pubkey),
|
||||
/// sorted `(created_at DESC, id ASC)`. Events with missing or non-hex-64 `d`
|
||||
/// tags are silently skipped (malformed).
|
||||
/// tags are silently skipped (malformed). Transport errors propagate — a
|
||||
/// partial inventory is never silently returned as complete.
|
||||
async fn fetch_all_owned_30177(
|
||||
state: &AppState,
|
||||
scope: &ArchiveScope,
|
||||
api_base_url: &str,
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
// Cursor state: start from "now" and page backwards by timestamp.
|
||||
let mut until: Option<u64> = None;
|
||||
let mut before_id: Option<String> = None;
|
||||
|
||||
// NIP-33 canonical map: agent_pubkey → (created_at, event_id, event).
|
||||
let mut canonical: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
|
||||
|
||||
loop {
|
||||
@@ -101,71 +171,33 @@ async fn fetch_all_owned_30177(
|
||||
filter["before_id"] = serde_json::json!(bid);
|
||||
}
|
||||
|
||||
// Transport failure propagates — partial inventory is not returned.
|
||||
let page =
|
||||
query_relay_at_with_keys(state, api_base_url, &[filter], &scope.keys, None).await?;
|
||||
|
||||
let page_len = page.len() as u64;
|
||||
let PageResult {
|
||||
canonical: new_canonical,
|
||||
next_until,
|
||||
next_before_id,
|
||||
full_page,
|
||||
} = reduce_page(canonical, page);
|
||||
canonical = new_canonical;
|
||||
|
||||
for ev in page {
|
||||
// Extract and validate agent pubkey from `d` tag.
|
||||
let d_raw = ev
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.as_slice().first().map(String::as_str) == Some("d"))
|
||||
.and_then(|t| t.as_slice().get(1).cloned())
|
||||
.unwrap_or_default();
|
||||
let agent_pubkey = d_raw.to_ascii_lowercase();
|
||||
if !is_valid_agent_pubkey(&agent_pubkey) {
|
||||
continue; // malformed d tag — skip
|
||||
}
|
||||
|
||||
let ts = ev.created_at.as_secs();
|
||||
let id = ev.id.to_hex();
|
||||
|
||||
// Canonical ordering: higher created_at wins;
|
||||
// on tie, lexicographically LOWER event ID wins (ascending).
|
||||
let supersedes = canonical
|
||||
.get(&agent_pubkey)
|
||||
.map(|(existing_ts, existing_id, _)| {
|
||||
ts > *existing_ts || (ts == *existing_ts && id < *existing_id)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if supersedes {
|
||||
canonical.insert(agent_pubkey, (ts, id, ev));
|
||||
}
|
||||
}
|
||||
|
||||
// Stop when the relay returned a partial page — no more data.
|
||||
if page_len < PAGE_SIZE {
|
||||
if !full_page {
|
||||
break;
|
||||
}
|
||||
|
||||
// Compute the minimum (oldest) event across all seen events to use
|
||||
// as the `until` boundary for the next page.
|
||||
let cursor = canonical.values().fold(
|
||||
(u64::MAX, String::new()),
|
||||
|(acc_ts, acc_id), (ts, id, _)| {
|
||||
// Oldest = smallest created_at; on tie, LARGEST id (descending)
|
||||
// so we can use before_id to skip it on the next page.
|
||||
if *ts < acc_ts || (*ts == acc_ts && *id > acc_id) {
|
||||
(*ts, id.clone())
|
||||
} else {
|
||||
(acc_ts, acc_id)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Detect no-progress (cursor didn't advance) — stop to avoid loops.
|
||||
if until == Some(cursor.0) && before_id.as_deref() == Some(&cursor.1) {
|
||||
// Guard against degenerate relay behaviour.
|
||||
let no_progress =
|
||||
until == Some(next_until) && before_id.as_deref() == Some(next_before_id.as_str());
|
||||
if no_progress {
|
||||
break;
|
||||
}
|
||||
|
||||
until = Some(cursor.0);
|
||||
before_id = Some(cursor.1);
|
||||
until = Some(next_until);
|
||||
before_id = Some(next_before_id);
|
||||
}
|
||||
|
||||
// Sort by (created_at DESC, id ASC) for stable presentation.
|
||||
let mut events: Vec<nostr::Event> = canonical.into_values().map(|(_, _, ev)| ev).collect();
|
||||
events.sort_by(|a, b| {
|
||||
let ts = b.created_at.as_secs().cmp(&a.created_at.as_secs());
|
||||
@@ -224,10 +256,17 @@ async fn fetch_and_verify_kind0(
|
||||
// ── Archive snapshot loader ───────────────────────────────────────────────
|
||||
|
||||
/// Load the relay's `kind:13535` archive snapshot for the tri-state join.
|
||||
/// Uses the pre-scoped `api_base_url` so it queries the same relay instance
|
||||
/// captured by `capture_archive_scope` — no separate state read.
|
||||
async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, HashSet<String>) {
|
||||
// Fetch the NIP-11 relay-information document at the scoped URL.
|
||||
///
|
||||
/// Uses `scope.keys` for NIP-98 authentication (same generation as the
|
||||
/// inventory query). Returns `(false, empty)` for ALL failure conditions —
|
||||
/// query failure, absent relay self, absent snapshot, and invalid signature.
|
||||
/// Only a successfully fetched, verified, relay-signed snapshot returns `true`.
|
||||
async fn load_archive_snapshot(
|
||||
state: &AppState,
|
||||
scope: &ArchiveScope,
|
||||
api_base_url: &str,
|
||||
) -> (bool, HashSet<String>) {
|
||||
// Fetch NIP-11 relay-information document at the scoped URL.
|
||||
let relay_self: Option<String> = async {
|
||||
let response = state
|
||||
.http_client
|
||||
@@ -256,10 +295,12 @@ async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, H
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some(relay_self) = relay_self else {
|
||||
// relay_self absent or fetch failed → unknown, not trusted-empty
|
||||
return (false, HashSet::new());
|
||||
};
|
||||
|
||||
let snaps = query_relay_at(
|
||||
// Use scope.keys for NIP-98 auth — same generation as the inventory query.
|
||||
let snaps = query_relay_at_with_keys(
|
||||
state,
|
||||
api_base_url,
|
||||
&[serde_json::json!({
|
||||
@@ -267,23 +308,29 @@ async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, H
|
||||
"kinds": [13535u32],
|
||||
"limit": 1,
|
||||
})],
|
||||
&scope.keys,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
.await;
|
||||
|
||||
// Query failure → unknown (not trusted-empty).
|
||||
let Ok(snaps) = snaps else {
|
||||
return (false, HashSet::new());
|
||||
};
|
||||
|
||||
match snaps.into_iter().next() {
|
||||
// No snapshot present yet → trusted-empty (relay self confirmed).
|
||||
None => (true, HashSet::new()),
|
||||
Some(snap) => {
|
||||
if !snap.verify_id()
|
||||
|| !snap.verify_signature()
|
||||
|| !snap.pubkey.to_hex().eq_ignore_ascii_case(&relay_self)
|
||||
{
|
||||
(false, HashSet::new())
|
||||
} else {
|
||||
let set: HashSet<String> =
|
||||
archived_pubkeys_from_snapshot(&snap).into_iter().collect();
|
||||
(true, set)
|
||||
// Invalid snapshot → unknown.
|
||||
return (false, HashSet::new());
|
||||
}
|
||||
let set: HashSet<String> = archived_pubkeys_from_snapshot(&snap).into_iter().collect();
|
||||
(true, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,25 +357,26 @@ fn parse_display_fields(content: &str) -> (Option<String>, Option<String>) {
|
||||
/// Query the relay's `kind:30177` inventory for agents owned by the current
|
||||
/// user. Pages to exhaustion; applies NIP-33 dedup; fetches each agent's
|
||||
/// `kind:0` for NIP-OA classification; joins the archive tri-state.
|
||||
/// Parses `kind:30177` content for `persona_id` and groups results.
|
||||
///
|
||||
/// All state is captured atomically via the seqlock before any I/O. The
|
||||
/// previous `cursor`/`page_size` parameters are removed — this command always
|
||||
/// returns a complete snapshot.
|
||||
/// All state is captured atomically via the seqlock before any I/O.
|
||||
#[tauri::command]
|
||||
pub async fn get_owned_agent_inventory(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<OwnedAgentInventorySnapshot, String> {
|
||||
let scope = state.capture_archive_scope(8)?;
|
||||
// Derive the API base URL from the epoch-captured scope — same pattern as
|
||||
// `scoped_archive_operation`. Never re-reads state.relay_url_override.
|
||||
let api_base_url = match &scope.relay_url_override {
|
||||
Some(url) => relay_http_base_url(url),
|
||||
None => relay_api_base_url(),
|
||||
};
|
||||
|
||||
let owned_events = fetch_all_owned_30177(&state, &scope, &api_base_url).await?;
|
||||
let (archive_state_trusted, archived_set) = load_archive_snapshot(&state, &api_base_url).await;
|
||||
let (archive_state_trusted, archived_set) =
|
||||
load_archive_snapshot(&state, &scope, &api_base_url).await;
|
||||
|
||||
// Bounded batch: fetch all kind:0 profiles concurrently but fail the
|
||||
// entire snapshot on transport error (a partial inventory is dangerous
|
||||
// for the no-third-mint gate).
|
||||
let mut instances = Vec::with_capacity(owned_events.len());
|
||||
for ev in owned_events {
|
||||
// Re-extract agent pubkey (already validated by fetch_all_owned_30177).
|
||||
@@ -340,10 +388,16 @@ pub async fn get_owned_agent_inventory(
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// Parse persona_id from kind:30177 content (authoritative per wire contract).
|
||||
let persona_id = managed_agent_content_from_event(&ev)
|
||||
.ok()
|
||||
.and_then(|c| c.persona_id);
|
||||
|
||||
// Fetch + NIP-01-verify the agent's kind:0.
|
||||
// Transport failure propagates — do NOT silently omit this instance.
|
||||
let (proof, display_name, picture) =
|
||||
match fetch_and_verify_kind0(&state, &scope, &api_base_url, &agent_pubkey).await {
|
||||
Err(_) => continue, // I/O failure — skip, will refresh
|
||||
Err(e) => return Err(format!("kind:0 fetch failed for {agent_pubkey}: {e}")),
|
||||
Ok(None) => (NipIaOwnerProof::MissingProfile, None, None),
|
||||
Ok(Some(k0)) => {
|
||||
let proof = classify_nip_ia_owner_proof(&k0, &scope.actor);
|
||||
@@ -365,12 +419,26 @@ pub async fn get_owned_agent_inventory(
|
||||
relay_url: api_base_url.clone(),
|
||||
nip_ia_owner_proof: proof,
|
||||
archive_state: OwnedAgentArchiveState { is_archived },
|
||||
persona_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Group instances: byPersonaId for those with a known persona, unknown for
|
||||
// those without. This grouping is the authoritative source for per-card
|
||||
// counts, Sheet filtering, and the start-control safeguard.
|
||||
let mut by_persona_id: HashMap<String, Vec<OwnedAgentInstance>> = HashMap::new();
|
||||
let mut unknown: Vec<OwnedAgentInstance> = Vec::new();
|
||||
for instance in instances {
|
||||
match &instance.persona_id {
|
||||
Some(pid) => by_persona_id.entry(pid.clone()).or_default().push(instance),
|
||||
None => unknown.push(instance),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(OwnedAgentInventorySnapshot {
|
||||
archive_state_trusted,
|
||||
instances,
|
||||
by_persona_id,
|
||||
unknown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -395,16 +463,12 @@ mod tests {
|
||||
assert!(!is_valid_agent_pubkey(&"g".repeat(64))); // non-hex
|
||||
}
|
||||
|
||||
/// Finding 6: `fetch_and_verify_kind0` rejects events with invalid NIP-01
|
||||
/// ID or signature. Verify the reject-if-tampered path by constructing a
|
||||
/// well-formed event and then checking that a tampered copy is rejected.
|
||||
///
|
||||
/// We can't call the async fn in a sync unit test, but we can directly
|
||||
/// exercise the verification predicates it delegates to, confirming the
|
||||
/// branches it would take.
|
||||
/// Finding 6: `fetch_and_verify_kind0` rejects events with tampered NIP-01
|
||||
/// ID or signature. Construct a genuine event, tamper it, and confirm the
|
||||
/// verification predicates it delegates to both reject the tampered copy.
|
||||
#[test]
|
||||
fn nip01_verification_rejects_tampered_event() {
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind};
|
||||
let agent = Keys::generate();
|
||||
let ev = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.sign_with_keys(&agent)
|
||||
@@ -417,22 +481,26 @@ mod tests {
|
||||
"genuine event must pass verify_signature"
|
||||
);
|
||||
|
||||
// Simulate what fetch_and_verify_kind0 would do with a genuinely signed
|
||||
// event: both checks pass and the kind and pubkey match.
|
||||
assert_eq!(ev.kind, nostr::Kind::Metadata, "kind:0 check");
|
||||
assert_eq!(
|
||||
ev.pubkey.to_hex(),
|
||||
agent.public_key().to_hex(),
|
||||
"authorship check"
|
||||
// Tamper: mutate the content so the event ID no longer matches.
|
||||
// Deserialize to raw JSON, swap the content field, reserialise.
|
||||
let mut raw: serde_json::Value =
|
||||
serde_json::from_str(&ev.as_json()).expect("event must be valid JSON");
|
||||
raw["content"] = serde_json::json!("tampered content");
|
||||
let tampered_json = serde_json::to_string(&raw).unwrap();
|
||||
let tampered = nostr::Event::from_json(&tampered_json)
|
||||
.expect("tampered JSON must still parse as an Event struct");
|
||||
|
||||
// The tampered copy must FAIL at least verify_id (content was changed).
|
||||
// fetch_and_verify_kind0 checks both and rejects if either fails.
|
||||
assert!(
|
||||
!tampered.verify_id() || !tampered.verify_signature(),
|
||||
"tampered event must fail at least one NIP-01 check"
|
||||
);
|
||||
}
|
||||
|
||||
/// Finding 6: when fetch_and_verify_kind0 returns None, the inventory
|
||||
/// code correctly maps to NipIaOwnerProof::MissingProfile. Verify the
|
||||
/// mapping is present in the `get_owned_agent_inventory` path.
|
||||
///
|
||||
/// We test this via the NipIaOwnerProof enum itself — MissingProfile must
|
||||
/// exist and be serializable (it was previously "dead" per Thufir's review).
|
||||
#[test]
|
||||
fn missing_profile_variant_is_reachable_and_serializable() {
|
||||
use super::super::NipIaOwnerProof;
|
||||
@@ -445,63 +513,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── reduce_page tests ────────────────────────────────────────────────────
|
||||
|
||||
/// reduce_page uses the raw page tail for the cursor, not the dedup map
|
||||
/// tail. When a page contains only malformed d-tags, the canonical map is
|
||||
/// empty but the cursor still advances from the raw events.
|
||||
#[test]
|
||||
fn canonical_ordering_later_created_at_wins() {
|
||||
fn reduce_page_cursor_from_raw_tail_not_dedup_map() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
let owner = Keys::generate();
|
||||
|
||||
// Two events with MALFORMED d-tags — they won't enter canonical,
|
||||
// but they ARE on the raw page and the cursor must advance from them.
|
||||
let ev_old = EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", "not-hex"]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
let ev_new = EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", "also-bad"]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
|
||||
// Simulate relay order: newest first. ev_new is first, ev_old is last.
|
||||
// The cursor must come from ev_old (the raw page tail = oldest event).
|
||||
let page = vec![ev_new.clone(), ev_old.clone()];
|
||||
let result = reduce_page(HashMap::new(), page);
|
||||
|
||||
// No events passed the pubkey validation — canonical stays empty.
|
||||
assert!(
|
||||
result.canonical.is_empty(),
|
||||
"malformed d tags must not enter canonical"
|
||||
);
|
||||
// Cursor must come from the raw tail (ev_old), not u64::MAX or empty.
|
||||
assert_eq!(result.next_until, ev_old.created_at.as_secs());
|
||||
assert_eq!(result.next_before_id, ev_old.id.to_hex());
|
||||
}
|
||||
|
||||
/// Two events with equal created_at: the one with the lexicographically
|
||||
/// smaller ID wins in the canonical map (NIP-33 tiebreak rule).
|
||||
#[test]
|
||||
fn reduce_page_equal_timestamp_lower_id_wins() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
let owner = Keys::generate();
|
||||
let agent_pk = "a".repeat(64);
|
||||
|
||||
let mut map: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
|
||||
|
||||
// Insert ev1 first.
|
||||
let ev1 = EventBuilder::new(Kind::Custom(30177), "")
|
||||
// Generate two events and keep trying until we have same created_at.
|
||||
// In practice, two Events built back-to-back in the same second will
|
||||
// share the timestamp — but we can't guarantee that in a unit test.
|
||||
// Instead, use two events and pick the winner based on the rule.
|
||||
let ev1 = EventBuilder::new(Kind::Custom(30177), "v1")
|
||||
.tags([Tag::parse(["d", &agent_pk]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
let ts1 = ev1.created_at.as_secs();
|
||||
let id1 = ev1.id.to_hex();
|
||||
map.insert(agent_pk.clone(), (ts1, id1.clone(), ev1.clone()));
|
||||
|
||||
// ev2 has the same created_at but a potentially different id.
|
||||
let ev2 = EventBuilder::new(Kind::Custom(30177), "v2")
|
||||
.tags([Tag::parse(["d", &agent_pk]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
let ts2 = ev2.created_at.as_secs();
|
||||
|
||||
let id1 = ev1.id.to_hex();
|
||||
let id2 = ev2.id.to_hex();
|
||||
let ts1 = ev1.created_at.as_secs();
|
||||
let ts2 = ev2.created_at.as_secs();
|
||||
|
||||
// Apply the canonical supersedes logic.
|
||||
let supersedes = map
|
||||
.get(&agent_pk)
|
||||
.map(|(ets, eid, _)| ts2 > *ets || (ts2 == *ets && id2 < *eid))
|
||||
.unwrap_or(true);
|
||||
let page = vec![ev1.clone(), ev2.clone()];
|
||||
let result = reduce_page(HashMap::new(), page);
|
||||
assert_eq!(result.canonical.len(), 1);
|
||||
|
||||
if supersedes {
|
||||
map.insert(agent_pk.clone(), (ts2, id2.clone(), ev2.clone()));
|
||||
}
|
||||
|
||||
// Exactly one canonical event per agent_pk.
|
||||
assert_eq!(map.len(), 1);
|
||||
let (_ts, _id, canonical) = map.get(&agent_pk).unwrap();
|
||||
|
||||
// If timestamps differ, the later one wins.
|
||||
let (_, winning_id, _) = result.canonical.get(&agent_pk).unwrap();
|
||||
if ts1 != ts2 {
|
||||
if ts2 > ts1 {
|
||||
assert_eq!(canonical.id, ev2.id);
|
||||
// Whichever has higher created_at wins.
|
||||
if ts1 > ts2 {
|
||||
assert_eq!(winning_id, &id1);
|
||||
} else {
|
||||
assert_eq!(canonical.id, ev1.id);
|
||||
assert_eq!(winning_id, &id2);
|
||||
}
|
||||
} else {
|
||||
// Equal timestamps: lower event ID wins.
|
||||
if id2 < id1 {
|
||||
assert_eq!(canonical.id, ev2.id);
|
||||
// Equal timestamps: lower id wins.
|
||||
if id1 < id2 {
|
||||
assert_eq!(winning_id, &id1);
|
||||
} else {
|
||||
assert_eq!(canonical.id, ev1.id);
|
||||
assert_eq!(winning_id, &id2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A full page (PAGE_SIZE events) sets full_page = true; a partial page
|
||||
/// does not.
|
||||
#[test]
|
||||
fn reduce_page_full_page_detection() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
let owner = Keys::generate();
|
||||
|
||||
// Build PAGE_SIZE events with distinct d-tags.
|
||||
let full_page_events: Vec<nostr::Event> = (0..PAGE_SIZE)
|
||||
.map(|i| {
|
||||
let pk = format!("{:0>64}", format!("{i:x}"));
|
||||
EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", &pk]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = reduce_page(HashMap::new(), full_page_events);
|
||||
assert!(result.full_page, "PAGE_SIZE events must set full_page");
|
||||
|
||||
// One event less → partial.
|
||||
let partial: Vec<nostr::Event> = (0..(PAGE_SIZE - 1))
|
||||
.map(|i| {
|
||||
let pk = format!("{:0>64}", format!("{i:x}"));
|
||||
EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", &pk]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let result = reduce_page(HashMap::new(), partial);
|
||||
assert!(!result.full_page, "partial page must NOT set full_page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_agent_pubkeys_yield_separate_canonical_entries() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
@@ -510,16 +643,17 @@ mod tests {
|
||||
let agent1 = "a".repeat(64);
|
||||
let agent2 = "b".repeat(64);
|
||||
|
||||
let mut map: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
|
||||
for pk in [&agent1, &agent2] {
|
||||
let ev = EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", pk]).unwrap()])
|
||||
let page = vec![
|
||||
EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", &agent1]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
let ts = ev.created_at.as_secs();
|
||||
let id = ev.id.to_hex();
|
||||
map.insert(pk.to_string(), (ts, id, ev));
|
||||
}
|
||||
assert_eq!(map.len(), 2);
|
||||
.unwrap(),
|
||||
EventBuilder::new(Kind::Custom(30177), "")
|
||||
.tags([Tag::parse(["d", &agent2]).unwrap()])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap(),
|
||||
];
|
||||
let result = reduce_page(HashMap::new(), page);
|
||||
assert_eq!(result.canonical.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -472,8 +472,14 @@ async fn expired_bound_profile_mints_fresh_empty_condition_tag() {
|
||||
let (result, observed_event, _) =
|
||||
run_with_observation(&state, &scope, ArchiveKind::Archive, &agent_pubkey).await;
|
||||
|
||||
// The relay may accept or reject based on condition eval, but the
|
||||
// submitted request MUST carry exactly one fresh empty-condition auth tag.
|
||||
// NIP-IA §Published-profile rule: time clauses MUST NOT be evaluated by
|
||||
// the relay — an expired profile MUST still be accepted for the owner path.
|
||||
// `result.expect()` proves the zombie-agent path is not broken.
|
||||
let relay_result = result.expect(
|
||||
"expired-profile owner-path archive must succeed: NIP-IA time clauses must not be evaluated"
|
||||
);
|
||||
|
||||
// The submitted request MUST carry exactly one fresh empty-condition auth tag.
|
||||
let observed_event = observed_event.expect("must have observed a production-signed event");
|
||||
let auth_tags: Vec<&[String]> = observed_event
|
||||
.tags
|
||||
@@ -490,12 +496,21 @@ async fn expired_bound_profile_mints_fresh_empty_condition_tag() {
|
||||
auth_tags[0][2], "",
|
||||
"fresh-minted tag must have EMPTY condition (not the expired profile condition)"
|
||||
);
|
||||
// Distinct from the profile tag: the profile tag has condition=past, the
|
||||
// fresh tag has condition="". The assertion above confirms this.
|
||||
|
||||
// The result depends on whether the relay accepts an expired profile tag
|
||||
// or not. Either way, the WIRE form was correct.
|
||||
let _ = result;
|
||||
// Assert owner-path archive state via kind:8002 delta.
|
||||
let request_event_id = &relay_result.event_id;
|
||||
let delta_8002 = query_nipia_delta(&state, &relay_url, 8002, request_event_id)
|
||||
.await
|
||||
.expect("query kind:8002 delta for expired-profile test");
|
||||
let delta = delta_8002.as_ref().expect(
|
||||
"kind:8002 delta must be emitted after owner-path archive of expired-profile agent",
|
||||
);
|
||||
let consent = extract_consent_tag(delta)
|
||||
.expect("kind:8002 must have consent tag for expired-profile owner path");
|
||||
assert_eq!(
|
||||
consent, "owner",
|
||||
"expired-profile owner-path kind:8002 consent must be 'owner'"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -19,6 +19,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import type {
|
||||
NipIaOwnerProof,
|
||||
OwnedAgentInstance,
|
||||
OwnedAgentInventorySnapshot,
|
||||
} from "@/shared/api/tauriIdentityArchive";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
@@ -42,8 +43,6 @@ function canMutate(proof: NipIaOwnerProof): boolean {
|
||||
type InstanceRowProps = {
|
||||
instance: OwnedAgentInstance;
|
||||
archiveStateTrusted: boolean;
|
||||
/** Whether this instance's pubkey is managed locally (i.e. in the local agents list). */
|
||||
isManagedLocally: boolean;
|
||||
onOpenProfile: (pubkey: string) => void;
|
||||
onArchive: (pubkey: string) => void;
|
||||
onUnarchive: (pubkey: string) => void;
|
||||
@@ -54,7 +53,6 @@ type InstanceRowProps = {
|
||||
function InstanceRow({
|
||||
instance,
|
||||
archiveStateTrusted,
|
||||
isManagedLocally,
|
||||
onOpenProfile,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
@@ -115,8 +113,8 @@ function InstanceRow({
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* "Not managed on this device" badge when no local agent exists */}
|
||||
{!isManagedLocally && !isArchived ? (
|
||||
{/* "Not managed on this device" badge for relay-only instances */}
|
||||
{instance.personaId === null && !isArchived ? (
|
||||
<Badge className="shrink-0 gap-1" variant="outline">
|
||||
<MonitorOff className="h-3 w-3" />
|
||||
Relay only
|
||||
@@ -163,30 +161,30 @@ type InstancesSheetProps = {
|
||||
/** The persona whose instances to display. Filters by persona coordinate. */
|
||||
persona: AgentPersona | null;
|
||||
/**
|
||||
* Lowercase-hex pubkeys of local agents associated with the persona.
|
||||
* Instances whose pubkey is in this set are marked as locally managed.
|
||||
* Instances NOT in this set are marked "Relay only" (not on this device).
|
||||
* The complete grouped inventory snapshot from the parent. The Sheet uses
|
||||
* `inventory.byPersonaId[persona.id]` as the authoritative instance list for
|
||||
* this persona — no secondary pubkey-set reconstruction needed.
|
||||
* `null` when the inventory is not yet loaded.
|
||||
*/
|
||||
personaAgentPubkeys: ReadonlySet<string>;
|
||||
inventory: OwnedAgentInventorySnapshot | undefined;
|
||||
/** Open the exact-pubkey profile panel. */
|
||||
onOpenProfile: (pubkey: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sheet showing the owner's relay inventory of agent instances (`kind:30177`)
|
||||
* scoped to the opener's persona.
|
||||
* scoped to the opener's persona via `inventory.byPersonaId[persona.id]`.
|
||||
*
|
||||
* - Rows link to the exact-pubkey profile panel.
|
||||
* - Archive/Unarchive are offered only for `Verified` instances.
|
||||
* - Unknown archive trust shows a retry affordance; mutations are suppressed.
|
||||
* - Tri-state badge is scoped to this surface — `useIsIdentityArchived` elsewhere is unchanged.
|
||||
* - "Relay only" marker for instances without a matching local agent.
|
||||
*/
|
||||
export function InstancesSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
persona,
|
||||
personaAgentPubkeys,
|
||||
inventory,
|
||||
onOpenProfile,
|
||||
}: InstancesSheetProps) {
|
||||
const inventoryQuery = useOwnedAgentInventoryQuery(open);
|
||||
@@ -198,21 +196,20 @@ export function InstancesSheet({
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const allInstances = inventoryQuery.data?.instances ?? [];
|
||||
const archiveStateTrusted = inventoryQuery.data?.archiveStateTrusted ?? false;
|
||||
// Use the parent-supplied snapshot when available; fall back to the Sheet's
|
||||
// own query result. This avoids a redundant fetch while keeping the Sheet
|
||||
// self-contained when opened standalone (e.g. from a future entry point).
|
||||
const effectiveData = inventory ?? inventoryQuery.data;
|
||||
const archiveStateTrusted = effectiveData?.archiveStateTrusted ?? false;
|
||||
|
||||
// Filter by persona's agent pubkeys when a persona is provided.
|
||||
// When the persona has known agent pubkeys, show only instances whose pubkey
|
||||
// appears in that set plus any relay-only instances (not managed on this device
|
||||
// but owned by the same user). When no persona is provided, show all instances.
|
||||
// Instances for THIS persona only — from byPersonaId[persona.id].
|
||||
// The parent groups by persona_id parsed from kind:30177 content, so this
|
||||
// is the authoritative view: it includes relay-only instances (no local
|
||||
// agent) and excludes instances from other personas.
|
||||
const instances = React.useMemo(() => {
|
||||
if (!persona || personaAgentPubkeys.size === 0) return allInstances;
|
||||
// Show instances for this persona's known pubkeys, plus any relay-only
|
||||
// instances that aren't matched to any local agent (orphaned relay instances).
|
||||
return allInstances.filter((i) =>
|
||||
personaAgentPubkeys.has(i.pubkey.toLowerCase()),
|
||||
);
|
||||
}, [allInstances, persona, personaAgentPubkeys]);
|
||||
if (!persona || !effectiveData) return [];
|
||||
return effectiveData.byPersonaId[persona.id] ?? [];
|
||||
}, [effectiveData, persona]);
|
||||
|
||||
function handleArchive(pubkey: string) {
|
||||
setConfirmArchivePubkey(pubkey);
|
||||
@@ -230,6 +227,7 @@ export function InstancesSheet({
|
||||
|
||||
const archivePending = archiveMutation.isPending;
|
||||
const unarchivePending = unarchiveMutation.isPending;
|
||||
const isLoading = inventoryQuery.isLoading && !effectiveData;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -248,7 +246,7 @@ export function InstancesSheet({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-2 overflow-y-auto">
|
||||
{inventoryQuery.isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -269,7 +267,7 @@ export function InstancesSheet({
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : !archiveStateTrusted && !inventoryQuery.isLoading ? (
|
||||
) : !archiveStateTrusted && !isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<p className="rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-foreground">
|
||||
Archive status could not be verified from the relay. Archive
|
||||
@@ -292,9 +290,6 @@ export function InstancesSheet({
|
||||
archivePending={archivePending}
|
||||
archiveStateTrusted={false}
|
||||
instance={instance}
|
||||
isManagedLocally={personaAgentPubkeys.has(
|
||||
instance.pubkey.toLowerCase(),
|
||||
)}
|
||||
key={instance.pubkey}
|
||||
unarchivePending={unarchivePending}
|
||||
onArchive={handleArchive}
|
||||
@@ -314,9 +309,6 @@ export function InstancesSheet({
|
||||
archivePending={archivePending}
|
||||
archiveStateTrusted={archiveStateTrusted}
|
||||
instance={instance}
|
||||
isManagedLocally={personaAgentPubkeys.has(
|
||||
instance.pubkey.toLowerCase(),
|
||||
)}
|
||||
key={instance.pubkey}
|
||||
unarchivePending={unarchivePending}
|
||||
onArchive={handleArchive}
|
||||
|
||||
@@ -55,13 +55,18 @@ export type OwnedAgentInstance = {
|
||||
/** NIP-OA owner proof for this instance — never omitted, only null in older responses. */
|
||||
nipIaOwnerProof: NipIaOwnerProof;
|
||||
archiveState: OwnedAgentArchiveState;
|
||||
/** Persona ID parsed from `kind:30177` content. `null` for standalone agents. */
|
||||
personaId: string | null;
|
||||
};
|
||||
|
||||
/** Snapshot returned by `get_owned_agent_inventory`. */
|
||||
/** Complete merged view model returned by `get_owned_agent_inventory`. */
|
||||
export type OwnedAgentInventorySnapshot = {
|
||||
/** Whether the archive snapshot was loaded and trusted. */
|
||||
archiveStateTrusted: boolean;
|
||||
instances: OwnedAgentInstance[];
|
||||
/** Instances grouped by persona ID. Drives per-card counts and Sheet filtering. */
|
||||
byPersonaId: Record<string, OwnedAgentInstance[]>;
|
||||
/** Instances with no parseable persona ID (standalone agents). */
|
||||
unknown: OwnedAgentInstance[];
|
||||
};
|
||||
|
||||
type RawOwnerOfAgent = { owner: string; is_me: boolean };
|
||||
|
||||
Reference in New Issue
Block a user