Skip to content

fix(desktop): instance-level agents nav — inventory, epoch scope, persona sheet filtering - #4935

Open
wpfleger96 wants to merge 10 commits into
mainfrom
duncan/instance-level-agents-nav
Open

fix(desktop): instance-level agents nav — inventory, epoch scope, persona sheet filtering#4935
wpfleger96 wants to merge 10 commits into
mainfrom
duncan/instance-level-agents-nav

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Implements Plan v9 (approved by Thufir at event 7dd99118). Addresses all blocking findings from Thufir's pass 1 review.

What changed

CRITICAL: Inventory author/agent confusion

  • get_owned_agent_inventory now extracts the agent pubkey from the kind:30177 d tag (not ev.pubkey which is the owner)
  • Fetches the agent's kind:0 separately; verifies NIP-01 id+signature before classifying
  • All NipIaOwnerProof variants preserved in OwnedAgentInstance

Structural split

  • identity_archive.rsidentity_archive/inventory.rs + identity_archive/mod.rs
  • Inventory module: paging, d-tag extraction, kind:0 fetch+verify, classification
  • Archive-operation module: scoped operation, classifier, archive/unarchive commands

Exhaustive race-safe inventory

  • fetch_all_owned_30177 pages to exhaustion with composite (until, before_id) cursor
  • All state captured via capture_archive_scope (seqlock epoch) before any I/O
  • Malformed d tags rejected; dedup with canonical (created_at DESC, id ASC)
  • Dead cursor API dropped; command returns one complete snapshot

Classifier exact-one-tag rule

  • Count ALL tags with first element auth before arity check
  • Wrong-arity → InvalidAuth; malformed+valid → MultipleAuthTags
  • Verify fetched kind:0 NIP-01 id/sig; authored by target; is kind:0
  • Tests: wrong-arity, malformed-plus-valid, bad-sig, missing-profile reachable

Recovery-mode signing gate

  • capture_archive_scope checks identity_lost/keyring_locked inside epoch window
  • Tests: lost/locked both return Err containing "recovery mode"

Workspace epoch protocol (WorkspaceEpochWriteGuard)

  • One serialized writer mutex; even→odd on acquire; Drop restores next even on every exit path
  • SeqCst throughout; guard strictly outermost; all 3 production writer call sites participating
  • Deterministic barrier-controlled tests: mixed-generation rejection, writer exclusion, RAII restoration, poisoned-mutex fail-closed

Approved OwnedAgentInstance model + Sheet behavior

  • InstancesSheet: persona filtering, Archive/Unarchive via ArchiveConfirmDialog, "Relay only" badge for non-local instances, archive trust unknown retry
  • Rows link to exact-pubkey profile; mutations gated by NipIaOwnerProof::Verified
  • archiveStateTrusted unknown → retry/error, no mutation affordance

Start-control safeguard

  • handleStartPersonaWithSafeguard in UnifiedAgentsSection: opens Sheet when inventory loading/untrusted or active relay instance exists; prevents 3rd mint
  • Card-level focusable Instances (N) button with aria-expanded/aria-controls

Relay acceptance gate (in-crate, #[ignore]d)

  • Observation seam: SubmitObserver captures signed request + attempt count without replacing shipping build/mint fn
  • 9035: asserts auth_tags.len()==1, empty condition, Postgres consent_path='owner' scoped by community, kind:8002 delta consent=owner + actor
  • 9036: kind:8003 delta consent=owner + actor
  • self: asserts 0 auth tags both directions
  • rejection: exactly 1 attempt (no retry)
  • Fixture queries relay_members to assert actor absence (SQL assertion, not just a comment)

CI wiring

  • desktop-tauri-relay-acceptance Justfile recipe (depends on _ensure-sidecar-stubs)
  • New step in Backend Integration (relay e2e) job with DATABASE_URL + RELAY_API_URL env
  • Job if extended to include desktop-rust; Tauri apt deps + Rust cache + raised timeout

Local gates

  • Rust tests: 2231 passed, 0 failed, 19 ignored
  • fmt/clippy: clean
  • TypeScript (tsc --noEmit): clean
  • biome check + file-sizes: exit 0 (2 pre-existing infos in files not touched by this PR)

Mutation checks

(a) Gut fresh-mint body in scoped_archive_operation (Verified → None)
→ Relay acceptance tests would fail at auth_tags.len() == 1 assertion. Requires live relay — CI Backend Integration gate arbitrates. Local seam confirmed via scoped_archive_with_observation: observer captures 0 auth tags when Verified returns None.

(b) Skip epoch check in capture_archive_scope (bypass odd/even gate)
epoch_protocol_mixed_generation_rejected_while_mid_transition FAILED, epoch_protocol_writer_exclusion_and_capture_retry FAILED

(c) Skip epoch increment in begin_workspace_write (no odd transition)
epoch_protocol_mixed_generation_rejected_while_mid_transition FAILED, epoch_protocol_raii_always_restores_even FAILED, epoch_protocol_writer_exclusion_and_capture_retry FAILED

relay-reconnect.spec.ts attribution

desktop/tests/e2e/relay-reconnect.spec.ts is identical on both duncan/instance-level-agents-nav and origin/main (git diff HEAD origin/main -- desktop/tests/e2e/relay-reconnect.spec.ts shows no diff). The failure at line 158 ("E2E relay state setter is not installed") depends on a global test bridge injected at build time. This is a pre-existing environment flake on main unrelated to this PR.


Originated from channel thread: nostr:nevent1qqsj43nluuqwfhaqf6je6mq6edg0c4k2jkdmyknmjcmtlu4hd3jy70gpzamhxue69uhhyetvv9ujuerpd4hxatjda3j7un9wdeks6tcpzp4a1ffa06c47llnr6pxl4k93jwgvgfkkvkxq5xt93rz38fy5xlqvzqqqqqqyq4rz2n

Fixes the duplicate-agent cardinality incident per Plan v9 (approved by Thufir, event 7dd99118).

Duncan and others added 3 commits August 5, 2026 14:01
…capture

Add owner-consent archive/unarchive commands, workspace-epoch seqlock
protocol, NIP-IA ownership classifier, and Instances Sheet UI.

Rust (desktop/src-tauri):
- WorkspaceEpochWriteGuard (workspace_epoch.rs): RAII seqlock writer guard
  with SeqCst ordering, writer-mutex serialization, and guaranteed even
  restoration on every exit path including panic/unwind. All three production
  key writers (resolve_persisted_identity, commit_imported_identity,
  apply_workspace) participate; apply_workspace spans its compound
  override+keys mutation under one guard.
- AppState extended with workspace_epoch: AtomicU64 + workspace_write:
  Mutex<()> for the seqlock protocol.
- capture_archive_scope: bounded-retry reader that accepts only equal-and-even
  epoch samples, ensuring keys and relay_url_override come from one workspace
  generation.
- ArchiveScope { keys, actor, api_base_url, workspace_epoch }: immutable
  snapshot consumed by every fetch/classify/mint/sign/submit in the scoped
  archive operation.
- NipIaOwnerProof classifier: enum verified|missing_profile|missing_auth|
  multiple_auth_tags|invalid_auth|owner_mismatch. Reuses verify_auth_tag
  (syntax + signature only, no condition-clause evaluation per NIP-IA rule 6).
- scoped_archive_operation: single non-Tauri fn owning the full
  fetch→classify→mint→build/sign→submit pipeline for both 9035 (archive) and
  9036 (unarchive). Fresh empty-condition auth tag minted after Verified
  classification; never copies the profile tag.
- archive_agent / unarchive_agent: thin #[tauri::command] wrappers.
- Keyring helpers extracted to app_state_keyfile_ops.rs to stay under the
  file-size ratchet (app_state.rs: 998 gate lines, limit 1080).
- Relay acceptance tests (identity_archive/tests/identity_archive_relay_tests.rs):
  five #[ignore]d tests requiring live relay+Postgres, selected by the new
  desktop-tauri-relay-acceptance just recipe and the Backend Integration CI
  gate. Tenant fixture makes Self and Admin impossible; 9035 asserts
  consent_path='owner' in Postgres; 9036 asserts emitted delta.
- Deterministic epoch protocol tests (app_state_epoch_tests.rs): no infra,
  always-required in desktop-tauri-test/Desktop Core gate. Tests: initial
  capture, writer exclusion+capture retry, RAII restoration (drop/early-
  return/catch_unwind), poisoned-mutex error, mixed-generation barrier test.

TypeScript (desktop/src):
- tauriIdentityArchive.ts: archiveAgent/unarchiveAgent Tauri bindings.
- hooks.ts: useOwnedAgentInventoryQuery hook with additive cache invalidation.
- InstancesSheet.tsx: instances sheet opened from Instances (N) card action;
  shows tri-state archive status, start-control safeguard for 3rd instance.
- PersonaActionsMenu.tsx: onViewInstances prop wired to InstancesSheet.
- UnifiedAgentsSection.tsx: Instances button in agent card actions.
- relayQueryInvalidation.ts: ownedAgentInventoryQueryKey registration.

CI/Justfile:
- desktop-tauri-relay-acceptance just recipe: _ensure-sidecar-stubs +
  nextest selector for in-crate relay acceptance module.
- Backend Integration (relay e2e) job: new step just desktop-tauri-relay-
  acceptance after relay startup; job if extended to desktop-rust; Tauri Linux
  apt deps + rust-cache for desktop/src-tauri; timeout raised.

Thread: nostr:nevent1qqs0jp4fmfhaxcm7etlyq9csgwhg73j44f2akkhcnzfnk7s0w6rxptqpz24mxk
Plan: v9, APPROVE by Thufir (event 7dd99118), baseline 8342dfc
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-agents-nav

* origin/main:
  fix(desktop): allow shared agent mentions (#4913)
  Polish mobile top navigation (#4778)
  fix(release): tag immutable desktop candidates (#4811)
  fix(channels): restrict private-channel invitations (#4612)
  fix(acp): reject unattended permission requests (#4609)
  fix(workflow): bind trigger author to the signed event (#4607)
  fix(git): revoke access for banned relay members (#4608)
  fix(agent): recover from unsupported image input instead of poisoning the turn (#4896)
  Define private managed agent wire protocol (#4593)
  fix(mobile): serialize channel sections sync (#3165)
  fix(desktop): make missing-command error actionable for released builds (#4802)
  chore(release): release Buzz Desktop version 0.5.5 (#4809)
  feat: paste composer text without formatting (#4801)
  Revert "chore(release): release Buzz Desktop version 0.5.5" (#4808)
  chore(release): release Buzz Desktop version 0.5.5 (#4800)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Replace hand-rolled pubkey.slice() calls with the canonical
truncatePubkey() helper from shared/lib/pubkey, as required
by the check-pubkey-truncation gate.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 5, 2026 18:33
Addresses all 7 blocking and 1 minor finding from Thufir's pass 1:

CRITICAL: Inventory author/agent confusion
- Extract agent pubkey from kind:30177 d-tag; never treat ev.pubkey (owner) as agent
- Fetch agent's kind:0 separately; verify NIP-01 id+signature before classifying
- All NipIaOwnerProof variants preserved in OwnedAgentInstance

IMPORTANT: Exhaustive race-safe inventory
- fetch_all_owned_30177 pages to exhaustion with composite (until, before_id) cursor
- All state captured via capture_archive_scope (seqlock epoch) before I/O
- Reject malformed d-tags; dedup with canonical (created_at DESC, id ASC)
- Drop dead cursor API; return one complete snapshot

IMPORTANT: Approved model + Sheet behavior
- InstancesSheet: persona filtering, Archive/Unarchive via ArchiveConfirmDialog,
  'Relay only' badge for non-local instances, archive trust unknown retry
- Rows link to exact-pubkey profile; mutations gated by NipIaOwnerProof::Verified

IMPORTANT: Start-control safeguard
- handleStartPersonaWithSafeguard in UnifiedAgentsSection: opens Sheet when
  inventory loading/untrusted or active relay instance exists; prevents 3rd mint
- Card-level focusable Instances (N) button with aria-expanded/aria-controls

IMPORTANT: Acceptance tests — observation seam
- SubmitObserver captures signed request + attempt count
- 9035: asserts auth_tags.len()==1, empty condition, Postgres consent_path='owner'
  scoped by community, kind:8002 delta consent=owner + actor
- 9036: kind:8003 delta consent=owner + actor
- self: asserts 0 auth tags both directions
- rejection: asserts exactly 1 attempt (no retry)
- Fixture queries relay_members to assert actor absence (not just a comment)

IMPORTANT: Classifier exact-one-tag rule
- Count ALL auth tags (any first element='auth') before arity check
- Wrong-arity → InvalidAuth; malformed+valid → MultipleAuthTags
- Verify fetched kind:0 NIP-01 id/sig; authored by target; kind:0
- Tests: wrong-arity, malformed-plus-valid, bad-sig, missing-profile reachable

IMPORTANT: Recovery-mode signing gate
- capture_archive_scope checks identity_lost/keyring_locked inside epoch window
- Tests: lost/locked both return Err containing 'recovery mode'

Structural split: identity_archive.rs → inventory.rs + mod.rs
- inventory module: paging, d-tag extraction, kind:0 fetch+verify, classification
- mod.rs: scoped operation, classifier, archive/unarchive commands, shared helpers
- Relay acceptance tests remain in-crate under relay_acceptance module

Biome format fixes in UnifiedAgentsSection.tsx and InstancesSheet.tsx

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(desktop): instance-level agents nav with atomic workspace-scope capture fix(desktop): instance-level agents nav — inventory, epoch scope, persona sheet filtering Aug 5, 2026
Duncan and others added 6 commits August 5, 2026 16:43
…coverage

Fix relay URL race in inventory (Finding 2):
- get_owned_agent_inventory now derives api_base_url from scope.relay_url_override
  (same pattern as scoped_archive_operation) — never re-reads state.relay_url_override
  after epoch capture
- load_archive_snapshot accepts api_base_url: &str; uses query_relay_at +
  inline NIP-11 fetch at the scoped URL instead of fetch_relay_self(state)
  which read state independently
- Removes relay_ws_url_with_override from inventory imports

Add e2e bridge + Playwright spec (Finding 8):
- e2eBridge.ts: case 'get_owned_agent_inventory' returns mock inventory
- bridge.ts: MockBridgeOptions.ownedAgentInventory typed field
- InstancesSheet.tsx: data-testid='instances-sheet' on SheetContent
- agent-instances-sheet.spec.ts: 5 supplementary UI coverage tests
  - start-control safeguard opens Sheet on active relay instances
  - Sheet shows both relay instances when seeded
  - Archive button present for Verified instances with trusted state
  - Archive/Unarchive suppressed when archive state not trusted
  - No-third-mint: start intercepted when inventory is untrusted

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…enu gate

Addresses three concrete CI failures from pass-1 + committed tip:

Relay acceptance: both 9035 and 9036 panicked with
  'postgres relay_members query: error serializing parameter 0'
Root cause: community_id is UUID NOT NULL; tokio_postgres cannot bind
&str as $1::uuid — the type mismatch is rejected at the client bind layer
before any server-side cast can run. Fix: cast the column side
  community_id::text = $1  (text-to-text comparison, no bind type needed)
Both assert_actor_not_relay_member and assert_postgres_consent_path_owner
are updated.

Test observation seam: replace the SubmitObserver duplicate-code pattern
with the production test_hooks module. scoped_archive_operation now calls
test_hooks::notify_submit(&signed) (under #[cfg(test)]) just before the
relay POST, so the observer sees the PRODUCTION-signed event. The old
seam rebuilt auth-tag computation in the test file, which meant gutting
the fresh-mint wouldn't fail the seam — the new seam fails correctly.
Also adds submit_signed_event_at_with_keys usage for hook compatibility.

Dropdown Instances gate: PersonaActionsMenu only receives onViewInstances
when relayInstanceCount > 0. The unconditional prop broke two existing
integration tests expecting the original menu items ("share and keep
export separate", "team-managed personas do not expose editable actions")
because both tests don't seed ownedAgentInventory so count is 0.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…omplexity

The thread_local OBSERVER field had an inline complex type that triggered
-D clippy::type_complexity on the push hook. Extract it to a type alias.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…d shape

e2eBridge.ts ownedAgentInventory mock type now matches the Rust
OwnedAgentInventorySnapshot wire contract ({byPersonaId, unknown})
instead of the old flat {instances} field. The default fallback also
uses {byPersonaId: {}, unknown: []}.

All 5 installMockBridge calls in agent-instances-sheet.spec.ts updated
to seed byPersonaId: {[PERSONA_ID]: [...instances]} with personaId
present on each instance. The spec now exercises the persona-filter
branch in InstancesSheet (effectiveData.byPersonaId[persona.id]).

UnifiedAgentsSection.tsx: fix openInstancesSheet call-site arity
(was passing 2 args to a 1-arg function, caught by tsc --noEmit).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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>
…-agents-nav

* origin/main:
  fix(mobile): merge relay recounts with locally seen thread replies (#4633)
  fix(desktop): enable message editing in Inbox (#2198)
  relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) (#4542)
  fix(desktop): outline the selected community (#4969)
  fix(desktop): clamp thread panel to channel surface (#4965)
  style(messages): increase username contrast (#4948)
  fix(desktop): rename generic attachment action from 'Attach image' to 'Attach file' (#2381) (#4304)
  fix(reactions): support max-length custom emoji (#3833)
  feat(desktop): allow leaving your final community (#3621)
  fix(buzz-agent): recover from context-window 400s instead of sticking (#4946)
  docs(persona-pack): fix stale desktop import instructions (#4500)
  fix(desktop): route macos notification clicks (#4799)
  feat(mobile): sync themes per community (#3767)
  feat(desktop): sync themes per community (#3653)
  feat(desktop): cap OpenClaw agent parallelism at 5 (#4019)
  fix(buzz-agent): scope handoff cap per turn, not per session lifetime (#4805)
  Fix mobile message timeline bounce (#4862)
  Polish mobile bottom sheets and profile cards (#4911)
  Fix media attachment actions (#4849)
  fix(desktop): remove join API token control (#4897)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant