From 4343c6d55e1b37ba009c5cde60099aff3027f3e4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 13:45:28 -0400 Subject: [PATCH 001/291] chore(desktop): move biome-ignore comments to assertion lines in pulse hooks (#1545) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/src/features/pulse/hooks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/pulse/hooks.ts b/desktop/src/features/pulse/hooks.ts index 7fc810c629..f816afc62b 100644 --- a/desktop/src/features/pulse/hooks.ts +++ b/desktop/src/features/pulse/hooks.ts @@ -33,8 +33,8 @@ export const pulseQueryKeys = { export function useLikedNotesQuery(pubkey?: string, enabled = true) { return useQuery({ queryKey: pulseQueryKeys.likedNotes(pubkey ?? ""), - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey queryFn: async () => + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey withoutProjectComments(await getLikedNotes(pubkey!, 50)), enabled: enabled && !!pubkey, staleTime: 15_000, @@ -46,8 +46,8 @@ export function useLikedNotesQuery(pubkey?: string, enabled = true) { export function useMyNotesQuery(pubkey?: string) { return useQuery({ queryKey: pulseQueryKeys.myNotes(pubkey ?? ""), - // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey queryFn: async () => + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey withoutProjectComments(await getUserNotes(pubkey!, { limit: 50 })), enabled: !!pubkey, staleTime: 15_000, From 2eebeb54bcca5f82ec95daa3c698a60e7e01b811 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 6 Jul 2026 11:50:05 -0600 Subject: [PATCH 002/291] chore(release): release Buzz Desktop version 0.3.44 (#1544) --- CHANGELOG.md | 7 +++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c62b86e08..a8f33395bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.3.44 + +- fix(desktop): keep thread-summary badges mounted through scrollback prepends ([#1533](https://github.com/block/buzz/pull/1533)) ([`6abf614fd`](https://github.com/block/buzz/commit/6abf614fd4eb512cf2e4175f17dc09892a8b472d)) +- refactor(shell): drop bundled PortableGit, add BUZZ_SHELL override + dialect hint ([#1536](https://github.com/block/buzz/pull/1536)) ([`cfa208983`](https://github.com/block/buzz/commit/cfa20898313080b7a106ae0e092d8ed6815daeb0)) +- feat(nips,relay,acp): NIP-AM durable encrypted agent turn metrics (kind 44200) ([#1441](https://github.com/block/buzz/pull/1441)) ([`71265ca36`](https://github.com/block/buzz/commit/71265ca36105dbf62453a99c998c3f3dd134a304)) + + ## v0.3.43 - fix(desktop): sync agent relay profile when persona avatar changes ([#1512](https://github.com/block/buzz/pull/1512)) ([`ce901f7c1`](https://github.com/block/buzz/commit/ce901f7c1e548a0aab0d9e97f4951ea134f6b3d2)) diff --git a/desktop/package.json b/desktop/package.json index 8be1aae9d7..7a1cf8f5f1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.3.43", + "version": "0.3.44", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5189327c18..5684429877 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -885,7 +885,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.3.43" +version = "0.3.44" dependencies = [ "anyhow", "atomic-write-file", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7ad214e09f..1791cf5313 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.3.43" +version = "0.3.44" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2daa17114a..5e622d5945 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.3.43", + "version": "0.3.44", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From c2ee4d162753a3338dc434f85c80a82eb284b3d2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 13:57:05 -0400 Subject: [PATCH 003/291] fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal (#1508) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src-tauri/src/commands/profile.rs | 1 + desktop/src-tauri/src/models.rs | 5 + desktop/src-tauri/src/nostr_convert.rs | 1 + desktop/src/features/onboarding/hooks.ts | 37 ++++-- desktop/src/features/profile/hooks.ts | 18 ++- .../profile/lib/selfProfileStorage.test.mjs | 47 ++++++++ .../profile/lib/selfProfileStorage.ts | 22 +++- .../profile/ui/UserProfilePanelUtils.ts | 2 + desktop/src/shared/api/tauri.ts | 2 + desktop/src/shared/api/types.ts | 5 + desktop/src/testing/e2eBridge.ts | 31 ++++- desktop/tests/e2e/onboarding.spec.ts | 112 +++++++++++++++++- 13 files changed, 268 insertions(+), 19 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e5d5f2e236..68b79d0e4e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -94,8 +94,8 @@ const overrides = new Map([ // #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies // contract (replies-only, root excluded — the query keys on root_event_id, // which root rows lack). Documentation accuracy, not code growth. - // linux-updater-manual: isAutoUpdateSupported() binding adds 7 lines. - ["src/shared/api/tauri.ts", 1347], + // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. + ["src/shared/api/tauri.ts", 1349], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index bfe3b156fc..884ea154e6 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -319,5 +319,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { about: None, nip05_handle: None, owner_pubkey: None, + has_profile_event: false, } } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 5eb64b3a7f..4d534636af 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -16,6 +16,11 @@ pub struct ProfileInfo { pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, + /// `true` when a real kind:0 event was found on the relay; `false` for the + /// synthesized fallback returned when no metadata event exists. The + /// onboarding gate uses this to distinguish "new user with no profile" from + /// "returning user whose display_name happens to be empty". + pub has_profile_event: bool, } #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 0f416495d3..64c3b16c02 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -304,6 +304,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { about, nip05_handle, owner_pubkey: profile_valid_oa_owner_pubkey(event), + has_profile_event: true, }) } diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index a6556374a2..debd918167 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -124,7 +124,7 @@ type UseFirstRunOnboardingGateOptions = { identityIsFetching: boolean; identityStatus: QueryStatus; isSharedIdentity: boolean; - profileDisplayName: string | null | undefined; + profileHasEvent: boolean | undefined; profileIsFetching: boolean; profileStatus: QueryStatus; }; @@ -219,7 +219,7 @@ export function useFirstRunOnboardingGate({ identityIsFetching, identityStatus, isSharedIdentity, - profileDisplayName, + profileHasEvent, profileIsFetching, profileStatus, }: UseFirstRunOnboardingGateOptions) { @@ -288,19 +288,32 @@ export function useFirstRunOnboardingGate({ return; } - // If the relay already has a profile with a display name for this pubkey, - // the user has previously completed onboarding (possibly on another - // machine or app data directory). Skip the onboarding flow and mark as - // complete so they go straight to the app. + // If the relay has a real kind:0 metadata event for this pubkey, the user + // has previously completed onboarding (possibly on another machine or app + // data directory). Skip the onboarding flow and mark as complete so they + // go straight to the app. + // + // We gate on `hasProfileEvent` — a flag set by the Tauri backend when a + // real kind:0 event was found — rather than any field value. This correctly + // handles the case where a returning user's display_name is empty: the event + // still exists, so onboarding is skipped. A missing event (new user, or no + // kind:0 on the relay) always shows onboarding regardless of display_name. const hasExistingProfile = - profileStatus === "success" && - typeof profileDisplayName === "string" && - profileDisplayName.trim().length > 0; + profileStatus === "success" && profileHasEvent === true; setGateState((current) => updateActiveGateState(current, currentPubkey, (activeGateState) => { + // Re-read localStorage here to handle the webkit2gtk WAL race: the + // synchronous useState initializer may have run before the WAL was + // merged into the main SQLite file, returning null for a flag that is + // actually present. By the time this effect fires (identity + profile + // settled), the WAL has had time to merge and the read is reliable. + const hasCompletedAfterRecheck = + readOnboardingCompletion(currentPubkey); const alreadyOnboarded = - activeGateState.hasCompletedCurrentPubkey || hasExistingProfile; + activeGateState.hasCompletedCurrentPubkey || + hasCompletedAfterRecheck || + hasExistingProfile; if (alreadyOnboarded && typeof window !== "undefined") { window.localStorage.setItem( onboardingCompletionStorageKey(currentPubkey), @@ -321,7 +334,7 @@ export function useFirstRunOnboardingGate({ hasSettledCurrentPubkey, identityStatus, isSharedIdentity, - profileDisplayName, + profileHasEvent, profileIsFetching, profileStatus, ]); @@ -382,7 +395,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { identityIsFetching: identityQuery.fetchStatus === "fetching", identityStatus: identityQuery.status, isSharedIdentity, - profileDisplayName: profileQuery.data?.displayName, + profileHasEvent: profileQuery.data?.hasProfileEvent, profileIsFetching: profileQuery.fetchStatus === "fetching", profileStatus: profileQuery.status, }); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index a6d6af07e6..1c7ad232c6 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -75,6 +75,10 @@ async function persistSelfProfile( avatarUrl: profile.avatarUrl, avatarDataUrl, updatedAt: Date.now(), + // Only persist the presence bit when true — no-event fallbacks + // (hasProfileEvent: false) must not be cached as real profiles, + // which would cause the onboarding gate to skip on next restart. + ...(profile.hasProfileEvent && { hasProfileEvent: true }), }); } @@ -105,6 +109,10 @@ export function useProfileQuery(enabled = true) { about: null, nip05Handle: null, ownerPubkey: null, + // Only true when the cache entry was explicitly written with a + // real kind:0-backed profile. Older entries (absent field) and + // no-event fallbacks default to false — conservative is correct. + hasProfileEvent: cached.hasProfileEvent === true, } satisfies Profile) : undefined, [cached, pubkey], @@ -370,7 +378,15 @@ export function useUsersBatchQuery( for (const [pubkey, summary] of Object.entries(profiles)) { queryClient.setQueryData( ["user-profile", pubkey], - (existing) => existing ?? { pubkey, about: null, ...summary }, + (existing) => + existing ?? { + pubkey, + about: null, + // Batch endpoint gives UserProfileSummary (no event-presence flag). + // These cached summaries are never used for the onboarding gate. + hasProfileEvent: false, + ...summary, + }, ); } }, [query.data, queryClient]); diff --git a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs index c59f7be4c0..6201465463 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs +++ b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs @@ -212,6 +212,53 @@ function makeCache(overrides = {}) { }; } +test("parseSelfProfileCache: hasProfileEvent true is preserved", () => { + const result = parseSelfProfileCache({ + version: 1, + displayName: null, + avatarUrl: null, + avatarDataUrl: null, + updatedAt: 1700000000000, + hasProfileEvent: true, + }); + assert.equal(result?.hasProfileEvent, true); +}); + +test("parseSelfProfileCache: hasProfileEvent false is omitted (conservative default)", () => { + const result = parseSelfProfileCache({ + version: 1, + displayName: null, + avatarUrl: null, + avatarDataUrl: null, + updatedAt: 1700000000000, + hasProfileEvent: false, + }); + assert.equal(result?.hasProfileEvent, undefined); +}); + +test("parseSelfProfileCache: absent hasProfileEvent field is omitted (legacy v1 migration)", () => { + const result = parseSelfProfileCache({ + version: 1, + displayName: "Alice", + avatarUrl: null, + avatarDataUrl: null, + updatedAt: 1700000000000, + }); + assert.equal(result?.hasProfileEvent, undefined); +}); + +test("parseSelfProfileCache: non-boolean hasProfileEvent is coerced to absent", () => { + const result = parseSelfProfileCache({ + version: 1, + displayName: null, + avatarUrl: null, + avatarDataUrl: null, + updatedAt: 1700000000000, + hasProfileEvent: "yes", + }); + assert.equal(result?.hasProfileEvent, undefined); +}); + test("shouldFetchAvatar: URL changed → fetch", () => { const existing = makeCache({ avatarUrl: "https://relay.example.com/old.jpg", diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index 910f108993..5eeec69b18 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -43,6 +43,15 @@ export type SelfProfileCache = { avatarDataUrl: string | null; /** ms timestamp of last successful profile fetch. 0 = never fetched. */ updatedAt: number; + /** + * True only when the cached result was backed by a real kind:0 metadata + * event on the relay. False (or absent in older v1 entries) means the + * backend returned the synthesized no-event fallback. + * + * Conservative default: absent = false. Callers must not promote an absent + * or false entry to hasProfileEvent: true — that reopens the onboarding bug. + */ + hasProfileEvent?: boolean; }; const DEFAULT_CACHE: SelfProfileCache = Object.freeze({ @@ -87,8 +96,19 @@ export function parseSelfProfileCache(json: unknown): SelfProfileCache | null { typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) ? obj.updatedAt : 0; + // Conservative: absent field in older v1 entries defaults to false. + // Only a stored true value (written from a profile with has_profile_event) + // is promoted — absent/false must never become true. + const hasProfileEvent = obj.hasProfileEvent === true ? true : undefined; - return { version: 1, displayName, avatarUrl, avatarDataUrl, updatedAt }; + return { + version: 1, + displayName, + avatarUrl, + avatarDataUrl, + updatedAt, + ...(hasProfileEvent !== undefined && { hasProfileEvent }), + }; } /** diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 478f352593..39bcf28cc3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -165,6 +165,8 @@ export function buildPersonaDraftProfile(persona: AgentPersona): Profile { about: null, nip05Handle: null, ownerPubkey: null, + // Draft profile synthesised from persona config — not backed by a kind:0 event. + hasProfileEvent: false, }; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 493bb41cc4..2f9b1524d0 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -59,6 +59,7 @@ type RawProfile = { about: string | null; nip05_handle: string | null; owner_pubkey: string | null; + has_profile_event?: boolean; }; type RawUserProfileSummary = Omit & { @@ -427,6 +428,7 @@ function fromRawProfile(profile: RawProfile): Profile { about: profile.about, nip05Handle: profile.nip05_handle, ownerPubkey: profile.owner_pubkey, + hasProfileEvent: profile.has_profile_event ?? false, }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 27a92640f6..426e1e468a 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -115,6 +115,11 @@ export type Profile = { about: string | null; nip05Handle: string | null; ownerPubkey: string | null; + /** True when a real kind:0 metadata event exists on the relay for this pubkey. + * False for the synthesized fallback returned when no event is present. + * Used by the onboarding gate to distinguish new users from returning users + * whose display name happens to be empty. */ + hasProfileEvent: boolean; }; export type UserProfileSummary = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ea1f3d6f0a..5c28f5803d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -197,6 +197,9 @@ type RawProfile = { nip05_handle: string | null; owner_pubkey: string | null; is_agent?: boolean; + /** Mirrors the Rust `has_profile_event` flag: true when a real kind:0 event + * backed this profile, false for the synthesized empty fallback. */ + has_profile_event: boolean; }; type RawUserProfileSummary = { @@ -1594,6 +1597,7 @@ function resetMockManagedAgents(config?: E2eConfig) { nip05_handle: null, owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, + has_profile_event: true, }); for (const channel of mockChannels) { const isSeedChannel = @@ -1777,6 +1781,7 @@ function seedMockSearchProfiles(config?: E2eConfig) { nip05_handle: seed.nip05Handle ?? null, owner_pubkey: seed.ownerPubkey ?? null, is_agent: seed.isAgent ?? false, + has_profile_event: true, }; mockProfiles.set(pubkey, profile); applyMockDisplayName(pubkey, seed.displayName); @@ -1805,6 +1810,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null { nip05_handle: null, owner_pubkey: null, is_agent: mockAgentPubkeys.has(normalizedPubkey), + has_profile_event: true, }; } @@ -2515,8 +2521,14 @@ const mockProfiles = new Map([ nip05_handle: null, owner_pubkey: null, is_agent: false, + has_profile_event: true, }, ], + // alice, bob, and charlie are intentionally NOT seeded here — they are + // covered by mockDisplayNames + mockAgentPubkeys and synthesised on demand + // by getMockProfileByPubkey. Static seeds would cause ensureMockProfile to + // return has_profile_event:true when alice/bob/charlie are used as the + // active first-run identity, incorrectly skipping onboarding page 1. [ PROFILE_ONLY_AGENT_PUBKEY, { @@ -2527,6 +2539,7 @@ const mockProfiles = new Map([ nip05_handle: null, owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, + has_profile_event: true, }, ], [ @@ -2539,6 +2552,7 @@ const mockProfiles = new Map([ nip05_handle: null, owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, + has_profile_event: true, }, ], ]); @@ -2666,6 +2680,10 @@ function importMockIdentity(nsec: string) { about: null, nip05_handle: null, owner_pubkey: null, + // A non-empty username means this identity is registered in + // mockDisplayNames — it has a real mock relay profile (kind:0). + // A truly new identity (no username) has no event yet. + has_profile_event: username.length > 0, }); } @@ -2706,13 +2724,18 @@ function ensureMockProfile(config: E2eConfig | undefined): RawProfile { return existing; } + const displayName = getMockMemberDisplayName(config); const profile = { pubkey, - display_name: getMockMemberDisplayName(config), + display_name: displayName, avatar_url: null, about: null, nip05_handle: null, owner_pubkey: null, + // Synthesised fallback: no kind:0 event exists on the relay for this + // identity. Always false regardless of display name so the onboarding + // gate cannot mistake a blank first-run identity for a returning user. + has_profile_event: false, }; mockProfiles.set(pubkey, profile); return profile; @@ -4557,6 +4580,7 @@ async function handleGetProfile(config: E2eConfig | undefined) { avatar_url: null, nip05_handle: null, owner_pubkey: null, + has_profile_event: false, }; } const content = JSON.parse(events[0].content ?? "{}"); @@ -4567,6 +4591,7 @@ async function handleGetProfile(config: E2eConfig | undefined) { avatar_url: content.picture ?? null, nip05_handle: content.nip05 ?? null, owner_pubkey: null, + has_profile_event: true, }; } @@ -4651,6 +4676,7 @@ async function handleUpdateProfile( avatar_url: updated.picture ?? null, nip05_handle: updated.nip05 ?? null, owner_pubkey: null, + has_profile_event: true, }; } @@ -4683,6 +4709,7 @@ async function handleGetUserProfile( avatar_url: null, nip05_handle: null, owner_pubkey: null, + has_profile_event: false, }; } const content = JSON.parse(events[0].content ?? "{}"); @@ -4693,6 +4720,7 @@ async function handleGetUserProfile( avatar_url: content.picture ?? null, nip05_handle: content.nip05 ?? null, owner_pubkey: null, + has_profile_event: true, }; } @@ -6589,6 +6617,7 @@ async function handleCreateManagedAgent( nip05_handle: null, owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, + has_profile_event: true, }); syncMockRelayAgentsFromManagedAgents(); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index a9ee8dfbf5..bc136ed815 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -694,6 +694,88 @@ test("identity fallback text does not count as a real onboarding name", async ({ await expect(page.getByTestId("onboarding-next")).toBeDisabled(); }); +// Regression test for the H2 predicate fix (PR #1508). +// A blank first-run identity (no kind:0 event on the relay) must see +// onboarding even when the mock bridge returns display_name: "" — the gate +// must depend on `hasProfileEvent`, not on `typeof displayName === "string"`. +test("first-run blank identity with no profile event sees onboarding", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + // Do NOT seed searchProfiles for tyler's pubkey, so ensureMockProfile + // constructs a synthesised profile with has_profile_event: false. + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await page.goto("/"); + + await expectIncompleteOnboarding(page); +}); + +// Regression test for the H2 predicate fix (PR #1508). +// A returning user who has a real kind:0 profile event with an empty +// display_name must skip onboarding — they are already onboarded. +test("returning user with blank display name and real profile event skips onboarding", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + // Seed tyler's pubkey into searchProfiles with an empty displayName. + // seedMockSearchProfiles stores this into mockProfiles with + // has_profile_event: true, simulating a real kind:0 event with no name. + await installMockBridge( + page, + { + searchProfiles: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + displayName: "", + }, + ], + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + // Profile event exists → onboarding is skipped, app renders. + await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); + await expectHomeView(page); +}); + +// Regression test for the cache-seed defect (PR #1508 CRITICAL fix). +// Sequence: no-event profile fetched and cached with hasProfileEvent absent → +// reload with cache present → onboarding must still show. Previously the +// initialData seed hardcoded hasProfileEvent: true for any updatedAt > 0 +// entry, reopening the original bug on the second app load. +test("no-event profile cached then reloaded still sees onboarding", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + // Seed a stale v1 cache entry WITHOUT hasProfileEvent (simulating a cache + // written by the old code path or a no-event fallback). updatedAt > 0 so + // the seed is eligible, but hasProfileEvent is absent → conservative false. + const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:ws://localhost:3000:${TEST_IDENTITIES.tyler.pubkey}`; + await page.addInitScript( + ({ key, cache }) => { + window.localStorage.setItem(key, JSON.stringify(cache)); + }, + { + key: SELF_PROFILE_CACHE_KEY, + cache: { + version: 1, + displayName: null, + avatarUrl: null, + avatarDataUrl: null, + updatedAt: 1_700_000_000_000, + // hasProfileEvent deliberately absent — legacy/no-event entry. + }, + }, + ); + // No profile event on the relay either — ensureMockProfile uses false. + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await page.goto("/"); + + // Cache seed must NOT promote hasProfileEvent to true. Onboarding shows. + await expectIncompleteOnboarding(page); +}); + test("avatar step uses an add-image placeholder before an avatar is chosen", async ({ page, }) => { @@ -1037,8 +1119,21 @@ test("existing relay profile with display name auto-skips onboarding without loc }) => { // A user whose relay profile already has a display name should skip // onboarding even without the localStorage completion flag. + // Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes + // has_profile_event: true into mockProfiles — the harness-intended mechanism + // for simulating a returning user with a real kind:0 event. Do NOT use the + // static mockProfiles seed (removed in PR #1508); that path collides with + // FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs. await seedActiveIdentity(page, TEST_IDENTITIES.alice); - await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await installMockBridge( + page, + { + searchProfiles: [ + { pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" }, + ], + }, + { skipOnboardingSeed: true }, + ); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); @@ -1280,8 +1375,21 @@ test("existing relay profile with display name auto-completes onboarding", async // A user whose relay profile already has a display name should skip // onboarding entirely — they've already set up their identity previously // (possibly on another machine or app data directory). + // Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes + // has_profile_event: true into mockProfiles — the harness-intended mechanism + // for simulating a returning user with a real kind:0 event. Do NOT use the + // static mockProfiles seed (removed in PR #1508); that path collides with + // FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs. await seedActiveIdentity(page, TEST_IDENTITIES.alice); - await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await installMockBridge( + page, + { + searchProfiles: [ + { pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" }, + ], + }, + { skipOnboardingSeed: true }, + ); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); From ccfdf45bc5dd61ef7d59644293d84deed24e1789 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 13:58:15 -0400 Subject: [PATCH 004/291] chore(desktop): use dot access for host header assignment (#1548) --- desktop/tests/e2e/scrollback-buzzbugs.perf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/tests/e2e/scrollback-buzzbugs.perf.ts b/desktop/tests/e2e/scrollback-buzzbugs.perf.ts index 4c718effbe..184e371c97 100644 --- a/desktop/tests/e2e/scrollback-buzzbugs.perf.ts +++ b/desktop/tests/e2e/scrollback-buzzbugs.perf.ts @@ -145,7 +145,7 @@ test("MEASURE: scroll-back pagination latency in target channel", async ({ delete fwd["sec-ch-ua"]; delete fwd["sec-ch-ua-mobile"]; delete fwd["sec-ch-ua-platform"]; - if (COMMUNITY_HOST) fwd["host"] = COMMUNITY_HOST; + if (COMMUNITY_HOST) fwd.host = COMMUNITY_HOST; const resp = await route.fetch({ headers: fwd }); const ms = Date.now() - t0; const buf = await resp.body(); From 346efe08b3ca6e847b6c9c2f843cdb8834db123c Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:36:53 -0400 Subject: [PATCH 005/291] fix(desktop): publish new agent profiles to active relay (#1546) Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/agents.rs | 18 ++++++++++++------ desktop/src-tauri/src/commands/agents_tests.rs | 5 +++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df890931fa..21ee1a1461 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -886,9 +886,13 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. + let profile_relay_url = crate::relay::effective_agent_relay_url( + &resolved_relay_url, + &relay_ws_url_with_override(&state), + ); let profile_sync_error = (sync_managed_agent_profile( &state, - &resolved_relay_url, + &profile_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), @@ -1212,11 +1216,13 @@ pub(crate) async fn reconcile_agent_profile( } }; - if expected_avatar.is_empty() { - return Ok(()); - } + let expected_avatar = if expected_avatar.is_empty() { + None + } else { + Some(expected_avatar) + }; - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { + if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { return Ok(()); } @@ -1228,7 +1234,7 @@ pub(crate) async fn reconcile_agent_profile( &relay_url, &agent_keys, &data.name, - Some(&expected_avatar), + expected_avatar.as_deref(), data.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f141c6ae50..fcb39095bc 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -82,6 +82,11 @@ fn profile_needs_sync_when_missing() { assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); } +#[test] +fn profile_needs_sync_when_missing_even_without_expected_avatar() { + assert!(profile_needs_sync(None, "Duncan", None)); +} + #[test] fn profile_needs_sync_when_name_diverges() { let existing = profile(Some("Stilgar"), Some("https://x/a.png")); From 3b43743e1f2acd192705a80629ceadfa7a737831 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 15:01:09 -0400 Subject: [PATCH 006/291] feat(desktop): add typeahead search to persona model dropdown (#1542) Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/playwright.config.ts | 1 + .../agents/ui/PersonaModelCombobox.tsx | 220 ++++++++++++++++++ .../features/agents/ui/PersonaModelField.tsx | 4 +- desktop/tests/e2e/persona-env-vars.spec.ts | 27 +-- ...persona-model-combobox-screenshots.spec.ts | 151 ++++++++++++ 5 files changed, 385 insertions(+), 18 deletions(-) create mode 100644 desktop/src/features/agents/ui/PersonaModelCombobox.tsx create mode 100644 desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac..861a04f988 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", "**/send-channel-binding.spec.ts", + "**/persona-model-combobox-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx new file mode 100644 index 0000000000..9ac98a1bfe --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx @@ -0,0 +1,220 @@ +import * as React from "react"; +import { Check, ChevronDown, Search } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + type PersonaDropdownOption, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "./personaDialogPickers"; + +type PersonaModelComboboxProps = { + disabled?: boolean; + id: string; + onValueChange: (value: string) => void; + options: readonly PersonaDropdownOption[]; + placeholder: string; + value: string; +}; + +export function PersonaModelCombobox({ + disabled, + id, + onValueChange, + options, + placeholder, + value, +}: PersonaModelComboboxProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + + const selectedOption = options.find((option) => option.value === value); + + const filteredOptions = React.useMemo(() => { + if (query.trim() === "") return options; + const lower = query.toLowerCase(); + return options.filter((option) => + option.label.toLowerCase().includes(lower), + ); + }, [options, query]); + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { + setQuery(""); + setHighlightedIndex(0); + } + } + + function selectOption(optionValue: string) { + onValueChange(optionValue); + handleOpenChange(false); + } + + // Walk the filtered list from `from` in `direction` (+1 or -1), wrapping + // around once, and return the first non-disabled index. Returns `from` if + // every option is disabled so the highlight doesn't vanish unexpectedly. + function nextEnabledIndex(from: number, direction: 1 | -1): number { + const len = filteredOptions.length; + for (let step = 1; step <= len; step++) { + const candidate = (from + direction * step + len * step) % len; + if (!filteredOptions[candidate]?.disabled) return candidate; + } + return from; + } + + function handleKeyDown(event: React.KeyboardEvent) { + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + if (filteredOptions.length > 0) { + setHighlightedIndex((i) => nextEnabledIndex(i, 1)); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + if (filteredOptions.length > 0) { + setHighlightedIndex((i) => nextEnabledIndex(i, -1)); + } + break; + } + case "Enter": { + event.preventDefault(); + const target = filteredOptions[highlightedIndex]; + if (target && !target.disabled) selectOption(target.value); + break; + } + case "Escape": { + event.preventDefault(); + handleOpenChange(false); + break; + } + } + } + + // Reset highlight whenever the filtered list changes so the highlighted + // row stays within bounds and lands on the first enabled option. + React.useEffect(() => { + if (filteredOptions.length === 0) { + setHighlightedIndex(0); + return; + } + // Walk forward from -1 to land on the first enabled index. + const len = filteredOptions.length; + for (let i = 0; i < len; i++) { + if (!filteredOptions[i]?.disabled) { + setHighlightedIndex(i); + return; + } + } + setHighlightedIndex(0); + }, [filteredOptions]); + + return ( +
+ + + + + event.preventDefault()} + sideOffset={5} + style={{ + minWidth: "var(--radix-popover-trigger-width)", + width: "var(--radix-popover-trigger-width)", + }} + > +
+ + setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search models…" + // Popover supports onOpenAutoFocus; we preventDefault above so + // Radix doesn't move focus to the first focusable. But we still + // want the input focused immediately, so use the callback ref. + ref={(el) => el?.focus()} + spellCheck={false} + value={query} + /> +
+
event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( + + )) + ) : ( +

+ No models match +

+ )} +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaModelField.tsx b/desktop/src/features/agents/ui/PersonaModelField.tsx index e0b4b92790..5e47b9c718 100644 --- a/desktop/src/features/agents/ui/PersonaModelField.tsx +++ b/desktop/src/features/agents/ui/PersonaModelField.tsx @@ -3,7 +3,7 @@ import { motion } from "motion/react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; -import { PersonaDropdownField } from "./PersonaDropdownField"; +import { PersonaModelCombobox } from "./PersonaModelCombobox"; import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; import { type PersonaDropdownOption, @@ -56,7 +56,7 @@ export function PersonaModelField({ Optional ) : null} - ( ); } -async function openModelMenu( +async function openModelCombobox( page: import("@playwright/test").Page, model: import("@playwright/test").Locator, ) { + // PersonaModelCombobox renders a role="combobox" trigger + a Radix Popover + // with a search and plain - - {tooltipLabel} - + + {iconUrl ? ( + + ) : ( + workspaceInitials(workspace.name) || "🐝" + )} + + {showBadge ? ( + + {badgeLabel} + + ) : null} + + + + {tooltipLabel} + + + {menu} + + ); } /** * Discord/Slack-style vertical rail of workspaces on the far left of the app. * Shows a mention-count badge for inactive workspaces (observed via - * `useWorkspaceUnread`) and switches relays on click. + * `useWorkspaceUnread`) and switches relays on click. Right-click opens a + * per-workspace menu: mark all as read, copy relay URL, workspace settings. * * Hidden entirely with a single workspace — a rail of one adds no value. */ @@ -130,14 +155,35 @@ export function WorkspaceRail({ activeWorkspaceId, onSwitchWorkspace, onAddWorkspace, + onUpdateWorkspace, + onRemoveWorkspace, }: WorkspaceRailProps) { - const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId); + const { unreadByWorkspace, markWorkspaceRead } = useWorkspaceUnread( + workspaces, + activeWorkspaceId, + ); const iconsByWorkspace = useWorkspaceIcons(workspaces); const isFullscreen = useIsFullscreen(); + const { markAllChannelsRead } = useAppShell(); + const [editingWorkspace, setEditingWorkspace] = + React.useState(null); if (workspaces.length <= 1) { return null; } + const handleMarkAllRead = (workspace: Workspace) => { + if (workspace.id === activeWorkspaceId) { + markAllChannelsRead(); + return; + } + markWorkspaceRead(workspace.id).catch((error) => { + console.warn( + `[WorkspaceRail] mark all read failed workspace=${workspace.id}:`, + error, + ); + }); + }; + // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen). const topPaddingClass = isMacPlatform() && !isFullscreen @@ -158,6 +204,27 @@ export function WorkspaceRail({ key={workspace.id} iconUrl={iconsByWorkspace[workspace.id] ?? null} isActive={workspace.id === activeWorkspaceId} + menu={ + <> + handleMarkAllRead(workspace)}> + + Mark all as read + + { + void navigator.clipboard.writeText(workspace.relayUrl); + }} + > + + Copy relay URL + + + setEditingWorkspace(workspace)}> + + Workspace settings + + + } onSwitch={() => onSwitchWorkspace(workspace.id)} unread={ unreadByWorkspace[workspace.id] ?? { @@ -182,6 +249,16 @@ export function WorkspaceRail({ Add workspace + 1} + onOpenChange={(open) => { + if (!open) setEditingWorkspace(null); + }} + onRemove={onRemoveWorkspace} + onSave={onUpdateWorkspace} + open={editingWorkspace !== null} + workspace={editingWorkspace} + /> ); } diff --git a/desktop/src/features/workspaces/useWorkspaceUnread.ts b/desktop/src/features/workspaces/useWorkspaceUnread.ts index 03461eb08d..dd196b7b90 100644 --- a/desktop/src/features/workspaces/useWorkspaceUnread.ts +++ b/desktop/src/features/workspaces/useWorkspaceUnread.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { getIdentity } from "@/shared/api/tauri"; +import { markWorkspaceRead } from "@/features/workspaces/workspaceMarkRead"; import { pollWorkspaceUnread } from "@/features/workspaces/workspaceUnreadObserver"; import type { Workspace } from "./types"; @@ -49,7 +50,10 @@ function seedWorkspaceStates( export function useWorkspaceUnread( workspaces: Workspace[], activeWorkspaceId: string | null, -): Record { +): { + unreadByWorkspace: Record; + markWorkspaceRead: (workspaceId: string) => Promise; +} { const [unreadByWorkspace, setUnreadByWorkspace] = React.useState< Record >(() => seedWorkspaceStates(workspaces, {})); @@ -153,5 +157,26 @@ export function useWorkspaceUnread( }; }, [activeWorkspaceId, workspaces]); - return unreadByWorkspace; + const workspacesRef = React.useRef(workspaces); + workspacesRef.current = workspaces; + + const markRead = React.useCallback( + async (workspaceId: string) => { + const workspace = workspacesRef.current.find( + (candidate) => candidate.id === workspaceId, + ); + if (!workspace || workspaceId === activeWorkspaceId) return; + + const { pubkey } = await getIdentity(); + await markWorkspaceRead(workspace, pubkey); + // Optimistic clear — the next poll re-verifies against the relay. + setUnreadByWorkspace((previous) => ({ + ...previous, + [workspaceId]: { hasUnread: false, state: "ready" }, + })); + }, + [activeWorkspaceId], + ); + + return { unreadByWorkspace, markWorkspaceRead: markRead }; } diff --git a/desktop/src/features/workspaces/workspaceMarkRead.test.mjs b/desktop/src/features/workspaces/workspaceMarkRead.test.mjs new file mode 100644 index 0000000000..1f4bcc3c62 --- /dev/null +++ b/desktop/src/features/workspaces/workspaceMarkRead.test.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test, { beforeEach } from "node:test"; + +import { + chunkChannelContexts, + publishWorkspaceReadState, +} from "./workspaceMarkRead.ts"; + +const PUBKEY = "a".repeat(64); +const READ_AT = 1_700_000_000; + +function makeLocalStorage() { + const store = new Map(); + return { + get length() { + return store.size; + }, + key: (i) => [...store.keys()][i] ?? null, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }; +} + +beforeEach(() => { + globalThis.localStorage = makeLocalStorage(); + globalThis.window = { localStorage: globalThis.localStorage }; +}); + +test("chunkChannelContexts puts all channels in one blob when they fit", () => { + const chunks = chunkChannelContexts(["chan-1", "chan-2"], READ_AT, "client"); + assert.equal(chunks.length, 1); + assert.deepEqual(chunks[0], { "chan-1": READ_AT, "chan-2": READ_AT }); +}); + +test("chunkChannelContexts splits when the blob exceeds the byte budget", () => { + const ids = Array.from({ length: 40 }, (_, i) => `channel-${i}`); + const chunks = chunkChannelContexts(ids, READ_AT, "client", 512); + assert.ok(chunks.length > 1); + const merged = Object.assign({}, ...chunks); + assert.equal(Object.keys(merged).length, ids.length); + const encoder = new TextEncoder(); + for (const contexts of chunks) { + const bytes = encoder.encode( + JSON.stringify({ v: 1, client_id: "client", contexts }), + ).length; + assert.ok(bytes <= 512, `chunk exceeds budget: ${bytes}`); + } +}); + +test("chunkChannelContexts drops overflow beyond maxSlots instead of flooding", () => { + const ids = Array.from({ length: 100 }, (_, i) => `channel-${i}`); + const chunks = chunkChannelContexts(ids, READ_AT, "client", 128, 2); + assert.equal(chunks.length, 2); +}); + +function membersEvent(channelIds) { + return { + id: "e".repeat(64), + pubkey: "relay", + created_at: 1, + kind: 39002, + tags: channelIds.map((id) => ["d", id]), + content: "", + sig: "sig", + }; +} + +function metadataEvent(channelId, extraTags = []) { + return { + id: "f".repeat(64), + pubkey: "relay", + created_at: 1, + kind: 39000, + tags: [["d", channelId], ...extraTags], + content: "", + sig: "sig", + }; +} + +function makeClient({ channelIds, metadata }) { + const published = []; + return { + published, + async fetchEvents(filter) { + if (filter.kinds?.includes(39002)) return [membersEvent(channelIds)]; + if (filter.kinds?.includes(39000)) return metadata; + return []; + }, + async publishEvent(event) { + published.push(event); + }, + }; +} + +test("publishWorkspaceReadState publishes one read-state event covering observed channels", async () => { + const client = makeClient({ + channelIds: ["chan-1", "chan-2"], + metadata: [metadataEvent("chan-1"), metadataEvent("chan-2")], + }); + const encrypted = []; + + await publishWorkspaceReadState({ + client, + pubkey: PUBKEY, + relayUrl: "wss://relay.example", + nowSeconds: READ_AT, + encrypt: async (plaintext) => { + encrypted.push(plaintext); + return `cipher:${encrypted.length}`; + }, + sign: async (input) => ({ + id: `signed-${encrypted.length}`, + pubkey: PUBKEY, + created_at: input.createdAt, + kind: input.kind, + tags: input.tags, + content: input.content, + sig: "sig", + }), + }); + + assert.equal(client.published.length, 1); + const event = client.published[0]; + assert.equal(event.kind, 30078); + assert.equal(event.created_at, READ_AT); + assert.ok( + event.tags.some( + (tag) => tag[0] === "d" && tag[1].startsWith("read-state:"), + ), + ); + assert.ok( + event.tags.some((tag) => tag[0] === "t" && tag[1] === "read-state"), + ); + + const blob = JSON.parse(encrypted[0]); + assert.equal(blob.v, 1); + assert.deepEqual(blob.contexts, { "chan-1": READ_AT, "chan-2": READ_AT }); +}); + +test("publishWorkspaceReadState skips archived channels and publishes nothing when none remain", async () => { + const client = makeClient({ + channelIds: ["chan-1"], + metadata: [metadataEvent("chan-1", [["archived", "true"]])], + }); + + await publishWorkspaceReadState({ + client, + pubkey: PUBKEY, + relayUrl: "wss://relay.example", + nowSeconds: READ_AT, + encrypt: async (plaintext) => plaintext, + sign: async () => { + throw new Error("must not sign when there is nothing to publish"); + }, + }); + + assert.equal(client.published.length, 0); +}); + +test("publishWorkspaceReadState reuses stable slot ids so blobs are replaceable", async () => { + const makeArgs = (client) => ({ + client, + pubkey: PUBKEY, + relayUrl: "wss://relay.example", + nowSeconds: READ_AT, + encrypt: async (plaintext) => plaintext, + sign: async (input) => ({ + id: Math.random().toString(), + pubkey: PUBKEY, + created_at: input.createdAt, + kind: input.kind, + tags: input.tags, + content: input.content, + sig: "sig", + }), + }); + + const clientA = makeClient({ + channelIds: ["chan-1"], + metadata: [metadataEvent("chan-1")], + }); + await publishWorkspaceReadState(makeArgs(clientA)); + const clientB = makeClient({ + channelIds: ["chan-1"], + metadata: [metadataEvent("chan-1")], + }); + await publishWorkspaceReadState(makeArgs(clientB)); + + const dTag = (event) => event.tags.find((tag) => tag[0] === "d")[1]; + assert.equal(dTag(clientA.published[0]), dTag(clientB.published[0])); +}); diff --git a/desktop/src/features/workspaces/workspaceMarkRead.ts b/desktop/src/features/workspaces/workspaceMarkRead.ts new file mode 100644 index 0000000000..16b2e6b6cb --- /dev/null +++ b/desktop/src/features/workspaces/workspaceMarkRead.ts @@ -0,0 +1,141 @@ +import { READ_STATE_MAX_PLAINTEXT_BYTES } from "@/features/channels/readState/readStateFormat"; +import type { Workspace } from "@/features/workspaces/types"; +import { fetchObservedChannels } from "@/features/workspaces/workspaceUnreadObserver"; +import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; +import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_READ_STATE } from "@/shared/constants/kinds"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +// Slot ceiling mirrors READ_STATE_MAX_SLOTS; beyond it we drop the remainder +// rather than flood the relay — the next regular client publish catches up. +const OBSERVER_MAX_SLOTS = 8; + +const OBSERVER_CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.observer-client-id"; +const OBSERVER_SLOT_ID_KEY_PREFIX = "buzz.nip-rs.observer-slot-id"; + +type MarkReadRelay = { + fetchEvents(filter: RelaySubscriptionFilter): Promise; + publishEvent(event: RelayEvent): Promise; +}; + +type SignEvent = (input: { + kind: number; + content: string; + createdAt?: number; + tags: string[][]; +}) => Promise; + +/** + * Split channel read markers into NIP-RS blobs that each fit the single-slot + * plaintext budget. Greedy fill, order-preserving. Returns at most `maxSlots` + * chunks — overflow channels are dropped (grow-only semantics make this safe; + * they simply stay unread until read normally). + */ +export function chunkChannelContexts( + channelIds: string[], + readAt: number, + clientId: string, + maxBytes: number = READ_STATE_MAX_PLAINTEXT_BYTES, + maxSlots: number = OBSERVER_MAX_SLOTS, +): Array> { + const encoder = new TextEncoder(); + const blobBytes = (contexts: Record) => + encoder.encode(JSON.stringify({ v: 1, client_id: clientId, contexts })) + .length; + + const chunks: Array> = []; + let current: Record = {}; + + for (const channelId of channelIds) { + const candidate = { ...current, [channelId]: readAt }; + if (Object.keys(current).length > 0 && blobBytes(candidate) > maxBytes) { + chunks.push(current); + if (chunks.length >= maxSlots) { + return chunks; + } + current = { [channelId]: readAt }; + continue; + } + current = candidate; + } + + if (Object.keys(current).length > 0) { + chunks.push(current); + } + return chunks; +} + +/** + * Publish read-state blobs marking every observed channel on an INACTIVE + * workspace's relay as read-now. Grow-only NIP-RS semantics: other devices + * max-merge these markers, so publishing "everything read at `nowSeconds`" + * can never regress a marker that is already further ahead. + */ +export async function publishWorkspaceReadState(args: { + client: MarkReadRelay; + pubkey: string; + relayUrl: string; + nowSeconds?: number; + encrypt?: (plaintext: string) => Promise; + sign?: SignEvent; +}): Promise { + const { client, pubkey, relayUrl } = args; + const encrypt = args.encrypt ?? nip44EncryptToSelf; + const sign = args.sign ?? signRelayEvent; + const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000); + + const channels = await fetchObservedChannels(client, pubkey); + if (channels.length === 0) return; + + const clientId = persistedId(`${OBSERVER_CLIENT_ID_KEY_PREFIX}:${pubkey}`); + const chunks = chunkChannelContexts( + channels.map((channel) => channel.id), + nowSeconds, + clientId, + ); + + for (let index = 0; index < chunks.length; index++) { + const slotId = persistedId( + `${OBSERVER_SLOT_ID_KEY_PREFIX}:${pubkey}:${relayUrl}:${index}`, + ); + const ciphertext = await encrypt( + JSON.stringify({ v: 1, client_id: clientId, contexts: chunks[index] }), + ); + const event = await sign({ + kind: KIND_READ_STATE, + content: ciphertext, + createdAt: nowSeconds, + tags: [ + ["d", `read-state:${slotId}`], + ["t", "read-state"], + ], + }); + await client.publishEvent(event); + } +} + +export async function markWorkspaceRead( + workspace: Workspace, + pubkey: string, +): Promise { + await withReadOnlyRelayClient(workspace.relayUrl, (client) => + publishWorkspaceReadState({ + client, + pubkey, + relayUrl: workspace.relayUrl, + }), + ); +} + +function persistedId(key: string): string { + let value = localStorage.getItem(key); + if (!value) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + value = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + setLocalStorageItemWithRecovery(key, value); + } + return value; +} diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/workspaces/workspaceUnreadObserver.ts index 223d14fe2b..8e8ea5e70c 100644 --- a/desktop/src/features/workspaces/workspaceUnreadObserver.ts +++ b/desktop/src/features/workspaces/workspaceUnreadObserver.ts @@ -44,6 +44,46 @@ type ObservedChannel = { archived: boolean; }; +/** + * List the channels this pubkey is a member of on the observed relay, + * excluding archived channels and hidden DMs — the same visibility set the + * unread poll and "mark all as read" must agree on. + */ +export async function fetchObservedChannels( + client: WorkspaceUnreadRelay, + pubkey: string, +): Promise { + const memberEvents = await client.fetchEvents({ + kinds: [KIND_NIP29_GROUP_MEMBERS], + "#p": [pubkey], + limit: MEMBER_CHANNEL_LIMIT, + }); + const channelIds = extractMemberChannelIds(memberEvents); + if (channelIds.length === 0) { + return []; + } + + const [metadataEvents, visibilityEvents] = await Promise.all([ + client.fetchEvents({ + kinds: [KIND_NIP29_GROUP_METADATA], + "#d": channelIds, + limit: METADATA_LIMIT, + }), + client.fetchEvents({ + kinds: [KIND_DM_VISIBILITY], + "#p": [pubkey], + limit: 1, + }), + ]); + + const hiddenDmIds = extractHiddenDmIds(visibilityEvents); + return resolveObservedChannels(channelIds, metadataEvents).filter( + (channel) => + !channel.archived && + (channel.channelType !== "dm" || !hiddenDmIds.has(channel.id)), + ); +} + export async function pollWorkspaceUnread( workspace: Workspace, pubkey: string, @@ -63,44 +103,17 @@ export async function fetchWorkspaceUnread(args: { const normalizedPubkey = pubkey.toLowerCase(); const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000); - const memberEvents = await client.fetchEvents({ - kinds: [KIND_NIP29_GROUP_MEMBERS], - "#p": [pubkey], - limit: MEMBER_CHANNEL_LIMIT, - }); - const channelIds = extractMemberChannelIds(memberEvents); - if (channelIds.length === 0) { + const channels = await fetchObservedChannels(client, pubkey); + if (channels.length === 0) { return { hasUnread: false, mentionCount: 0 }; } - - const [metadataEvents, visibilityEvents, readStateEvents] = await Promise.all( - [ - client.fetchEvents({ - kinds: [KIND_NIP29_GROUP_METADATA], - "#d": channelIds, - limit: METADATA_LIMIT, - }), - client.fetchEvents({ - kinds: [KIND_DM_VISIBILITY], - "#p": [pubkey], - limit: 1, - }), - client.fetchEvents({ - kinds: [KIND_READ_STATE], - authors: [pubkey], - "#t": ["read-state"], - since: nowSeconds - READ_STATE_HORIZON_SECONDS, - limit: READ_STATE_FETCH_LIMIT, - }), - ], - ); - - const hiddenDmIds = extractHiddenDmIds(visibilityEvents); - const channels = resolveObservedChannels(channelIds, metadataEvents).filter( - (channel) => - !channel.archived && - (channel.channelType !== "dm" || !hiddenDmIds.has(channel.id)), - ); + const readStateEvents = await client.fetchEvents({ + kinds: [KIND_READ_STATE], + authors: [pubkey], + "#t": ["read-state"], + since: nowSeconds - READ_STATE_HORIZON_SECONDS, + limit: READ_STATE_FETCH_LIMIT, + }); const readState = await mergeReadStateEvents( readStateEvents, pubkey, diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index 653a9c861d..745ebaa90e 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -11,6 +11,7 @@ import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; const AUTH_TIMEOUT_MS = 8_000; const HISTORY_TIMEOUT_MS = 8_000; +const PUBLISH_TIMEOUT_MS = 8_000; type PendingHistory = { events: RelayEvent[]; @@ -19,10 +20,17 @@ type PendingHistory = { timeout: number; }; +type PendingPublish = { + resolve: () => void; + reject: (error: Error) => void; + timeout: number; +}; + /** - * Minimal read-only relay session for inactive-workspace observation. - * It never reads or mutates the active workspace backend relay URL; callers pass - * an explicit URL and should disconnect as soon as their polling batch finishes. + * Minimal relay session for inactive-workspace observation (and the rail's + * cross-relay "mark all as read" publish). It never reads or mutates the + * active workspace backend relay URL; callers pass an explicit URL and should + * disconnect as soon as their polling batch finishes. */ export class ReadOnlyRelayClient { private wsId: number | null = null; @@ -35,6 +43,7 @@ export class ReadOnlyRelayClient { timeout: number; } | null = null; private histories = new Map(); + private publishes = new Map(); private generation = 0; private readonly relayUrl: string; @@ -79,6 +88,12 @@ export class ReadOnlyRelayClient { this.histories.delete(subId); } + for (const [eventId, pending] of this.publishes) { + window.clearTimeout(pending.timeout); + pending.reject(error); + this.publishes.delete(eventId); + } + this.onMessageChannel = null; this.connectPromise = null; } @@ -88,6 +103,32 @@ export class ReadOnlyRelayClient { return this.requestHistory(filter); } + async publishEvent(event: RelayEvent): Promise { + await this.connect(); + if (this.wsId === null) { + throw new Error("Read-only relay socket is not connected."); + } + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + this.publishes.delete(event.id); + reject(new Error("Timed out publishing to observer relay.")); + }, PUBLISH_TIMEOUT_MS); + + this.publishes.set(event.id, { resolve, reject, timeout }); + + void this.sendRaw(["EVENT", event]).catch((error) => { + window.clearTimeout(timeout); + this.publishes.delete(event.id); + reject( + error instanceof Error + ? error + : new Error("Failed to publish to observer relay."), + ); + }); + }); + } + private async openConnection(): Promise { const generation = ++this.generation; this.onMessageChannel = new Channel((message) => { @@ -229,6 +270,20 @@ export class ReadOnlyRelayClient { } private handleOk(eventId: string, success: boolean, message: string): void { + const publish = this.publishes.get(eventId); + if (publish) { + window.clearTimeout(publish.timeout); + this.publishes.delete(eventId); + if (success) { + publish.resolve(); + } else { + publish.reject( + new Error(message || "Observer relay rejected the event."), + ); + } + return; + } + if (!this.authRequest || this.authRequest.pendingEventId !== eventId) { return; } From 3729a6515617abdae03117d9713dfeb7e1ff74c2 Mon Sep 17 00:00:00 2001 From: Yoav Amit Date: Mon, 6 Jul 2026 17:24:03 -0400 Subject: [PATCH 009/291] fix(setup): fix syntax error in seed-local-community.sh (#1547) --- scripts/seed-local-community.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/seed-local-community.sh b/scripts/seed-local-community.sh index 9a79761c2d..41ff403d0f 100755 --- a/scripts/seed-local-community.sh +++ b/scripts/seed-local-community.sh @@ -67,7 +67,11 @@ for h in hosts: if not seen: raise SystemExit("could not derive a host from RELAY_URL") -print(",\n".join(f" ('{h.replace("'", "''")}')" for h in seen)) +lines = [] +for h in seen: + escaped = h.replace(chr(39), chr(39) * 2) + lines.append(f" ('{escaped}')") +print(",\n".join(lines)) PY ) From 4c598dcef577bb204f9666eaac5b95743a0f8736 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 17:40:57 -0400 Subject: [PATCH 010/291] feat(desktop): add Slack-like Drafts inbox with persistence and image fix (#1539) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 8 + desktop/src/features/home/lib/inbox.ts | 3 +- desktop/src/features/home/ui/HomeView.tsx | 3 +- .../src/features/home/ui/InboxListPane.tsx | 14 +- .../features/messages/lib/useDrafts.test.mjs | 684 ++++++++++++++++++ .../src/features/messages/lib/useDrafts.ts | 396 +++++++++- .../src/features/messages/ui/DraftsPanel.tsx | 379 ++++++++++ .../messages/ui/DraftsPanelPredicate.test.mjs | 106 +++ .../features/messages/ui/MessageComposer.tsx | 82 ++- .../MessageComposerDraftImagePersist.test.mjs | 402 ++++++++++ .../ui/MessageComposerDraftPredicate.test.mjs | 215 ++++++ .../features/messages/ui/draftSubmitKey.ts | 22 + .../messages/ui/useDraftPersistSnapshot.ts | 122 ++++ .../messages/ui/useMentionSendFlow.ts | 10 +- .../features/workspaces/useWorkspaceInit.ts | 10 +- desktop/tests/e2e/drafts-screenshots.spec.ts | 270 +++++++ 17 files changed, 2652 insertions(+), 75 deletions(-) create mode 100644 desktop/src/features/messages/lib/useDrafts.test.mjs create mode 100644 desktop/src/features/messages/ui/DraftsPanel.tsx create mode 100644 desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs create mode 100644 desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs create mode 100644 desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs create mode 100644 desktop/src/features/messages/ui/draftSubmitKey.ts create mode 100644 desktop/src/features/messages/ui/useDraftPersistSnapshot.ts create mode 100644 desktop/tests/e2e/drafts-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 861a04f988..d04166fcc1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -69,6 +69,7 @@ export default defineConfig({ "**/reaction-order.spec.ts", "**/send-channel-binding.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", + "**/drafts-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 68b79d0e4e..05cf677ee3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -171,6 +171,14 @@ const overrides = new Map([ // +3: provider tri-state applied in update_managed_agent handler // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). ["src-tauri/src/commands/agent_models.rs", 1071], + // draft-persistence predicate: submit-time `loadDraft` check + inline comment + // + deps-array entry in submitMessage closes the never-persisted-boundary + // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to + // split MessageComposer into submit/edit/media sub-modules. + // +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore + // path writes in the draft-key effect body, fixing the image-drop bug on + // top-level nav switch (StrictMode simulate-unmount race on remount). + ["src/features/messages/ui/MessageComposer.tsx", 1021], ]); await runFileSizeCheck({ diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index ae6ab7c162..a083b9fa7e 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -23,7 +23,8 @@ export type InboxFilter = | "needs_action" | "activity" | "agent_activity" - | "reminders"; + | "reminders" + | "drafts"; export type InboxItem = { avatarUrl: string | null; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 02141fe53c..bcbba80240 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -112,7 +112,8 @@ export function HomeView({ const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } = useHistorySearchState(INBOX_SEARCH_KEYS); const isReminders = filter === "reminders"; - const isMessagesMode = !isReminders; + const isDrafts = filter === "drafts"; + const isMessagesMode = !isReminders && !isDrafts; const remindersQuery = useRemindersQuery(currentPubkey); const dueReminderCount = countDueReminders(remindersQuery.data ?? []); // `?item=` is Messages-mode-only machinery: a reminder never enters the diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 6acf8c2ed8..921f9eff3f 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -13,6 +13,7 @@ import { type InboxItem, type InboxTypeLabel, } from "@/features/home/lib/inbox"; +import { DraftsPanel } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; @@ -52,6 +53,7 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "activity", label: "Activity" }, { value: "agent_activity", label: "Agents" }, { value: "reminders", label: "Reminders" }, + { value: "drafts", label: "Drafts" }, ]; const INBOX_HEADER_ICON_BUTTON_CLASS = @@ -137,6 +139,7 @@ export function InboxListPane({ }: InboxListPaneProps) { const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; + const isDrafts = filter === "drafts"; const scrollRef = React.useRef(null); const unreadVisibleItemCount = React.useMemo( () => @@ -399,7 +402,7 @@ export function InboxListPane({
+ ) : isDrafts ? ( +
+ +
) : (
[...store.keys()][i] ?? null, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +} + +function installFreshLocalStorage() { + const ls = makeLocalStorage(); + if (typeof globalThis.window === "undefined") { + globalThis.window = { localStorage: ls }; + } else { + globalThis.window.localStorage = ls; + } + Object.defineProperty(globalThis, "localStorage", { + get: () => globalThis.window.localStorage, + configurable: true, + }); + return ls; +} + +installFreshLocalStorage(); + +// ── Module import ───────────────────────────────────────────────────────────── +// We import the standalone storage functions (not the React hook) so tests +// run without a React renderer context. +import { + clearAllDrafts, + clearDraftEntry, + getActiveDraftEntries, + getAllDraftEntries, + getSentDraftEntries, + initDraftStore, + loadDraftEntry, + markDraftSentEntry, + persistDraftEntry, + saveDraftEntry, +} from "./useDrafts.ts"; + +// Minimal ImetaMedia fixtures. +const IMG_A = { + url: "https://cdn.example.com/a.jpg", + sha256: "aabbccdd", + size: 1024, + type: "image/jpeg", + uploaded: 0, +}; +const IMG_B = { + url: "https://cdn.example.com/b.png", + sha256: "eeff0011", + size: 2048, + type: "image/png", + uploaded: 0, +}; + +function setup(pubkey = "pubkey-alice") { + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore(pubkey); +} + +function makeDraft(overrides = {}) { + const now = new Date().toISOString(); + return { + content: "Hello world", + selectionStart: 11, + selectionEnd: 11, + channelId: "chan-1", + createdAt: now, + updatedAt: now, + pendingImeta: [], + spoileredAttachmentUrls: [], + ...overrides, + }; +} + +// ── save / load round-trip ──────────────────────────────────────────────────── + +test("save_load_round_trip_preserves_content_and_attachments", () => { + setup(); + saveDraftEntry( + "chan-1", + makeDraft({ + pendingImeta: [IMG_A], + spoileredAttachmentUrls: ["https://cdn.example.com/a.jpg"], + }), + ); + const loaded = loadDraftEntry("chan-1"); + assert.ok(loaded, "draft should exist"); + assert.equal(loaded.content, "Hello world"); + assert.equal(loaded.pendingImeta.length, 1); + assert.equal(loaded.pendingImeta[0].url, IMG_A.url); + assert.deepEqual(loaded.spoileredAttachmentUrls, [ + "https://cdn.example.com/a.jpg", + ]); +}); + +test("save_load_round_trip_survives_restart_via_localstorage", () => { + setup(); + saveDraftEntry( + "chan-persist", + makeDraft({ + channelId: "chan-persist", + content: "Persisted draft", + pendingImeta: [IMG_B], + }), + ); + + // Simulate restart: clear in-memory cache, same localStorage + pubkey. + clearAllDrafts(); + initDraftStore("pubkey-alice"); + const loaded = loadDraftEntry("chan-persist"); + assert.ok(loaded, "draft should survive simulated restart"); + assert.equal(loaded.content, "Persisted draft"); + assert.equal(loaded.pendingImeta[0].url, IMG_B.url); +}); + +// ── persistDraftEntry (image-drop fix) ──────────────────────────────────────── + +test("persist_draft_saves_images_on_channel_switch_and_restores_them", () => { + setup(); + persistDraftEntry("chan-A", "Draft with image", "chan-A", [IMG_A], []); + const saved = loadDraftEntry("chan-A"); + assert.ok(saved, "draft for chan-A should exist"); + assert.equal(saved.pendingImeta.length, 1, "image should be persisted"); + assert.equal(saved.pendingImeta[0].url, IMG_A.url); +}); + +test("persist_draft_clears_draft_when_content_and_attachments_are_empty", () => { + setup(); + saveDraftEntry("chan-1", makeDraft({ content: "Something" })); + // Persist empty — should remove the draft. + persistDraftEntry("chan-1", " ", "chan-1", [], []); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "empty persist should clear draft", + ); +}); + +test("persist_draft_preserves_createdAt_on_update", () => { + setup(); + persistDraftEntry("chan-1", "v1", "chan-1", [], []); + const first = loadDraftEntry("chan-1"); + assert.ok(first); + const createdAt = first.createdAt; + + persistDraftEntry("chan-1", "v2", "chan-1", [], []); + const second = loadDraftEntry("chan-1"); + assert.ok(second); + assert.equal( + second.createdAt, + createdAt, + "createdAt must not change on update", + ); + assert.equal(second.content, "v2"); +}); + +// ── clearDraftEntry ─────────────────────────────────────────────────────────── + +test("clearDraft_removes_entry_from_store_and_localstorage", () => { + setup(); + persistDraftEntry("chan-del", "to delete", "chan-del", [], []); + clearDraftEntry("chan-del"); + assert.equal(loadDraftEntry("chan-del"), undefined); +}); + +// ── corruption tolerance ────────────────────────────────────────────────────── + +test("corrupt_localstorage_json_is_silently_ignored", () => { + setup("pubkey-corrupt"); + localStorage.setItem("buzz-drafts.v1:pubkey-corrupt", "{not-valid-json"); + // Re-init to force a fresh read from the corrupted store. + clearAllDrafts(); + initDraftStore("pubkey-corrupt"); + // Should return undefined, not throw. + assert.equal(loadDraftEntry("any-key"), undefined); +}); + +test("invalid_draft_entries_in_localstorage_are_skipped", () => { + setup("pubkey-invalid"); + const validDraft = { + content: "valid", + selectionStart: 0, + selectionEnd: 0, + channelId: "chan-v", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + pendingImeta: [], + spoileredAttachmentUrls: [], + }; + const data = JSON.stringify({ + "chan-v": validDraft, + "chan-bad": { content: 42, selectionStart: "no" }, + "chan-missing": { content: "x" }, + }); + localStorage.setItem("buzz-drafts.v1:pubkey-invalid", data); + clearAllDrafts(); + initDraftStore("pubkey-invalid"); + assert.ok(loadDraftEntry("chan-v"), "valid draft should load"); + assert.equal( + loadDraftEntry("chan-bad"), + undefined, + "invalid shape should be skipped", + ); + assert.equal( + loadDraftEntry("chan-missing"), + undefined, + "incomplete draft should be skipped", + ); +}); + +// ── identity scoping ────────────────────────────────────────────────────────── + +test("drafts_are_scoped_per_pubkey_and_do_not_leak_across_identities", () => { + setup("pubkey-alice"); + persistDraftEntry("chan-1", "alice draft", "chan-1", [], []); + + clearAllDrafts(); + initDraftStore("pubkey-bob"); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "bob must not see alice's draft", + ); + + clearAllDrafts(); + initDraftStore("pubkey-alice"); + assert.ok( + loadDraftEntry("chan-1"), + "alice's draft must survive identity switch", + ); +}); + +// ── eviction ───────────────────────────────────────────────────────────────── + +test("evicts_oldest_updated_entry_when_over_cap", () => { + setup("pubkey-evict"); + const MAX = 100; + for (let i = 0; i <= MAX; i++) { + const ts = new Date(1_000_000 + i * 1000).toISOString(); + saveDraftEntry( + `chan-${i}`, + makeDraft({ + channelId: `chan-${i}`, + content: `draft ${i}`, + createdAt: ts, + updatedAt: ts, + }), + ); + } + // chan-0 had the oldest updatedAt — it must have been evicted. + assert.equal( + loadDraftEntry("chan-0"), + undefined, + "oldest entry should be evicted", + ); + assert.ok(loadDraftEntry("chan-1"), "chan-1 should survive"); + assert.ok(loadDraftEntry(`chan-${MAX}`), `chan-${MAX} should survive`); +}); + +// ── getAllDraftEntries ──────────────────────────────────────────────────────── + +test("getAllDraftEntries_returns_all_entries_sorted_most_recently_updated_first", () => { + setup("pubkey-list"); + const old = "2025-01-01T00:00:00.000Z"; + const newer = "2025-06-01T00:00:00.000Z"; + const newest = "2025-12-01T00:00:00.000Z"; + + saveDraftEntry( + "chan-a", + makeDraft({ + channelId: "chan-a", + content: "a", + createdAt: old, + updatedAt: old, + }), + ); + saveDraftEntry( + "chan-b", + makeDraft({ + channelId: "chan-b", + content: "b", + createdAt: newer, + updatedAt: newer, + }), + ); + saveDraftEntry( + "chan-c", + makeDraft({ + channelId: "chan-c", + content: "c", + createdAt: newest, + updatedAt: newest, + }), + ); + + const all = getAllDraftEntries(); + assert.equal(all.length, 3); + assert.equal(all[0].key, "chan-c", "most recent first"); + assert.equal(all[1].key, "chan-b"); + assert.equal(all[2].key, "chan-a", "oldest last"); +}); + +test("getAllDraftEntries_returns_empty_array_when_no_drafts", () => { + setup("pubkey-empty"); + assert.deepEqual(getAllDraftEntries(), []); +}); + +// ── channelId correctness on key switch ────────────────────────────────────── +// Regression: composer effect body was re-persisting prevKey with the incoming +// channel's id, corrupting the outgoing draft's channelId metadata. +// The first test below demonstrates the bug path — calling persistDraftEntry +// for key-A with channelId-B DOES overwrite the metadata, proving that the +// redundant body-side persist was the corruption source and had to be removed. +// The second test asserts the correct post-fix behavior: a normal A→B switch +// leaves draft A's channelId untouched. + +test("persist_draft_bug_path_overwrites_channelId_confirming_removal_was_right", () => { + setup(); + // Simulate correct outgoing save (cleanup runs first in React, correct channel). + persistDraftEntry("chan-A", "draft text", "chan-A", [IMG_A], []); + const afterCorrectSave = loadDraftEntry("chan-A"); + assert.ok(afterCorrectSave, "chan-A draft should exist after correct save"); + assert.equal( + afterCorrectSave.channelId, + "chan-A", + "channelId must be chan-A after correct save", + ); + + // Simulate the BUG path: a second persist of the same key but with the + // incoming channel's id (chan-B). This must NOT be done in practice, but + // we assert here that IF it were called, it would corrupt the metadata — + // confirming that removing the redundant body-side persist was the right fix. + persistDraftEntry("chan-A", "draft text", "chan-B", [IMG_A], []); + const afterBuggyOverwrite = loadDraftEntry("chan-A"); + assert.ok(afterBuggyOverwrite); + assert.equal( + afterBuggyOverwrite.channelId, + "chan-B", + "channelId IS overwritten when persist is called with wrong channel — confirms the redundant persist must be removed", + ); +}); + +test("persist_draft_outgoing_key_retains_original_channelId_when_body_persist_removed", () => { + // The correct behavior after the fix: only the cleanup call persists + // the outgoing draft. We simulate: persist A with channelId=A (cleanup), + // do NOT call persist A again with channelId=B (body removed), then verify A + // still has channelId=A when navigating to B's composer. + setup(); + persistDraftEntry("chan-A", "draft in A", "chan-A", [IMG_A], []); + // Simulate switch to channel B: persist B's draft (new channel). + persistDraftEntry("chan-B", "", "chan-B", [], []); // empty B draft, gets cleared + // A's draft must still have channelId=A. + const draftA = loadDraftEntry("chan-A"); + assert.ok(draftA, "chan-A draft should survive channel switch to B"); + assert.equal( + draftA.channelId, + "chan-A", + "chan-A channelId must not be corrupted by switch to chan-B", + ); + assert.equal( + draftA.pendingImeta.length, + 1, + "image must be preserved on chan-A draft", + ); +}); + +// ── thread-key handling ─────────────────────────────────────────────────────── + +test("thread_draft_key_stores_explicit_channelId_not_the_thread_key", () => { + setup(); + const threadKey = "thread:aaaa1234"; + const channelId = "the-channel-id"; + saveDraftEntry( + threadKey, + makeDraft({ + channelId, + content: "thread reply draft", + pendingImeta: [IMG_A], + }), + ); + const loaded = loadDraftEntry(threadKey); + assert.ok(loaded); + assert.equal( + loaded.channelId, + channelId, + "channelId must equal the explicit value", + ); + assert.equal(loaded.pendingImeta.length, 1); +}); + +// ── initDraftStore cache-reset safety ──────────────────────────────────────── + +test("initDraftStore_resets_cache_on_pubkey_change_without_clearAllDrafts", () => { + // Alice saves a draft. + setup("pubkey-alice"); + persistDraftEntry("chan-1", "alice draft", "chan-1", [], []); + + // Switch directly to bob without calling clearAllDrafts first. + // initDraftStore must reset the in-memory cache so alice's draft + // is not served under bob's identity. + initDraftStore("pubkey-bob"); + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "bob must not see alice's cached draft after direct initDraftStore switch", + ); +}); + +// ── status field: markDraftSent, getActiveDraftEntries, getSentDraftEntries ─── +// markDraftSentEntry snapshots the draft into a distinct `sent::` key +// and removes the original active key so composer cleanup and new drafts are +// never affected by the sent record's lifecycle. + +test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key", () => { + setup(); + persistDraftEntry("chan-1", "sent message content", "chan-1", [IMG_A], []); + markDraftSentEntry("chan-1", "sent message content", "chan-1", [IMG_A], []); + // Active key must be gone. + assert.equal( + loadDraftEntry("chan-1"), + undefined, + "active key must be cleared after markDraftSent", + ); + // Sent record must exist under a sent: key. + const sent = getSentDraftEntries(); + assert.equal(sent.length, 1, "one sent entry must exist"); + assert.equal(sent[0].draft.status, "sent", "status must be 'sent'"); + assert.equal( + sent[0].draft.content, + "sent message content", + "content preserved", + ); + assert.equal(sent[0].draft.pendingImeta.length, 1, "image preserved"); + assert.equal(sent[0].draft.pendingImeta[0].url, IMG_A.url); + assert.ok( + sent[0].key.startsWith("sent:chan-1:"), + "sent key must have sent: prefix", + ); +}); + +test("markDraftSent_writes_sent_record_even_when_active_key_already_cleared", () => { + // The never-persisted boundary is enforced at the call site (sentDraftKey + // is only set when a draft was actually persisted). This function writes + // unconditionally so a navigation-during-send race cannot cause data loss: + // if the active key was already cleared before send success, the snapshot + // content still produces a sent record (createdAt falls back to now). + setup(); + // Call without any prior persistDraftEntry — simulates the race where the + // active key was deleted by a composer cleanup before markDraftSent ran. + markDraftSentEntry("no-such-key", "content", "chan-x", [], []); + assert.equal( + loadDraftEntry("no-such-key"), + undefined, + "active key still absent", + ); + const sent = getSentDraftEntries(); + assert.equal( + sent.length, + 1, + "sent record is written even without a live active key", + ); + assert.equal(sent[0].draft.content, "content"); + assert.equal(sent[0].draft.status, "sent"); + assert.ok( + sent[0].key.startsWith("sent:no-such-key:"), + "sent key has correct prefix", + ); +}); + +test("markDraftSent_send_then_cleanup_preserves_sent_record", () => { + // Simulate the full composer lifecycle: + // 1. Draft exists on key A. + // 2. User sends -> markDraftSent(A) snapshots under sent:A:ts and clears A. + // 3. Composer cleanup calls persistDraft(A, "", ...) -> clearDraftEntry(A). + // The sent record under sent:A:ts must still exist after step 3. + setup(); + persistDraftEntry("chan-A", "my draft", "chan-A", [IMG_A], []); + markDraftSentEntry("chan-A", "my draft", "chan-A", [IMG_A], []); + // Simulate composer cleanup: empty persist on the now-absent active key. + persistDraftEntry("chan-A", "", "chan-A", [], []); + const sent = getSentDraftEntries(); + assert.equal(sent.length, 1, "sent record must survive composer cleanup"); + assert.equal(sent[0].draft.content, "my draft"); +}); + +test("markDraftSent_navigation_during_async_send_still_creates_sent_record", () => { + // Regression test for the async-send/navigation race (Thufir Pass-2 finding): + // 1. Persisted draft A exists at submit time. + // 2. Composer clears the editor (clearContent) then awaits onSend. + // 3. While onSend is in flight, user switches channel. MessageComposer + // cleanup runs persistDraftEntry(A, empty) -> clearDraftEntry(A) — active + // key is gone before send success. + // 4. Send succeeds; markDraftSentEntry(A, savedContent, ...) runs. + // The sent record MUST still be written from the passed-in snapshot. + setup(); + persistDraftEntry("chan-race", "race draft", "chan-race", [IMG_A], []); + // Simulate step 3: active key cleared by navigation-during-send cleanup. + persistDraftEntry("chan-race", "", "chan-race", [], []); + assert.equal( + loadDraftEntry("chan-race"), + undefined, + "active key should be cleared (simulating race)", + ); + // Simulate step 4: send succeeds, mark sent with full snapshot. + markDraftSentEntry("chan-race", "race draft", "chan-race", [IMG_A], []); + const sent = getSentDraftEntries(); + assert.equal( + sent.length, + 1, + "sent record must be written despite active key being gone", + ); + assert.equal( + sent[0].draft.content, + "race draft", + "snapshot content preserved", + ); + assert.equal( + sent[0].draft.pendingImeta.length, + 1, + "snapshot image preserved", + ); + assert.equal(sent[0].draft.status, "sent"); +}); + +test("markDraftSent_new_active_draft_after_send_is_independent", () => { + // After sending, a new draft typed in the same channel must appear in + // getActiveDraftEntries() as active, and the sent record must remain in + // getSentDraftEntries() -- they coexist under distinct keys. + setup("pubkey-coexist"); + persistDraftEntry("chan-X", "original draft", "chan-X", [], []); + markDraftSentEntry("chan-X", "original draft", "chan-X", [], []); + // New draft in the same channel. + persistDraftEntry("chan-X", "new draft after send", "chan-X", [IMG_B], []); + const active = getActiveDraftEntries(); + const sent = getSentDraftEntries(); + assert.equal(active.length, 1, "one active draft"); + assert.equal(active[0].draft.content, "new draft after send"); + assert.equal(active[0].draft.status, "active"); + assert.equal(sent.length, 1, "one sent record"); + assert.equal(sent[0].draft.content, "original draft"); + assert.equal(sent[0].draft.status, "sent"); +}); + +test("markDraftSent_double_send_in_same_channel_creates_two_distinct_sent_records", () => { + // Sending twice from the same channel must produce two independent sent + // records -- the timestamp suffix prevents key collision. + setup("pubkey-double-send"); + persistDraftEntry("chan-Y", "first draft", "chan-Y", [], []); + markDraftSentEntry("chan-Y", "first draft", "chan-Y", [], []); + // Second draft in the same channel. + persistDraftEntry("chan-Y", "second draft", "chan-Y", [], []); + markDraftSentEntry("chan-Y", "second draft", "chan-Y", [], []); + const sent = getSentDraftEntries(); + assert.equal(sent.length, 2, "two distinct sent records"); + const contents = sent.map((e) => e.draft.content).sort(); + assert.deepEqual(contents, ["first draft", "second draft"]); + const keys = sent.map((e) => e.key); + assert.notEqual(keys[0], keys[1], "sent keys must be distinct"); +}); + +test("getActiveDraftEntries_returns_only_active_drafts", () => { + setup("pubkey-active"); + persistDraftEntry("chan-active", "active draft", "chan-active", [], []); + persistDraftEntry("chan-sent", "sent draft", "chan-sent", [], []); + markDraftSentEntry("chan-sent", "sent draft", "chan-sent", [], []); + const active = getActiveDraftEntries(); + assert.equal(active.length, 1, "only one active draft"); + assert.equal(active[0].key, "chan-active"); + assert.equal(active[0].draft.status, "active"); +}); + +test("getSentDraftEntries_returns_only_sent_drafts", () => { + setup("pubkey-sent"); + persistDraftEntry("chan-active2", "still drafting", "chan-active2", [], []); + persistDraftEntry("chan-sent2", "already sent", "chan-sent2", [], []); + markDraftSentEntry("chan-sent2", "already sent", "chan-sent2", [], []); + const sent = getSentDraftEntries(); + assert.equal(sent.length, 1, "only one sent draft"); + assert.ok(sent[0].key.startsWith("sent:chan-sent2:"), "sent key has prefix"); + assert.equal(sent[0].draft.status, "sent"); +}); + +test("getActiveDraftEntries_and_getSentDraftEntries_partition_all_entries", () => { + setup("pubkey-partition"); + persistDraftEntry("ch-a", "draft a", "ch-a", [], []); + persistDraftEntry("ch-b", "draft b", "ch-b", [], []); + persistDraftEntry("ch-c", "draft c", "ch-c", [], []); + markDraftSentEntry("ch-b", "draft b", "ch-b", [], []); + const all = getAllDraftEntries(); + const active = getActiveDraftEntries(); + const sent = getSentDraftEntries(); + // ch-a, ch-c still active; sent:ch-b:ts is the sent record. + assert.equal(all.length, 3); + assert.equal(active.length + sent.length, all.length, "active + sent = all"); + assert.ok( + active.every((e) => e.draft.status === "active"), + "all active entries have status active", + ); + assert.ok( + sent.every((e) => e.draft.status === "sent"), + "all sent entries have status sent", + ); +}); + +// ── status migration: pre-status entries read as "active" ──────────────────── + +test("pre_status_entry_without_status_field_is_read_as_active", () => { + setup("pubkey-migrate"); + // Write a raw entry without the status field, simulating data persisted + // before the status field was introduced. + const legacyEntry = { + content: "legacy draft", + selectionStart: 0, + selectionEnd: 12, + channelId: "chan-legacy", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + pendingImeta: [], + spoileredAttachmentUrls: [], + // NOTE: no 'status' field + }; + localStorage.setItem( + "buzz-drafts.v1:pubkey-migrate", + JSON.stringify({ "chan-legacy": legacyEntry }), + ); + // Force re-read from localStorage. + clearAllDrafts(); + initDraftStore("pubkey-migrate"); + const loaded = loadDraftEntry("chan-legacy"); + assert.ok(loaded, "legacy entry must load without rejection"); + assert.equal(loaded.status, "active", "missing status defaults to 'active'"); + assert.equal(loaded.content, "legacy draft"); +}); + +test("pre_status_entry_appears_in_getActiveDraftEntries_after_migration", () => { + setup("pubkey-migrate2"); + const legacyEntry = { + content: "old draft", + selectionStart: 0, + selectionEnd: 9, + channelId: "chan-old", + createdAt: "2025-06-01T00:00:00.000Z", + updatedAt: "2025-06-01T00:00:00.000Z", + pendingImeta: [], + spoileredAttachmentUrls: [], + }; + localStorage.setItem( + "buzz-drafts.v1:pubkey-migrate2", + JSON.stringify({ "chan-old": legacyEntry }), + ); + clearAllDrafts(); + initDraftStore("pubkey-migrate2"); + const active = getActiveDraftEntries(); + assert.equal(active.length, 1, "legacy entry appears in active list"); + assert.equal(active[0].key, "chan-old"); + assert.equal(active[0].draft.status, "active"); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 23757743f6..62a12e0b50 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -1,59 +1,391 @@ import * as React from "react"; -import { trimMapToSize } from "@/shared/lib/trimMapToSize"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; export type DraftState = { content: string; selectionStart: number; selectionEnd: number; + /** + * The channel (or thread-scoped) ID this draft belongs to. + * Stored explicitly — do NOT parse the draft key to recover it. + * Thread draft keys use the form `thread:${threadHead.id}`; the + * channelId is the containing channel. + */ + channelId: string; + /** ISO-8601 timestamp when this draft was first created. */ + createdAt: string; + /** ISO-8601 timestamp when this draft was last updated. */ + updatedAt: string; + /** Pasted/uploaded image attachments, preserved across channel-switch. */ + pendingImeta: ImetaMedia[]; + /** URLs of imeta attachments marked as spoilered. */ + spoileredAttachmentUrls: string[]; + /** + * Lifecycle status of this draft. + * - "active": draft is in progress (not yet sent). + * - "sent": draft was sent; kept for the Drafts inbox "Sent" subsection. + * Entries persisted before this field was added have no status field — + * the read path treats absent status as "active" (see `isValidDraftState`). + */ + status: "active" | "sent"; }; -const sharedDrafts = new Map(); +/** Serialised shape stored in localStorage (same as DraftState for round-trips). */ +type StoredDrafts = Record; +const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; +const MAX_DRAFTS = 100; + +/** Module-level pubkey set by `initDraftStore`. Empty string = no identity. */ +let currentPubkey = ""; + +/** Monotonically-incrementing counter used to guarantee unique sent-record keys + * even when two sends happen within the same millisecond (e.g. in tests). */ +let _sentSeq = 0; + +function storageKey(): string { + return `${DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; +} + +/** + * Initialise (or re-initialise) the draft store for a given identity. + * Called from `useWorkspaceInit` alongside the other singleton resets. + * Resets the in-memory cache whenever the pubkey changes so a direct + * identity switch (without a prior `clearAllDrafts`) never serves the + * wrong identity's drafts. + */ +export function initDraftStore(pubkey: string): void { + if (currentPubkey !== pubkey) { + _memCache = null; + } + currentPubkey = pubkey; + // Eagerly load to surface corruption errors in console at startup rather + // than on first draft interaction. + readStore(); +} + +/** + * Reset the in-memory draft store on workspace switch. + * Replaces the old `clearAllDrafts()`. + */ export function clearAllDrafts(): void { - sharedDrafts.clear(); + currentPubkey = ""; + _memCache = null; +} + +// ── In-memory write-back cache ──────────────────────────────────────────────── +// We keep a parsed copy so reads are synchronous O(1) object lookups, +// and only flush to localStorage on writes. + +let _memCache: Map | null = null; + +function readStore(): Map { + if (_memCache !== null) return _memCache; + + const map = new Map(); + if (!currentPubkey) { + _memCache = map; + return map; + } + + const raw = localStorage.getItem(storageKey()); + if (!raw) { + _memCache = map; + return map; + } + + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) { + for (const [key, value] of Object.entries(parsed as StoredDrafts)) { + if (isValidDraftState(value)) { + map.set(key, value); + } + } + } + } catch (err) { + console.debug("[useDrafts] localStorage corrupt, starting fresh:", err); + } + + _memCache = map; + return map; +} + +function isValidDraftState(v: unknown): v is DraftState { + if (typeof v !== "object" || v === null) return false; + const d = v as Partial; + if ( + typeof d.content !== "string" || + typeof d.selectionStart !== "number" || + typeof d.selectionEnd !== "number" || + typeof d.channelId !== "string" || + typeof d.createdAt !== "string" || + typeof d.updatedAt !== "string" || + !Array.isArray(d.pendingImeta) || + !Array.isArray(d.spoileredAttachmentUrls) + ) { + return false; + } + // Migration: entries written before the status field was introduced have no + // status. Treat absent/invalid status as "active" rather than rejecting the + // entry — this avoids data loss on first run after the upgrade. + if (d.status === undefined || d.status === null) { + (d as DraftState).status = "active"; + } else if (d.status !== "active" && d.status !== "sent") { + return false; + } + return true; +} + +function flushStore(map: Map): void { + if (!currentPubkey) return; + const obj: StoredDrafts = {}; + for (const [k, v] of map) { + obj[k] = v; + } + setLocalStorageItemWithRecovery(storageKey(), JSON.stringify(obj)); +} + +/** + * Evict the least-recently-updated entry until the map is within `MAX_DRAFTS`. + */ +function evictOldest(map: Map): void { + if (map.size <= MAX_DRAFTS) return; + // Sort ascending by updatedAt; evict oldest until within cap. + const sorted = [...map.entries()].sort((a, b) => + a[1].updatedAt.localeCompare(b[1].updatedAt), + ); + const excess = map.size - MAX_DRAFTS; + for (let i = 0; i < excess; i++) { + map.delete(sorted[i][0]); + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── +// The standalone functions below are the primary storage layer. `useDrafts()` +// wraps them in `React.useCallback` for component use; the functions are also +// exported directly so non-React callers (tests, future inbox features) can +// use them without a React context. + +export function saveDraftEntry(draftKey: string, draft: DraftState): void { + if (draft.content.trim().length === 0 && draft.pendingImeta.length === 0) { + return; + } + const map = readStore(); + map.set(draftKey, draft); + evictOldest(map); + flushStore(map); +} + +export function loadDraftEntry(draftKey: string): DraftState | undefined { + return readStore().get(draftKey); +} + +export function clearDraftEntry(draftKey: string): void { + const map = readStore(); + if (map.has(draftKey)) { + map.delete(draftKey); + flushStore(map); + } +} + +/** + * Convenience: save if content or attachments are non-empty, otherwise clear. + * Preserves existing createdAt on updates; sets it on first save. + */ +export function persistDraftEntry( + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], +): void { + const hasContent = content.trim().length > 0 || pendingImeta.length > 0; + if (hasContent) { + const map = readStore(); + const existing = map.get(draftKey); + const now = new Date().toISOString(); + saveDraftEntry(draftKey, { + content, + selectionEnd: content.length, + selectionStart: content.length, + channelId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + pendingImeta, + spoileredAttachmentUrls, + status: "active", + }); + } else { + clearDraftEntry(draftKey); + } +} + +/** + * Returns all drafts sorted most-recently-updated first. + * Used by the Drafts inbox panel (Phase 2). + */ +export function getAllDraftEntries(): Array<{ + key: string; + draft: DraftState; +}> { + return [...readStore().entries()] + .sort((a, b) => b[1].updatedAt.localeCompare(a[1].updatedAt)) + .map(([key, draft]) => ({ key, draft })); +} + +/** + * Returns only active (unsent) drafts, sorted most-recently-updated first. + * Used by the "Drafts" subsection of the Drafts inbox panel. + */ +export function getActiveDraftEntries(): Array<{ + key: string; + draft: DraftState; +}> { + return getAllDraftEntries().filter((e) => e.draft.status === "active"); +} + +/** + * Returns only sent drafts, sorted most-recently-updated first. + * Used by the "Sent" subsection of the Drafts inbox panel. + */ +export function getSentDraftEntries(): Array<{ + key: string; + draft: DraftState; +}> { + return getAllDraftEntries().filter((e) => e.draft.status === "sent"); +} + +/** + * Mark a draft as sent by writing its content to a durable sent-record key. + * + * The active draft key is simultaneously cleared so the composer can create + * a fresh draft in the same channel without inheriting the sent status, and so + * the composer's empty-content cleanup can never delete the sent record. + * + * The sent record is stored under `sent::` — a key the + * composer never writes to — so active and sent records for the same channel + * can coexist in the store independently. + * + * The "never-persisted draft writes no sent record" boundary is enforced at + * the call site: callers only invoke this function when `sentDraftKey` is + * non-null, which only holds for drafts that were persisted before submit. + * This function writes unconditionally so the sent record is created even + * when the active key was already cleared by a composer cleanup that raced + * the async send (e.g. the user switched channels while send was in flight). + */ +export function markDraftSentEntry( + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], +): void { + const map = readStore(); + const existing = map.get(draftKey); + const now = new Date().toISOString(); + // Use the live entry's createdAt when available; fall back to now when the + // active key was already cleared by a navigation-during-send race. Either + // way the sent record is written — the race cannot cause data loss. + const createdAt = existing?.createdAt ?? now; + // Write the sent record under a stable, distinct key so it can never be + // overwritten by the composer's active-draft persist path. + // The `Date.now()-seq` suffix guarantees uniqueness even if two sends in the + // same channel happen within the same millisecond. + const sentKey = `sent:${draftKey}:${Date.now()}-${++_sentSeq}`; + map.set(sentKey, { + content, + selectionStart: content.length, + selectionEnd: content.length, + channelId, + createdAt, + updatedAt: now, + pendingImeta, + spoileredAttachmentUrls, + status: "sent", + }); + + // Clear the active draft key (if still present) so the composer starts fresh + // and any subsequent empty-content persist doesn't encounter the sent record. + map.delete(draftKey); + evictOldest(map); + flushStore(map); } export function useDrafts() { const saveDraft = React.useCallback( - (channelId: string, draft: DraftState) => { - if (draft.content.trim().length === 0) { - return; - } - sharedDrafts.set(channelId, draft); - trimMapToSize(sharedDrafts, 50); - }, + (draftKey: string, draft: DraftState) => saveDraftEntry(draftKey, draft), [], ); const loadDraft = React.useCallback( - (channelId: string): DraftState | undefined => { - return sharedDrafts.get(channelId); - }, + (draftKey: string): DraftState | undefined => loadDraftEntry(draftKey), [], ); - const clearDraft = React.useCallback((channelId: string) => { - sharedDrafts.delete(channelId); - }, []); - - /** Save draft if content is non-empty, otherwise clear it. */ - const persistDraft = React.useCallback( - (channelId: string, content: string) => { - if (content.trim().length > 0) { - saveDraft(channelId, { - content, - selectionEnd: content.length, - selectionStart: content.length, - }); - } else { - clearDraft(channelId); - } - }, - [saveDraft, clearDraft], + const clearDraft = React.useCallback( + (draftKey: string) => clearDraftEntry(draftKey), + [], ); - return { saveDraft, loadDraft, clearDraft, persistDraft }; + const persistDraft = React.useCallback( + ( + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], + ) => + persistDraftEntry( + draftKey, + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + ), + [], + ); + + const getAllDrafts = React.useCallback(() => getAllDraftEntries(), []); + + const getActiveDrafts = React.useCallback(() => getActiveDraftEntries(), []); + + const getSentDrafts = React.useCallback(() => getSentDraftEntries(), []); + + const markDraftSent = React.useCallback( + ( + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], + ) => + markDraftSentEntry( + draftKey, + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + ), + [], + ); + + return { + saveDraft, + loadDraft, + clearDraft, + persistDraft, + getAllDrafts, + getActiveDrafts, + getSentDrafts, + markDraftSent, + }; } export type UseDraftsResult = ReturnType; diff --git a/desktop/src/features/messages/ui/DraftsPanel.tsx b/desktop/src/features/messages/ui/DraftsPanel.tsx new file mode 100644 index 0000000000..199d2bf4a8 --- /dev/null +++ b/desktop/src/features/messages/ui/DraftsPanel.tsx @@ -0,0 +1,379 @@ +import { FileText, Lock, Pencil, Trash2 } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + clearDraftEntry, + getActiveDraftEntries, + getSentDraftEntries, + type DraftState, +} from "@/features/messages/lib/useDrafts"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +const SENT_DRAFT_PREFIX = "sent:"; +const THREAD_DRAFT_PREFIX = "thread:"; +const UNKNOWN_CHANNEL_LABEL = "Unknown channel"; + +type DraftListEntry = { + draft: DraftState; + key: string; +}; + +type DraftSection = { + entries: DraftListEntry[]; + label: string; + status: DraftState["status"]; +}; + +type DraftSource = { + channel: Channel | null; + label: string; +}; + +const UNKNOWN_DRAFT_SOURCE: DraftSource = { + channel: null, + label: UNKNOWN_CHANNEL_LABEL, +}; + +const draftTimeFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +function parseDraftTime(value: string): number { + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function formatDraftCreatedAt(draft: DraftState): string { + const time = parseDraftTime(draft.createdAt); + return time === 0 + ? "Unknown time" + : draftTimeFormatter.format(new Date(time)); +} + +function getOriginalDraftKey(draftKey: string): string { + if (!draftKey.startsWith(SENT_DRAFT_PREFIX)) { + return draftKey; + } + + const sentPayload = draftKey.slice(SENT_DRAFT_PREFIX.length); + const timestampSeparatorIndex = sentPayload.lastIndexOf(":"); + return timestampSeparatorIndex > 0 + ? sentPayload.slice(0, timestampSeparatorIndex) + : sentPayload; +} + +function getThreadRootId(draftKey: string): string | null { + const originalDraftKey = getOriginalDraftKey(draftKey); + if (!originalDraftKey.startsWith(THREAD_DRAFT_PREFIX)) { + return null; + } + + const id = originalDraftKey.slice(THREAD_DRAFT_PREFIX.length).trim(); + return id.length > 0 ? id : null; +} + +function isVisibleDraft(entry: DraftListEntry): boolean { + const content = entry.draft.content.trim(); + const attachmentCount = entry.draft.pendingImeta.length; + return content.length > 0 || attachmentCount > 0; +} + +function getDraftPreview(draft: DraftState): string { + const content = draft.content.trim(); + if (content.length > 0) { + return content; + } + + const attachmentCount = draft.pendingImeta.length; + if (attachmentCount === 1) { + return "1 attachment"; + } + if (attachmentCount > 1) { + return `${attachmentCount} attachments`; + } + return "Empty draft"; +} + +function readDraftSections(): DraftSection[] { + const active = getActiveDraftEntries().filter(isVisibleDraft); + const sent = getSentDraftEntries().filter(isVisibleDraft); + const sections: DraftSection[] = []; + + if (active.length > 0) { + sections.push({ label: "Drafts", status: "active", entries: active }); + } + if (sent.length > 0) { + sections.push({ label: "Sent", status: "sent", entries: sent }); + } + + return sections; +} + +function resolveDraftSources({ + channels, + currentPubkey, + drafts, + profiles, +}: { + channels: Channel[] | undefined; + currentPubkey: string | undefined; + drafts: DraftListEntry[]; + profiles: UserProfileLookup | undefined; +}): Map { + const channelsById = new Map( + (channels ?? []).map((channel) => [channel.id, channel]), + ); + const sources = new Map(); + + for (const entry of drafts) { + const channel = channelsById.get(entry.draft.channelId); + sources.set(entry.key, { + channel: channel ?? null, + label: channel + ? resolveChannelDisplayLabel(channel, currentPubkey, profiles) + : UNKNOWN_CHANNEL_LABEL, + }); + } + + return sources; +} + +function DraftRowActionButton({ + children, + disabled = false, + label, + onClick, +}: { + children: React.ReactNode; + disabled?: boolean; + label: string; + onClick: () => void; +}) { + return ( + + + + + {label} + + ); +} + +export function canOpenDraft(draft: DraftState, source: DraftSource): boolean { + return ( + draft.status !== "sent" && + source.channel !== null && + draft.channelId.length > 0 + ); +} + +function DraftRow({ + entry, + onDelete, + onOpen, + source, +}: { + entry: DraftListEntry; + onDelete: (draftKey: string) => void; + onOpen: (entry: DraftListEntry) => void; + source: DraftSource; +}) { + const isSent = entry.draft.status === "sent"; + const canOpen = canOpenDraft(entry.draft, source); + const isPrivate = source.channel?.visibility === "private"; + const isDm = source.channel?.channelType === "dm"; + const channelLabel = source.channel + ? isDm + ? source.label + : `#${source.label}` + : UNKNOWN_CHANNEL_LABEL; + + return ( +
+ + +
+ {isSent ? null : ( + onOpen(entry)} + > + + + )} + onDelete(entry.key)} + > + + +
+
+ ); +} + +export function DraftsPanel() { + const { goChannel } = useAppNavigation(); + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const channelsQuery = useChannelsQuery(); + const [sections, setSections] = + React.useState(readDraftSections); + + const refreshDrafts = React.useCallback(() => { + setSections(readDraftSections()); + }, []); + + React.useEffect(() => { + refreshDrafts(); + }, [refreshDrafts]); + + const drafts = React.useMemo( + () => sections.flatMap((section) => section.entries), + [sections], + ); + + const profilePubkeys = React.useMemo( + () => [ + ...new Set( + (channelsQuery.data ?? []) + .filter((channel) => + drafts.some((entry) => entry.draft.channelId === channel.id), + ) + .flatMap((channel) => channel.participantPubkeys), + ), + ], + [channelsQuery.data, drafts], + ); + const usersBatchQuery = useUsersBatchQuery(profilePubkeys, { + enabled: profilePubkeys.length > 0, + }); + const profiles = usersBatchQuery.data?.profiles; + + const sources = React.useMemo( + () => + resolveDraftSources({ + channels: channelsQuery.data, + currentPubkey, + drafts, + profiles, + }), + [channelsQuery.data, currentPubkey, drafts, profiles], + ); + + const handleOpen = React.useCallback( + (entry: DraftListEntry) => { + if (!entry.draft.channelId) { + return; + } + + const threadRootId = getThreadRootId(entry.key); + void goChannel( + entry.draft.channelId, + threadRootId ? { messageId: threadRootId, threadRootId } : undefined, + ); + }, + [goChannel], + ); + + const handleDelete = React.useCallback( + (draftKey: string) => { + clearDraftEntry(draftKey); + refreshDrafts(); + }, + [refreshDrafts], + ); + + if (sections.length === 0) { + return ( +
+ +

No drafts

+
+ ); + } + + return ( +
+ {sections.map((section) => ( +
+

+ {section.label} +

+ {section.entries.map((entry) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs b/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs new file mode 100644 index 0000000000..8b1aca2af1 --- /dev/null +++ b/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs @@ -0,0 +1,106 @@ +/** + * Unit tests for the canOpenDraft openability predicate in DraftsPanel. + * + * These tests import and exercise the ACTUAL exported `canOpenDraft` function — + * not a restatement of its logic — so any regression (e.g. reverting isSent or + * source.channel checks, or removing the export) breaks these tests immediately. + * + * Three properties under test: + * (a) active draft + resolved channel → canOpen = true + * (b) sent draft + resolved channel → canOpen = false (Delete-only) + * (c) active draft + unresolved channel (null) → canOpen = false (false affordance guard) + * (d) active draft + empty channelId → canOpen = false (belt-and-suspenders) + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// canOpenDraft is a pure function — no browser globals or React needed. +import { canOpenDraft } from "./DraftsPanel.tsx"; + +// Minimal Channel stub — only the fields canOpenDraft reads (none; it checks null/non-null). +const RESOLVED_CHANNEL = { + id: "chan-1", + visibility: "public", + channelType: "channel", +}; + +function activeDraft(channelId = "chan-1") { + return { + channelId, + content: "hello", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + pendingImeta: [], + status: "active", + }; +} + +function sentDraft(channelId = "chan-1") { + return { ...activeDraft(channelId), status: "sent" }; +} + +// ── (a) active + resolved channel → openable ────────────────────────────────── + +test("canOpenDraft_active_resolved_channel_returns_true", () => { + const draft = activeDraft("chan-1"); + const source = { channel: RESOLVED_CHANNEL, label: "#general" }; + assert.equal( + canOpenDraft(draft, source), + true, + "active draft with resolved channel should be openable", + ); +}); + +// ── (b) sent + resolved channel → NOT openable ─────────────────────────────── +// Composer restores only active/thread keys; sent: keys cannot be restored. +// Sent subsection is Delete-only (Will-confirmed behavior). + +test("canOpenDraft_sent_resolved_channel_returns_false", () => { + const draft = sentDraft("chan-1"); + const source = { channel: RESOLVED_CHANNEL, label: "#general" }; + assert.equal( + canOpenDraft(draft, source), + false, + "sent draft should not be openable regardless of channel resolution", + ); +}); + +// ── (c) active + null channel → NOT openable ───────────────────────────────── +// Channel left/archived/unknown: routing to an empty channel surface is a false affordance. + +test("canOpenDraft_active_null_channel_returns_false", () => { + const draft = activeDraft("chan-gone"); + const source = { channel: null, label: "Unknown channel" }; + assert.equal( + canOpenDraft(draft, source), + false, + "active draft with unresolved channel (null) should not be openable", + ); +}); + +// ── (d) active + empty channelId → NOT openable ────────────────────────────── +// Belt-and-suspenders: a draft with no channelId at all cannot be navigated to. + +test("canOpenDraft_empty_channelId_returns_false", () => { + const draft = activeDraft(""); + // channel stub present but channelId is empty — navigation would fail + const source = { channel: RESOLVED_CHANNEL, label: "#general" }; + assert.equal( + canOpenDraft(draft, source), + false, + "draft with empty channelId should not be openable", + ); +}); + +// ── (e) sent + null channel → NOT openable (doubly guarded) ────────────────── + +test("canOpenDraft_sent_null_channel_returns_false", () => { + const draft = sentDraft("chan-gone"); + const source = { channel: null, label: "Unknown channel" }; + assert.equal( + canOpenDraft(draft, source), + false, + "sent draft with unresolved channel should not be openable", + ); +}); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index f30d039ff2..392d7d8f8f 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -5,6 +5,7 @@ import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; +import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; @@ -51,6 +52,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useComposerContentState } from "./useComposerContentState"; +import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; type MessageComposerProps = { channelId?: string | null; @@ -156,6 +158,8 @@ function MessageComposerImpl({ const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< Set >(() => new Set()); + const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); + spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); @@ -164,7 +168,6 @@ function MessageComposerImpl({ const drafts = useDrafts(); const effectiveDraftKey = draftKey ?? channelId; - const previousDraftKeyRef = React.useRef(null); const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; // Snapshot composer state before edit mode so cancel can restore it. @@ -191,6 +194,38 @@ function MessageComposerImpl({ const media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; + // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on + // key change, and persist the outgoing draft in the cleanup. The StrictMode + // fix lives inside this hook — see useDraftPersistSnapshot.ts. + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger + React.useEffect(() => { + media.setUploadState({ status: "idle" }); + setIsEmojiPickerOpen(false); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + }, [effectiveDraftKey]); + const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); @@ -298,40 +333,6 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls, }); - // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { - const prevKey = previousDraftKeyRef.current; - if (prevKey) { - drafts.persistDraft(prevKey, syncComposerContentFromEditor()); - } - previousDraftKeyRef.current = effectiveDraftKey; - - const saved = effectiveDraftKey - ? drafts.loadDraft(effectiveDraftKey) - : undefined; - if (saved) { - setComposerContent(saved.content); - richText.setContent(saved.content); - } else { - setComposerContent(""); - richText.clearContent(); - } - - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - media.setUploadState({ status: "idle" }); - setIsEmojiPickerOpen(false); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - - return () => { - if (effectiveDraftKey) { - drafts.persistDraft(effectiveDraftKey, syncComposerContentFromEditor()); - } - }; - }, [effectiveDraftKey]); - // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { @@ -600,7 +601,15 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, - sentDraftKey: effectiveDraftKeyRef.current, + // resolveSentDraftKey checks at submit time (synchronously, before any + // await) whether a draft was actually persisted. If not — fast/ + // never-persisted send — it returns null so no sent record is written. + // The function is exported and tested directly in + // MessageComposerDraftPredicate.test.mjs. + sentDraftKey: resolveSentDraftKey( + effectiveDraftKeyRef.current, + drafts.loadDraft, + ), spoileredAttachmentUrls, trimmed, }); @@ -608,6 +617,7 @@ function MessageComposerImpl({ channelId, channelLinks.clearChannels, customEmoji, + drafts.loadDraft, emojiAutocomplete.clearEmojis, media.pendingImetaRef, media.setPendingImeta, diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs new file mode 100644 index 0000000000..ba4c1a22e4 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -0,0 +1,402 @@ +/** + * Regression test: draft images survive the top-level nav switch under + * React StrictMode. + * + * ── Background ──────────────────────────────────────────────────────────────── + * Bug: navigate Channel A → Inbox → back to Channel A. Images in the draft + * were lost; text survived. Root cause: React StrictMode double-invokes effects + * on mount (body → cleanup → body). The restore effect body called + * `media.setPendingImeta([image])` (async state update) then returned. + * StrictMode's simulate-unmount fired the cleanup BEFORE React committed the + * state update. `media.pendingImetaRef.current` was still `[]` at that point, + * so the cleanup called `persistDraftEntry(key, text, channel, [])` — + * overwriting the correctly-saved `[image]` with an empty list. The second + * effect body then loaded the now-corrupted draft. + * + * ── Fix ─────────────────────────────────────────────────────────────────────── + * `useDraftPersistLifecycle` (in useDraftPersistSnapshot.ts, extracted from + * `MessageComposer`) owns the full restore/persist lifecycle. Inside the + * effect body it writes `pendingImetaForPersistRef.current = saved.pendingImeta` + * SYNCHRONOUSLY — before the async `setPendingImeta` call. Because the write + * is synchronous (same microtask as the effect body), the cleanup closure + * always sees the restored value even when StrictMode fires the + * simulate-unmount before React commits the state update. + * + * ── What this test does ─────────────────────────────────────────────────────── + * We import and mount `useDraftPersistLifecycle` — the REAL production hook — + * inside ``. The harness component calls the hook directly + * and provides thin stub collaborators. The hook owns the effect body and + * cleanup; the harness does NOT replicate the restore/persist logic. + * + * **Hard requirement**: removing the synchronous + * `pendingImetaForPersistRef.current = saved.pendingImeta` write from the + * production hook's effect body causes test 1 to fail (imageCount 1 → 0), + * because the cleanup reads the stale `[]` and overwrites the saved draft. + * This was verified in isolation before commit. + * + * ── StrictMode requirement ──────────────────────────────────────────────────── + * React strips StrictMode effect double-invocation in production builds. + * This bug was reproduced in a dev build (`just desktop-dev`) where StrictMode + * is active. This test MUST run under `` to be meaningful; + * a plain mount would pass regardless of the fix. + * + * ── CI surface ──────────────────────────────────────────────────────────────── + * Runs under `pnpm test` (node:test with the React dev build). Not Playwright. + * A packaged-build E2E would not reproduce the bug. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── +// react-dom/client requires a small subset of the DOM API. We provide exactly +// what createRoot + commit need, without pulling in jsdom (not a project dep). + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) { + this._listeners[type] = []; + } + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + const listeners = this._listeners[e.type] ?? []; + for (const fn of listeners) { + fn(e); + } + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children[this.children.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) { + this._body = this.createElement("body"); + } + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + // HTMLIFrameElement is referenced in react-dom's getActiveElementDeep; stub it. + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.HTMLElement = MinimalNode; + // react uses IS_REACT_ACT_ENVIRONMENT to enable act() in non-browser envs. + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── localStorage shim ───────────────────────────────────────────────────────── + +function makeLocalStorage() { + const store = new Map(); + return { + get length() { + return store.size; + }, + key: (i) => [...store.keys()][i] ?? null, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +} + +function installFreshLocalStorage() { + const ls = makeLocalStorage(); + Object.defineProperty(globalThis, "localStorage", { + get: () => ls, + configurable: true, + }); + return ls; +} + +installFreshLocalStorage(); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +// Production hook under test — owns the restore effect, cleanup, and the +// synchronous ref write that is the StrictMode fix. +import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; + +// Real storage functions — the test uses them, not a replica. +import { + clearAllDrafts, + initDraftStore, + loadDraftEntry, + persistDraftEntry, +} from "../lib/useDrafts.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const IMG_A = { + url: "https://cdn.example.com/img-a.jpg", + sha256: "aabbccdd", + size: 1024, + type: "image/jpeg", + uploaded: 0, +}; + +function setupStore(pubkey) { + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore(pubkey); +} + +async function mountStrictMode(Comp) { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(React.StrictMode, null, React.createElement(Comp)), + ); + }); + return { + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/** + * Test 1: the FIXED path. + * + * Mounts a thin harness component that calls the REAL `useDraftPersistLifecycle` + * hook under ``. The hook owns the effect body and cleanup — + * the harness provides stub collaborators but does NOT replicate any of the + * restore/persist logic. + * + * The hook's own effect body writes `pendingImetaForPersistRef.current = + * saved.pendingImeta` synchronously before `setPendingImeta`. StrictMode's + * simulate-unmount cleanup fires before `setPendingImeta` commits, but reads + * the correct `[IMG_A]` from the ref. + * + * **Revert verification (confirmed before commit)**: removing the synchronous + * `pendingImetaForPersistRef.current = saved.pendingImeta` line from the + * production hook's effect body causes imageCount 1 → 0 and this test fails. + */ +test("strictmode_draft_restore_cleanup_preserves_images_via_production_hook", async () => { + const DRAFT_KEY = "chan-lifecycle-fixed"; + setupStore("pubkey-lifecycle-fixed"); + + // Seed: saved draft has an image. + persistDraftEntry(DRAFT_KEY, "hello from A", DRAFT_KEY, [IMG_A], []); + assert.equal( + loadDraftEntry(DRAFT_KEY)?.pendingImeta.length, + 1, + "precondition: store has the image", + ); + + // `asyncState` simulates media.pendingImeta: starts at [] on fresh mount + // (no committed state yet — the state update from setPendingImeta hasn't + // committed before StrictMode's simulate-unmount fires). + let asyncState = []; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + // The hook owns all draft-persist lifecycle — no restore/cleanup logic here. + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: (key) => loadDraftEntry(key), + persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => { + persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls); + }, + livePendingImeta: asyncState, + setPendingImeta: (imeta) => { + asyncState = imeta; // async — won't commit before StrictMode cleanup + }, + setContent: () => {}, + clearContent: () => {}, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => "hello from A", + }); + + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + + // After StrictMode double-invoke, the store must still contain the image. + // If the synchronous ref write were removed from the production hook, + // the first cleanup would persist [] and this assertion fails (imageCount=0). + const afterMount = loadDraftEntry(DRAFT_KEY); + assert.ok(afterMount, "draft must still exist after StrictMode mount"); + assert.equal( + afterMount.pendingImeta.length, + 1, + "image must survive StrictMode simulate-unmount cleanup — requires the synchronous ref write in useDraftPersistLifecycle's effect body", + ); + assert.equal(afterMount.pendingImeta[0].url, IMG_A.url); + + await handle.unmount(); +}); + +/** + * Test 2: no-draft path clears correctly under StrictMode. + * + * When there is no saved draft for the key, the hook takes the `else` branch + * and sets `pendingImetaForPersistRef.current = []`. The cleanup persists `[]`. + * Verifies the else-branch synchronous write is also correct under StrictMode. + */ +test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => { + const DRAFT_KEY = "chan-lifecycle-nodraft"; + setupStore("pubkey-lifecycle-nodraft"); + // No draft seeded — loadDraftEntry returns undefined. + + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: (key) => loadDraftEntry(key), + persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => { + persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls); + }, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: () => {}, + clearContent: () => {}, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => "", + }); + + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + + // No draft existed; the hook took the else branch. Cleanup writes an empty + // entry (or skips if key is falsy, but DRAFT_KEY is defined here). + const afterMount = loadDraftEntry(DRAFT_KEY); + const imageCount = afterMount?.pendingImeta?.length ?? 0; + assert.equal( + imageCount, + 0, + "no-draft path: cleanup must persist empty imeta, not a stale value", + ); + + await handle.unmount(); +}); diff --git a/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs new file mode 100644 index 0000000000..bc25802467 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs @@ -0,0 +1,215 @@ +/** + * Regression tests for the submit-time draft-persistence predicate used by + * MessageComposer's `submitMessage` handler. + * + * The predicate lives in `resolveSentDraftKey` (draftSubmitKey.ts), which + * MessageComposer calls directly. These tests import and exercise the ACTUAL + * exported function — not a restatement of its logic — so a regression at + * the call site (e.g. reverting to the inline expression or removing the call) + * is a conscious change, and a logic regression inside the function breaks + * these tests immediately. + * + * Three properties under test: + * (a) never-persisted key → null (fast send must not produce a sent record) + * (b) persisted key → key (normal send produces a sent record) + * (c) submit-time capture semantics: the value returned at submit time is + * stable even if the store entry is cleared before send success (proving + * the predicate is evaluated once at submit, not re-read at success). + * + * Integration scenarios (d)+(e) drive the full storage flow to confirm + * that the predicate output correctly gates markDraftSentEntry. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Browser-global shim ─────────────────────────────────────────────────────── + +function makeLocalStorage() { + const store = new Map(); + return { + get length() { + return store.size; + }, + key: (i) => [...store.keys()][i] ?? null, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +} + +function installFreshLocalStorage() { + const ls = makeLocalStorage(); + if (typeof globalThis.window === "undefined") { + globalThis.window = { localStorage: ls }; + } else { + globalThis.window.localStorage = ls; + } + Object.defineProperty(globalThis, "localStorage", { + get: () => globalThis.window.localStorage, + configurable: true, + }); + return ls; +} + +installFreshLocalStorage(); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import { resolveSentDraftKey } from "./draftSubmitKey.ts"; + +import { + clearAllDrafts, + getSentDraftEntries, + initDraftStore, + loadDraftEntry, + markDraftSentEntry, + persistDraftEntry, +} from "../lib/useDrafts.ts"; + +function setup(pubkey = "pubkey-predicate") { + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore(pubkey); +} + +const IMG_A = { + url: "https://cdn.example.com/a.jpg", + sha256: "aabbccdd", + size: 1024, + type: "image/jpeg", + uploaded: 0, +}; + +// ── (a) resolveSentDraftKey: never-persisted key → null ─────────────────────── +// If this test breaks, the function no longer gates fast/never-persisted sends. + +test("resolveSentDraftKey_unpersisted_key_returns_null", () => { + setup("pubkey-resolver-fast"); + const draftKey = "chan-fast"; + + // Store has no entry for this key. + const result = resolveSentDraftKey(draftKey, loadDraftEntry); + assert.equal( + result, + null, + "unpersisted key → resolveSentDraftKey returns null", + ); +}); + +// ── (b) resolveSentDraftKey: persisted key → key ────────────────────────────── + +test("resolveSentDraftKey_persisted_key_returns_key", () => { + setup("pubkey-resolver-normal"); + const draftKey = "chan-normal"; + + persistDraftEntry(draftKey, "hello", draftKey, [], []); + + const result = resolveSentDraftKey(draftKey, loadDraftEntry); + assert.equal( + result, + draftKey, + "persisted key → resolveSentDraftKey returns the key", + ); +}); + +// ── (c) resolveSentDraftKey: null/undefined effectiveDraftKey → null ────────── + +test("resolveSentDraftKey_null_effectiveKey_returns_null", () => { + setup("pubkey-resolver-null"); + const result = resolveSentDraftKey(null, loadDraftEntry); + assert.equal(result, null, "null effectiveDraftKey → null"); + + const result2 = resolveSentDraftKey(undefined, loadDraftEntry); + assert.equal(result2, null, "undefined effectiveDraftKey → null"); +}); + +// ── (d) Integration: never-persisted send → no sent record ─────────────────── +// Simulates submitMessage calling resolveSentDraftKey before the send: +// the resolver returns null → markDraftSentEntry is never called. + +test("submit_predicate_never_persisted_send_produces_no_sent_record", () => { + setup("pubkey-fast-send"); + const draftKey = "chan-fast-integration"; + + // Composer calls resolveSentDraftKey at submit time — store has no entry. + const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry); + assert.equal(sentDraftKey, null, "resolver returns null for fast send"); + + // markDraftSentEntry is never called (sentDraftKey is null → gate in + // useMentionSendFlow.ts:399 fires false). No sent record. + assert.equal(getSentDraftEntries().length, 0, "no sent record for fast send"); +}); + +// ── (e) Integration: persisted draft → sent record written ─────────────────── + +test("submit_predicate_persisted_draft_produces_sent_record", () => { + setup("pubkey-normal-send"); + const draftKey = "chan-normal-integration"; + + // Debounce persists the draft before submit. + persistDraftEntry(draftKey, "my draft content", draftKey, [], []); + + // Composer calls resolveSentDraftKey at submit time. + const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry); + assert.equal( + sentDraftKey, + draftKey, + "resolver returns key for persisted draft", + ); + + // Send succeeds — markDraftSentEntry called with the captured key. + markDraftSentEntry(draftKey, "my draft content", draftKey, [], []); + + const sent = getSentDraftEntries(); + assert.equal(sent.length, 1, "sent record created"); + assert.equal(sent[0].draft.content, "my draft content"); + assert.equal(sent[0].draft.status, "sent"); +}); + +// ── (f) Integration: persisted draft + race → sent record still written ─────── +// Simulates: persist → resolveSentDraftKey captures key at submit time → +// navigation race clears the active entry → send succeeds with captured key. +// markDraftSentEntry writes unconditionally → sent record exists with snapshot. + +test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written", () => { + setup("pubkey-race-send"); + const draftKey = "chan-race-pred"; + + // Step 1: debounce persists the draft. + persistDraftEntry(draftKey, "race content", draftKey, [IMG_A], []); + + // Step 2: resolver captures the key at submit time. + const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry); + assert.equal(sentDraftKey, draftKey, "resolver captures key at submit time"); + + // Step 3: navigation-during-send race — active key cleared by composer cleanup. + persistDraftEntry(draftKey, "", draftKey, [], []); // empty persist → clearDraftEntry + assert.equal( + loadDraftEntry(draftKey), + undefined, + "active key cleared by race before send success", + ); + + // Step 4: send succeeds; markDraftSentEntry called with captured sentDraftKey. + markDraftSentEntry(sentDraftKey, "race content", draftKey, [IMG_A], []); + + const sent = getSentDraftEntries(); + assert.equal( + sent.length, + 1, + "sent record written despite active key being cleared", + ); + assert.equal( + sent[0].draft.content, + "race content", + "snapshot content preserved", + ); + assert.equal( + sent[0].draft.pendingImeta.length, + 1, + "snapshot image preserved", + ); + assert.equal(sent[0].draft.status, "sent"); +}); diff --git a/desktop/src/features/messages/ui/draftSubmitKey.ts b/desktop/src/features/messages/ui/draftSubmitKey.ts new file mode 100644 index 0000000000..21ac4f0672 --- /dev/null +++ b/desktop/src/features/messages/ui/draftSubmitKey.ts @@ -0,0 +1,22 @@ +/** + * Resolves the sentDraftKey to pass to sendMessageWithMentionFlow at submit + * time. + * + * Returns the key when a draft was actually persisted in the store before the + * send fired (so a sent record should be written), or null when no entry + * exists (fast / never-persisted send — no sent record should be written). + * + * This is a pure function with no dependencies so it can be imported and + * exercised directly in Node .mjs tests without a React renderer. + * + * @param effectiveDraftKey - the draft key captured synchronously at submit time + * @param loadDraft - synchronous O(1) store read; returns undefined when absent + */ +export function resolveSentDraftKey( + effectiveDraftKey: string | null | undefined, + loadDraft: (key: string) => unknown, +): string | null { + return effectiveDraftKey && loadDraft(effectiveDraftKey) + ? effectiveDraftKey + : null; +} diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts new file mode 100644 index 0000000000..d1b0f70f6b --- /dev/null +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -0,0 +1,122 @@ +import * as React from "react"; + +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { DraftState } from "@/features/messages/lib/useDrafts"; + +type UseDraftPersistLifecycleParams = { + effectiveDraftKey: string | null | undefined; + channelId: string | null | undefined; + /** Load a saved draft from the store. */ + loadDraft: (draftKey: string) => DraftState | undefined; + /** Persist the current draft to the store (called in effect cleanup). */ + persistDraft: ( + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], + ) => void; + /** Live `pendingImeta` from React state — used for render-time ref sync. */ + livePendingImeta: ImetaMedia[]; + /** Async setter for pendingImeta — called after the synchronous snapshot. */ + setPendingImeta: (imeta: ImetaMedia[]) => void; + /** Set the rich-text editor content from a draft string. */ + setContent: (content: string) => void; + /** Clear the rich-text editor content (no-draft path). */ + clearContent: () => void; + /** Set the spoilered attachment URLs state. */ + setSpoileredAttachmentUrls: (urls: Set) => void; + /** + * Stable ref to the spoilered attachment URLs — read in the cleanup closure + * so it always captures the latest value at cleanup time. + */ + spoileredAttachmentUrlsRef: React.MutableRefObject>; + /** + * Read the current editor content synchronously — called in the cleanup + * closure to capture the latest text before the effect fires. + */ + syncComposerContentFromEditor: () => string; +}; + +/** + * Owns the draft-persist lifecycle for `MessageComposer`. + * + * This hook: + * - Holds `pendingImetaForPersistRef` — the ref the cleanup reads when + * persisting `pendingImeta` to the draft store. + * - Updates that ref on every render (render-time passive path) so normal + * add/remove-image operations are always captured. + * - Runs a `useEffect` keyed on `effectiveDraftKey` that restores a saved + * draft into the composer (content + imeta + spoilered urls) or clears it, + * and whose cleanup persists the outgoing draft before the key changes. + * + * **The StrictMode fix lives here.** + * When the restore effect body calls `setPendingImeta(saved.pendingImeta)`, + * that state update is async — it won't commit until React re-renders. + * React StrictMode (dev builds) simulates an unmount immediately after the + * effect body, before the re-render. Without the synchronous write the + * cleanup would read `[]` and overwrite the just-restored images. + * + * The effect body calls `snapshotPendingImeta` (the synchronous ref write) + * BEFORE `setPendingImeta`, so the cleanup always sees the correct value. + * + * Extracted from `MessageComposer` so the full lifecycle can be exercised + * directly in a StrictMode test without mounting the full composer. + */ +export function useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft, + persistDraft, + livePendingImeta, + setPendingImeta, + setContent, + clearContent, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, +}: UseDraftPersistLifecycleParams): void { + const pendingImetaForPersistRef = React.useRef([]); + // Render-time update: keep the ref in sync with committed state so the + // cleanup always reads the latest value during normal mounted operation. + pendingImetaForPersistRef.current = livePendingImeta; + + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger + React.useEffect(() => { + // The outgoing draft is persisted by the cleanup below, which runs before + // this body on key changes and has the correct outgoing channelId in its + // closure. Do NOT re-persist prevKey here: channelId in this render + // already reflects the incoming channel, which would corrupt the outgoing + // draft's channelId metadata. + + const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + if (saved) { + setContent(saved.content); + // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async + // state setter, so the cleanup closure (which may fire before the state + // update commits in React StrictMode's simulate-unmount pass) reads the + // correct value instead of the stale []. + pendingImetaForPersistRef.current = saved.pendingImeta; + setPendingImeta(saved.pendingImeta); + setSpoileredAttachmentUrls(new Set(saved.spoileredAttachmentUrls)); + } else { + clearContent(); + // Same synchronous snapshot on the empty path. + pendingImetaForPersistRef.current = []; + setPendingImeta([]); + setSpoileredAttachmentUrls(new Set()); + } + + return () => { + if (effectiveDraftKey) { + persistDraft( + effectiveDraftKey, + syncComposerContentFromEditor(), + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + ); + } + }; + }, [effectiveDraftKey]); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index ca457ecfb1..429921a282 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -63,7 +63,7 @@ type UseMentionSendFlowOptions = { channelType: ChannelType | null; contentRef: React.MutableRefObject; customEmoji: CustomEmoji[]; - drafts: Pick; + drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; onSendRef: React.MutableRefObject< @@ -397,7 +397,13 @@ export function useMentionSendFlow({ draft.capturedThreadContext, ); if (draft.sentDraftKey) { - drafts.clearDraft(draft.sentDraftKey); + drafts.markDraftSent( + draft.sentDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.sentDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + ); } } catch { // Only restore the composer content if the user is still on the diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index fe01c30ce4..99ab18aa17 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -8,7 +8,10 @@ import { } from "@/shared/api/tauri"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; -import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; +import { + clearAllDrafts, + initDraftStore, +} from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -156,6 +159,11 @@ export function useWorkspaceInit( } if (!cancelled) { + // Initialise the draft store for this identity so localStorage drafts + // are scoped to the correct pubkey before the app renders. + if (activeWorkspace.pubkey) { + initDraftStore(activeWorkspace.pubkey); + } setResult({ isReady: true, needsSetup: false, diff --git a/desktop/tests/e2e/drafts-screenshots.spec.ts b/desktop/tests/e2e/drafts-screenshots.spec.ts new file mode 100644 index 0000000000..0d14732ecf --- /dev/null +++ b/desktop/tests/e2e/drafts-screenshots.spec.ts @@ -0,0 +1,270 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/drafts"; + +// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts +const MOCK_PUBKEY = "deadbeef".repeat(8); +const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`; + +// Channel IDs from the mock bridge seed data +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; + +// Fixed timestamps for deterministic rendering +const CREATED_AT_1 = "2026-07-01T10:00:00.000Z"; +const CREATED_AT_2 = "2026-07-02T14:30:00.000Z"; +const CREATED_AT_3 = "2026-07-03T09:15:00.000Z"; +const CREATED_AT_SENT = "2026-07-04T16:45:00.000Z"; + +type StoredDraftState = { + content: string; + selectionStart: number; + selectionEnd: number; + channelId: string; + createdAt: string; + updatedAt: string; + pendingImeta: unknown[]; + spoileredAttachmentUrls: string[]; + status: "active" | "sent"; +}; + +type StoredDrafts = Record; + +/** Active drafts: text draft in #general, image-only draft in #agents, long text draft. */ +const ACTIVE_DRAFTS: StoredDrafts = { + [`channel:${GENERAL_CHANNEL_ID}`]: { + content: + "Hey team — I've been working on the new onboarding flow. Check out the latest mockups when you get a chance!", + selectionStart: 107, + selectionEnd: 107, + channelId: GENERAL_CHANNEL_ID, + createdAt: CREATED_AT_1, + updatedAt: CREATED_AT_1, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "active", + }, + [`channel:${AGENTS_CHANNEL_ID}`]: { + // Image-only draft — exercises the "1 attachment" fallback in getDraftPreview + content: "", + selectionStart: 0, + selectionEnd: 0, + channelId: AGENTS_CHANNEL_ID, + createdAt: CREATED_AT_2, + updatedAt: CREATED_AT_2, + pendingImeta: [ + { + url: "https://example.com/screenshot.png", + sha256: "abc123", + size: 204800, + type: "image/png", + dim: "1280x900", + }, + ], + spoileredAttachmentUrls: [], + status: "active", + }, +}; + +/** Active drafts + one sent record for shots that need both subsections. */ +const ACTIVE_AND_SENT_DRAFTS: StoredDrafts = { + ...ACTIVE_DRAFTS, + [`sent:channel:${GENERAL_CHANNEL_ID}:1720115100000-1`]: { + content: + "Shipping the draft message improvements in PR #1539 — image persistence, sent records, and the new Drafts inbox section.", + selectionStart: 119, + selectionEnd: 119, + channelId: GENERAL_CHANNEL_ID, + createdAt: CREATED_AT_3, + updatedAt: CREATED_AT_SENT, + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "sent", + }, +}; + +/** + * Patch the mock workspace to include the pubkey so initDraftStore gets the + * correct pubkey on app startup. The workspace is seeded by installMockBridge + * without a pubkey field; this addInitScript runs after that seed (init + * scripts execute in registration order) and adds it. + */ +async function patchWorkspacePubkey(page: import("@playwright/test").Page) { + await page.addInitScript( + ({ pubkey }) => { + const raw = window.localStorage.getItem("buzz-workspaces"); + const workspaces = raw + ? (JSON.parse(raw) as Array>) + : []; + if (workspaces[0]) { + workspaces[0].pubkey = pubkey; + window.localStorage.setItem( + "buzz-workspaces", + JSON.stringify(workspaces), + ); + } + }, + { pubkey: MOCK_PUBKEY }, + ); +} + +/** Seed draft localStorage before page load via addInitScript. */ +async function seedDraftStore( + page: import("@playwright/test").Page, + drafts: StoredDrafts, +) { + await page.addInitScript( + ({ storeKey, value }) => { + window.localStorage.setItem(storeKey, JSON.stringify(value)); + }, + { storeKey: DRAFT_STORE_KEY, value: drafts }, + ); +} + +/** Navigate to `/`, wait for inbox, then select the Drafts filter. */ +async function openDraftsPanel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Drafts" }).click(); + + // Dismiss the dropdown so it doesn't obscure the panel assertions. + await page.keyboard.press("Escape"); + + const panel = page.getByTestId("home-inbox-drafts"); + await expect(panel).toBeVisible({ timeout: 8_000 }); + return panel; +} + +test.describe("drafts screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error("CONSOLE ERROR:", msg.text().slice(0, 500)); + } + }); + }); + + test("01 — drafts section populated", async ({ page }) => { + await installMockBridge(page); + await patchWorkspacePubkey(page); + await seedDraftStore(page, ACTIVE_DRAFTS); + + const panel = await openDraftsPanel(page); + + // Both active draft rows should be visible + const draftRows = panel.locator("[data-testid^='home-draft-item-']"); + await expect(draftRows).toHaveCount(2, { timeout: 6_000 }); + + // The text draft row shows content + await expect( + panel.getByText( + "Hey team — I've been working on the new onboarding flow.", + ), + ).toBeVisible({ timeout: 5_000 }); + + // The image-only draft shows the attachment fallback + await expect(panel.getByText("1 attachment")).toBeVisible({ + timeout: 5_000, + }); + + // Section heading should be "DRAFTS" + await expect(panel.getByText("Drafts", { exact: true })).toBeVisible(); + + // Small settle before screenshot + await page.waitForTimeout(200); + + await panel.screenshot({ + path: `${SHOTS}/01-drafts-section-populated.png`, + }); + }); + + test("02 — sent subsection visible", async ({ page }) => { + await installMockBridge(page); + await patchWorkspacePubkey(page); + await seedDraftStore(page, ACTIVE_AND_SENT_DRAFTS); + + const panel = await openDraftsPanel(page); + + // Both subsection headings should render + await expect(panel.getByText("Drafts", { exact: true })).toBeVisible({ + timeout: 10_000, + }); + await expect(panel.getByText("Sent", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + + // At least one row in each subsection + const draftRows = panel.locator("[data-testid^='home-draft-item-']"); + await expect(draftRows).toHaveCount(3, { timeout: 6_000 }); + + // The sent draft content should appear + await expect( + panel.getByText("Shipping the draft message improvements", { + exact: false, + }), + ).toBeVisible({ timeout: 5_000 }); + + await page.waitForTimeout(200); + + await panel.screenshot({ path: `${SHOTS}/02-sent-subsection.png` }); + }); + + test("03 — hover actions visible", async ({ page }) => { + await installMockBridge(page); + await patchWorkspacePubkey(page); + await seedDraftStore(page, ACTIVE_DRAFTS); + + const panel = await openDraftsPanel(page); + + // Wait for the text draft row + const textDraftRow = panel.locator( + `[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`, + ); + await expect(textDraftRow).toBeVisible({ timeout: 6_000 }); + + // Hover to reveal action buttons + await textDraftRow.hover(); + + // Both action buttons should become visible on hover + const openDraftBtn = textDraftRow.getByRole("button", { + name: "Open draft", + exact: true, + }); + const deleteDraftBtn = textDraftRow.getByRole("button", { + name: "Delete draft", + }); + await expect(openDraftBtn).toBeVisible({ timeout: 4_000 }); + await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 }); + + await page.waitForTimeout(200); + + await panel.screenshot({ path: `${SHOTS}/03-hover-actions.png` }); + }); + + test("04 — empty state", async ({ page }) => { + await installMockBridge(page); + // No draft seed → empty state + + const panel = await openDraftsPanel(page); + + // Empty state: FileText icon + "No drafts" text + await expect(panel.getByText("No drafts")).toBeVisible({ timeout: 5_000 }); + + await page.waitForTimeout(200); + + await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` }); + }); +}); From 711078807777db77960404e51ffd862c08a33ded Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 17:41:56 -0400 Subject: [PATCH 011/291] feat(archive): add local-save archive with observer-feed default-on option (#1442) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 10 + desktop/src-tauri/build.rs | 9 + desktop/src-tauri/src/archive/mod.rs | 1464 +++++++++++++++++ desktop/src-tauri/src/archive/pipeline.rs | 390 +++++ desktop/src-tauri/src/archive/store.rs | 975 +++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/observer_archive.rs | 36 + desktop/src-tauri/src/lib.rs | 7 + desktop/src/app/AppShell.tsx | 4 + .../ingestArchivedObserverEvents.test.mjs | 284 ++++ .../src/features/agents/observerRelayStore.ts | 60 +- .../features/agents/ui/useObserverEvents.ts | 111 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 16 + .../local-archive/archiveSyncManager.test.mjs | 850 ++++++++++ .../local-archive/archiveSyncManager.ts | 318 ++++ .../observerArchivePreference.ts | 71 + .../ui/LocalArchiveSettingsCard.tsx | 525 ++++++ .../ui/localArchiveKinds.test.mjs | 369 +++++ .../local-archive/ui/localArchiveKinds.ts | 252 +++ .../useObserverArchiveSeed.test.mjs | 161 ++ .../local-archive/useObserverArchiveSeed.ts | 116 ++ .../features/settings/ui/SettingsPanels.tsx | 11 + .../src/features/settings/ui/SettingsView.tsx | 1 + desktop/src/shared/api/tauriArchive.ts | 217 +++ desktop/src/testing/e2eBridge.ts | 34 + .../e2e/local-archive-screenshots.spec.ts | 163 ++ desktop/tests/helpers/bridge.ts | 10 + 28 files changed, 6466 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/archive/mod.rs create mode 100644 desktop/src-tauri/src/archive/pipeline.rs create mode 100644 desktop/src-tauri/src/archive/store.rs create mode 100644 desktop/src-tauri/src/commands/observer_archive.rs create mode 100644 desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs create mode 100644 desktop/src/features/local-archive/archiveSyncManager.test.mjs create mode 100644 desktop/src/features/local-archive/archiveSyncManager.ts create mode 100644 desktop/src/features/local-archive/observerArchivePreference.ts create mode 100644 desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx create mode 100644 desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs create mode 100644 desktop/src/features/local-archive/ui/localArchiveKinds.ts create mode 100644 desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs create mode 100644 desktop/src/features/local-archive/useObserverArchiveSeed.ts create mode 100644 desktop/src/shared/api/tauriArchive.ts create mode 100644 desktop/tests/e2e/local-archive-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d04166fcc1..813c150615 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/local-archive-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 05cf677ee3..b410a1ce38 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -60,6 +60,16 @@ const overrides = new Map([ // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config // commands add ~40 lines. Queued to split. // branch cut; override bumped to cover the merged total. Queued to split. + // archive/mod.rs carries the full test module: 899 unit tests + 4 real-relay + // integration tests (ignored, live-relay only, wrapped in + // #[cfg(not(target_os = "windows"))] mod real_relay). The test module is the + // source of the overage — production logic is ~408 lines. The fix-round added + // 2 regression tests (F2: out-of-range kind + F3: atomicity invariant). + // read_archived_events Tauri command added ~39 lines (Phase 1 read-back). + // E2E test-depth hardening added the owner_p content round-trip assert and + // two empty-table drop asserts (~24 lines). Queued to split the test module + // into archive/mod_tests.rs in a follow-up. + ["src-tauri/src/archive/mod.rs", 1465], ["src-tauri/src/commands/agents.rs", 1437], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 3085493a30..cce1716dcc 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,6 +13,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -72,6 +73,14 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } + // Presence-only flag: when set (any non-empty value), observer-feed archive + // defaults to ON for the current identity on first run. OSS builds leave + // this unset → default OFF. No JSON validation needed — the command only + // checks `.is_some()`. + if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs new file mode 100644 index 0000000000..300c54532b --- /dev/null +++ b/desktop/src-tauri/src/archive/mod.rs @@ -0,0 +1,1464 @@ +//! Local-save archive — Tauri commands for archiving relay messages to a +//! per-identity SQLite database in the Buzz nest. +//! +//! # Architecture +//! +//! Two access proof paths, chosen by event kind: +//! +//! **Persistent scopes** (`channel_h`, `referenced_e`): the relay is the +//! source of truth. Candidates are grouped and re-queried via a batched +//! authed `/query`; only events the relay returns are inserted. +//! +//! **Ephemeral scope** (`owner_p`, kind 24200 observer frames): the relay +//! never stores these, so `/query` cannot verify them. The relay's REQ-time +//! `#p == authed reader` gate is the access control; local per-frame +//! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author +//! == agent) is applied fail-closed. + +mod pipeline; +pub mod store; + +use pipeline::{commit_archive, plan_archive, query_buckets}; + +use nostr::Event; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::app_state::AppState; +use crate::managed_agents::nest_dir; +use crate::relay::{query_relay, relay_ws_url_with_override}; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; +const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; + +// ── DB helpers ─────────────────────────────────────────────────────────────── + +fn open_db() -> Result { + let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; + let db_path = nest.join("archive").join("archive.db"); + store::open_archive_db(&db_path) +} + +fn identity_pubkey(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +// ── Scope type ─────────────────────────────────────────────────────────────── + +/// The three supported archive scope discriminants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeType { + ChannelH, + OwnerP, + ReferencedE, +} + +impl ScopeType { + fn as_str(&self) -> &'static str { + match self { + ScopeType::ChannelH => "channel_h", + ScopeType::OwnerP => "owner_p", + ScopeType::ReferencedE => "referenced_e", + } + } + + fn is_ephemeral(&self) -> bool { + matches!(self, ScopeType::OwnerP) + } +} + +// ── archive_events ─────────────────────────────────────────────────────────── + +/// One event candidate to archive. +#[derive(Debug, Deserialize)] +pub struct ArchiveCandidate { + /// Raw Nostr event JSON. + pub raw_event_json: String, + /// Which save scope this candidate was matched against. The backend + /// re-verifies this — it is never trusted blind. + pub matched_scope: MatchedScope, +} + +/// A scope match assertion from the frontend. +#[derive(Debug, Deserialize)] +pub struct MatchedScope { + pub scope_type: ScopeType, + pub scope_value: String, +} + +/// Result of a batch archive call. +#[derive(Debug, Serialize)] +pub struct ArchiveBatchResult { + /// Events successfully written to the store (event + scope rows). + pub persisted: u32, + /// Events dropped due to access denial or invalid payload (not an error). + pub dropped: u32, +} + +/// Archive a batch of event candidates. +/// +/// - Persistent scopes (`channel_h`, `referenced_e`): grouped by scope, then +/// batch-queried against the relay; only returned events are inserted. +/// - Ephemeral scope (`owner_p`): local validation only (no `/query`). +/// +/// # Send-safety +/// +/// `rusqlite::Connection` is `!Send`. All DB work is bracketed in scoped +/// `{ let conn = open_db()?; ... }` blocks that drop the connection before any +/// `.await`, exactly matching the pattern in `managed_agents/persona_events.rs`. +#[tauri::command] +pub async fn archive_events( + state: State<'_, AppState>, + candidates: Vec, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // ── Phase 1: plan (sync) ───────────────────────────────────────────────── + // Read subscriptions and build relay filters. Connection dropped before + // any .await. + let plan = { + let conn = open_db()?; + plan_archive(candidates, &identity_pk, &relay_url, &conn)? + // conn drops here + }; + + // ── Phase 2: relay queries (async) ─────────────────────────────────────── + // No Connection in scope — future is Send. + let state_ref: &AppState = &state; + let bucket_results = query_buckets(plan.buckets, state_ref).await; + + // ── Phase 3: persist (sync) ────────────────────────────────────────────── + let conn = open_db()?; + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + now, + &conn, + ) +} + +/// Validate an ephemeral observer frame (kind 24200) against ALL local rules. +/// +/// Rules (verbatim from spec): +/// 1. kind == 24200 +/// 2. `#p` contains `identity_pubkey` +/// 3. `agent` tag is present +/// 4. `frame == "telemetry"` (control frames are not archived) +/// 5. event author (pubkey) == agent tag value +/// 6. A matching `owner_p` save-subscription exists AND its `kinds` list +/// includes `24200` (kinds enforcement mirrors the persistent path). +fn validate_ephemeral_frame( + event: &Event, + identity_pk: &str, + scope_value: &str, + conn: &Connection, + sub_identity: &str, + relay_url: &str, +) -> Result<(), String> { + // 1. Kind guard. + if event.kind.as_u16() != KIND_AGENT_OBSERVER_FRAME { + return Err(format!( + "expected kind {KIND_AGENT_OBSERVER_FRAME}, got {}", + event.kind.as_u16() + )); + } + + // 2. `#p` contains current identity. + let p_matches = event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + if !p_matches { + return Err("observer frame #p does not match current identity".into()); + } + + // 3. `agent` tag present. + let agent_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "agent" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `agent` tag".to_string())?; + + // 4. `frame == "telemetry"`. + let frame_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "frame" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `frame` tag".to_string())?; + if frame_value != OBSERVER_FRAME_TELEMETRY { + return Err(format!("expected frame=telemetry, got {frame_value:?}")); + } + + // 5. Event author == agent tag value. + if event.pubkey.to_hex() != agent_value { + return Err("observer frame author does not match agent tag".into()); + } + + // 6. Matching owner_p subscription exists AND kind 24200 is in its kinds list. + let kinds_json = + store::get_subscription_kinds(conn, sub_identity, relay_url, "owner_p", scope_value)? + .ok_or_else(|| format!("no owner_p subscription for scope_value={scope_value:?}"))?; + let allowed_kinds: Vec = serde_json::from_str::>(&kinds_json).unwrap_or_default(); + if !allowed_kinds.contains(&(KIND_AGENT_OBSERVER_FRAME as u64)) { + return Err(format!( + "owner_p subscription kinds {kinds_json:?} does not include {KIND_AGENT_OBSERVER_FRAME}" + )); + } + + Ok(()) +} + +// ── create_save_subscription ───────────────────────────────────────────────── + +/// Create a save subscription after running a per-scope access probe. +/// +/// Probes: +/// - `channel_h`: verify the current user is a member of the channel (kind 39002 +/// `#p` contains our pubkey, or we are the event author / open channel). +/// - `referenced_e`: the referenced event id is currently readable via `/query`. +/// - `owner_p`: restricted to the current identity's own pubkey (v1). +#[tauri::command] +pub async fn create_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Vec, +) -> Result<(), String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // Reject kinds outside the valid NIP-01 range 0..=65535 — the nostr crate + // silently truncates larger values via `v as u16`, which would create + // unmatchable filters. + for &k in &kinds { + if k > u32::from(u16::MAX) { + return Err(format!("kind {k} is out of the valid range 0..=65535")); + } + } + + // Per-scope access probe. + match &scope_type { + ScopeType::ChannelH => { + probe_channel_access(&state, &identity_pk, &scope_value).await?; + } + ScopeType::ReferencedE => { + probe_event_readable(&state, &scope_value).await?; + } + ScopeType::OwnerP => { + // v1: only the current identity's own pubkey is allowed. + if scope_value != identity_pk { + return Err(format!( + "owner_p scope_value must equal current identity pubkey in v1 (got {scope_value:?})" + )); + } + } + } + + let kinds_json = + serde_json::to_string(&kinds).map_err(|e| format!("failed to serialize kinds: {e}"))?; + + let conn = open_db()?; + store::upsert_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + &kinds_json, + now, + ) +} + +/// Probe: the current user has access to `channel_id` (kind 39002 lists them). +async fn probe_channel_access( + state: &AppState, + identity_pk: &str, + channel_id: &str, +) -> Result<(), String> { + // Fetch the channel's members event (kind 39002, #d = channel_id). + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + // If no members event exists this could be an open channel — try to read + // its metadata (kind 39000) as a fallback proof of readability. + if events.is_empty() { + let meta = query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + if meta.is_empty() { + return Err(format!( + "channel {channel_id:?} not found or not accessible" + )); + } + // Open channel — readable, access granted. + return Ok(()); + } + + // Check that the current identity is listed as a member. + let ev = &events[0]; + let is_member = ev.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + // Also allow if we are the event author (e.g. the workspace owner who + // published the members event may not be in the `#p` list themselves). + let is_author = ev.pubkey.to_hex() == identity_pk; + + if is_member || is_author { + Ok(()) + } else { + Err(format!( + "current identity is not a member of channel {channel_id:?}" + )) + } +} + +/// Probe: the given event id is currently readable by the current user. +async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), String> { + let events = query_relay( + state, + &[serde_json::json!({ + "ids": [event_id], + "limit": 1 + })], + ) + .await?; + + if events.is_empty() { + return Err(format!("event {event_id:?} not found or not accessible")); + } + Ok(()) +} + +// ── list_save_subscriptions ────────────────────────────────────────────────── + +/// List all save subscriptions for the current identity + relay. +#[tauri::command] +pub fn list_save_subscriptions( + state: State<'_, AppState>, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::list_save_subscriptions(&conn, &identity_pk, &relay_url) +} + +// ── delete_save_subscription ───────────────────────────────────────────────── + +/// Delete a save subscription. +/// +/// Does NOT purge already-archived event data — retention is decoupled in v1. +/// GC of orphaned event rows happens in P4 purge commands, not here. +#[tauri::command] +pub fn delete_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::delete_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) +} + +// ── read_archived_events ───────────────────────────────────────────────────── + +/// Default page size for `read_archived_events`. +const DEFAULT_READ_LIMIT: i64 = 50; + +/// Read a paginated page of archived events for a scope. +/// +/// Returns at most `limit` events (default `DEFAULT_READ_LIMIT`) in +/// newest-first order. Pass the compound cursor `(before_created_at, +/// before_id)` — both `Some` — from the last row of the previous page to +/// walk backwards. The predicate mirrors `ORDER BY created_at DESC, id DESC` +/// so same-second siblings are never skipped at a page boundary. Pass +/// `None`/`None` to start at the newest end. +/// A returned page shorter than `limit` signals that the archive is exhausted. +/// +/// `kinds` is an optional filter; an empty array means "no kinds matched" +/// (not "all kinds") — callers should pass `null`/`None` when they want all. +#[tauri::command] +pub fn read_archived_events( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Option>, + before_created_at: Option, + before_id: Option, + limit: Option, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::read_archived_events( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + kinds.as_deref(), + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + use pipeline::BucketWithResult; + use rusqlite::Connection; + use uuid::Uuid; + + // ── Helpers ────────────────────────────────────────────────────────────── + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(super::store::SCHEMA).unwrap(); + conn + } + + fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event { + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_pk]).unwrap(), + Tag::parse(["frame", frame_type]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + fn add_sub( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + store::upsert_save_subscription( + conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + 0, + ) + .unwrap(); + } + + /// Run the full archive pipeline synchronously with a fake relay response. + /// + /// Calls `plan_archive` → injects fake relay events → `commit_archive`. + /// This mirrors `archive_events` without the async relay calls. + fn run_batch_sync( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + ) -> ArchiveBatchResult { + let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); + + // Synthesize BucketWithResult from the fake relay response. + let fake_ids: std::collections::HashSet = + fake_relay_events.iter().map(|e| e.id.to_hex()).collect(); + let bucket_results: Vec = plan + .buckets + .into_iter() + .map(|b| BucketWithResult { + scope_type_str: b.scope_type_str, + scope_value: b.scope_value, + allowed_kinds: b.allowed_kinds, + group: b.group, + returned_ids: fake_ids.clone(), + relay_failed: false, + }) + .collect(); + + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + identity_pk, + relay_url, + 0, + conn, + ) + .unwrap() + } + + fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate { + ArchiveCandidate { + raw_event_json: event.as_json(), + matched_scope: MatchedScope { + scope_type, + scope_value: scope_value.to_string(), + }, + } + } + + // ── Ephemeral validator — individual condition rejection ────────────────── + + #[test] + fn test_ephemeral_validator_accepts_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + assert!( + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url) + .is_ok() + ); + } + + #[test] + fn test_ephemeral_validator_rejects_wrong_kind() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::TextNote, "hello") + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("kind")); + } + + #[test] + fn test_ephemeral_validator_rejects_missing_p_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("#p")); + } + + #[test] + fn test_ephemeral_validator_rejects_missing_agent_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent")); + } + + #[test] + fn test_ephemeral_validator_rejects_control_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, "control"); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("telemetry")); + } + + #[test] + fn test_ephemeral_validator_rejects_wrong_author() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let other_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&other_keys) // wrong signer + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("author")); + } + + #[test] + fn test_ephemeral_validator_rejects_no_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Deliberately do NOT add a subscription. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("owner_p subscription")); + } + + #[test] + fn test_ephemeral_validator_rejects_kind_not_in_subscription() { + // Subscription exists but kinds = [1] (not 24200) — must be rejected. + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("24200"), + "expected kind 24200 in error, got: {msg}" + ); + } + + // ── archive pipeline — persistent path ─────────────────────────────────── + + #[test] + fn test_persistent_channel_h_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Confirm the event is in the store. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0)) + .unwrap(); + assert_eq!(scope_count, 1); + } + + #[test] + fn test_persistent_channel_h_drops_when_relay_does_not_return_event() { + // Relay returns empty — event not proven accessible. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns nothing. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_drops_when_no_subscription() { + // No subscription at all — drop before even querying. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + // Intentionally no subscription. + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay would return the event, but no sub → dropped. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_drops_kind_not_in_subscription() { + // Subscription is for kind 9 only; event is kind 7 (reaction). + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + // kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter + let ev = EventBuilder::new(Kind::Reaction, "+") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof via StoredEvent.channel_id), + // but kind 7 is not in the subscription's kinds list. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_h_less_event_persists_when_relay_returns_it() { + // An h-less event (e.g. reaction kind:7) that the relay returns under + // the scoped #h filter (via StoredEvent.channel_id fallback) must be + // persisted. The local tag scanner would have dropped it; the scoped + // filter proof must not. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]"); + + // Build a reaction without an `h` tag — local re-derivation would drop it. + let ev = EventBuilder::new(Kind::Reaction, "+") + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns it (relay used StoredEvent.channel_id to match). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Scope row uses bucket's scope_value, not a local-derived value. + let scope_val: String = conn + .query_row( + "SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_val, chan); + } + + #[test] + fn test_persistent_referenced_e_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let ref_id = "a".repeat(64); + add_sub( + &conn, + &identity_pk, + relay_url, + "referenced_e", + &ref_id, + "[9]", + ); + + let ev = EventBuilder::new(Kind::Custom(9), "reply") + .tags(vec![Tag::parse(["e", &ref_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)]; + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + } + + #[test] + fn test_mixed_batch_persisted_and_dropped_counted_exactly() { + // Two channel_h candidates: relay only returns one. dropped must be 1. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + + // Fake relay only returns ev1. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 1); + } + + // ── archive pipeline — ephemeral path ──────────────────────────────────── + + #[test] + fn test_ephemeral_path_persists_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + // Fake relay returns nothing (not consulted for ephemeral path). + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + } + + #[test] + fn test_ephemeral_path_drops_kind_not_in_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // kinds = [1], not [24200] + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + // ── Invalid input dropped ───────────────────────────────────────────────── + + #[test] + fn test_malformed_json_is_dropped() { + let result = Event::from_json("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn test_tampered_event_fails_verify_id() { + let keys = Keys::generate(); + let mut ev_json: serde_json::Value = serde_json::from_str( + &EventBuilder::new(Kind::TextNote, "ok") + .sign_with_keys(&keys) + .unwrap() + .as_json(), + ) + .unwrap(); + ev_json["content"] = serde_json::Value::String("tampered".into()); + let tampered = ev_json.to_string(); + let ev = Event::from_json(&tampered).unwrap(); + assert!(!ev.verify_id()); + } + + // ── F2: out-of-range kind ───────────────────────────────────────────────── + + #[test] + fn test_out_of_range_kind_is_dropped() { + // kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200 + // via `v as u16`, so without the raw-kind check the validator would + // reason about 24200 while the persisted raw_json still says 89736. + // The fix rejects it before Event::from_json. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let owner_pk = &identity_pk; + // Build a valid kind-24200 frame, then mutate only the raw kind to 89736. + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let pk = owner_keys.public_key().to_hex(); + add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap(); + raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536)); + let bad_json = raw.to_string(); + + let cand = ArchiveCandidate { + raw_event_json: bad_json, + matched_scope: MatchedScope { + scope_type: ScopeType::OwnerP, + scope_value: pk.clone(), + }, + }; + let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0, "out-of-range kind must be dropped"); + assert_eq!(result.dropped, 1); + // No event row must exist. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + let _ = owner_pk; // silence unused-var + } + + // ── F3: transactional atomicity ─────────────────────────────────────────── + + #[test] + fn test_commit_archive_rolls_back_when_scope_write_would_fail() { + // Verify the split-schema invariant: if we simulate a mid-batch + // failure (by putting the DB into a state where the scope table is + // missing), the event row must NOT survive. + // + // We can't actually make upsert_event_scope fail on a healthy DB, so + // we verify the positive side: with a healthy DB, both rows are always + // written together or neither is. We confirm via run_batch_sync that + // after a full successful commit both tables have matching row counts. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-txn"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + let result = run_batch_sync( + cands, + &identity_pk, + relay_url, + &conn, + vec![ev1.clone(), ev2.clone()], + ); + assert_eq!(result.persisted, 2); + assert_eq!(result.dropped, 0); + + // Every event row must have exactly one corresponding scope row. + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + event_count, scope_count, + "every event row must have a matching scope row — split-schema invariant" + ); + } + + // ── Real-relay integration tests ────────────────────────────────────────── + // + // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` + // and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that + // are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at + // test-binary launch before any test runs. The guard compiles the whole + // cluster out of the Windows test binary. These tests are `#[ignore]` and + // require a live relay anyway — there is no Windows relay in CI. + // + // Run (Linux/macOS only): + // + // RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \ + // archive::tests::real_relay -- --ignored --nocapture + // + // The relay must be running with a Postgres backend (same docker compose + // as the existing e2e suite). Each test creates its own keypair + channel so + // concurrent runs don't interfere. + + #[cfg(not(target_os = "windows"))] + mod real_relay { + use super::*; + use crate::app_state::build_app_state; + use std::path::Path; + + fn relay_ws_url_from_env() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) + } + + fn relay_http_base() -> String { + relay_ws_url_from_env() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + + /// Build a test AppState wired with specific identity keys and relay URL. + /// Mirrors production `archive_events`: the same `query_buckets` call path + /// is exercised, including NIP-98 signing inside `query_relay`. + fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); + state + } + + /// Submit a signed event to the relay via `POST /events`. + /// The staging relay accepts events without NIP-98; this is only used for + /// test setup (publishing events that the archive pipeline will later query). + async fn submit_event_to_relay(ev: &Event) -> serde_json::Value { + let http = reqwest::Client::new(); + let resp = http + .post(format!("{}/events", relay_http_base())) + .header("X-Pubkey", ev.pubkey.to_hex()) + .header("Content-Type", "application/json") + .body(ev.as_json()) + .send() + .await + .expect("submit event to relay"); + assert!( + resp.status().is_success(), + "relay event submit failed: {}", + resp.status() + ); + resp.json().await.expect("parse submit response") + } + + /// Create an open channel on the relay. Returns the channel UUID string. + async fn create_relay_channel(keys: &Keys) -> String { + let channel_id = Uuid::new_v4().to_string(); + let ev = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = submit_event_to_relay(&ev).await; + assert!( + resp["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {resp}" + ); + channel_id + } + + /// Open a file-backed archive DB at `path` and insert one save subscription. + fn file_db_with_subscription( + path: &Path, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + let conn = store::open_archive_db(path).expect("open file archive db"); + add_sub( + &conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + ); + // conn drops here — file is flushed before the caller reopens it + } + + /// Run plan → real relay query (via the production `query_buckets` path, + /// including NIP-98 signing) → commit, using a file-backed archive DB. + /// + /// Mirrors the open/drop/query/reopen pattern of production `archive_events`: + /// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`). + /// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed. + /// 3. Reopen DB for `commit_archive`. + /// + /// Returns `ArchiveBatchResult`; caller reopens the file for row assertions. + async fn run_batch_real_relay( + candidates: Vec, + state: &AppState, + db_path: &Path, + ) -> ArchiveBatchResult { + let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(state); + + // Phase 1: plan (sync). Connection dropped before any .await. + let plan = { + let conn = store::open_archive_db(db_path).expect("open archive db for plan"); + plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap() + // conn drops here + }; + + // Phase 2: relay queries (async) — no Connection in scope. + // Uses the real `query_buckets` path: query_relay → NIP-98 signed /query. + let bucket_results = query_buckets(plan.buckets, state).await; + + // Phase 3: persist (sync). Fresh connection, same file. + let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + 0, + &conn, + ) + .unwrap() + } + + /// Happy path: publish a kind:9 message to a channel, then run the archive + /// pipeline against the real relay. Asserts exact event + scope rows in a + /// file-backed SQLite archive, reopened after commit to prove persistence. + #[tokio::test] + #[ignore] + async fn test_real_relay_channel_h_happy_path_persists_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + // Create a channel and publish a kind:9 message. + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + + // Give the relay a moment to persist. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // File-backed archive DB with a channel_h subscription. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[9]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}"); + assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}"); + + // Reopen the same file to assert exact row counts — proves file-backed persistence. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1, "archived_events should have 1 row"); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes \ + WHERE scope_type = 'channel_h' AND scope_value = ?1", + [&channel_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row"); + + // Confirm the stored raw_json round-trips to the original event. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex()); + + println!( + "✓ real relay: event {} archived under channel_h:{}", + msg_ev.id.to_hex(), + channel_id + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + + /// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9 + /// channel message. The relay stores the event, but the archive's kind filter drops it + /// because 9 ∉ {1059}. + #[tokio::test] + #[ignore] + async fn test_real_relay_kind_mismatch_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + let channel_id = create_relay_channel(&keys).await; + // Publish a kind:9 channel message — relay accepts it, but the subscription's + // kind filter is [1059] so the archive pipeline must drop it. + let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Subscription allows kinds=[1059] only — kind:9 must be dropped. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[1059]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped"); + assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on kind-mismatch" + ); + + println!( + "✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])" + ); + } + + /// No-subscription drop: event arrives but no save_subscription row exists. + #[tokio::test] + #[ignore] + async fn test_real_relay_no_subscription_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + submit_event_to_relay(&msg_ev).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Empty file-backed archive DB — no subscription row. + // plan_archive drops the whole group because no subscription matches. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + store::open_archive_db(&db_path).expect("init empty archive db"); + // conn from init drops here; DB file exists but has no subscription rows + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "no-sub: should be dropped"); + assert_eq!(result.dropped, 1, "no-sub: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on no-subscription" + ); + + println!("✓ real relay: no-subscription correctly dropped event"); + } + + /// Owner_p ephemeral path: a locally-built valid 24200 frame is archived + /// without a relay query (ephemeral events are never stored on the relay). + #[tokio::test] + #[ignore] + async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(owner_keys.clone(), &relay_url); + let identity_pk = owner_keys.public_key().to_hex(); + + // Build a valid kind:24200 observer frame addressed to the owner. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + + // File-backed archive DB with an owner_p subscription for kind 24200. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "owner_p", + &identity_pk, + "[24200]", + ); + + // owner_p candidates bypass the relay entirely — query_buckets gets an + // empty bucket list and the ephemeral path handles the frame locally. + let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!( + result.persisted, 1, + "owner_p: valid frame should be persisted" + ); + assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped"); + + // Reopen the same file to assert exact row counts. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1); + + // Confirm the stored raw_json round-trips to the original frame. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), ev.id.to_hex()); + + println!( + "✓ real relay: owner_p ephemeral frame {} archived", + ev.id.to_hex() + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + } +} diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs new file mode 100644 index 0000000000..bcfbaf2fd3 --- /dev/null +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -0,0 +1,390 @@ +//! Archive pipeline — three-phase plan/query/commit split. +//! +//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! +//! # Send-safety +//! +//! `rusqlite::Connection` is `!Send`. No `&Connection` borrow crosses an +//! `.await` point in any function here. Phase 1 (`plan_archive`) and Phase 3 +//! (`commit_archive`) are sync; Phase 2 (`query_buckets`) is async and never +//! holds a `Connection` reference. + +use nostr::{Event, JsonUtil}; +use rusqlite::Connection; + +use crate::app_state::AppState; +use crate::relay::query_relay; + +use super::{store, validate_ephemeral_frame, ArchiveBatchResult, ArchiveCandidate, MatchedScope}; + +// ── Private helpers ─────────────────────────────────────────────────────────── + +/// Extract the raw `kind` integer from an event JSON string and return it as +/// `Some(u64)` only if it is in the valid NIP-01 range `0..=65535`. +/// +/// Returns `None` for malformed JSON, a missing `kind` field, a non-integer +/// `kind`, or any value outside `0..=65535`. Used to detect the `nostr` +/// crate's silent `v as u16` truncation before deserialization. +fn raw_kind_value(raw: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(raw).ok()?; + let kind = v.get("kind")?.as_u64()?; + if kind > 65535 { + return None; + } + Some(kind) +} + +// ── Private types ──────────────────────────────────────────────────────────── + +/// A parsed, sig-verified candidate ready for further processing. +pub(super) struct Parsed { + pub(super) event: Event, + pub(super) raw_json: String, + pub(super) matched_scope: MatchedScope, +} + +/// One scope bucket: a set of candidates that share a scope type+value, +/// with the relay filter already built and the subscription kinds loaded. +pub(super) struct Bucket { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) filter: serde_json::Value, + pub(super) group: Vec, +} + +/// Output of the sync planning phase. +pub(super) struct ArchivePlan { + pub(super) buckets: Vec, + pub(super) ephemeral: Vec, + /// Events already accounted as dropped during planning (no subscription, + /// unknown scope type, parse failure, bad sig). + pub(super) pre_dropped: u32, +} + +/// A bucket with the relay's response attached. +pub(super) struct BucketWithResult { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) group: Vec, + /// Event ids returned by the relay for the scoped filter. + pub(super) returned_ids: std::collections::HashSet, + /// True if the relay query failed (network error); entire group dropped. + pub(super) relay_failed: bool, +} + +// ── Phase 1 ────────────────────────────────────────────────────────────────── + +/// Phase 1 (sync): parse all candidates, group persistent ones into per-scope +/// buckets, and load the subscription kinds for each bucket. +/// +/// Returns an [`ArchivePlan`] with no `&Connection` remaining — safe to hold +/// across `.await`. +pub(super) fn plan_archive( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, +) -> Result { + let mut persistent_raw: Vec = Vec::new(); + let mut ephemeral: Vec = Vec::new(); + let mut pre_dropped: u32 = 0; + + for cand in candidates { + // Range-validate raw kind before nostr::Event normalizes it. + // `nostr 0.44.3` `Kind::deserialize` does `v as u16`, which silently + // truncates e.g. `89736` (= 24200 + 65536) to `24200`. We reject any + // raw `kind` outside 0..=65535 so the validator never reasons about a + // truncated kind while persisting the original out-of-range value. + let raw_kind = match raw_kind_value(&cand.raw_event_json) { + Some(k) => k, + None => { + pre_dropped += 1; + continue; + } + }; + + let event = match Event::from_json(&cand.raw_event_json) { + Ok(e) => e, + Err(_) => { + pre_dropped += 1; + continue; + } + }; + + // Assert the deserialized kind matches the raw value (paranoia check). + if event.kind.as_u16() as u64 != raw_kind { + pre_dropped += 1; + continue; + } + + if !event.verify_id() || !event.verify_signature() { + pre_dropped += 1; + continue; + } + + if cand.matched_scope.scope_type.is_ephemeral() { + ephemeral.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } else { + persistent_raw.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } + } + + // Group persistent candidates by (scope_type, scope_value). + use std::collections::HashMap; + let mut scope_groups: HashMap<(String, String), Vec> = HashMap::new(); + for p in persistent_raw { + let key = ( + p.matched_scope.scope_type.as_str().to_string(), + p.matched_scope.scope_value.clone(), + ); + scope_groups.entry(key).or_default().push(p); + } + + let mut buckets: Vec = Vec::with_capacity(scope_groups.len()); + for ((scope_type_str, scope_value), mut group) in scope_groups { + // No subscription → drop the whole group. + let kinds_json = match store::get_subscription_kinds( + conn, + identity_pk, + relay_url, + &scope_type_str, + &scope_value, + )? { + Some(k) => k, + None => { + pre_dropped += group.len() as u32; + continue; + } + }; + + let allowed_kinds: Vec = + serde_json::from_str::>(&kinds_json).unwrap_or_default(); + + // Deduplicate by event id within the bucket. + let mut seen = std::collections::HashSet::new(); + group.retain(|p| seen.insert(p.event.id.to_hex())); + + let ids: Vec = group.iter().map(|p| p.event.id.to_hex()).collect(); + + // Build a *scoped* relay filter: ids + scope tag + kinds. + let filter = match scope_type_str.as_str() { + "channel_h" => serde_json::json!({ + "ids": ids, + "#h": [&scope_value], + "kinds": allowed_kinds, + }), + "referenced_e" => serde_json::json!({ + "ids": ids, + "#e": [&scope_value], + "kinds": allowed_kinds, + }), + _ => { + pre_dropped += group.len() as u32; + continue; + } + }; + + buckets.push(Bucket { + scope_type_str, + scope_value, + allowed_kinds, + filter, + group, + }); + } + + Ok(ArchivePlan { + buckets, + ephemeral, + pre_dropped, + }) +} + +// ── Phase 2 ────────────────────────────────────────────────────────────────── + +/// Phase 2 (async): fire one relay query per bucket and collect results. +/// +/// `state` is `&AppState` — a `Copy` reference — so no `!Send` value is held +/// across `.await`. +pub(super) async fn query_buckets(buckets: Vec, state: &AppState) -> Vec { + let mut results: Vec = Vec::with_capacity(buckets.len()); + for bucket in buckets { + let (returned_ids, relay_failed) = match query_relay(state, &[bucket.filter]).await { + Ok(evs) => (evs.iter().map(|e| e.id.to_hex()).collect(), false), + Err(_) => (std::collections::HashSet::new(), true), + }; + results.push(BucketWithResult { + scope_type_str: bucket.scope_type_str, + scope_value: bucket.scope_value, + allowed_kinds: bucket.allowed_kinds, + group: bucket.group, + returned_ids, + relay_failed, + }); + } + results +} + +// ── Phase 3 ────────────────────────────────────────────────────────────────── + +/// Phase 3 (sync): apply relay results and write accepted events to the store. +/// +/// All event + scope upserts run inside a single SQLite transaction: either +/// every write in the batch commits or none do. This preserves the invariant +/// that every `archived_events` row has at least one matching `archived_event_scopes` +/// row — a partial failure can never leave an orphaned event with no scope proof. +pub(super) fn commit_archive( + bucket_results: Vec, + ephemeral: Vec, + pre_dropped: u32, + identity_pk: &str, + relay_url: &str, + now: i64, + conn: &Connection, +) -> Result { + let mut persisted: u32 = 0; + let mut dropped: u32 = pre_dropped; + + // Collect writes; count drops first, then execute inside a single + // transaction so event and scope rows are always committed atomically. + struct WriteRow<'a> { + eid: String, + kind: i64, + pubkey: String, + created_at: i64, + raw_json: &'a str, + scope_type: &'a str, + scope_value: &'a str, + } + let mut writes: Vec> = Vec::new(); + + // ── Persistent path ────────────────────────────────────────────────────── + for result in &bucket_results { + if result.relay_failed { + dropped += result.group.len() as u32; + continue; + } + + for p in &result.group { + let eid = p.event.id.to_hex(); + + // Relay proof: event was returned for the scoped filter. + if !result.returned_ids.contains(&eid) { + dropped += 1; + continue; + } + + // Kind enforcement: event.kind must be in the subscription's list. + if !result + .allowed_kinds + .contains(&(p.event.kind.as_u16() as u64)) + { + dropped += 1; + continue; + } + + // The relay returning this event for {ids, #h/#e, kinds} IS the + // proof of scope membership. Use scope_value directly; no local + // tag re-derivation (which would incorrectly drop h-less events + // matched via the relay's StoredEvent.channel_id fallback). + writes.push(WriteRow { + eid, + kind: p.event.kind.as_u16() as i64, + pubkey: p.event.pubkey.to_hex(), + created_at: p.event.created_at.as_secs() as i64, + raw_json: &p.raw_json, + scope_type: &result.scope_type_str, + scope_value: &result.scope_value, + }); + } + } + + // ── Ephemeral path (owner_p) ───────────────────────────────────────────── + // Fully local validation — no relay query. + let mut validated_ephemeral: Vec<(String, &Parsed)> = Vec::new(); + for p in &ephemeral { + match validate_ephemeral_frame( + &p.event, + identity_pk, + &p.matched_scope.scope_value, + conn, + identity_pk, + relay_url, + ) { + Ok(()) => validated_ephemeral.push((p.event.id.to_hex(), p)), + Err(_) => { + dropped += 1; + } + } + } + + // ── Commit all writes atomically ───────────────────────────────────────── + if !writes.is_empty() || !validated_ephemeral.is_empty() { + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin archive transaction: {e}"))?; + + for w in &writes { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + &w.eid, + w.kind, + &w.pubkey, + w.created_at, + w.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + &w.eid, + w.scope_type, + w.scope_value, + now, + )?; + persisted += 1; + } + + for (eid, p) in &validated_ephemeral { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + eid, + p.event.kind.as_u16() as i64, + &p.event.pubkey.to_hex(), + p.event.created_at.as_secs() as i64, + &p.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + eid, + "owner_p", + &p.matched_scope.scope_value, + now, + )?; + persisted += 1; + } + + tx.commit() + .map_err(|e| format!("failed to commit archive transaction: {e}"))?; + } + + Ok(ArchiveBatchResult { persisted, dropped }) +} diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs new file mode 100644 index 0000000000..b70a96749e --- /dev/null +++ b/desktop/src-tauri/src/archive/store.rs @@ -0,0 +1,975 @@ +//! Local SQLite archive store for saved relay messages. +//! +//! Three tables: +//! - `archived_events` — one raw event row per (identity, relay, event_id) +//! - `archived_event_scopes` — N scope membership rows per raw event (many-to-many) +//! - `save_subscriptions` — which scopes the user has subscribed to save +//! +//! WAL + `busy_timeout=5000` matches `managed_agents/retention.rs`. +//! Raw event rows are GC'd when their last scope row is deleted. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; + +// ── Schema ───────────────────────────────────────────────────────────────── + +pub(super) const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); + +CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) +); + +CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + kinds TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) +); +"; + +// ── Open / init ───────────────────────────────────────────────────────────── + +/// Open (or create) the archive database at the given path. +/// +/// Applies WAL journaling and `busy_timeout=5000` on every connection, +/// matching `managed_agents/retention.rs`. Creates all three tables if they +/// don't already exist. +pub fn open_archive_db(path: &Path) -> Result { + // Ensure the parent directory exists so `Connection::open` doesn't fail. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create archive dir: {e}"))?; + } + + let conn = Connection::open(path).map_err(|e| format!("failed to open archive db: {e}"))?; + + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(|e| format!("failed to set WAL mode: {e}"))?; + conn.pragma_update(None, "busy_timeout", 5000) + .map_err(|e| format!("failed to set busy_timeout: {e}"))?; + + conn.execute_batch(SCHEMA) + .map_err(|e| format!("failed to initialize archive schema: {e}"))?; + + Ok(conn) +} + +// ── Save subscriptions ────────────────────────────────────────────────────── + +/// A save subscription row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SaveSubscription { + pub identity_pubkey: String, + pub relay_url: String, + pub scope_type: String, + pub scope_value: String, + /// JSON-encoded integer array, e.g. `[1,6,39000]`. + pub kinds: String, + pub created_at: i64, +} + +/// Insert or replace a save subscription. `kinds` must be a JSON int array. +pub fn upsert_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + now: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO save_subscriptions + (identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, scope_type, scope_value) + DO UPDATE SET kinds = excluded.kinds", + params![ + identity_pubkey, + relay_url, + scope_type, + scope_value, + kinds_json, + now + ], + ) + .map_err(|e| format!("failed to upsert save subscription: {e}"))?; + Ok(()) +} + +/// List all save subscriptions for the given identity + relay. +pub fn list_save_subscriptions( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at + FROM save_subscriptions + WHERE identity_pubkey = ?1 AND relay_url = ?2 + ORDER BY created_at ASC", + ) + .map_err(|e| format!("prepare list_save_subscriptions: {e}"))?; + + let rows = stmt + .query_map(params![identity_pubkey, relay_url], |row| { + Ok(SaveSubscription { + identity_pubkey: row.get(0)?, + relay_url: row.get(1)?, + scope_type: row.get(2)?, + scope_value: row.get(3)?, + kinds: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| format!("query list_save_subscriptions: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read list_save_subscriptions row: {e}")) +} + +/// Delete a save subscription. Does NOT purge archived event data (retention +/// is decoupled in v1). Returns `true` if a row was deleted. +pub fn delete_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + ) + .map_err(|e| format!("failed to delete save subscription: {e}"))?; + Ok(affected > 0) +} + +/// Return true if a matching save subscription exists for the given scope. +#[allow(dead_code)] +pub fn has_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get(0), + ) + .map_err(|e| format!("failed to check save subscription: {e}"))?; + Ok(count > 0) +} + +/// Return the `kinds` JSON string for a matching save subscription, or `None` +/// if no subscription exists. +pub fn get_subscription_kinds( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result, String> { + let result = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("failed to fetch subscription kinds: {e}"))?; + Ok(result) +} + +// ── Archived events ───────────────────────────────────────────────────────── + +/// Upsert an event row (idempotent on the PK). +/// +/// Does nothing if the event is already archived (same identity/relay/id). +pub fn upsert_archived_event( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + kind: i64, + pubkey: &str, + created_at: i64, + raw_json: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + kind, + pubkey, + created_at, + raw_json, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert archived event: {e}"))?; + Ok(()) +} + +/// Upsert a scope membership row for an event. +/// +/// Idempotent: if the (identity, relay, id, scope_type, scope_value) PK already +/// exists the row is left unchanged. +pub fn upsert_event_scope( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + scope_type: &str, + scope_value: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_event_scopes + (identity_pubkey, relay_url, id, scope_type, scope_value, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, id, scope_type, scope_value) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + scope_type, + scope_value, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert event scope: {e}"))?; + Ok(()) +} + +/// Read a paginated page of archived events for a given scope. +/// +/// Returns the `raw_json` of matching events in newest-first order +/// (`ORDER BY created_at DESC, id DESC`). The optional compound cursor +/// `(before_created_at, before_id)` implements keyset pagination: both fields +/// must be `Some` together to activate the cursor (passing one `Some` and one +/// `None` is a logic error at the call site — the store treats mixed `Some`/ +/// `None` as no cursor). The predicate mirrors the sort order exactly: +/// `(created_at < before_created_at) OR (created_at = before_created_at AND +/// id < before_id)`. Pass `None`/`None` to start at the newest end. +/// +/// A scalar `created_at`-only cursor would skip same-second siblings at a page +/// boundary because rows are ordered by `(created_at DESC, id DESC)` — two +/// rows with equal `created_at` on different pages would both be excluded by +/// `created_at < before`. The compound cursor avoids this. +/// +/// An optional `kinds` slice filters by event kind; `None` admits all kinds. +/// +/// Returns at most `limit` rows (caller is responsible for a sane default). +pub fn read_archived_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds: Option<&[i64]>, + before_created_at: Option, + before_id: Option<&str>, + limit: i64, +) -> Result, String> { + // Build clauses and positional params together so slot numbers are always + // contiguous (rusqlite rejects gaps like ?4 then ?6 with no ?5 in between). + // + // Fixed params: identity_pubkey, relay_url, scope_type, scope_value = ?1–?4. + // Optional params appended in declaration order, limit always last. + + let mut next_slot: usize = 5; + let mut extra_clauses = String::new(); + let mut kinds_json: Option = None; + let mut before_at_val: Option = None; + let mut before_id_val: Option = None; + + if let Some(ks) = kinds { + kinds_json = Some(serde_json::to_string(ks).unwrap_or_else(|_| "[]".to_string())); + extra_clauses.push_str(&format!( + " AND ae.kind IN (SELECT value FROM json_each(?{next_slot}))" + )); + next_slot += 1; + } + // Compound cursor: both fields must be Some to activate. The predicate + // mirrors ORDER BY (created_at DESC, id DESC) exactly so no same-second + // sibling is skipped at a page boundary. + if let (Some(bat), Some(bid)) = (before_created_at, before_id) { + before_at_val = Some(bat); + before_id_val = Some(bid.to_owned()); + extra_clauses.push_str(&format!( + " AND (ae.created_at < ?{next_slot} \ + OR (ae.created_at = ?{next_slot} AND ae.id < ?{}))", + next_slot + 1, + )); + next_slot += 2; + } + let limit_slot = next_slot; + + let sql = format!( + "SELECT ae.raw_json \ + FROM archived_events ae \ + INNER JOIN archived_event_scopes aes \ + ON aes.identity_pubkey = ae.identity_pubkey \ + AND aes.relay_url = ae.relay_url \ + AND aes.id = ae.id \ + WHERE ae.identity_pubkey = ?1 \ + AND ae.relay_url = ?2 \ + AND aes.scope_type = ?3 \ + AND aes.scope_value = ?4\ + {extra_clauses}\ + ORDER BY ae.created_at DESC, ae.id DESC \ + LIMIT ?{limit_slot}", + ); + + // Build the param list dynamically to match the generated SQL. + let mut params: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(scope_type.to_owned()), + Box::new(scope_value.to_owned()), + ]; + if let Some(kj) = kinds_json { + params.push(Box::new(kj)); + } + if let (Some(bat), Some(bid)) = (before_at_val, before_id_val) { + // Both slots use the same created_at value (the OR predicate references + // it twice); the id slot follows. + params.push(Box::new(bat)); + params.push(Box::new(bid)); + } + params.push(Box::new(limit)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("prepare read_archived_events: {e}"))?; + + let rows = stmt + .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) + .map_err(|e| format!("query read_archived_events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read read_archived_events row: {e}")) +} + +/// GC: delete orphaned event rows whose last scope row was just removed. +/// +/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only +/// events with zero remaining scope rows are deleted. +#[allow(dead_code)] // Used by P4 purge commands; not yet wired to a Tauri command. +pub fn gc_orphaned_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND id NOT IN ( + SELECT id FROM archived_event_scopes + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + )", + params![identity_pubkey, relay_url], + ) + .map_err(|e| format!("failed to gc orphaned events: {e}"))?; + Ok(affected) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn + } + + // ── Schema init ────────────────────────────────────────────────────────── + + #[test] + fn test_schema_init_creates_all_tables() { + let conn = in_memory(); + // Verify all three tables exist by inserting a row in each. + conn.execute( + "INSERT INTO save_subscriptions VALUES ('pk','relay','channel_h','abc','[1]',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_events VALUES ('pk','relay','id1',1,'author',0,'{}',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_event_scopes VALUES ('pk','relay','id1','channel_h','abc',0)", + [], + ) + .unwrap(); + } + + #[test] + fn test_schema_init_is_idempotent() { + // Running SCHEMA twice must not error. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + } + + // ── Save subscriptions ─────────────────────────────────────────────────── + + #[test] + fn test_upsert_save_subscription_inserts_and_updates_kinds() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1]"); + + // Update kinds. + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1,6]", 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1,6]"); + } + + #[test] + fn test_list_save_subscriptions_scoped_to_identity_and_relay() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk1", "wss://r1", "channel_h", "a", "[1]", 1).unwrap(); + upsert_save_subscription(&conn, "pk2", "wss://r1", "channel_h", "b", "[1]", 2).unwrap(); + upsert_save_subscription(&conn, "pk1", "wss://r2", "channel_h", "c", "[1]", 3).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk1", "wss://r1").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].scope_value, "a"); + } + + #[test] + fn test_delete_save_subscription_removes_row() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc").unwrap(); + assert!(deleted); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); + } + + #[test] + fn test_delete_save_subscription_returns_false_when_not_found() { + let conn = in_memory(); + let deleted = + delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "nope").unwrap(); + assert!(!deleted); + } + + #[test] + fn test_has_save_subscription_true_and_false() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk", "[24200]", 1).unwrap(); + assert!(has_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk").unwrap()); + assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); + } + + // ── Archived events ────────────────────────────────────────────────────── + + #[test] + fn test_upsert_archived_event_is_idempotent() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + // Second call must not error or duplicate. + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + // ── Many-to-many scope rows ────────────────────────────────────────────── + + #[test] + fn test_one_event_gets_multiple_scope_rows() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "evref", 200).unwrap(); + // Idempotent second insert. + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 201).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE id = 'id1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + } + + // ── GC ─────────────────────────────────────────────────────────────────── + + #[test] + fn test_gc_removes_event_when_last_scope_deleted() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + // Delete the only scope row manually. + conn.execute("DELETE FROM archived_event_scopes WHERE id = 'id1'", []) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 1); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_gc_leaves_event_with_remaining_scope() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "ref", 200).unwrap(); + // Delete only one scope row. + conn.execute( + "DELETE FROM archived_event_scopes WHERE scope_type = 'referenced_e'", + [], + ) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 0); + } + + // ── read_archived_events ───────────────────────────────────────────────── + + fn seed_events(conn: &Connection) { + // Three events in scope "channel_h/chan1" for identity "pk"/"wss://r". + // created_at: 300 (newest), 200, 100 (oldest). + for (id, kind, created_at, raw) in &[ + ("e1", 9i64, 300i64, r#"{"id":"e1","created_at":300}"#), + ("e2", 9i64, 200i64, r#"{"id":"e2","created_at":200}"#), + ("e3", 42i64, 100i64, r#"{"id":"e3","created_at":100}"#), + ] { + upsert_archived_event( + conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", id, "channel_h", "chan1", 999).unwrap(); + } + // One event in a different scope — must never appear in chan1 results. + upsert_archived_event( + conn, + "pk", + "wss://r", + "e4", + 9, + "author", + 250, + r#"{"id":"e4"}"#, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", "e4", "channel_h", "chan2", 999).unwrap(); + } + + #[test] + fn test_read_archived_events_returns_newest_first() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 3); + // Newest first: e1 (300), e2 (200), e3 (100). + let ids: Vec<&str> = rows + .iter() + .map(|r| { + if r.contains("\"e1\"") { + "e1" + } else if r.contains("\"e2\"") { + "e2" + } else { + "e3" + } + }) + .collect(); + assert_eq!(ids, ["e1", "e2", "e3"]); + } + + #[test] + fn test_read_archived_events_keyset_cursor_excludes_at_boundary() { + let conn = in_memory(); + seed_events(&conn); + // Compound cursor at e1 (created_at=300, id="e1"): excludes e1 itself, + // returns e2 and e3. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(300), + Some("e1"), + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e1\""))); + } + + #[test] + fn test_read_archived_events_keyset_cursor_advances_correctly() { + let conn = in_memory(); + seed_events(&conn); + // Page 1: before=None/None, limit=2 → e1, e2. + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + assert_eq!(page1.len(), 2); + // Page 2: compound cursor at e2 (created_at=200, id="e2") → e3 only. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + assert_eq!(page2.len(), 1); + assert!(page2[0].contains("\"e3\"")); + // No overlap between pages. + assert!(page1.iter().all(|r| !r.contains("\"e3\""))); + } + + #[test] + fn test_read_archived_events_kind_filter() { + let conn = in_memory(); + seed_events(&conn); + // Only kind 9 (e1 and e2); e3 is kind 42. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + Some(&[9]), + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e3\""))); + } + + #[test] + fn test_read_archived_events_scope_isolation() { + let conn = in_memory(); + seed_events(&conn); + // chan2 has only e4; chan1 results must not include e4. + let chan1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(chan1.iter().all(|r| !r.contains("\"e4\""))); + + let chan2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan2", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(chan2.len(), 1); + assert!(chan2[0].contains("\"e4\"")); + } + + #[test] + fn test_read_archived_events_identity_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different identity — must see no rows. + let rows = read_archived_events( + &conn, + "pk2", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_relay_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different relay — must see no rows. + let rows = read_archived_events( + &conn, + "pk", + "wss://other", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_empty_result() { + let conn = in_memory(); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "nope", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_limit_respected() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + // Must be the newest (e1, created_at=300). + assert!(rows[0].contains("\"e1\"")); + } + + #[test] + fn test_read_archived_events_no_duplicates_across_pages() { + let conn = in_memory(); + seed_events(&conn); + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + // Compound cursor at e2 (the oldest in page1: created_at=200, id="e2"). + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + // All event ids across both pages are unique. + let all: Vec<_> = page1.iter().chain(page2.iter()).collect(); + assert_eq!(all.len(), 3); // 2 + 1 = 3 total, no duplication. + } + + /// Regression for the scalar-cursor same-second skip defect (Thufir IMPORTANT). + /// + /// The writer stores `created_at` in whole seconds, so two events can share + /// the same timestamp. The sort order is `(created_at DESC, id DESC)`, so + /// a page split exactly at a same-second boundary leaves one sibling on each + /// side. With only `created_at < before` the second-page sibling would be + /// permanently excluded. The compound `(created_at < ?) OR (created_at = ? + /// AND id < ?)` predicate mirrors the sort key exactly and avoids the skip. + #[test] + fn test_read_archived_events_same_second_cursor_no_skip() { + let conn = in_memory(); + // Two events share created_at=1000. Sort order: "z" (id "z") > "a" (id "a"), + // so ORDER BY created_at DESC, id DESC yields: ("z", 1000) first, ("a", 1000) second. + // A third event has created_at=500. + for (id, kind, created_at, raw) in &[ + ("z", 9i64, 1000i64, r#"{"id":"z","created_at":1000}"#), + ("a", 9i64, 1000i64, r#"{"id":"a","created_at":1000}"#), + ("old", 9i64, 500i64, r#"{"id":"old","created_at":500}"#), + ] { + upsert_archived_event( + &conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", id, "channel_h", "same_sec", 999).unwrap(); + } + + // Page 1: limit=1 → should return ("z", 1000) only (newest by compound sort). + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(page1.len(), 1); + assert!(page1[0].contains("\"z\""), "page1 must be the 'z' row"); + + // Page 2: compound cursor at ("z", 1000). + // With a scalar cursor (created_at < 1000), row "a" would be SKIPPED. + // With the compound cursor, "a" must appear on page 2. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + Some(1000), + Some("z"), + 2, + ) + .unwrap(); + // Must contain "a" (same-second sibling) and "old" (strictly older). + assert_eq!(page2.len(), 2, "page2 must return both remaining rows"); + assert!( + page2.iter().any(|r| r.contains("\"a\"")), + "same-second sibling 'a' must not be skipped" + ); + assert!( + page2.iter().any(|r| r.contains("\"old\"")), + "'old' row must appear on page2" + ); + // No overlap with page1. + assert!(page2.iter().all(|r| !r.contains("\"z\""))); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index af30c3d8b9..e4298bcb05 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -22,6 +22,7 @@ mod media_transcode; mod mesh_llm; mod messages; mod notifications; +mod observer_archive; pub mod pairing; mod personas; mod prevent_sleep; @@ -61,6 +62,7 @@ pub use media_download::*; pub use mesh_llm::*; pub use messages::*; pub use notifications::*; +pub use observer_archive::*; pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs new file mode 100644 index 0000000000..cdd932a0c6 --- /dev/null +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -0,0 +1,36 @@ +//! Build-time flag for observer-feed archive default. +//! +//! When `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` is set at build time (internal +//! builds), `observer_archive_default_enabled()` returns `true` and the +//! frontend auto-seeds an `owner_p` save subscription for the current identity +//! on first run. +//! +//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in +//! manually via the Local Archive settings card. + +/// Returns `true` when an internal build has observer-feed archive default-on. +/// +/// The frontend calls this once at startup to decide whether to seed the +/// `owner_p` save subscription. The result is stable for the lifetime of the +/// binary — it is baked at compile time. +#[tauri::command] +pub fn observer_archive_default_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_observer_archive_default_enabled_returns_bool() { + // The command must return a plain bool without panicking. + // Whether it's true or false depends on the build environment; + // what we assert here is just that the return type is correct and + // the function is callable. + let result = observer_archive_default_enabled(); + // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT + // baked in), this should be false. + assert!(!result, "expected false in OSS/test build"); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e41d46a291..00084a6531 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod app_state; +mod archive; mod commands; mod deep_link; mod events; @@ -613,6 +614,12 @@ pub fn run() { get_agent_memory, relay_reconnect_hook, relay_reconnect_hook_configured, + observer_archive_default_enabled, + archive::archive_events, + archive::create_save_subscription, + archive::list_save_subscriptions, + archive::delete_save_subscription, + archive::read_archived_events, is_auto_update_supported, ]) .build(tauri::generate_context!()) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4bf88366aa..022ae67e84 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -49,6 +49,8 @@ import { useUserStatusSubscription, } from "@/features/user-status/hooks"; import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; +import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; +import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -147,6 +149,8 @@ export function AppShell() { ); usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); + useArchiveSync(); + useObserverArchiveSeed(identityQuery.data?.pubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs new file mode 100644 index 0000000000..49add6c6a9 --- /dev/null +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -0,0 +1,284 @@ +/** + * Tests for ingestArchivedObserverEvents — the read-back ingest seam that loads + * archived observer frames from the local SQLite archive into the observer store. + * + * These tests use node:test's synchronous-friendly import pattern combined with + * test-only exports (_testRegisterKnownAgents, _decryptFn injection, and the + * existing injectObserverEventsForE2E) to exercise behavior without requiring + * a Tauri runtime or React context. + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + ingestArchivedObserverEvents, + injectObserverEventsForE2E, + getAgentObserverSnapshot, + resetAgentObserverStore, + _testRegisterKnownAgents, +} from "@/features/agents/observerRelayStore.ts"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = "a".repeat(64); +const OTHER_PUBKEY = "b".repeat(64); +const SUB_ID = "test-sub-1"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRawEvent(overrides = {}) { + return { + id: "e".repeat(64), + pubkey: AGENT_PUBKEY, + created_at: 1000, + kind: 24200, + tags: [ + ["p", OTHER_PUBKEY], + ["agent", AGENT_PUBKEY], + ["frame", "telemetry"], + ], + content: "encrypted", + sig: "s".repeat(128), + ...overrides, + }; +} + +function makeObserverEvent(overrides = {}) { + return { + seq: 1, + timestamp: "2026-01-01T00:00:01.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: {}, + ...overrides, + }; +} + +// Decrypt fn that resolves to a known observer event. +function makeDecrypt(returnEvent) { + return () => Promise.resolve(returnEvent); +} + +// Decrypt fn that always rejects. +function makeDecryptFail() { + return () => Promise.reject(new Error("decryption failed")); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ingestArchivedObserverEvents", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_unknown_agent_drops_event_before_decrypt", async () => { + // knownAgentPubkeys is empty after reset. + // Even with a successful decrypt fn, the event must be dropped. + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + await ingestArchivedObserverEvents([makeRawEvent()], decryptFn); + assert.equal( + decryptCalled, + false, + "decrypt must not be called for unknown agent", + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_mismatched_sender_drops_event_before_decrypt", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + // event.pubkey differs from agent tag value + const badEvent = makeRawEvent({ pubkey: OTHER_PUBKEY }); + await ingestArchivedObserverEvents([badEvent], decryptFn); + assert.equal( + decryptCalled, + false, + "decrypt must not be called for mismatched sender", + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_non_telemetry_frame_tag_drops_event", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + const nonTelemetryEvent = makeRawEvent({ + tags: [ + ["p", OTHER_PUBKEY], + ["agent", AGENT_PUBKEY], + ["frame", "control"], // not "telemetry" + ], + }); + await ingestArchivedObserverEvents([nonTelemetryEvent], decryptFn); + assert.equal(decryptCalled, false, "non-telemetry frame must be dropped"); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_decrypt_failure_silently_dropped", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Good event that passes all guards but fails decrypt. + await ingestArchivedObserverEvents([makeRawEvent()], makeDecryptFail()); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + // Error is silently dropped — no crash, no event in store. + assert.equal(snap.events.length, 0); + }); + + it("test_successful_ingest_adds_event_to_store", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const obs = makeObserverEvent({ seq: 1 }); + await ingestArchivedObserverEvents([makeRawEvent()], makeDecrypt(obs)); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 1); + assert.equal(snap.events[0].seq, 1); + }); + + it("test_dedup_does_not_add_live_present_event", async () => { + // Pre-seed a live event via E2E injection. + const liveObs = makeObserverEvent({ + seq: 5, + timestamp: "2026-01-01T00:00:05.000Z", + }); + injectObserverEventsForE2E(AGENT_PUBKEY, [liveObs]); + + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Try to ingest an archived event with the SAME (seq, timestamp) — must be deduped. + const archivedObs = makeObserverEvent({ + seq: 5, + timestamp: "2026-01-01T00:00:05.000Z", + }); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(archivedObs), + ); + + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal( + snap.events.length, + 1, + "dedup: duplicate seq+timestamp must not add a second entry", + ); + }); + + it("test_older_archived_event_sorts_before_live", async () => { + // Pre-seed a newer live event. + const liveObs = makeObserverEvent({ + seq: 2, + timestamp: "2026-01-01T00:00:02.000Z", + }); + injectObserverEventsForE2E(AGENT_PUBKEY, [liveObs]); + + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Ingest an older archived event. + const archivedObs = makeObserverEvent({ + seq: 1, + timestamp: "2026-01-01T00:00:01.000Z", + }); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(archivedObs), + ); + + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 2); + // Ascending time order: older first. + assert.equal( + snap.events[0].seq, + 1, + "older archived event must sort before newer live event", + ); + assert.equal(snap.events[1].seq, 2); + }); + + it("test_multiple_events_ingested_in_order", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Three events: seq 3, 1, 2 — must end up sorted 1, 2, 3. + const events = [ + makeObserverEvent({ seq: 3, timestamp: "2026-01-01T00:00:03.000Z" }), + makeObserverEvent({ seq: 1, timestamp: "2026-01-01T00:00:01.000Z" }), + makeObserverEvent({ seq: 2, timestamp: "2026-01-01T00:00:02.000Z" }), + ]; + let callIdx = 0; + const decryptFn = () => Promise.resolve(events[callIdx++]); + // All three raw events pass the guards (same pubkey/agent tag). + await ingestArchivedObserverEvents( + [makeRawEvent(), makeRawEvent(), makeRawEvent()], + decryptFn, + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 3); + assert.deepEqual( + snap.events.map((e) => e.seq), + [1, 2, 3], + ); + }); +}); + +// ── Cursor advance test (pure logic, no store needed) ───────────────────────── + +describe("load-older cursor advance logic", () => { + it("test_cursor_advances_to_last_row_compound_key", () => { + // Mirrors the cursor-update logic in useLoadArchivedObserverEvents. + // Events arrive newest-first (as the store returns them). + // The cursor should be the LAST element — the oldest on this page — + // capturing both created_at and id to mirror the compound sort key + // so same-second siblings are never skipped at a page boundary. + const events = [ + { id: "e1", created_at: 1000 }, + { id: "e2", created_at: 900 }, + { id: "e3", created_at: 800 }, + { id: "e4", created_at: 500 }, + ]; + const oldestEvent = events[events.length - 1]; + const cursor = { createdAt: oldestEvent.created_at, id: oldestEvent.id }; + assert.deepEqual( + cursor, + { createdAt: 500, id: "e4" }, + "cursor must capture the last (oldest) row's created_at + id", + ); + }); + + it("test_short_page_signals_archive_exhausted", () => { + // A page with fewer events than the limit signals end-of-archive. + const PAGE_SIZE = 50; + const page = Array.from({ length: 30 }, (_, i) => ({ + created_at: 1000 - i, + })); + const exhausted = page.length < PAGE_SIZE; + assert.equal( + exhausted, + true, + "short page must signal archive is exhausted", + ); + }); + + it("test_full_page_signals_more_archive_available", () => { + const PAGE_SIZE = 50; + const page = Array.from({ length: 50 }, (_, i) => ({ + created_at: 1000 - i, + })); + const exhausted = page.length < PAGE_SIZE; + assert.equal( + exhausted, + false, + "full page must signal more archive may be available", + ); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index e8474ed5ec..adf6ea92d9 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -20,7 +20,7 @@ import { processTranscriptEvent, } from "./ui/agentSessionTranscript"; -const MAX_OBSERVER_EVENTS = 800; +const MAX_OBSERVER_EVENTS = 3000; export type ObserverSnapshot = { connectionState: ConnectionState; @@ -424,6 +424,52 @@ export function useManagedAgentObserverBridge( }, [queryClient]); } +/** + * Ingest a batch of raw archived observer events from the local archive into + * the store. Applies the same security guards as the live relay path: + * + * - Event must have an `agent` tag pointing to a known/trusted pubkey + * (registered via `useManagedAgentObserverBridge`). + * - The event sender (`pubkey`) must match the `agent` tag value. + * - Event must decrypt successfully via `decryptObserverEvent`. + * + * Routes through `appendAgentEvent` so dedup on `(seq, timestamp)` and + * sort are reused — archived events that are already present (live-delivered) + * are silently skipped. Failed decryptions are silently dropped (same as + * live path error handling). + * + * Note: events for agents not currently registered in `knownAgentPubkeys` + * (e.g. an agent that is stopped but has archived history) are dropped. + * The caller should ensure the agent is registered before calling. + * + * `_decryptFn` is only used by tests to inject a mock decryption function. + * Production callers must always omit it. + */ +export async function ingestArchivedObserverEvents( + rawEvents: RelayEvent[], + _decryptFn: (event: RelayEvent) => Promise = decryptObserverEvent, +): Promise { + for (const event of rawEvents) { + const agentPubkey = observerTag(event, "agent"); + const frame = observerTag(event, "frame"); + if (!agentPubkey || frame !== "telemetry") { + continue; + } + if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + continue; + } + if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { + continue; + } + try { + const parsed = (await _decryptFn(event)) as ObserverEvent; + appendAgentEvent(agentPubkey, parsed); + } catch { + // Silently drop decrypt failures — same as live path error handling. + } + } +} + /** * E2E-only: inject synthetic observer events directly into the store, bypassing * the relay-security knownAgentPubkeys filter. Exercises the real @@ -473,3 +519,15 @@ export function resetAgentObserverStore() { notifyListeners(); void unsubscribe?.(); } + +/** + * Test-only: register a set of agent pubkeys as trusted for a given + * subscription id. Mirrors the effect of mounting `useManagedAgentObserverBridge` + * in a React tree. Only call from tests — never from production code. + */ +export function _testRegisterKnownAgents( + subscriptionId: string, + pubkeys: readonly string[], +): void { + registerKnownAgents(subscriptionId, pubkeys); +} diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 6631b16776..73603a029d 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -4,8 +4,14 @@ import { ensureRelayObserverSubscription, getAgentObserverSnapshot, getAgentTranscript, + ingestArchivedObserverEvents, subscribeAgentObserverStore, } from "@/features/agents/observerRelayStore"; +import { + listSaveSubscriptions, + readArchivedEvents, +} from "@/shared/api/tauriArchive"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { TranscriptItem } from "./agentSessionTypes"; // Stable subscribe reference shared by all useSyncExternalStore hooks. @@ -45,3 +51,108 @@ export function useAgentTranscript( return React.useSyncExternalStore(subscribeToStore, getSnapshot); } + +const ARCHIVED_EVENTS_PAGE_SIZE = 50; + +/** + * Load-older-on-scroll for archived observer frames. + * + * Checks whether an `owner_p` save subscription exists for the current + * identity. If one does, exposes `fetchOlderArchived` and `hasOlderArchived` + * for wiring into a sentinel-based scroll loader. + * + * Degrades cleanly when no subscription exists (returns `hasOlderArchived: + * false` without making any archive calls). + */ +export function useLoadArchivedObserverEvents(enabled: boolean) { + const identityQuery = useIdentityQuery(); + const identityPubkey = identityQuery.data?.pubkey ?? null; + + // Whether the current identity has an owner_p save subscription. + const [hasSubscription, setHasSubscription] = React.useState( + null, + ); + const [hasOlderArchived, setHasOlderArchived] = React.useState(true); + const isFetchingRef = React.useRef(false); + // Compound keyset cursor: tracks both `created_at` and `id` of the oldest + // event seen so far. Mirrors the SQL `ORDER BY created_at DESC, id DESC` so + // same-second siblings are never skipped at a page boundary. + const cursorRef = React.useRef<{ createdAt: number; id: string } | null>( + null, + ); + + // Check for an owner_p subscription once per identity. + React.useEffect(() => { + if (!enabled || !identityPubkey) { + return; + } + let cancelled = false; + listSaveSubscriptions() + .then((subs) => { + if (cancelled) { + return; + } + const hasSub = subs.some( + (s) => s.scopeType === "owner_p" && s.scopeValue === identityPubkey, + ); + setHasSubscription(hasSub); + if (!hasSub) { + setHasOlderArchived(false); + } + }) + .catch(() => { + if (!cancelled) { + setHasSubscription(false); + setHasOlderArchived(false); + } + }); + return () => { + cancelled = true; + }; + }, [enabled, identityPubkey]); + + const fetchOlderArchived = React.useCallback(async () => { + if ( + !enabled || + !identityPubkey || + !hasSubscription || + isFetchingRef.current || + !hasOlderArchived + ) { + return; + } + + isFetchingRef.current = true; + try { + const before = cursorRef.current ?? undefined; + const events = await readArchivedEvents("owner_p", identityPubkey, { + kinds: [24200], + before: before ?? null, + limit: ARCHIVED_EVENTS_PAGE_SIZE, + }); + + if (events.length > 0) { + // Cursor = the last row in newest-first order = the oldest event on + // this page. Capture both created_at and id to mirror the compound + // sort key so same-second siblings are not skipped on the next page. + const oldestEvent = events[events.length - 1]; + cursorRef.current = { + createdAt: oldestEvent.created_at, + id: oldestEvent.id, + }; + await ingestArchivedObserverEvents(events); + } + + // A short page means the archive is exhausted. + if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) { + setHasOlderArchived(false); + } + } catch (error) { + console.error("[useLoadArchivedObserverEvents] fetch failed:", error); + } finally { + isFetchingRef.current = false; + } + }, [enabled, identityPubkey, hasSubscription, hasOlderArchived]); + + return { fetchOlderArchived, hasOlderArchived }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 92d08ba546..cf21b399bc 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -51,6 +51,8 @@ import { setTranscriptTimestampsEnabled, useTranscriptTimestampsEnabled, } from "@/features/agents/ui/transcriptTimestampPreference"; +import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents"; +import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; type AgentSessionThreadPanelProps = { @@ -95,6 +97,8 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); + const topSentinelRef = React.useRef(null); + const sessionChannelId = channelId ?? channel?.id ?? null; const now = useNow(1000); const { events } = useObserverEvents(isLive, agent.pubkey); @@ -120,6 +124,17 @@ export function AgentSessionThreadPanel({ latestActivityAt === null ? undefined : `Last updated ${new Date(latestActivityAt).toLocaleString()}`; + + const { fetchOlderArchived, hasOlderArchived } = + useLoadArchivedObserverEvents(isLive); + + useLoadOlderOnScroll({ + fetchOlder: fetchOlderArchived, + hasOlderMessages: hasOlderArchived, + isLoading: false, + scrollContainerRef: scrollRef, + sentinelRef: topSentinelRef, + }); const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`; const [rawFeedState, setRawFeedState] = React.useState(() => ({ scopeKey: rawFeedScopeKey, @@ -347,6 +362,7 @@ export function AgentSessionThreadPanel({ className="overflow-y-auto px-3 pb-4" panelPadding > +
{ filter, callback, unsubbed } + + return { + subs, + subscribeLive(filter, callback) { + const key = JSON.stringify(filter); + subs.set(key, { filter, callback, unsubbed: false }); + return Promise.resolve(async () => { + const entry = subs.get(key); + if (entry) entry.unsubbed = true; + }); + }, + push(filter, event) { + const key = JSON.stringify(filter); + const entry = subs.get(key); + if (!entry) throw new Error(`no subscription for filter ${key}`); + entry.callback(event); + }, + activeCount() { + return [...subs.values()].filter((e) => !e.unsubbed).length; + }, + }; +} + +/** + * Fake tauriArchive module — captures invocations for assertion. + * createSaveSubscription is an upsert (matching store.rs ON CONFLICT behaviour). + */ +function makeFakeArchive() { + let subs = []; + const archiveCalls = []; + const listeners = new Set(); + + return { + async listSaveSubscriptions() { + return subs; + }, + async createSaveSubscription(scopeType, scopeValue, kinds) { + // Upsert: update kinds if scope already exists, otherwise append. + const existing = subs.findIndex( + (s) => s.scopeType === scopeType && s.scopeValue === scopeValue, + ); + if (existing >= 0) { + subs = subs.map((s, i) => (i === existing ? { ...s, kinds } : s)); + } else { + subs = [ + ...subs, + { + scopeType, + scopeValue, + kinds, + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]; + } + for (const l of listeners) l(); + }, + async deleteSaveSubscription(scopeType, scopeValue) { + const before = subs.length; + subs = subs.filter( + (s) => !(s.scopeType === scopeType && s.scopeValue === scopeValue), + ); + if (subs.length < before) { + for (const l of listeners) l(); + return true; + } + return false; + }, + async archiveEvents(candidates) { + archiveCalls.push(candidates); + return { persisted: candidates.length, dropped: 0 }; + }, + onSubscriptionChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + archiveCalls, + setSubs(s) { + subs = s; + }, + }; +} + +/** Helper: wait for microtasks/promises to settle */ +function tick() { + return new Promise((r) => setTimeout(r, 0)); +} + +/** Build a manager wired to fakes. */ +function makeManager(relay, archive, extra = {}) { + return new ArchiveSyncManager({ + relayClient: relay, + listSaveSubscriptions: () => archive.listSaveSubscriptions(), + archiveEvents: (c) => archive.archiveEvents(c), + onSubscriptionChange: (l) => archive.onSubscriptionChange(l), + ...extra, + }); +} + +// ── kinds decoding ──────────────────────────────────────────────────────────── + +/** + * Inline the decode logic (matches tauriArchive.ts:decodeRawSubscription) + * so we can test it without the full Tauri env. + */ +function decodeKinds(kindsStr) { + try { + const parsed = JSON.parse(kindsStr); + if ( + Array.isArray(parsed) && + parsed.every((k) => typeof k === "number" && Number.isFinite(k)) + ) { + return parsed; + } + return null; // malformed + } catch { + return null; + } +} + +test("decodeKinds_valid_array_returns_numbers", () => { + assert.deepEqual( + decodeKinds("[9,40002,45001,45003]"), + [9, 40002, 45001, 45003], + ); +}); + +test("decodeKinds_empty_array_is_valid", () => { + assert.deepEqual(decodeKinds("[]"), []); +}); + +test("decodeKinds_non_array_returns_null", () => { + assert.equal(decodeKinds('"string"'), null); + assert.equal(decodeKinds("42"), null); + assert.equal(decodeKinds("null"), null); +}); + +test("decodeKinds_array_with_non_number_returns_null", () => { + assert.equal(decodeKinds('["9","40002"]'), null); + assert.equal(decodeKinds("[9, null, 40002]"), null); +}); + +test("decodeKinds_malformed_json_returns_null", () => { + assert.equal(decodeKinds("not-json"), null); + assert.equal(decodeKinds(""), null); +}); + +// ── Kind preset arrays (derived from constants, not literals) ───────────────── + +/** + * Inline the preset arrays matching LocalArchiveSettingsCard.tsx. + * Values verified against desktop/src/shared/constants/kinds.ts. + */ +const KIND_STREAM_MESSAGE = 9; +const KIND_STREAM_MESSAGE_V2 = 40002; +const KIND_STREAM_MESSAGE_DIFF = 40008; +const KIND_FORUM_POST = 45001; +const KIND_FORUM_COMMENT = 45003; +const KIND_DELETION = 5; +const KIND_REACTION = 7; +const KIND_NIP29_DELETE_EVENT = 9005; +const KIND_STREAM_MESSAGE_EDIT = 40003; +const KIND_SYSTEM_MESSAGE = 40099; +const KIND_HUDDLE_STARTED = 48100; +const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; +const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; +const KIND_HUDDLE_ENDED = 48103; + +// Presets — keep in sync with LocalArchiveSettingsCard.tsx +const PRESET_MESSAGES = [ + KIND_STREAM_MESSAGE, // 9 + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_STREAM_MESSAGE_DIFF, // 40008 + KIND_FORUM_POST, // 45001 + KIND_FORUM_COMMENT, // 45003 +]; + +const PRESET_AUX = [ + KIND_DELETION, // 5 + KIND_REACTION, // 7 + KIND_NIP29_DELETE_EVENT, // 9005 + KIND_STREAM_MESSAGE_EDIT, // 40003 +]; + +const PRESET_ALL = [ + KIND_DELETION, // 5 + KIND_REACTION, // 7 + KIND_NIP29_DELETE_EVENT, // 9005 + KIND_STREAM_MESSAGE, // 9 + 40001, // legacy + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_FORUM_POST, // 45001 (from CHANNEL_MESSAGE_EVENT_KINDS spread) + KIND_FORUM_COMMENT, // 45003 (from CHANNEL_MESSAGE_EVENT_KINDS spread) + KIND_STREAM_MESSAGE_EDIT, // 40003 + KIND_STREAM_MESSAGE_DIFF, // 40008 + KIND_SYSTEM_MESSAGE, // 40099 + KIND_HUDDLE_STARTED, // 48100 + KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 + KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 + KIND_HUDDLE_ENDED, // 48103 +]; + +test("preset_messages_contains_correct_kinds", () => { + // Must include all four CHANNEL_MESSAGE_EVENT_KINDS + diff rows + assert.ok( + PRESET_MESSAGES.includes(9), + "must include kind 9 (stream message)", + ); + assert.ok( + PRESET_MESSAGES.includes(40002), + "must include kind 40002 (stream message v2)", + ); + assert.ok( + PRESET_MESSAGES.includes(45001), + "must include kind 45001 (forum post)", + ); + assert.ok( + PRESET_MESSAGES.includes(45003), + "must include kind 45003 (forum comment)", + ); + assert.ok( + PRESET_MESSAGES.includes(40008), + "must include kind 40008 (diff rows — visible content)", + ); + // Must NOT misclassify edits as messages + assert.ok( + !PRESET_MESSAGES.includes(40003), + "must NOT include kind 40003 (edits — aux, not messages)", + ); +}); + +test("preset_aux_contains_correct_kinds", () => { + assert.ok(PRESET_AUX.includes(5), "must include kind 5 (NIP-09 deletion)"); + assert.ok(PRESET_AUX.includes(7), "must include kind 7 (reaction)"); + assert.ok( + PRESET_AUX.includes(9005), + "must include kind 9005 (Buzz-native deletion)", + ); + assert.ok( + PRESET_AUX.includes(40003), + "must include kind 40003 (stream message edit)", + ); + // Edits are aux, not messages — must not overlap with messages preset (except shared reaction) + assert.ok(!PRESET_AUX.includes(9), "must NOT include kind 9 (message)"); + assert.ok( + !PRESET_AUX.includes(40002), + "must NOT include kind 40002 (message v2)", + ); +}); + +test("preset_all_is_superset_of_messages_and_aux", () => { + for (const k of PRESET_MESSAGES) { + assert.ok( + PRESET_ALL.includes(k), + `PRESET_ALL must include kind ${k} from PRESET_MESSAGES`, + ); + } + for (const k of PRESET_AUX) { + assert.ok( + PRESET_ALL.includes(k), + `PRESET_ALL must include kind ${k} from PRESET_AUX`, + ); + } +}); + +test("preset_messages_exact_saved_kind_array", () => { + assert.deepEqual( + [...PRESET_MESSAGES].sort((a, b) => a - b), + [9, 40002, 40008, 45001, 45003], + ); +}); + +test("preset_aux_exact_saved_kind_array", () => { + assert.deepEqual( + [...PRESET_AUX].sort((a, b) => a - b), + [5, 7, 9005, 40003], + ); +}); + +// ── Subscription-change notifier ───────────────────────────────────────────── + +/** + * Test the notifier contract inline — mirrors what tauriArchive.ts exports. + */ +function makeNotifier() { + const listeners = new Set(); + return { + onSubscriptionChange(l) { + listeners.add(l); + return () => listeners.delete(l); + }, + notify() { + for (const l of listeners) l(); + }, + }; +} + +test("subscription_change_notifier_fires_registered_listener", () => { + const n = makeNotifier(); + let fired = 0; + n.onSubscriptionChange(() => { + fired++; + }); + n.notify(); + assert.equal(fired, 1); +}); + +test("subscription_change_notifier_unregister_stops_firing", () => { + const n = makeNotifier(); + let fired = 0; + const off = n.onSubscriptionChange(() => { + fired++; + }); + off(); + n.notify(); + assert.equal(fired, 0); +}); + +test("subscription_change_notifier_fires_multiple_listeners", () => { + const n = makeNotifier(); + let a = 0; + let b = 0; + n.onSubscriptionChange(() => { + a++; + }); + n.onSubscriptionChange(() => { + b++; + }); + n.notify(); + assert.equal(a, 1); + assert.equal(b, 1); +}); + +// ── ArchiveSyncManager (real class, injected fakes) ─────────────────────────── + +test("manager_opens_one_sub_per_saved_subscription", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 1); + mgr.destroy(); +}); + +test("manager_builds_correct_filter_for_channel_h", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-abc", + kinds: [9, 40002], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + const keys = [...relay.subs.keys()]; + assert.equal(keys.length, 1); + const filter = JSON.parse(keys[0]); + assert.deepEqual(filter["#h"], ["chan-abc"]); + assert.deepEqual(filter.kinds, [9, 40002]); + assert.equal(filter.limit, 0); + mgr.destroy(); +}); + +test("manager_builds_correct_filter_for_owner_p", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "owner_p", + scopeValue: "pubkey123", + kinds: [24200], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + const keys = [...relay.subs.keys()]; + const filter = JSON.parse(keys[0]); + assert.deepEqual(filter["#p"], ["pubkey123"]); + mgr.destroy(); +}); + +test("manager_forwards_events_to_archive_events_on_flush", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + // Use flushBatchSize=1 so flush fires immediately on first event + const mgr = makeManager(relay, archive, { flushBatchSize: 1 }); + await mgr.start(); + await tick(); + + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "ev1", + kind: 9, + pubkey: "pk", + created_at: 1, + content: "hi", + tags: [], + }); + await tick(); + + assert.equal(archive.archiveCalls.length, 1); + assert.equal(archive.archiveCalls[0].length, 1); + assert.equal(archive.archiveCalls[0][0].matchedScope.scopeType, "channel_h"); + assert.equal(archive.archiveCalls[0][0].matchedScope.scopeValue, "chan-1"); + mgr.destroy(); +}); + +test("manager_resubscribes_when_subscription_added", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 0); + + // Simulate create_save_subscription — upserts subs then fires notifier + await archive.createSaveSubscription("channel_h", "chan-new", [9]); + await tick(); + + assert.equal(relay.activeCount(), 1); + mgr.destroy(); +}); + +test("manager_removes_sub_when_subscription_deleted", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 1); + + await archive.deleteSaveSubscription("channel_h", "chan-1"); + await tick(); + + // The sub should be unsubbed now + const entry = [...relay.subs.values()][0]; + assert.equal(entry.unsubbed, true); + mgr.destroy(); +}); + +test("manager_resubscribes_with_new_filter_when_kinds_upserted", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + + // Confirm initial subscription is active with kinds=[9] + assert.equal(relay.activeCount(), 1); + const oldKeys = [...relay.subs.keys()]; + assert.equal(oldKeys.length, 1); + assert.deepEqual(JSON.parse(oldKeys[0]).kinds, [9]); + + // Upsert same scope with different kinds — fake archive replaces kinds + fires notifier + await archive.createSaveSubscription("channel_h", "chan-1", [9, 40002]); + await tick(); + + // Old subscription must be unsubbed + const oldEntry = relay.subs.get(oldKeys[0]); + assert.equal( + oldEntry.unsubbed, + true, + "old kinds=[9] filter must be torn down", + ); + + // New subscription with kinds=[9,40002] must be active + assert.equal(relay.activeCount(), 1, "exactly one active sub after upsert"); + const newKeys = [...relay.subs.keys()].filter((k) => k !== oldKeys[0]); + assert.equal(newKeys.length, 1); + assert.deepEqual( + JSON.parse(newKeys[0]).kinds, + [9, 40002], + "new filter must use updated kinds", + ); + + mgr.destroy(); +}); + +test("manager_retries_subscription_after_subscribeLive_failure", async () => { + // First subscribeLive call rejects; key must NOT be in active so a second + // resubscribeAll (triggered by any config change) retries it. + let callCount = 0; + const relay = { + subs: new Map(), + subscribeLive(filter, callback) { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error("simulated relay failure")); + } + const key = JSON.stringify(filter); + relay.subs.set(key, { filter, callback, unsubbed: false }); + return Promise.resolve(async () => { + const entry = relay.subs.get(key); + if (entry) entry.unsubbed = true; + }); + }, + activeCount() { + return [...relay.subs.values()].filter((e) => !e.unsubbed).length; + }, + }; + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-retry", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); // first subscribeLive rejects — key must be absent + assert.equal(relay.activeCount(), 0, "no active sub after failure"); + + // Trigger resubscribeAll via a config change notification — second call succeeds. + await archive.createSaveSubscription("channel_h", "chan-retry", [9]); + await tick(); + assert.equal(relay.activeCount(), 1, "sub created on retry after failure"); + assert.equal(callCount, 2, "subscribeLive called exactly twice"); + mgr.destroy(); +}); + +test("manager_no_duplicate_sub_when_two_reloads_overlap", async () => { + // Under single-flight serialization: when a second resubscribeAll request + // arrives while the first subscribeLive is still pending, it sets + // reloadPending and returns immediately (no concurrent body runs). The + // first subscribe resolves and activates normally, then the coalescing loop + // runs the second pass — but by then the key is already active, so no + // duplicate subscription is opened. + let resolveFirst; + let callCount = 0; + const disposedCount = { value: 0 }; + + const relay = { + subs: new Map(), + subscribeLive(filter, _callback) { + callCount++; + const key = JSON.stringify(filter); + const disposeHandle = async () => { + disposedCount.value++; + const entry = relay.subs.get(key); + if (entry) entry.unsubbed = true; + }; + if (callCount === 1) { + // Slow first call — resolver exposed so the test can unblock it later. + return new Promise((resolve) => { + resolveFirst = () => { + relay.subs.set(key, { filter, unsubbed: false }); + resolve(disposeHandle); + }; + }); + } + // A second call should not be reached under single-flight. + relay.subs.set(key, { filter, unsubbed: false }); + return Promise.resolve(disposeHandle); + }, + activeCount() { + return [...relay.subs.values()].filter((e) => !e.unsubbed).length; + }, + }; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-dup", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + + // Fire first resubscribeAll (goes async, subscribeLive pending). + const first = mgr.start(); + await tick(); // let it reach the subscribeLive await + + // Fire second resubscribeAll before the first subscribe resolves. + // Under single-flight this sets reloadPending and returns synchronously. + // biome-ignore lint/complexity/useLiteralKeys: intentional private access in test + mgr["resubscribeAll"](); + await tick(); + + // First subscribe still pending — resolve it now. + resolveFirst(); + await first; + await tick(); + + // subscribeLive was called exactly once (single-flight blocked the second). + assert.equal(callCount, 1, "subscribeLive called only once"); + // Exactly one active subscription. + assert.equal(relay.activeCount(), 1, "exactly one active sub"); + // No extra dispose leaked. + assert.equal(disposedCount.value, 0, "no dispose called for a healthy sub"); + + mgr.destroy(); +}); + +test("manager_disposes_stale_sub_when_deleted_before_resolve", async () => { + // A subscription is deleted while its subscribeLive call is in flight. + // Under single-flight: the delete sets reloadPending; the first pass + // finishes (activates K), then the coalescing loop runs a second pass which + // lists [] and tears K down via the normal teardown loop. Net result: dispose + // is called and K is absent from active. + let resolveSubscribe; + let disposeCalled = false; + + const relay = { + subscribeLive(_filter, _callback) { + return new Promise((resolve) => { + resolveSubscribe = () => + resolve(async () => { + disposeCalled = true; + }); + }); + }, + }; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-stale", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + + // Start: subscribeLive is pending (first doResubscribe pass). + const started = mgr.start(); + await tick(); // reaches the subscribeLive await + + // Delete the subscription before the subscribe resolves; sets reloadPending + // so the coalescing loop runs a second pass after the first finishes. + await archive.deleteSaveSubscription("channel_h", "chan-stale"); + await tick(); + + // Now resolve the first subscribe — first pass activates K, then the second + // pass (reloadPending) runs, lists [], and tears K down. + resolveSubscribe(); + await started; // waits for both passes to complete + await tick(); + + // Dispose must have been called (by the teardown loop in the second pass). + assert.equal( + disposeCalled, + true, + "dispose must be called when subscription is removed", + ); + // Key must NOT be in active. + assert.equal( + // biome-ignore lint/complexity/useLiteralKeys: intentional private access in test + mgr["active"].size, + 0, + "active must be empty — deleted key must not remain", + ); + + mgr.destroy(); +}); + +test("manager_handles_out_of_order_list_resolution", async () => { + // Regression test for the defect Thufir found at pass-3: a stale + // listSaveSubscriptions result from an older reload can overwrite the + // wantedKeys published by a newer reload that already knows K was deleted. + // + // Setup: reload A starts and its list resolves LATE (K present); meanwhile + // K is deleted and reload B completes with an empty list. Then A's stale + // list (with K) resolves last. + // + // Under single-flight serialization, A and B cannot interleave — B is + // queued as reloadPending and only runs after A's full pass completes. So + // when A's late list resolves it sees [K] and subscribes K; then B runs, + // lists [], and tears K down. The stale-overwrite defect is structurally + // impossible. + let resolveAList; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-ooo", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + + // Intercept listSaveSubscriptions: first call is slow (returns a manually + // resolvable promise with a snapshot captured at call time); subsequent + // calls return the current state immediately. + let listCallCount = 0; + const fakeList = () => { + listCallCount++; + if (listCallCount === 1) { + // Capture the snapshot NOW (K is still present at this point). + const snapshot = archive.listSaveSubscriptions(); + // Return a promise we control — the caller decides when to resolve it. + return new Promise((resolve) => { + resolveAList = () => resolve(snapshot); + }); + } + return archive.listSaveSubscriptions(); + }; + + const relay = makeFakeRelayClient(); + const mgr = new ArchiveSyncManager({ + relayClient: relay, + listSaveSubscriptions: fakeList, + archiveEvents: (c) => archive.archiveEvents(c), + onSubscriptionChange: (l) => archive.onSubscriptionChange(l), + }); + + // Start: first list call is pending (reload A in flight). + const started = mgr.start(); + await tick(); // A is suspended at listSaveSubscriptions + + // Delete K — this notifies the listener, which sets reloadPending (B queued). + await archive.deleteSaveSubscription("channel_h", "chan-ooo"); + await tick(); + + // Now resolve A's stale list result (K is present in the snapshot A captured). + resolveAList(); + await started; // waits for A's full pass + B's coalescing pass + await tick(); + + // K must NOT be active after both passes complete. A's pass subscribed it, + // B's pass tore it down. + assert.equal( + relay.activeCount(), + 0, + "K must not be active after delete-then-list-resolve", + ); +}); + +test("manager_flushes_buffer_on_destroy", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive, { + flushBatchSize: 100, + flushIdleMs: 10000, + }); + await mgr.start(); + await tick(); + + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "ev1", + kind: 9, + pubkey: "pk", + created_at: 1, + content: "hi", + tags: [], + }); + relay.push(filter, { + id: "ev2", + kind: 9, + pubkey: "pk", + created_at: 2, + content: "yo", + tags: [], + }); + // Buffer holds 2 events — flushBatchSize not reached yet + assert.equal(archive.archiveCalls.length, 0); + + mgr.destroy(); // should flush on destroy + await tick(); + assert.equal(archive.archiveCalls.length, 1); + assert.equal(archive.archiveCalls[0].length, 2); +}); diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts new file mode 100644 index 0000000000..aadfcb0c7b --- /dev/null +++ b/desktop/src/features/local-archive/archiveSyncManager.ts @@ -0,0 +1,318 @@ +import { relayClient as defaultRelayClient } from "@/shared/api/relayClient"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; +import type { RelayEvent } from "@/shared/api/types"; +import { + archiveEvents as defaultArchiveEvents, + listSaveSubscriptions as defaultListSaveSubscriptions, + onSubscriptionChange as defaultOnSubscriptionChange, + type SaveSubscription, + type ScopeType, +} from "@/shared/api/tauriArchive"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FLUSH_BATCH_SIZE = 25; +const FLUSH_IDLE_MS = 2_000; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Dependency injection interface — production uses module singletons; tests inject fakes. */ +export interface ArchiveSyncDeps { + relayClient: { + subscribeLive: ( + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, + ) => Promise<() => Promise>; + }; + listSaveSubscriptions: () => Promise; + archiveEvents: ( + candidates: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }>, + ) => Promise; + onSubscriptionChange: (listener: () => void) => () => void; + flushBatchSize?: number; + flushIdleMs?: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function buildFilter(sub: SaveSubscription): RelaySubscriptionFilter { + const base = { kinds: sub.kinds, limit: 0 } as const; + switch (sub.scopeType) { + case "channel_h": + return { ...base, "#h": [sub.scopeValue] }; + case "owner_p": + return { ...base, "#p": [sub.scopeValue] }; + case "referenced_e": + return { ...base, "#e": [sub.scopeValue] }; + } +} + +/** Stable key encoding scope + kinds — ensures kinds changes trigger resubscribe. */ +function subKey( + scopeType: ScopeType, + scopeValue: string, + kinds: number[], +): string { + const sortedKinds = [...kinds].sort((a, b) => a - b).join(","); + return `${scopeType}:${scopeValue}:${sortedKinds}`; +} + +/** Scope-only key used to find and tear down a stale sub when kinds change. */ +function scopeKey(scopeType: ScopeType, scopeValue: string): string { + return `${scopeType}:${scopeValue}`; +} + +// ── ArchiveSyncManager ──────────────────────────────────────────────────────── + +/** + * Always-on manager that opens one live relay subscription per saved archive + * config and forwards matched events to `archive_events` in debounced batches. + * + * Lifecycle: created once at app-shell mount (see `useArchiveSync`), destroyed + * on workspace switch. Resubscribes automatically when subscriptions change + * via the module-level notifier in `tauriArchive.ts`. + * + * Accepts optional `deps` for testing — production callers pass nothing. + */ +export class ArchiveSyncManager { + private readonly deps: Required< + Omit + >; + private readonly flushBatchSize: number; + private readonly flushIdleMs: number; + + // full subKey (scope+kinds) → unsub + private active = new Map Promise>(); + // Single-flight reload state — exactly one doResubscribe body runs at a time. + // Any reload request arriving while one is running sets reloadPending so that + // the loop runs one additional full pass after the current one finishes. + // This structural guarantee makes concurrent list/subscribe awaits impossible, + // eliminating all interleaving defects without per-boundary guards. + private reloading = false; + private reloadPending = false; + private buffer: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }> = []; + private flushTimer: ReturnType | null = null; + private destroyed = false; + private offSubscriptionChange: (() => void) | null = null; + + constructor(deps?: ArchiveSyncDeps) { + this.deps = { + relayClient: deps?.relayClient ?? defaultRelayClient, + listSaveSubscriptions: + deps?.listSaveSubscriptions ?? defaultListSaveSubscriptions, + archiveEvents: deps?.archiveEvents ?? defaultArchiveEvents, + onSubscriptionChange: + deps?.onSubscriptionChange ?? defaultOnSubscriptionChange, + }; + this.flushBatchSize = deps?.flushBatchSize ?? FLUSH_BATCH_SIZE; + this.flushIdleMs = deps?.flushIdleMs ?? FLUSH_IDLE_MS; + } + + async start(): Promise { + // Register the change listener before the initial load so that any + // subscription change arriving while the first pass is running sets + // reloadPending and gets picked up by the coalescing loop. + this.offSubscriptionChange = this.deps.onSubscriptionChange(() => { + this.resubscribeAll(); + }); + await this.runReloadLoop(); + } + + destroy(): void { + this.destroyed = true; + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.offSubscriptionChange?.(); + this.offSubscriptionChange = null; + // Flush any buffered events before tearing down. + if (this.buffer.length > 0) { + const toFlush = this.buffer.splice(0); + void this.deps.archiveEvents(toFlush).catch((err: unknown) => { + console.warn("[archiveSyncManager] flush on destroy failed:", err); + }); + } + for (const [, unsub] of this.active) { + void unsub(); + } + this.active.clear(); + } + + /** + * Request a reload of all save subscriptions. + * + * If no reload is currently running, starts one immediately via + * `runReloadLoop`. If one is already running, sets `reloadPending` so + * the loop runs exactly one additional full pass after the current pass + * completes — no matter how many changes arrive mid-run, they coalesce + * into a single follow-up pass. + */ + private resubscribeAll(): void { + if (this.reloading) { + this.reloadPending = true; + return; + } + void this.runReloadLoop(); + } + + /** + * Runs `doResubscribe` in a loop, draining any reload requests that + * arrive while a pass is in flight. The loop terminates once no reload + * is pending and the manager has not been destroyed. + * + * Awaited directly by `start()` for the initial load. + */ + private async runReloadLoop(): Promise { + this.reloading = true; + try { + do { + this.reloadPending = false; + await this.doResubscribe(); + } while (this.reloadPending && !this.destroyed); + } finally { + this.reloading = false; + } + } + + /** + * One full reload pass: fetch the current subscription list, tear down + * removed or kind-changed subscriptions, and open new ones. + * + * Only one instance of this method ever runs at a time (enforced by + * `runReloadLoop`). That single-flight guarantee makes concurrent + * list/subscribe awaits structurally impossible. + */ + private async doResubscribe(): Promise { + if (this.destroyed) return; + + let subs: SaveSubscription[]; + try { + subs = await this.deps.listSaveSubscriptions(); + } catch (err) { + console.warn("[archiveSyncManager] list_save_subscriptions failed:", err); + return; + } + + if (this.destroyed) return; + + // Full keys (scope+kinds) for the current subscription list. + const wanted = new Set( + subs.map((s) => subKey(s.scopeType, s.scopeValue, s.kinds)), + ); + + // Tear down subscriptions that are no longer needed or whose kinds changed. + // A stale entry whose scope is still present but with different kinds will + // have a different full key and be absent from `wanted`, so it gets torn + // down here and recreated below with the new filter. + for (const [key, unsub] of this.active) { + if (!wanted.has(key)) { + void unsub(); + this.active.delete(key); + } + } + + // Open new subscriptions for any full key not already active. + // No concurrency guards are needed here: single-flight serialization + // ensures this loop body is the only async path running, so nothing + // can add a duplicate entry to `active` between iterations. + for (const sub of subs) { + if (this.destroyed) return; + + const key = subKey(sub.scopeType, sub.scopeValue, sub.kinds); + if (this.active.has(key)) continue; + + const scopeType = sub.scopeType; + const scopeValue = sub.scopeValue; + const filter = buildFilter(sub); + + let dispose: (() => Promise) | undefined; + try { + dispose = await this.deps.relayClient.subscribeLive( + filter, + (event: RelayEvent) => { + this.enqueue(event, scopeType, scopeValue); + }, + ); + } catch (err) { + console.warn( + `[archiveSyncManager] subscribeLive failed for ${scopeKey(scopeType, scopeValue)}:`, + err, + ); + // Do NOT add key to active — next resubscribeAll will retry. + continue; + } + + // A destroy() call may have arrived while subscribeLive was pending. + // Tear down the just-opened subscription and bail out. + if (this.destroyed) { + void dispose(); + return; + } + + this.active.set(key, dispose); + } + } + + private enqueue( + event: RelayEvent, + scopeType: ScopeType, + scopeValue: string, + ): void { + if (this.destroyed) return; + this.buffer.push({ + rawEventJson: JSON.stringify(event), + matchedScope: { scopeType, scopeValue }, + }); + if (this.buffer.length >= this.flushBatchSize) { + this.flush(); + } else { + this.scheduleFlush(); + } + } + + private scheduleFlush(): void { + if (this.flushTimer !== null) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.flush(); + }, this.flushIdleMs); + } + + private flush(): void { + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.buffer.length === 0) return; + const batch = this.buffer.splice(0); + void this.deps.archiveEvents(batch).catch((err: unknown) => { + console.warn("[archiveSyncManager] archive_events failed:", err); + }); + } +} + +// ── React hook ──────────────────────────────────────────────────────────────── + +import * as React from "react"; + +/** + * Starts the ArchiveSyncManager at app-shell mount and tears it down when the + * component unmounts (workspace switch). No return value needed — the manager + * runs entirely in the background. + */ +export function useArchiveSync(): void { + React.useEffect(() => { + const manager = new ArchiveSyncManager(); + void manager.start(); + return () => { + manager.destroy(); + }; + }, []); +} diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 0000000000..8377f01c4c --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,71 @@ +/** + * Persists whether the user has made an explicit choice about the + * observer-feed archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on workspace switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Returns `true` if the user has already made an explicit choice for this + * identity (either opted in or opted out). When `false`, the seeding path + * may fire. + */ +export function hasExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + return window.localStorage.getItem(storageKey(identityPubkey)) !== null; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitObserverArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (create_save_subscription is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitObserverArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx new file mode 100644 index 0000000000..85d078cd69 --- /dev/null +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -0,0 +1,525 @@ +import { Archive, Trash2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + createSaveSubscription, + deleteSaveSubscription, + listSaveSubscriptions, + type SaveSubscription, + type ScopeType, +} from "@/shared/api/tauriArchive"; +import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { Switch } from "@/shared/ui/switch"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; + +import { + buildSubscriptionRequest, + isGroupFullyChecked, + isGroupIndeterminate, + KIND_GROUPS, + parseCustomKinds, + toggleGroup, + toggleKind, +} from "./localArchiveKinds"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function scopeLabel( + sub: SaveSubscription, + channelNameById: Map, +): string { + if (sub.scopeType === "channel_h") { + return channelNameById.get(sub.scopeValue) ?? sub.scopeValue; + } + if (sub.scopeType === "owner_p") { + return "My agent session frames"; + } + return sub.scopeValue; +} + +function kindSummary(kinds: number[]): string { + if (kinds.length === 0) return "no kinds"; + if (kinds.length <= 4) return kinds.join(", "); + return `${kinds.slice(0, 3).join(", ")} +${kinds.length - 3} more`; +} + +// ── Observer-feed archive section ───────────────────────────────────────────── + +type ObserverSectionProps = { + enabled: boolean; + toggling: boolean; + onToggle: (checked: boolean) => void; +}; + +function ObserverArchiveSection({ + enabled, + toggling, + onToggle, +}: ObserverSectionProps) { + return ( +
+

Agent observer feed

+ + +
+ +

+ Saves kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed + to your pubkey. These are ephemeral — not stored by the relay — so + local archiving is the only way to retain them. +

+
+ +
+
+
+ ); +} + +// ── Add-subscription form ───────────────────────────────────────────────────── + +type KindChecklistProps = { + checkedKinds: ReadonlySet; + onChange: (next: Set) => void; +}; + +function KindChecklist({ checkedKinds, onChange }: KindChecklistProps) { + return ( +
+ {KIND_GROUPS.map((group) => { + const fullyChecked = isGroupFullyChecked(group, checkedKinds); + const indeterminate = isGroupIndeterminate(group, checkedKinds); + return ( +
+ {/* Group header */} +
+ + onChange(toggleGroup(group, checkedKinds)) + } + /> + +
+ {/* Individual kind checkboxes */} +
+ {group.items.map(({ kind, label }) => ( +
+ + onChange(toggleKind(kind, checkedKinds)) + } + /> + +
+ ))} +
+
+ ); + })} +
+ ); +} + +// ── Custom kinds input ──────────────────────────────────────────────────────── + +type CustomKindsInputProps = { + value: string; + onChange: (raw: string) => void; +}; + +function CustomKindsInput({ value, onChange }: CustomKindsInputProps) { + const { invalid } = parseCustomKinds(value); + const hasInvalid = invalid.length > 0; + return ( +
+ + onChange(e.target.value)} + placeholder="e.g. 30023 1337" + type="text" + value={value} + /> +

+ Space- or comma-separated non-negative integers. Kinds already in the + checklist above are ignored. +

+ {hasInvalid && ( +

+ Invalid tokens (ignored):{" "} + {invalid.map((t, i) => ( + + {i > 0 && ", "} + {t} + + ))} +

+ )} +
+ ); +} + +// ── Add-subscription form (Steps 1 + 2) ────────────────────────────────────── + +type AddFormProps = { + channels: Array<{ id: string; name: string }>; + onSaved: () => void; + onCancel: () => void; +}; + +function AddSubscriptionForm({ channels, onSaved, onCancel }: AddFormProps) { + const [selectedChannelId, setSelectedChannelId] = React.useState(""); + const [checkedKinds, setCheckedKinds] = React.useState>( + new Set(), + ); + const [customKindsRaw, setCustomKindsRaw] = React.useState(""); + const [isAdding, setIsAdding] = React.useState(false); + + const { valid: customKinds } = parseCustomKinds(customKindsRaw); + const request = buildSubscriptionRequest( + "channel_h", + selectedChannelId, + checkedKinds, + customKinds, + ); + const canAdd = request !== null; + + const handleAdd = React.useCallback(async () => { + if (request === null) return; + + setIsAdding(true); + try { + await createSaveSubscription( + request.scopeType, + request.scopeValue, + request.kinds, + ); + onSaved(); + toast.success("Archive subscription created."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create subscription.", + ); + } finally { + setIsAdding(false); + } + }, [request, onSaved]); + + const handleCancel = () => { + setSelectedChannelId(""); + setCheckedKinds(new Set()); + setCustomKindsRaw(""); + onCancel(); + }; + + return ( + +
+ {/* Channel picker */} +
+ + +
+ + {/* Event types (per-kind checklist) */} +
+

Event types

+ +
+ + {/* Advanced: custom kinds */} + + +
+ + +
+
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function LocalArchiveSettingsCard() { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const [subs, setSubs] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [deletingKey, setDeletingKey] = React.useState(null); + const [isAddingOpen, setIsAddingOpen] = React.useState(false); + const [observerToggling, setObserverToggling] = React.useState(false); + + const pubkey = identityQuery.data?.pubkey ?? ""; + + const channelNameById = React.useMemo>(() => { + const map = new Map(); + for (const ch of channelsQuery.data ?? []) { + map.set(ch.id, ch.name); + } + return map; + }, [channelsQuery.data]); + + const joinedChannels = React.useMemo( + () => (channelsQuery.data ?? []).filter((ch) => ch.isMember), + [channelsQuery.data], + ); + + const reload = React.useCallback(async () => { + try { + const rows = await listSaveSubscriptions(); + setSubs(rows); + } catch (err) { + console.warn("[LocalArchiveSettingsCard] list failed:", err); + } finally { + setIsLoading(false); + } + }, []); + + React.useEffect(() => { + void reload(); + }, [reload]); + + const handleDelete = React.useCallback( + async (scopeType: ScopeType, scopeValue: string) => { + const key = `${scopeType}:${scopeValue}`; + setDeletingKey(key); + try { + await deleteSaveSubscription(scopeType, scopeValue); + await reload(); + toast.success("Archive subscription removed."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to remove subscription.", + ); + } finally { + setDeletingKey(null); + } + }, + [reload], + ); + + const observerEnabled = subs.some((s) => s.scopeType === "owner_p"); + + const handleObserverToggle = React.useCallback( + async (checked: boolean) => { + if (!pubkey) return; + setObserverToggling(true); + try { + if (checked) { + await createSaveSubscription("owner_p", pubkey, [ + KIND_AGENT_OBSERVER_FRAME, + ]); + setExplicitObserverArchiveChoice(pubkey, true); + toast.success("Observer feed archive enabled."); + } else { + await deleteSaveSubscription("owner_p", pubkey); + setExplicitObserverArchiveChoice(pubkey, false); + toast.success("Observer feed archive disabled."); + } + await reload(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : "Failed to update observer archive.", + ); + } finally { + setObserverToggling(false); + } + }, + [pubkey, reload], + ); + + // Non-observer subscriptions shown in the active-subscriptions list. + const channelSubs = subs.filter((s) => s.scopeType !== "owner_p"); + + return ( +
+ + +
+ {/* Observer-feed archive — dedicated first-class section */} + void handleObserverToggle(checked)} + toggling={observerToggling} + /> + + {/* Channel subscriptions */} +
+

+ Channel subscriptions + {channelSubs.length > 0 ? ` (${channelSubs.length})` : ""} +

+ {isLoading ? ( + +
+ Loading… +
+
+ ) : channelSubs.length === 0 ? ( + +
+ No channel subscriptions yet. Add one below. +
+
+ ) : ( + + {channelSubs.map((sub) => { + const key = `${sub.scopeType}:${sub.scopeValue}`; + return ( +
+ +
+

+ {scopeLabel(sub, channelNameById)} +

+

+ {sub.scopeType} · kinds: {kindSummary(sub.kinds)} +

+
+ +
+ ); + })} +
+ )} +
+ + {/* Add channel subscription */} +
+

Add channel subscription

+ {isAddingOpen ? ( + setIsAddingOpen(false)} + onSaved={() => { + setIsAddingOpen(false); + void reload(); + }} + /> + ) : ( + + +
+

Subscribe to a channel

+

+ Choose a channel and select which event types to archive. +

+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs b/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs new file mode 100644 index 0000000000..9d7f9d8ec0 --- /dev/null +++ b/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs @@ -0,0 +1,369 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + KIND_GROUPS, + buildFinalKinds, + buildSubscriptionRequest, + isGroupFullyChecked, + isGroupIndeterminate, + parseCustomKinds, + toggleGroup, + toggleKind, +} from "./localArchiveKinds.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function groupByLabel(label) { + const g = KIND_GROUPS.find((g) => g.label === label); + if (!g) throw new Error(`group not found: ${label}`); + return g; +} + +function kindsOfGroup(label) { + return groupByLabel(label).items.map((i) => i.kind); +} + +// ── parseCustomKinds ────────────────────────────────────────────────────────── + +test("parseCustomKinds_emptyString_returnsEmpty", () => { + const result = parseCustomKinds(""); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_singleValidKind_returnsIt", () => { + const result = parseCustomKinds("60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_multipleValidKinds_returnsSorted", () => { + const result = parseCustomKinds("30000 10000 20000"); + // buildFinalKinds sorts, parseCustomKinds preserves insertion order + assert.deepEqual(result.valid, [30000, 10000, 20000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_commaAndSpaceSeparated_bothWork", () => { + const result = parseCustomKinds("30000,20000 10000"); + assert.deepEqual(result.valid, [30000, 20000, 10000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_negativeInteger_isInvalid", () => { + const result = parseCustomKinds("-5"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["-5"]); +}); + +test("parseCustomKinds_float_isInvalid", () => { + const result = parseCustomKinds("1.5"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["1.5"]); +}); + +test("parseCustomKinds_kindAbove65535_isInvalid", () => { + // NIP-01 kind range is 0..=65535; values above are rejected to prevent + // the nostr crate's silent u16 truncation from creating unmatchable filters. + const result = parseCustomKinds("65536 99999 89736"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["65536", "99999", "89736"]); +}); + +test("parseCustomKinds_65535_isValid_boundary", () => { + const result = parseCustomKinds("65535"); + assert.deepEqual(result.valid, [65535]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_nonNumericToken_isInvalid", () => { + const result = parseCustomKinds("abc"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["abc"]); +}); + +test("parseCustomKinds_mixedValidAndInvalid_separateCorrectly", () => { + const result = parseCustomKinds("60000 abc 50000 -1"); + assert.deepEqual(result.valid, [60000, 50000]); + assert.deepEqual(result.invalid, ["abc", "-1"]); +}); + +test("parseCustomKinds_duplicateTokens_deduped", () => { + const result = parseCustomKinds("60000 60000 60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_kindAlreadyInGroups_silentlyIgnored", () => { + // Kind 9 is in the "Messages & posts" group — not invalid but not returned + const result = parseCustomKinds("9 60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_zero_isValid", () => { + const result = parseCustomKinds("0"); + assert.deepEqual(result.valid, [0]); + assert.deepEqual(result.invalid, []); +}); + +// ── buildFinalKinds ─────────────────────────────────────────────────────────── + +test("buildFinalKinds_onlyCheckedKinds_sortedDeduped", () => { + const result = buildFinalKinds(new Set([40002, 9, 7]), []); + assert.deepEqual(result, [7, 9, 40002]); +}); + +test("buildFinalKinds_onlyCustomKinds_sortedDeduped", () => { + const result = buildFinalKinds(new Set(), [99999, 88888]); + assert.deepEqual(result, [88888, 99999]); +}); + +test("buildFinalKinds_unionWithDedup_sortedCorrectly", () => { + const result = buildFinalKinds(new Set([9, 40002]), [99999, 9]); + // 9 appears in both — deduplicated to one entry + assert.deepEqual(result, [9, 40002, 99999]); +}); + +test("buildFinalKinds_emptyInputs_returnsEmpty", () => { + const result = buildFinalKinds(new Set(), []); + assert.deepEqual(result, []); +}); + +// ── toggleKind ──────────────────────────────────────────────────────────────── + +test("toggleKind_uncheckedKind_addsIt", () => { + const result = toggleKind(9, new Set([7])); + assert.ok(result.has(9)); + assert.ok(result.has(7)); + assert.equal(result.size, 2); +}); + +test("toggleKind_checkedKind_removesIt", () => { + const result = toggleKind(9, new Set([9, 7])); + assert.ok(!result.has(9)); + assert.ok(result.has(7)); + assert.equal(result.size, 1); +}); + +// ── toggleGroup ─────────────────────────────────────────────────────────────── + +test("toggleGroup_noneChecked_checksAll", () => { + const group = groupByLabel("Messages & posts"); + const result = toggleGroup(group, new Set()); + for (const item of group.items) { + assert.ok( + result.has(item.kind), + `expected kind ${item.kind} to be checked`, + ); + } +}); + +test("toggleGroup_allChecked_uncheksAll", () => { + const group = groupByLabel("Messages & posts"); + const allChecked = new Set(kindsOfGroup("Messages & posts")); + const result = toggleGroup(group, allChecked); + assert.equal(result.size, 0); +}); + +test("toggleGroup_partiallyChecked_checksAll", () => { + const group = groupByLabel("Messages & posts"); + const kinds = kindsOfGroup("Messages & posts"); + // Only one kind checked + const partial = new Set([kinds[0]]); + const result = toggleGroup(group, partial); + for (const item of group.items) { + assert.ok(result.has(item.kind)); + } +}); + +test("toggleGroup_doesNotAffectOtherKinds", () => { + const group = groupByLabel("Messages & posts"); + const outerKind = 99999; + const baseSet = new Set([outerKind]); + const result = toggleGroup(group, baseSet); + assert.ok(result.has(outerKind), "outer kind preserved"); +}); + +// ── isGroupFullyChecked / isGroupIndeterminate ──────────────────────────────── + +test("isGroupFullyChecked_allInGroup_returnsTrue", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const all = new Set(kindsOfGroup("Reactions, edits & deletions")); + assert.equal(isGroupFullyChecked(group, all), true); +}); + +test("isGroupFullyChecked_noneInGroup_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + assert.equal(isGroupFullyChecked(group, new Set()), false); +}); + +test("isGroupIndeterminate_someButNotAll_returnsTrue", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const kinds = kindsOfGroup("Reactions, edits & deletions"); + const partial = new Set([kinds[0]]); + assert.equal(isGroupIndeterminate(group, partial), true); +}); + +test("isGroupIndeterminate_noneChecked_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + assert.equal(isGroupIndeterminate(group, new Set()), false); +}); + +test("isGroupIndeterminate_allChecked_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const all = new Set(kindsOfGroup("Reactions, edits & deletions")); + assert.equal(isGroupIndeterminate(group, all), false); +}); + +// ── Observer source fixed [24200] ───────────────────────────────────────────── + +test("observerSource_fixedKind_is24200", () => { + // The observer source always produces exactly [24200] — verify the constant + // used in localArchiveKinds matches KIND_AGENT_OBSERVER_FRAME. + // We verify this indirectly: kind 24200 must NOT appear in KIND_GROUPS + // (it is not a channel event kind) so it would not be silently filtered + // out of custom input — but more importantly it is never used in the + // channel checklist, keeping the two paths cleanly separated. + const allGroupedKinds = KIND_GROUPS.flatMap((g) => + g.items.map((i) => i.kind), + ); + assert.ok( + !allGroupedKinds.includes(24200), + "kind 24200 must not appear in channel kind groups", + ); +}); + +// ── Empty selection blocks Add ──────────────────────────────────────────────── + +test("buildFinalKinds_emptySelection_producesEmptyArray", () => { + // The Add button is disabled when buildFinalKinds returns length === 0. + const result = buildFinalKinds(new Set(), []); + assert.equal(result.length, 0); +}); + +// ── Group + custom union (deduped / sorted) ─────────────────────────────────── + +test("buildFinalKinds_groupAndCustom_unioned_sorted_deduped", () => { + const messagesKinds = kindsOfGroup("Messages & posts"); + const customKinds = [99999, 88888]; + const result = buildFinalKinds(new Set(messagesKinds), customKinds); + // Must be sorted ascending + const sorted = [...result].sort((a, b) => a - b); + assert.deepEqual(result, sorted); + // Must include all messages kinds + for (const k of messagesKinds) { + assert.ok(result.includes(k)); + } + // Must include custom kinds + assert.ok(result.includes(99999)); + assert.ok(result.includes(88888)); + // No duplicates + assert.equal(result.length, new Set(result).size); +}); + +// ── Single-kind selection ───────────────────────────────────────────────────── + +test("buildFinalKinds_singleKind_returnsSingleElementArray", () => { + const result = buildFinalKinds(new Set([9]), []); + assert.deepEqual(result, [9]); +}); + +// ── Malformed custom input: mixed valid/invalid ─────────────────────────────── + +test("parseCustomKinds_malformedInput_onlyValidReturned", () => { + const { valid, invalid } = parseCustomKinds("hello 60000 1.5 50000 -2"); + assert.deepEqual(valid, [60000, 50000]); + assert.deepEqual(invalid, ["hello", "1.5", "-2"]); +}); + +// ── buildSubscriptionRequest — six acceptance cases ─────────────────────────── + +// Helper: get all kinds in a named group +function kindsOfGroupBySR(label) { + const g = KIND_GROUPS.find((g) => g.label === label); + if (!g) throw new Error(`group not found: ${label}`); + return g.items.map((i) => i.kind); +} + +test("buildSubscriptionRequest_singleKindSelection_exactKindsArray", () => { + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set([9]), + [], + ); + assert.ok(req !== null); + assert.equal(req.scopeType, "channel_h"); + assert.equal(req.scopeValue, "channel-abc"); + assert.deepEqual(req.kinds, [9]); +}); + +test("buildSubscriptionRequest_groupHeaderToggle_allKindsInGroup", () => { + const messagesKinds = kindsOfGroupBySR("Messages & posts"); + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(messagesKinds), + [], + ); + assert.ok(req !== null); + assert.deepEqual( + req.kinds, + [...messagesKinds].sort((a, b) => a - b), + ); +}); + +test("buildSubscriptionRequest_groupPlusCustomKind_unionDedupedSorted", () => { + const messagesKinds = kindsOfGroupBySR("Messages & posts"); + const customKinds = [50000, 50001]; + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(messagesKinds), + customKinds, + ); + assert.ok(req !== null); + // Must be sorted ascending + const sorted = [...req.kinds].sort((a, b) => a - b); + assert.deepEqual(req.kinds, sorted, "kinds must be sorted"); + // Must include all messages kinds and both custom kinds + for (const k of messagesKinds) { + assert.ok(req.kinds.includes(k), `missing messages kind ${k}`); + } + assert.ok(req.kinds.includes(50000)); + assert.ok(req.kinds.includes(50001)); + // No duplicates + assert.equal(req.kinds.length, new Set(req.kinds).size); +}); + +test("buildSubscriptionRequest_emptySelectionChannelH_returnsNull", () => { + // Empty selection blocks Add — buildSubscriptionRequest returns null + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(), + [], + ); + assert.equal(req, null); +}); + +test("buildSubscriptionRequest_observerSourceFixedKind_exactlyKind24200", () => { + const req = buildSubscriptionRequest( + "owner_p", + "pubkey-abc", + new Set(), // checked kinds ignored for owner_p + [], + ); + assert.ok(req !== null); + assert.equal(req.scopeType, "owner_p"); + assert.equal(req.scopeValue, "pubkey-abc"); + assert.deepEqual(req.kinds, [24200]); +}); + +test("buildSubscriptionRequest_emptyPubkeyOwnerP_returnsNull", () => { + // Empty pubkey (identity not yet loaded) blocks owner_p Add + const req = buildSubscriptionRequest("owner_p", "", new Set(), []); + assert.equal(req, null); +}); diff --git a/desktop/src/features/local-archive/ui/localArchiveKinds.ts b/desktop/src/features/local-archive/ui/localArchiveKinds.ts new file mode 100644 index 0000000000..0717559f71 --- /dev/null +++ b/desktop/src/features/local-archive/ui/localArchiveKinds.ts @@ -0,0 +1,252 @@ +import { + CHANNEL_AUX_EVENT_KINDS, + CHANNEL_MESSAGE_EVENT_KINDS, + KIND_AGENT_OBSERVER_FRAME, + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, + KIND_STREAM_MESSAGE_DIFF, + KIND_SYSTEM_MESSAGE, +} from "@/shared/constants/kinds"; + +// ── Kind groups (derived from shared constants — never raw literals) ────────── + +export type KindGroup = { + /** Group label shown as a section header. */ + label: string; + /** Individual kinds in this group, each with a human-readable name. */ + items: ReadonlyArray<{ kind: number; label: string }>; +}; + +/** + * Ordered list of kind groups presented in the Step 2 checklist. + * Every item derives its kind value from a named constant in kinds.ts. + */ +export const KIND_GROUPS: ReadonlyArray = [ + { + label: "Messages & posts", + items: [ + ...CHANNEL_MESSAGE_EVENT_KINDS.map((k) => ({ + kind: k, + label: kindLabel(k), + })), + { kind: KIND_STREAM_MESSAGE_DIFF, label: "Message diffs (kind 40008)" }, + ], + }, + { + label: "Reactions, edits & deletions", + items: CHANNEL_AUX_EVENT_KINDS.map((k) => ({ + kind: k, + label: kindLabel(k), + })), + }, + { + label: "Huddle events", + items: [ + { kind: KIND_HUDDLE_STARTED, label: "Huddle started" }, + { kind: KIND_HUDDLE_PARTICIPANT_JOINED, label: "Participant joined" }, + { kind: KIND_HUDDLE_PARTICIPANT_LEFT, label: "Participant left" }, + { kind: KIND_HUDDLE_ENDED, label: "Huddle ended" }, + ], + }, + { + label: "System messages", + items: [ + { kind: KIND_SYSTEM_MESSAGE, label: "System messages (kind 40099)" }, + ], + }, +] as const; + +/** Human-readable label for a known kind number. */ +function kindLabel(kind: number): string { + switch (kind) { + case 5: + return "Event deletions (kind 5)"; + case 7: + return "Reactions (kind 7)"; + case 9: + return "Stream messages (kind 9)"; + case 9005: + return "Buzz-native deletions (kind 9005)"; + case 40002: + return "Stream messages v2 (kind 40002)"; + case 40003: + return "Message edits (kind 40003)"; + case 45001: + return "Forum posts (kind 45001)"; + case 45003: + return "Forum comments (kind 45003)"; + default: + return `Kind ${kind}`; + } +} + +/** All kinds covered by KIND_GROUPS, as a flat sorted set for dedup checks. */ +const GROUPED_KINDS: ReadonlySet = new Set( + KIND_GROUPS.flatMap((g) => g.items.map((i) => i.kind)), +); + +// ── Custom-kinds parser ─────────────────────────────────────────────────────── + +export type ParsedCustomKinds = { + /** Valid non-negative integer kind numbers not already in KIND_GROUPS. */ + valid: number[]; + /** + * Tokens that failed validation (not a non-negative integer, or duplicate + * of a grouped kind), returned so the UI can show inline feedback. + */ + invalid: string[]; +}; + +/** + * Parse a free-text custom kinds input. + * + * Rules: + * - Split on whitespace and/or commas. + * - Accept non-negative integers in the valid NIP-01 kind range 0..=65535 only + * (no floats, no negatives, no hex, no values > 65535). + * - Reject tokens that duplicate a kind already present in KIND_GROUPS. + * - Deduplicate valid tokens (keep first occurrence). + * - Return both valid numbers and invalid tokens for inline feedback. + */ +export function parseCustomKinds(raw: string): ParsedCustomKinds { + const tokens = raw.split(/[\s,]+/).filter((t) => t.length > 0); + const valid: number[] = []; + const invalid: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + // Must be a non-negative integer with no leading sign, decimal point, etc. + if (!/^\d+$/.test(token)) { + invalid.push(token); + continue; + } + const n = parseInt(token, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + invalid.push(token); + continue; + } + if (GROUPED_KINDS.has(n)) { + // Already available in the checklist — silently ignore (not an error). + continue; + } + if (seen.has(n)) { + continue; // deduplicate + } + seen.add(n); + valid.push(n); + } + + return { valid, invalid }; +} + +// ── Final kinds computation ─────────────────────────────────────────────────── + +/** + * Compute the final sorted, deduped kinds array to pass to + * `createSaveSubscription` for a channel_h subscription. + * + * @param checkedKinds - Set of kind numbers checked via the group checklist. + * @param customKinds - Valid custom kind numbers from `parseCustomKinds`. + */ +export function buildFinalKinds( + checkedKinds: ReadonlySet, + customKinds: ReadonlyArray, +): number[] { + const merged = new Set([...checkedKinds, ...customKinds]); + return [...merged].sort((a, b) => a - b); +} + +// ── Group-toggle helpers ────────────────────────────────────────────────────── + +/** Returns whether all kinds in a group are in `checkedKinds`. */ +export function isGroupFullyChecked( + group: KindGroup, + checkedKinds: ReadonlySet, +): boolean { + return group.items.every((i) => checkedKinds.has(i.kind)); +} + +/** Returns whether some (but not all) kinds in a group are in `checkedKinds`. */ +export function isGroupIndeterminate( + group: KindGroup, + checkedKinds: ReadonlySet, +): boolean { + const some = group.items.some((i) => checkedKinds.has(i.kind)); + const all = group.items.every((i) => checkedKinds.has(i.kind)); + return some && !all; +} + +/** + * Toggle all kinds in a group: if the group is fully checked, uncheck all; + * otherwise check all. Returns the updated set (new object). + */ +export function toggleGroup( + group: KindGroup, + checkedKinds: ReadonlySet, +): Set { + const next = new Set(checkedKinds); + if (isGroupFullyChecked(group, checkedKinds)) { + for (const item of group.items) { + next.delete(item.kind); + } + } else { + for (const item of group.items) { + next.add(item.kind); + } + } + return next; +} + +/** Toggle a single kind in `checkedKinds`. Returns the updated set. */ +export function toggleKind( + kind: number, + checkedKinds: ReadonlySet, +): Set { + const next = new Set(checkedKinds); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; +} + +// ── Subscription request builder ────────────────────────────────────────────── + +export type SubscriptionRequest = { + scopeType: "channel_h" | "owner_p"; + scopeValue: string; + kinds: number[]; +}; + +/** + * Build the final subscription request to pass to `createSaveSubscription`. + * + * - For `owner_p`: kinds is always `[KIND_AGENT_OBSERVER_FRAME]`, scopeValue is + * the identity pubkey. + * - For `channel_h`: kinds is the sorted deduped union of checkedKinds and + * customKinds; scopeValue is the channel id. + * + * Returns `null` when the request would be invalid (empty pubkey for owner_p, + * empty channelId or zero kinds for channel_h). + */ +export function buildSubscriptionRequest( + source: "channel_h" | "owner_p", + scopeValue: string, + checkedKinds: ReadonlySet, + customKinds: ReadonlyArray, +): SubscriptionRequest | null { + if (!scopeValue) return null; + if (source === "owner_p") { + return { + scopeType: "owner_p", + scopeValue, + kinds: [KIND_AGENT_OBSERVER_FRAME], + }; + } + const kinds = buildFinalKinds(checkedKinds, customKinds); + if (kinds.length === 0) return null; + return { scopeType: "channel_h", scopeValue, kinds }; +} diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs new file mode 100644 index 0000000000..506f91c6af --- /dev/null +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -0,0 +1,161 @@ +/** + * Tests for useObserverArchiveSeed seeding logic. + * + * The hook is a thin React wrapper around an async seed routine. We test the + * behaviour by driving the deps-injection interface directly — no React needed. + * This mirrors the pattern used in archiveSyncManager.test.mjs. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// We call the seeding function directly via deps-injection rather than +// mounting the hook, so we import the seed-coordination logic by +// re-implementing a minimal version of `maybeSeed` driven by the same deps +// interface. The test boundary is the async coordination logic, not React. + +// ── Fake deps factory ──────────────────────────────────────────────────────── + +function makeDeps({ + defaultOn = false, + hasExplicitChoice = false, + createShouldFail = false, +} = {}) { + const calls = { createSaveSubscription: [], setExplicitChoice: [] }; + + return { + calls, + observerArchiveDefaultEnabled: async () => defaultOn, + createSaveSubscription: async (scopeType, scopeValue, kinds) => { + if (createShouldFail) throw new Error("create failed"); + calls.createSaveSubscription.push({ scopeType, scopeValue, kinds }); + }, + hasExplicitChoice: (_pubkey) => hasExplicitChoice, + setExplicitChoice: (pubkey, enabled) => { + calls.setExplicitChoice.push({ pubkey, enabled }); + }, + }; +} + +// Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts. +// Kept in sync with the source by structural mirroring (not bytewise copy) — +// change the source and update this accordingly. +async function runSeed(pubkey, deps) { + if (!pubkey) return; + if (deps.hasExplicitChoice(pubkey)) return; + + let defaultOn; + try { + defaultOn = await deps.observerArchiveDefaultEnabled(); + } catch { + return; + } + + if (!defaultOn) return; + + try { + await deps.createSaveSubscription("owner_p", pubkey, [24200]); + } catch { + return; // transient failure — do NOT set explicit choice + } + + deps.setExplicitChoice(pubkey, true); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test("test_internal_build_unset_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 1, + "should call createSaveSubscription once", + ); + const call = deps.calls.createSaveSubscription[0]; + assert.equal(call.scopeType, "owner_p"); + assert.equal(call.scopeValue, "pubkey123"); + assert.deepEqual(call.kinds, [24200]); +}); + +test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.setExplicitChoice.length, + 1, + "should persist explicit choice after successful seed", + ); + assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123"); + assert.equal(deps.calls.setExplicitChoice[0].enabled, true); +}); + +test("test_explicit_choice_set_does_not_reseed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 0, + "should not call createSaveSubscription when explicit choice is already set", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not update explicit choice when already set", + ); +}); + +test("test_oss_build_does_not_seed", async () => { + const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 0, + "should not call createSaveSubscription in OSS build", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not persist explicit choice in OSS build", + ); +}); + +test("test_create_failure_does_not_persist_explicit_choice", async () => { + const deps = makeDeps({ + defaultOn: true, + hasExplicitChoice: false, + createShouldFail: true, + }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 0, + "createSaveSubscription should throw (called internally but errors)", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should NOT persist explicit choice after a transient create failure", + ); +}); + +test("test_empty_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("", deps); + + assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); + +test("test_undefined_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed(undefined, deps); + + assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts new file mode 100644 index 0000000000..6bb9bc326b --- /dev/null +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -0,0 +1,116 @@ +/** + * First-run seeding for observer-feed archive. + * + * When an internal build has `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` set and the + * current identity has not yet made an explicit choice, this hook auto-creates + * an `owner_p` save subscription (kind 24200 observer frames, scoped to the + * current identity's pubkey). + * + * OSS builds return `false` from `observer_archive_default_enabled` → no-op. + * After any explicit user action (seeding or opt-out), the localStorage flag + * prevents re-seeding on subsequent starts. + */ + +import * as React from "react"; + +import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { + createSaveSubscription, + observerArchiveDefaultEnabled, +} from "@/shared/api/tauriArchive"; +import { + hasExplicitObserverArchiveChoice, + setExplicitObserverArchiveChoice, +} from "./observerArchivePreference"; + +/** + * Deps interface for testing. Production callers pass nothing. + */ +export interface ObserverArchiveSeedDeps { + observerArchiveDefaultEnabled: () => Promise; + createSaveSubscription: ( + scopeType: "owner_p", + scopeValue: string, + kinds: number[], + ) => Promise; + hasExplicitChoice: (pubkey: string) => boolean; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; +} + +const defaultDeps: ObserverArchiveSeedDeps = { + observerArchiveDefaultEnabled, + createSaveSubscription, + hasExplicitChoice: hasExplicitObserverArchiveChoice, + setExplicitChoice: setExplicitObserverArchiveChoice, +}; + +/** + * Seed the observer-feed archive subscription for `pubkey` once per identity + * per device on internal builds. + * + * @param pubkey - current identity pubkey. When undefined (identity not yet + * loaded), the hook waits until it becomes available. + * @param deps - optional dep-injection for tests. + */ +export function useObserverArchiveSeed( + pubkey: string | undefined, + deps: ObserverArchiveSeedDeps = defaultDeps, +): void { + React.useEffect(() => { + if (!pubkey) return; + + // Already made an explicit choice for this identity — never re-seed. + if (deps.hasExplicitChoice(pubkey)) return; + + let cancelled = false; + + async function maybeSeed(): Promise { + // pubkey is checked above but TypeScript doesn't narrow across the async + // boundary — re-guard here so the call below is type-safe. + if (!pubkey) return; + + let defaultOn: boolean; + try { + defaultOn = await deps.observerArchiveDefaultEnabled(); + } catch (err) { + console.warn("[useObserverArchiveSeed] flag check failed:", err); + return; + } + + if (cancelled) return; + + if (!defaultOn) { + // OSS build (flag off): don't persist a choice — leave null so seeding + // can still fire if this identity later runs an internal build. + return; + } + + // Internal build + no prior choice → auto-seed. + try { + await deps.createSaveSubscription("owner_p", pubkey, [ + KIND_AGENT_OBSERVER_FRAME, + ]); + } catch (err) { + console.warn( + "[useObserverArchiveSeed] createSaveSubscription failed:", + err, + ); + // Do NOT set the localStorage flag — a transient failure (relay + // unreachable, archive DB not yet initialized) should retry on next + // startup rather than permanently suppress seeding. + return; + } + + if (cancelled) return; + + // Persist the explicit choice so this never re-fires. + deps.setExplicitChoice(pubkey, true); + } + + void maybeSeed(); + + return () => { + cancelled = true; + }; + }, [pubkey, deps]); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 911a3343a9..4104d91050 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { + Archive, BellRing, Bot, Check, @@ -26,6 +27,7 @@ import type { import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound"; import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard"; import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard"; +import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard"; import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, @@ -71,6 +73,7 @@ export type SettingsSection = | "shortcuts" | "relay-members" | "custom-emoji" + | "local-archive" | "mobile" | "updates" | "doctor"; @@ -88,6 +91,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "shortcuts", "relay-members", "custom-emoji", + "local-archive", "mobile", "updates", "doctor", @@ -177,6 +181,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Smile, featureGate: "custom-emoji", }, + { + value: "local-archive", + label: "Local Archive", + icon: Archive, + }, { value: "mobile", label: "Mobile", @@ -632,6 +641,8 @@ export function renderSettingsSection( return ; case "custom-emoji": return ; + case "local-archive": + return ; case "mobile": return ; case "updates": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 4a8de7cb7b..f70b72aa9d 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -54,6 +54,7 @@ const settingsNavGroups: Array<{ "notifications", "shortcuts", "custom-emoji", + "local-archive", ], }, { diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts new file mode 100644 index 0000000000..4940ec4212 --- /dev/null +++ b/desktop/src/shared/api/tauriArchive.ts @@ -0,0 +1,217 @@ +import { invokeTauri } from "./tauri"; + +// ── Wire-shape types (raw Tauri responses) ─────────────────────────────────── + +/** + * `list_save_subscriptions` returns rows directly from SQLite. + * The `kinds` column is stored as a JSON text string (e.g. `"[9,40002]"`), + * NOT a number array — it must be decoded before use. + */ +type RawSaveSubscription = { + identity_pubkey: string; + relay_url: string; + scope_type: string; + scope_value: string; + /** JSON-encoded integer array, e.g. `"[9,40002]"`. */ + kinds: string; + created_at: number; +}; + +// ── Public types ───────────────────────────────────────────────────────────── + +export type ScopeType = "channel_h" | "owner_p" | "referenced_e"; + +export type SaveSubscription = { + identityPubkey: string; + relayUrl: string; + scopeType: ScopeType; + scopeValue: string; + kinds: number[]; + createdAt: number; +}; + +export type ArchiveBatchResult = { + persisted: number; + dropped: number; +}; + +// ── Subscription-change notifier ───────────────────────────────────────────── + +/** + * Module-level notifier for subscription mutations (create/delete). + * The archive sync manager subscribes to this to reload live subscriptions + * without needing manager instances threaded through UI props. + */ +const subscriptionChangeListeners = new Set<() => void>(); + +export function onSubscriptionChange(listener: () => void): () => void { + subscriptionChangeListeners.add(listener); + return () => subscriptionChangeListeners.delete(listener); +} + +function notifySubscriptionChange(): void { + for (const listener of subscriptionChangeListeners) { + listener(); + } +} + +// ── Decoder ────────────────────────────────────────────────────────────────── + +function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription { + let kinds: number[] = []; + try { + const parsed = JSON.parse(raw.kinds); + if ( + Array.isArray(parsed) && + parsed.every((k) => typeof k === "number" && Number.isFinite(k)) + ) { + kinds = parsed as number[]; + } else { + console.warn( + "[tauriArchive] malformed kinds JSON (not number[]):", + raw.kinds, + ); + } + } catch { + console.warn("[tauriArchive] failed to parse kinds JSON:", raw.kinds); + } + return { + identityPubkey: raw.identity_pubkey, + relayUrl: raw.relay_url, + scopeType: raw.scope_type as ScopeType, + scopeValue: raw.scope_value, + kinds, + createdAt: raw.created_at, + }; +} + +// ── API wrappers ───────────────────────────────────────────────────────────── + +/** + * Returns `true` when the build has observer-feed archive default-on. + * + * Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time; + * OSS builds never set it, so this returns `false`. The frontend calls this + * once at startup to decide whether to auto-seed an `owner_p` subscription. + */ +export async function observerArchiveDefaultEnabled(): Promise { + return invokeTauri("observer_archive_default_enabled"); +} + +/** + * Create a save subscription. + * Runs an access probe on the backend (channel membership, event readability). + * `kinds` is sent as a plain number array — Tauri serializes it correctly. + */ +export async function createSaveSubscription( + scopeType: ScopeType, + scopeValue: string, + kinds: number[], +): Promise { + await invokeTauri("create_save_subscription", { + scopeType, + scopeValue, + kinds, + }); + notifySubscriptionChange(); +} + +/** + * List all save subscriptions for the current identity + relay. + * Decodes the raw `kinds` string column into `number[]`. + */ +export async function listSaveSubscriptions(): Promise { + const rows = await invokeTauri( + "list_save_subscriptions", + ); + return rows.map(decodeRawSubscription); +} + +/** + * Delete a save subscription. + * Returns `true` if a row was removed, `false` if it didn't exist. + */ +export async function deleteSaveSubscription( + scopeType: ScopeType, + scopeValue: string, +): Promise { + const removed = await invokeTauri("delete_save_subscription", { + scopeType, + scopeValue, + }); + if (removed) { + notifySubscriptionChange(); + } + return removed; +} + +/** + * Archive a batch of event candidates. + * + * Wire-shape note (verified against Rust source at `archive/mod.rs`): + * - `ArchiveCandidate` has no `#[serde(rename_all)]`, so struct field names + * are verbatim: `raw_event_json`, `matched_scope`. + * - `MatchedScope` field names are also verbatim: `scope_type`, `scope_value`. + * - `ScopeType` enum has `#[serde(rename_all = "snake_case")]`: values are + * `"channel_h"`, `"owner_p"`, `"referenced_e"`. + * - Tauri 2 only camelCases top-level command arg names, NOT nested struct + * fields — so `candidates` is passed as-is, with snake_case field names. + */ +export async function archiveEvents( + candidates: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }>, +): Promise { + return invokeTauri("archive_events", { + candidates: candidates.map((c) => ({ + raw_event_json: c.rawEventJson, + matched_scope: { + scope_type: c.matchedScope.scopeType, + scope_value: c.matchedScope.scopeValue, + }, + })), + }); +} + +/** + * Read a paginated page of archived raw events for a scope. + * + * Returns at most `limit` raw Nostr events (default 50) in newest-first order. + * Use `before` — a compound cursor `{ createdAt, id }` taken from the **last** + * (oldest) row of the previous page — to load the next older page. The + * predicate on the Rust side mirrors `ORDER BY created_at DESC, id DESC` + * exactly, so same-second siblings are never skipped at a page boundary. + * A page shorter than `limit` signals the archive is exhausted. + * + * Pass `kinds: null` (or omit) to admit all archived kinds. An empty array + * `[]` matches nothing — callers that want all events must omit/null `kinds`. + */ +export async function readArchivedEvents( + scopeType: ScopeType, + scopeValue: string, + opts?: { + kinds?: number[] | null; + before?: { createdAt: number; id: string } | null; + limit?: number; + }, +): Promise { + const rawRows = await invokeTauri("read_archived_events", { + scopeType, + scopeValue, + kinds: opts?.kinds ?? null, + beforeCreatedAt: opts?.before?.createdAt ?? null, + beforeId: opts?.before?.id ?? null, + limit: opts?.limit ?? null, + }); + return rawRows + .map((raw) => { + try { + return JSON.parse(raw) as import("@/shared/api/types").RelayEvent; + } catch { + console.warn("[tauriArchive] failed to parse archived raw_json:", raw); + return null; + } + }) + .filter((e): e is import("@/shared/api/types").RelayEvent => e !== null); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5c28f5803d..b2c71b1f28 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -162,6 +162,14 @@ type E2eConfig = { meshReporterPubkey?: string; uploadDelayMs?: number; uploadDescriptors?: RawBlobDescriptor[]; + // Seed rows returned by `list_save_subscriptions`. Each entry uses the same + // snake_case wire shape the Rust backend returns so tests can drive the + // LocalArchiveSettingsCard without a real SQLite database. + saveSubscriptions?: Array<{ + scope_type: string; + scope_value: string; + kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" + }>; }; relayHttpUrl?: string; relayWsUrl?: string; @@ -8758,6 +8766,32 @@ export function maybeInstallE2eTauriMocks() { // Return the no-canvas success shape — content null means no canvas set. return { content: null, updated_at: null, author: null }; } + // ── Local-save archive ────────────────────────────────────────────── + // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests + // without requiring a real SQLite backend. `activeConfig.mock.saveSubscriptions` + // seeds the initial list; create/delete return success shapes so the + // component's reload path behaves correctly. + case "list_save_subscriptions": { + const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY; + return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({ + identity_pubkey: ident.pubkey, + relay_url: DEFAULT_RELAY_WS_URL, + scope_type: s.scope_type, + scope_value: s.scope_value, + kinds: s.kinds, + created_at: Math.floor(Date.now() / 1000), + })); + } + case "create_save_subscription": + // UI calls this then re-fetches via list_save_subscriptions; returning + // null (Rust Ok(())) is sufficient to let the component proceed. + return null; + case "delete_save_subscription": + // Returns true == row removed; mirrors Rust success path. + return true; + case "archive_events": + // Returns the ArchiveBatchResult shape the UI expects. + return { persisted: 0, dropped: 0 }; default: throw new Error(`Unsupported mocked Tauri command: ${command}`); } diff --git a/desktop/tests/e2e/local-archive-screenshots.spec.ts b/desktop/tests/e2e/local-archive-screenshots.spec.ts new file mode 100644 index 0000000000..3cf4a51582 --- /dev/null +++ b/desktop/tests/e2e/local-archive-screenshots.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/local-archive"; + +// Well-known channel IDs from the mock bridge seed (e2eBridge.ts mockChannels). +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +// Navigate to the Local Archive settings panel. +async function openLocalArchiveSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-local-archive").click(); + const card = page.getByTestId("settings-local-archive"); + await expect(card).toBeVisible({ timeout: 10_000 }); + return card; +} + +async function settleAnimations(el: import("@playwright/test").Locator) { + await el.evaluate((node) => + Promise.all(node.getAnimations({ subtree: true }).map((a) => a.finished)), + ); +} + +test.describe("local archive screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error("CONSOLE ERROR:", msg.text().slice(0, 500)); + } + }); + }); + + test("01 — subscriptions list with channel entry and observer toggle on", async ({ + page, + }) => { + await installMockBridge(page, { + saveSubscriptions: [ + { + scope_type: "channel_h", + scope_value: GENERAL_CHANNEL_ID, + kinds: "[9,40002,40003]", + }, + { + scope_type: "owner_p", + scope_value: "deadbeef".repeat(8), + kinds: "[24200]", + }, + ], + }); + + const card = await openLocalArchiveSettings(page); + + // Channel subscription row appears in the channel list. + await expect( + card.getByTestId(`local-archive-sub-channel_h:${GENERAL_CHANNEL_ID}`), + ).toBeVisible({ timeout: 5_000 }); + // owner_p rows are filtered out of the channel list and surfaced via the + // dedicated observer section instead — toggle should be visible and checked. + const observerToggle = card.getByTestId("local-archive-observer-toggle"); + await expect(observerToggle).toBeVisible({ timeout: 5_000 }); + await expect(observerToggle).toBeChecked(); + await settleAnimations(card); + await card.screenshot({ path: `${SHOTS}/01-subscriptions-list.png` }); + }); + + test("02 — Add form opens directly to channel picker", async ({ page }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + // The source-picker step no longer exists — clicking Add opens the channel + // subscription form directly (channel select is the first visible field). + await card.getByTestId("local-archive-open-add").click(); + await expect(card.getByTestId("local-archive-channel-select")).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(card); + await card.screenshot({ path: `${SHOTS}/02-add-form-channel-picker.png` }); + }); + + test("03 — Step 2 kind checklist with indeterminate group header", async ({ + page, + }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + // Navigate to the kind checklist — clicking Add opens the form directly. + await card.getByTestId("local-archive-open-add").click(); + + // Step 2 should be visible now. Select a channel so the form becomes valid. + await card + .getByTestId("local-archive-channel-select") + .selectOption({ value: GENERAL_CHANNEL_ID }); + + // Check a subset of the first group's items to trigger the indeterminate + // state on the group header checkbox. + const firstGroupItems = card + .locator("[data-testid^='local-archive-kind-']") + .first(); + await firstGroupItems.click(); + + await settleAnimations(card); + await card.screenshot({ + path: `${SHOTS}/03-step2-kind-checklist-indeterminate.png`, + }); + }); + + test("04 — custom kinds entry with invalid-token error", async ({ page }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + await card.getByTestId("local-archive-open-add").click(); + + // Type invalid tokens into the custom kinds field. + await card + .getByTestId("local-archive-custom-kinds") + .fill("30023 bad-token 1337 notanumber"); + + // Error message should appear. + await expect( + card.getByTestId("local-archive-custom-kinds-error"), + ).toBeVisible({ timeout: 5_000 }); + await settleAnimations(card); + await card.screenshot({ + path: `${SHOTS}/04-custom-kinds-invalid-error.png`, + }); + }); + + test("05 — observer archive section with toggle", async ({ page }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + // Observer archive is now a dedicated first-class section, not an add-flow + // source. Assert the section, descriptive copy, and toggle are all visible. + const observerSection = card.getByTestId("local-archive-observer-section"); + await expect(observerSection).toBeVisible({ timeout: 5_000 }); + // The section description mentions observer frames and their ephemeral nature. + await expect( + observerSection.getByText(/not stored by the relay/i), + ).toBeVisible(); + await expect( + card.getByTestId("local-archive-observer-toggle"), + ).toBeVisible(); + await settleAnimations(card); + await card.screenshot({ path: `${SHOTS}/05-observer-section.png` }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f6dba86b73..307ed728a7 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -190,6 +190,16 @@ type MockBridgeOptions = { image?: string; filename?: string; }[]; + /** + * Seed rows returned by the mocked `list_save_subscriptions` command. + * Drives the LocalArchiveSettingsCard subscription list view in screenshot + * and UI tests without a real SQLite backend. + */ + saveSubscriptions?: Array<{ + scope_type: string; + scope_value: string; + kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" + }>; }; type BridgeOptions = { From 76806cb1f654650a412b7f0368f495062a9ee224 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 6 Jul 2026 16:05:44 -0600 Subject: [PATCH 012/291] fix(desktop): stop edit-channel dialog hanging on "Saving..." (#1557) Signed-off-by: Wes Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> --- desktop/src/features/channels/hooks.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 91e6aebd62..2c41ff1e74 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -268,8 +268,20 @@ export function useUpdateChannelMutation(channelId: string | null) { ), ); }, - onSettled: async () => { - await invalidateChannelState(queryClient, channelId); + onSettled: () => { + // refetchType "none": onSuccess already cached the relay-returned detail; + // awaiting the full channel-list refetch kept the edit dialog stuck on + // "Saving..." (same failure #1360 fixed for create). + void queryClient.invalidateQueries({ + queryKey: channelsQueryKey, + refetchType: "none", + }); + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: channelDetailQueryKey(channelId), + refetchType: "none", + }); + } }, }); } @@ -285,8 +297,9 @@ export function useSetChannelTopicMutation(channelId: string | null) { return setChannelTopic({ ...input, channelId }); }, - onSettled: async () => { - await invalidateChannelState(queryClient, channelId); + onSettled: () => { + // fire-and-forget: awaiting the channels-list refetch blocks the dialog + void invalidateChannelState(queryClient, channelId); }, }); } @@ -302,8 +315,9 @@ export function useSetChannelPurposeMutation(channelId: string | null) { return setChannelPurpose({ ...input, channelId }); }, - onSettled: async () => { - await invalidateChannelState(queryClient, channelId); + onSettled: () => { + // fire-and-forget: awaiting the channels-list refetch blocks the dialog + void invalidateChannelState(queryClient, channelId); }, }); } From d8355d318853f85ffaf53dc7b5215bc998b460c4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 18:23:38 -0400 Subject: [PATCH 013/291] feat(desktop,buzz-acp): add harness-agnostic config bridge and setup-listener mode (#1411) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-acp/src/lib.rs | 14 + crates/buzz-acp/src/setup_mode.rs | 769 +++++++++++ desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 30 +- .../src-tauri/src/commands/agent_config.rs | 48 + desktop/src-tauri/src/commands/agents.rs | 9 +- desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/config_bridge/goose.rs | 39 +- .../src/managed_agents/config_bridge/mod.rs | 9 + .../managed_agents/config_bridge/reader.rs | 6 +- .../config_bridge/reader_tests.rs | 15 + .../src-tauri/src/managed_agents/env_vars.rs | 4 + desktop/src-tauri/src/managed_agents/mod.rs | 4 + .../src-tauri/src/managed_agents/readiness.rs | 1209 +++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 86 ++ desktop/src-tauri/src/managed_agents/types.rs | 1 + desktop/src/features/agents/hooks.ts | 27 + .../agents/openEditAgentEvent.test.mjs | 105 ++ .../src/features/agents/openEditAgentEvent.ts | 51 + .../features/agents/ui/CreateAgentDialog.tsx | 219 ++- .../features/agents/ui/EditAgentDialog.tsx | 311 ++--- .../features/agents/ui/EnvVarsEditor.test.mjs | 265 ++++ .../src/features/agents/ui/EnvVarsEditor.tsx | 286 +++- .../agents/ui/PersonaAdvancedFields.tsx | 6 + .../src/features/agents/ui/PersonaDialog.tsx | 49 +- .../ui/createAgentLocalModeGate.test.mjs | 412 ++++++ .../ui/editAgentProviderDiscovery.test.mjs | 302 ++++ .../agents/ui/personaDialogPickers.tsx | 149 ++ .../agents/ui/personaProviderModelFields.tsx | 226 +++ .../messages/lib/formatTimelineMessages.ts | 2 + desktop/src/features/messages/types.ts | 7 + .../src/features/messages/ui/MessageRow.tsx | 9 + .../ui/configNudgeAuthPubkey.test.mjs | 130 ++ .../messages/ui/configNudgeAuthPubkey.ts | 31 + .../features/profile/ui/UserProfilePanel.tsx | 19 + desktop/src/shared/api/tauri.ts | 26 + desktop/src/shared/api/types.ts | 1 + desktop/src/shared/lib/computeConfigNudge.ts | 47 + desktop/src/shared/lib/configNudge.test.mjs | 377 +++++ desktop/src/shared/lib/configNudge.ts | 133 ++ .../src/shared/ui/config-nudge-attachment.tsx | 125 ++ desktop/src/shared/ui/markdown.test.mjs | 127 ++ desktop/src/shared/ui/markdown.tsx | 30 +- desktop/src/shared/ui/markdown/types.ts | 8 + desktop/src/testing/e2eBridge.ts | 5 + .../e2e/agent-readiness-screenshots.spec.ts | 298 ++++ desktop/tests/e2e/smoke.spec.ts | 16 + 47 files changed, 5774 insertions(+), 270 deletions(-) create mode 100644 crates/buzz-acp/src/setup_mode.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness.rs create mode 100644 desktop/src/features/agents/openEditAgentEvent.test.mjs create mode 100644 desktop/src/features/agents/openEditAgentEvent.ts create mode 100644 desktop/src/features/agents/ui/EnvVarsEditor.test.mjs create mode 100644 desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs create mode 100644 desktop/src/features/agents/ui/personaProviderModelFields.tsx create mode 100644 desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs create mode 100644 desktop/src/features/messages/ui/configNudgeAuthPubkey.ts create mode 100644 desktop/src/shared/lib/computeConfigNudge.ts create mode 100644 desktop/src/shared/lib/configNudge.test.mjs create mode 100644 desktop/src/shared/lib/configNudge.ts create mode 100644 desktop/src/shared/ui/config-nudge-attachment.tsx create mode 100644 desktop/tests/e2e/agent-readiness-screenshots.spec.ts diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4a7fdc8514..9fa46e5ce3 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8,6 +8,7 @@ mod observer; mod pool; mod queue; mod relay; +mod setup_mode; mod usage; pub use usage::TurnUsage; @@ -1094,6 +1095,19 @@ async fn tokio_main() -> Result<()> { .init(); let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + + // ── Setup-mode early branch ─────────────────────────────────────────────── + // + // When the desktop determines an agent is not ready (missing credentials, + // model, or provider), it spawns buzz-acp with BUZZ_ACP_SETUP_PAYLOAD set. + // We enter the minimal setup-listener path and never start the agent pool. + if let Some(payload) = setup_mode::SetupPayload::from_env() + .map_err(|e| anyhow::anyhow!("setup payload error: {e}"))? + { + tracing::info!("buzz-acp: setup payload present, entering setup-listener mode"); + return setup_mode::run_setup_listener(config, payload).await; + } + tracing::info!("buzz-acp starting: {}", config.summary()); let observer = config diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs new file mode 100644 index 0000000000..9fe51f415f --- /dev/null +++ b/crates/buzz-acp/src/setup_mode.rs @@ -0,0 +1,769 @@ +//! Setup-mode listener for not-ready agents. +//! +//! When the desktop determines an agent is `NotReady` (missing provider, +//! model, or credentials), it spawns `buzz-acp` with a setup-mode payload +//! instead of starting the normal agent pool. This module implements that +//! early-branch path: +//! +//! ```text +//! Config::from_cli() +//! └─ SetupPayload::from_env()? +//! ├─ Some(payload) → run_setup_listener(config, payload) [this module] +//! └─ None → normal pool path (unchanged) +//! ``` +//! +//! # Contract (NON-NEGOTIABLE) +//! +//! * **Desktop is the ONLY readiness source.** `buzz-acp` trusts the payload +//! passed by the desktop and does NOT re-derive readiness. +//! * **Normal startup gains no second readiness path.** The early branch is +//! entered only when `BUZZ_ACP_SETUP_PAYLOAD` is set. +//! * `spawn_key_refusal`-class identity failures are outside this path: no +//! valid key → no safe process to post as the agent. +//! +//! # Nudge mechanics +//! +//! Connect → subscribe → on each matching event: +//! 1. Apply `ignore_self` gate. +//! 2. Apply `author_allowed` gate (same as normal mode) so the nudge goes +//! only to authors the real agent would answer. +//! 3. Require an explicit @mention via `event_mentions_agent`. +//! 4. Apply `filter::match_event` so channel/kind rules still constrain. +//! 5. Build and publish a nudge reply (surface-correct copy). +//! 6. Deduplicate by event-id (reconnect replay must not double-nudge). + +use std::collections::HashSet; + +use anyhow::Result; +use buzz_core::kind::{ + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, + KIND_WORKFLOW_APPROVAL_REQUESTED, +}; +use nostr::EventId; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + author_allowed, + config::Config, + event_mentions_agent, filter, + relay::{HarnessRelay, RelayEventPublisher}, +}; + +// ── Payload ─────────────────────────────────────────────────────────────────── + +/// Env var carrying the JSON-encoded setup payload. +pub(crate) const SETUP_PAYLOAD_ENV_VAR: &str = "BUZZ_ACP_SETUP_PAYLOAD"; + +/// A single missing requirement, surface-discriminated so the nudge copy +/// names exactly what to set and where. +/// +/// This is the Rust counterpart to the desktop's `Requirement` type +/// (`managed_agents/readiness.rs`). Desktop serializes it; `buzz-acp` +/// deserializes and renders copy from it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub(crate) enum RequirementPayload { + /// A normalized dropdown field (provider or model) missing. + NormalizedField { field: String }, + /// An env-backed credential that is absent. + EnvKey { key: String }, + /// A CLI authentication step that must be completed interactively. + CliLogin { + probe_args: Vec, + setup_copy: String, + }, +} + +impl RequirementPayload { + /// Human-readable instruction fragment for the nudge copy. + fn instruction(&self) -> String { + match self { + RequirementPayload::NormalizedField { field } => { + format!("set the **{}** field in Edit Agent dropdowns", field) + } + RequirementPayload::EnvKey { key } => { + format!("set `{}` in Edit Agent → Environment variables", key) + } + RequirementPayload::CliLogin { setup_copy, .. } => setup_copy.clone(), + } + } +} + +/// The full setup payload passed by the desktop when spawning in setup mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SetupPayload { + /// Human-readable agent display name (for the nudge message). + pub agent_name: String, + /// Hex-encoded agent pubkey. Carried so the desktop card can open + /// the Edit Agent dialog for this agent directly from the nudge. + pub agent_pubkey: String, + /// Surface-discriminated list of missing requirements. + pub requirements: Vec, +} + +impl SetupPayload { + /// Read and deserialize the setup payload from the env var, if present. + /// + /// Returns `Ok(None)` when the env var is absent (normal mode). + /// Returns `Err` if the var is present but malformed. + pub(crate) fn from_env() -> Result> { + Self::from_raw_env_value(std::env::var(SETUP_PAYLOAD_ENV_VAR).ok()) + } + + /// Parse an optional raw env-var value into a `SetupPayload`. + /// + /// `None` or empty string → `Ok(None)` (normal mode, no setup payload). + /// Non-empty, valid JSON → `Ok(Some(payload))`. + /// Non-empty, malformed JSON → `Err`. + /// + /// This is the pure core of `from_env()` and is the preferred target for + /// unit tests — it requires no global env mutation and is safe to call + /// concurrently. + pub(crate) fn from_raw_env_value(raw: Option) -> Result> { + let raw = match raw { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let payload = serde_json::from_str::(&raw) + .map_err(|e| anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: {e}"))?; + Ok(Some(payload)) + } + + /// Build the nudge message body from the requirements. + /// + /// The body contains two parts separated by a blank line: + /// 1. Human-readable markdown (unchanged; used by CLI and non-card clients). + /// 2. A fenced `buzz:config-nudge` sentinel block containing the structured + /// payload as JSON. The desktop client parses this block to render a + /// `ConfigNudgeCard`; clients that don't understand it see a code block. + fn nudge_body(&self) -> String { + let prose = if self.requirements.is_empty() { + format!( + "**{}** needs configuration before it can respond. Open Edit Agent to configure it.", + self.agent_name, + ) + } else { + let steps: Vec = self + .requirements + .iter() + .map(|r| format!("- {}", r.instruction())) + .collect(); + + format!( + "**{}** needs configuration before it can respond:\n{}\n\nOpen Edit Agent in the Buzz app to set these.", + self.agent_name, + steps.join("\n"), + ) + }; + + // SAFETY: `self` is fully `Serialize`; this can only fail if the types + // contain non-string map keys, which they don't — panic is acceptable. + let sentinel_json = + serde_json::to_string(self).expect("SetupPayload must be serializable to JSON"); + + format!("{}\n\n```buzz:config-nudge\n{}\n```", prose, sentinel_json) + } +} + +// ── setup listener ──────────────────────────────────────────────────────────── + +/// Run the setup-mode event loop. +/// +/// Connects to the relay, subscribes to channels, and responds to @mentions +/// of this agent with a surface-correct setup nudge. Never starts the agent +/// pool. Reconnects on relay close (mirroring normal mode) so the nudge +/// listener survives transient disconnects; `nudged_event_ids` deduplication +/// guards against replay on reconnect. +pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> Result<()> { + tracing::info!( + agent = %payload.agent_name, + requirements = payload.requirements.len(), + "buzz-acp entering setup mode" + ); + + let pubkey_hex = config.keys.public_key().to_hex(); + + // Parse BUZZ_AUTH_TAG for relay membership / NIP-OA. + let relay_auth_tag: Option = std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok()); + + let startup_watermark: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut relay = + HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + .await + .map_err(|e| anyhow::anyhow!("setup-mode relay connect error: {e}"))?; + + if let Err(e) = relay.set_startup_watermark(startup_watermark).await { + tracing::warn!("setup-mode: failed to set startup watermark: {e}"); + } + + relay + .subscribe_membership_notifications() + .await + .map_err(|e| anyhow::anyhow!("setup-mode membership subscribe error: {e}"))?; + + tracing::info!("setup-mode: connected and subscribed to membership notifications"); + + // Resolve owner for author-gate (same priority as normal mode). + let startup_owner = crate::resolve_agent_owner(&config); + let owner_cache = crate::OwnerCache::new(startup_owner); + + // Discover channels and subscribe (using a "mentions" rule so we get + // notified when someone @-mentions the agent). + let channel_info_map = relay + .discover_channels() + .await + .map_err(|e| anyhow::anyhow!("setup-mode channel discovery error: {e}"))?; + + tracing::info!( + "setup-mode: discovered {} channel(s)", + channel_info_map.len() + ); + + let channel_ids: Vec = channel_info_map.keys().copied().collect(); + + // Build subscription rules: mentions only (setup mode must not react to + // every message in a channel). + let rules = build_setup_subscription_rules(&config); + + let channel_filters = crate::config::resolve_channel_filters(&config, &channel_ids, &rules); + + if channel_filters.is_empty() { + tracing::warn!( + "setup-mode: no channel subscriptions resolved — nudge listener will sit idle" + ); + } + + for (channel_id, filter) in &channel_filters { + if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + tracing::warn!("setup-mode: failed to subscribe to channel {channel_id}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to channel {channel_id}"); + } + } + + let publisher = relay.event_publisher(); + let rest_client = relay.rest_client(); + + // Deduplicate by event-id so reconnect replay cannot double-nudge. + let mut nudged_event_ids: HashSet = HashSet::new(); + + loop { + let Some(buzz_event) = relay.next_event().await else { + tracing::warn!("setup-mode: relay event stream ended — requesting reconnect"); + if let Err(e) = relay.reconnect().await { + tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); + break; + } + continue; + }; + + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + + // Handle membership notifications so we subscribe to new channels + // and drop removed ones — no session/queue drain needed (no pool). + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION + || kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION + { + handle_setup_membership(&mut relay, &buzz_event, &config, &rules, &channel_ids).await; + continue; + } + + // Ignore non-message kinds (relay housekeeping, etc.). + if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED { + continue; + } + + // ignore_self: don't react to our own messages. + if buzz_event.event.pubkey.to_hex() == pubkey_hex { + continue; + } + + // Require an explicit @mention of this agent — setup mode must not + // nudge on every channel event even if subscribe_mode is "all". + if !event_mentions_agent(&buzz_event.event, &pubkey_hex) { + continue; + } + + // Apply the same author gate as normal mode so the nudge only goes + // to authors the real agent would have answered. + let author_hex = buzz_event.event.pubkey.to_hex(); + let allowed = author_allowed( + &config.respond_to, + &config.respond_to_allowlist, + &author_hex, + &owner_cache, + &rest_client, + ) + .await; + + // Apply channel/kind filter rules. + let filter_matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + &rules, + &pubkey_hex, + ) + .await + .is_some(); + + // Pure gate: author gate verdict + event-id dedup. + if !should_nudge_for_event( + buzz_event.event.id, + allowed, + filter_matched, + &mut nudged_event_ids, + ) { + continue; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + &publisher, + &config.keys, + buzz_event.channel_id, + &buzz_event.event, + &payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + } + + Ok(()) +} + +/// Outcome of the pure per-event gate checks in setup mode. +/// +/// Callers compute the async gates (`author_allowed`, `filter::match_event`) +/// up-front, then pass the boolean results here. This helper handles +/// everything that is synchronous and stateful: the author gate verdict +/// and event-id dedup. +/// +/// Returns `true` when the event should produce a nudge. +#[must_use] +pub(crate) fn should_nudge_for_event( + event_id: EventId, + author_allowed: bool, + filter_matched: bool, + nudged_event_ids: &mut HashSet, +) -> bool { + if !author_allowed { + tracing::debug!("setup-mode: event filtered by author gate"); + return false; + } + if !filter_matched { + return false; + } + if !nudged_event_ids.insert(event_id) { + tracing::debug!(%event_id, "setup-mode: skipping already-nudged event"); + return false; + } + true +} + +/// Build the subscription rules used in setup mode. +/// +/// Always uses "mentions" mode: setup mode must not react to every event. +/// Even if the agent's normal config is `subscribe_mode = all`, setup mode +/// enforces explicit @mention filtering (see `event_mentions_agent` call in +/// the loop above). +fn build_setup_subscription_rules(config: &Config) -> Vec { + use crate::config::SubscribeMode; + + let kinds = config + .kinds_override + .clone() + .unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]); + + match &config.subscribe_mode { + // Config mode: load the actual rules, but they will be filtered by + // the explicit event_mentions_agent check in the main loop anyway. + SubscribeMode::Config => match crate::config::load_rules(&config.config_path) { + Ok(rules) => rules, + Err(e) => { + tracing::warn!( + "setup-mode: could not load config rules ({e}); falling back to mentions" + ); + vec![mentions_rule(kinds)] + } + }, + _ => vec![mentions_rule(kinds)], + } +} + +fn mentions_rule(kinds: Vec) -> filter::SubscriptionRule { + use std::sync::{atomic::AtomicU32, Arc}; + filter::SubscriptionRule { + name: "setup-mentions".into(), + channels: filter::ChannelScope::All("all".into()), + kinds, + require_mention: true, + filter: None, + compiled_filter: None, + consecutive_timeouts: Arc::new(AtomicU32::new(0)), + prompt_tag: Some("@mention".into()), + } +} + +/// Handle membership add/remove events in setup mode. +/// +/// Subscribe new channels; unsubscribe removed ones. No queue/session +/// teardown — there is no pool. +async fn handle_setup_membership( + relay: &mut HarnessRelay, + buzz_event: &crate::relay::BuzzEvent, + config: &Config, + rules: &[filter::SubscriptionRule], + _initial_channel_ids: &[Uuid], +) { + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + let channel_id = buzz_event.channel_id; + + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION { + // Subscribe to the newly-joined channel. + let ids = vec![channel_id]; + let filters = crate::config::resolve_channel_filters(config, &ids, rules); + for (cid, filter) in filters { + if let Err(e) = relay.subscribe_channel(cid, filter).await { + tracing::warn!("setup-mode: failed to subscribe to new channel {cid}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to new channel {cid}"); + } + } + } else if kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION { + if let Err(e) = relay.unsubscribe_channel(channel_id).await { + tracing::warn!("setup-mode: failed to unsubscribe from channel {channel_id}: {e}"); + } + } +} + +/// Build and publish a setup nudge reply to the triggering event. +/// +/// Threading: flat reply to the thread root if one exists; otherwise reply +/// to the triggering event itself. P-tags the asker. +async fn publish_setup_nudge( + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + channel_id: Uuid, + triggering_event: &nostr::Event, + payload: &SetupPayload, +) -> Result<()> { + use buzz_sdk::ThreadRef; + + // Parse NIP-10 thread tags to determine reply target. + let thread_tags = crate::queue::parse_thread_tags(triggering_event); + + let thread_ref = if let Some(root_str) = &thread_tags.root_event_id { + // Threaded event: reply flat to the root. + let root_id = nostr::EventId::from_hex(root_str) + .map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?; + Some(ThreadRef { + root_event_id: root_id, + parent_event_id: root_id, + }) + } else { + // Top-level event: reply to the triggering event. + Some(ThreadRef { + root_event_id: triggering_event.id, + parent_event_id: triggering_event.id, + }) + }; + + let body = payload.nudge_body(); + let author_hex = triggering_event.pubkey.to_hex(); + + let event_builder = buzz_sdk::build_message( + channel_id, + &body, + thread_ref.as_ref(), + &[&author_hex], // p-tag the asker + false, + &[], + ) + .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; + + let signed = event_builder + .sign_with_keys(keys) + .map_err(|e| anyhow::anyhow!("failed to sign setup nudge: {e}"))?; + + publisher + .publish_event(signed) + .await + .map_err(|e| anyhow::anyhow!("failed to publish setup nudge: {e}"))?; + + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_payload_from_raw_returns_none_when_absent() { + // None → Ok(None): normal startup, no setup payload. + let result = SetupPayload::from_raw_env_value(None).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_none_on_empty_string() { + // Empty string → Ok(None): treated the same as absent. + let result = SetupPayload::from_raw_env_value(Some(String::new())).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_err_on_malformed_json() { + // Malformed JSON → Err: no global env mutation, safe to run concurrently. + let result = SetupPayload::from_raw_env_value(Some("not-valid-json{{{".into())); + assert!(result.is_err(), "malformed JSON must return Err"); + } + + #[test] + fn setup_payload_deserializes_correctly() { + let json = r#"{ + "agent_name": "Fizz", + "agent_pubkey": "aabbccddeeff0011", + "requirements": [ + {"surface": "normalized_field", "field": "provider"}, + {"surface": "env_key", "key": "ANTHROPIC_API_KEY"} + ] + }"#; + let payload: SetupPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.agent_name, "Fizz"); + assert_eq!(payload.requirements.len(), 2); + } + + #[test] + fn nudge_body_names_all_requirements() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!( + body.contains("provider"), + "nudge body should mention the missing provider field" + ); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "nudge body should mention the missing env key" + ); + assert!(body.contains("Fizz"), "nudge body should name the agent"); + } + + #[test] + fn nudge_body_codex_copy_does_not_mention_openai_api_key() { + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login --with-api-key`".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + !body.contains("OPENAI_API_KEY"), + "codex nudge must not mention OPENAI_API_KEY; got: {body:?}" + ); + assert!( + body.contains("codex login"), + "codex nudge must mention `codex login`; got: {body:?}" + ); + } + + #[test] + fn nudge_body_empty_requirements_falls_back_to_generic() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![], + }; + let body = payload.nudge_body(); + assert!(body.contains("Fizz")); + assert!(body.contains("needs configuration")); + } + + // ── sentinel block tests ─────────────────────────────────────────────────── + + #[test] + fn nudge_body_contains_sentinel_block() { + // The body must end with a ```buzz:config-nudge fence so the desktop + // can detect and strip it before rendering the ConfigNudgeCard. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + body.contains("```buzz:config-nudge\n"), + "body must open the sentinel fence; got: {body:?}" + ); + assert!( + body.ends_with("```"), + "body must close the sentinel fence; got: {body:?}" + ); + } + + #[test] + fn nudge_body_sentinel_round_trips_payload() { + // The JSON inside the sentinel block must deserialize back to an + // equivalent SetupPayload (same agent_name and requirements). + let payload = SetupPayload { + agent_name: "Atlas".to_string(), + agent_pubkey: "ddeeff00".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "model".to_string(), + }, + RequirementPayload::EnvKey { + key: "OPENAI_API_KEY".to_string(), + }, + RequirementPayload::CliLogin { + probe_args: vec!["codex".to_string(), "login".to_string()], + setup_copy: "run `codex login`".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + + // Extract the JSON between the fence markers. + let fence_open = "```buzz:config-nudge\n"; + let fence_close = "\n```"; + let start = body + .rfind(fence_open) + .expect("sentinel open fence not found") + + fence_open.len(); + let end = body[start..] + .rfind(fence_close) + .expect("sentinel close fence not found") + + start; + let json = &body[start..end]; + + let recovered: SetupPayload = + serde_json::from_str(json).expect("sentinel JSON must deserialize"); + assert_eq!(recovered.agent_name, payload.agent_name); + assert_eq!(recovered.agent_pubkey, payload.agent_pubkey); + assert_eq!(recovered.requirements.len(), payload.requirements.len()); + } + + #[test] + fn nudge_body_prose_still_present_with_sentinel() { + // Existing prose checks must pass — the sentinel is APPENDED, not a + // replacement, so all prior `body.contains(...)` invariants hold. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!(body.contains("provider"), "prose must name the field"); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "prose must name the key" + ); + assert!(body.contains("Fizz"), "prose must name the agent"); + // Sentinel is also present. + assert!( + body.contains("```buzz:config-nudge"), + "sentinel must follow" + ); + } + + // ── should_nudge_for_event gate tests ───────────────────────────────────── + // + // These tests exercise the loop-wiring for the two safety-critical guards: + // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one + // nudge. They use the extracted `should_nudge_for_event` helper, which is + // the exact code the live loop calls. + + fn fake_event_id(byte: u8) -> EventId { + EventId::from_byte_array([byte; 32]) + } + + #[test] + fn test_non_allowlisted_author_returns_no_nudge() { + // author_allowed = false → should return false regardless of other args. + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xAA); + + let result = should_nudge_for_event( + event_id, false, // author NOT allowed + true, // filter matched — would otherwise nudge + &mut dedup, + ); + + assert!(!result, "non-allowlisted author must not produce a nudge"); + // Dedup set must remain empty — no phantom insertion for blocked author. + assert!( + dedup.is_empty(), + "dedup set must not record event for blocked author" + ); + } + + #[test] + fn test_same_event_id_twice_nudges_exactly_once() { + // The first call with a given event-id should return true; the second + // call with the identical id must return false (replay dedup). + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xBB); + + let first = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!(first, "first occurrence must be accepted"); + + // Simulate reconnect replay: same event arrives again. + let second = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!( + !second, + "replay of the same event-id must be rejected (dedup)" + ); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 813c150615..75de302e2e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", + "**/agent-readiness-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b410a1ce38..f7890909d2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -93,7 +93,19 @@ const overrides = new Map([ // PGID resolution helper + PID-recycling safety guard added for orphan sweep. // activity-feed threads avatar_url into build_managed_agent_summary for the // assistant-bubble pinned snapshot. - ["src-tauri/src/managed_agents/runtime.rs", 2150], + // +1 for agent_pubkey field in setup payload (config-nudge card wire). + ["src-tauri/src/managed_agents/runtime.rs", 2208], + // config-bridge setup-payload env-boundary fix adds readiness wiring in + // spawn_agent_child; load-bearing security fix, queued to split. + ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], + // config-bridge-aware requirements: goose_requirements + injection tests + // (4 new tests in goose_file_config_tests module) + test-determinism fixes + // for the 3 existing goose tests that previously read real disk config. + // New file in this PR; queued to split. + // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. + // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). + // +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix). + ["src-tauri/src/managed_agents/readiness.rs", 1215], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -105,7 +117,16 @@ const overrides = new Map([ // contract (replies-only, root excluded — the query keys on root_event_id, // which root rows lack). Documentation accuracy, not code growth. // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. - ["src/shared/api/tauri.ts", 1349], + // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. + // +26 lines from PRs landing on main between prior rebase and this rebase. + ["src/shared/api/tauri.ts", 1375], + // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog + // shows required markers and credential amber rows (parity with + // CreateAgentDialog). +23 lines of gate wiring. Queued to split. + // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds + // ~16 lines. Queued to split. + ["src/features/agents/ui/PersonaDialog.tsx", 1032], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); @@ -136,6 +157,8 @@ const overrides = new Map([ // +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView // props restored after 826d735fe removal (UserProfilePanel.tsx still needs them). ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], + // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). + ["src/features/profile/ui/UserProfilePanel.tsx", 1014], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at @@ -154,7 +177,8 @@ const overrides = new Map([ ["src/features/channels/readState/readStateManager.ts", 1030], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2119], + // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). + ["src/shared/ui/markdown.tsx", 2152], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // permission-outcome (fix #1381 regression): pendingPermissions state map, diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 1667b9412b..59547764db 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -1,9 +1,11 @@ +use serde::Serialize; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ config_bridge::{ + read_goose_file_config, reader::read_config_surface, types::{ AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, @@ -16,6 +18,24 @@ use crate::{ }, }; +/// Subset of the goose file config exposed to the frontend for gate evaluation. +/// +/// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. +/// The gate uses this to know which requirements are already satisfied in the +/// harness config file, so it can show "Set in goose config" rather than +/// surfacing a false missing-key marker. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeFileConfigSubset { + /// Provider set in the harness config file, if any. + pub provider: Option, + /// Model set in the harness config file, if any. + pub model: Option, + /// Flat credential env keys found in the harness config file's `extra` map + /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + pub satisfied_env_keys: Vec, +} + /// Resolve the config surface with persona values applied. /// /// The pipeline: resolve the linked persona's prompt/model/provider, inject @@ -136,6 +156,34 @@ fn retag_persona_default(field: &mut Option) { } } +/// Get the file-layer config for a runtime — used by the Create/Edit/Persona +/// dialogs to know which requirements are already satisfied in the harness +/// config file (e.g. `~/.config/goose/config.yaml`), so they can show +/// "Set in goose config" instead of surfacing a false required-field marker. +/// +/// Returns `null` when the runtime has no config file or it cannot be parsed. +/// Currently only "goose" is supported; other runtimes return `null`. +#[tauri::command] +pub fn get_runtime_file_config(runtime_id: String) -> Option { + match runtime_id.as_str() { + "goose" => { + let cfg = read_goose_file_config()?; + let satisfied_env_keys = cfg + .extra + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, _)| k) + .collect(); + Some(RuntimeFileConfigSubset { + provider: cfg.provider, + model: cfg.model, + satisfied_env_keys, + }) + } + _ => None, + } +} + /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 21ee1a1461..475df6e7f7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -795,7 +795,14 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string) }), - provider: snapshot_provider, + provider: snapshot_provider.or_else(|| { + input + .provider + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }), persona_source_version: snapshot_source_version, mcp_toolsets: input .mcp_toolsets diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 00084a6531..9c1f7c2972 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -527,6 +527,7 @@ pub fn run() { get_agent_models, discover_agent_models, get_agent_config_surface, + get_runtime_file_config, put_agent_session_config, mesh_availability, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index 4f835d1775..914acf3f59 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -53,15 +53,21 @@ fn parse_goose_config(yaml_str: &str) -> Option { if let Some(ref ap) = active_provider { extra.insert("active_provider".to_string(), ap.clone()); } - if let Some(host) = yaml_string(&map, "DATABRICKS_HOST") - .or_else(|| nested.as_ref().and_then(|n| n.host.clone())) - { + // Flat DATABRICKS_HOST key — always canonicalize to the literal env-key name + // so `file_key_present("DATABRICKS_HOST")` works regardless of active_provider. + // This is Will's typical config: flat key, no explicit active_provider. + if let Some(flat_host) = yaml_string(&map, "DATABRICKS_HOST") { + extra.insert("DATABRICKS_HOST".to_string(), flat_host); + } else if let Some(nested_host) = nested.as_ref().and_then(|n| n.host.clone()) { + // Nested providers..host — canonicalize to DATABRICKS_HOST when + // the active provider is a databricks variant; otherwise store verbatim + // (future provider types may have a different canonical key). let host_key = match active_provider.as_deref() { Some("databricks_v2") | Some("databricks") => "DATABRICKS_HOST".to_string(), Some(p) => format!("{p}.host"), None => "provider.host".to_string(), }; - extra.insert(host_key, host); + extra.insert(host_key, nested_host); } Some(RuntimeFileConfig { @@ -267,6 +273,31 @@ GOOSE_TELEMETRY_ENABLED: false "#; let cfg = parse_goose_config(yaml).unwrap(); assert_eq!(cfg.provider.as_deref(), Some("databricks")); + // The flat DATABRICKS_HOST key must be canonical in extra so that + // `file_key_present("DATABRICKS_HOST")` returns true. This is Will's + // typical config — flat key, no active_provider. + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://block-lakehouse-production.cloud.databricks.com/"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' in extra" + ); + } + + #[test] + fn goose_provider_databricks_flat_host_no_active_provider() { + // GOOSE_PROVIDER=databricks + flat DATABRICKS_HOST, no active_provider. + // extra["DATABRICKS_HOST"] must be canonical. + let yaml = r#" +GOOSE_PROVIDER: databricks +DATABRICKS_HOST: https://dbc.example.com +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks")); + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://dbc.example.com"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' even with GOOSE_PROVIDER set" + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index 654ae4857d..f8b045fc72 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -7,3 +7,12 @@ mod schema_walker; pub(crate) mod types; pub(crate) use types::*; + +/// Read the goose harness config file (`~/.config/goose/config.yaml`). +/// +/// Used by readiness evaluation to silence requirements that are already +/// satisfied in the file config layer — the harness reads this file at startup +/// so env vars we would otherwise require are not needed from Buzz. +pub(crate) fn read_goose_file_config() -> Option { + goose::read_config_file() +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 1c23bc6e31..778f32dabf 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -360,7 +360,11 @@ fn build_provider_field( (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + let (value, origin, overridden_value, overridden_origin) = match resolve_with_override(tiers) { + Some(resolved) => resolved, + None if is_required => (None, ConfigOrigin::EnvVar, None, None), + None => return None, + }; let write_via = if let Some(env_key) = provider_env_var { ConfigWriteMechanism::RespawnWithEnvVar { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index b35623d6a5..cfb7f29c16 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -659,3 +659,18 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { "thinking_effort must not appear in advanced when normalized" ); } + +#[test] +fn missing_required_provider_still_returns_dropdown_field() { + let provider = build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, true) + .expect("required provider field should be surfaced even when empty"); + + assert_eq!(provider.value, None); + assert_eq!(provider.origin, ConfigOrigin::EnvVar); + assert!(provider.is_required); +} + +#[test] +fn missing_optional_provider_stays_hidden() { + assert!(build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, false).is_none()); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 4383086c10..57ed8739bd 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -94,6 +94,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 68ab8f5a61..06d8a8bc71 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod persona_events; mod personas; #[cfg(windows)] mod process_lifecycle; +pub(crate) mod readiness; #[cfg(feature = "mesh-llm")] mod relay_mesh; mod repos; @@ -32,6 +33,9 @@ pub use persona_card::*; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; +pub(crate) use readiness::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, +}; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; pub use repos::{ diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs new file mode 100644 index 0000000000..ca987d24ea --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -0,0 +1,1209 @@ +//! Agent readiness evaluation. +//! +//! # Overview +//! +//! Before spawning a managed agent (or before deciding whether to enter +//! setup-mode nudge), the desktop must know whether the agent has every +//! piece of configuration it will need to start successfully. This module +//! provides: +//! +//! * [`EffectiveAgentEnv`] — the resolved environment a spawn would actually +//! see: baked build defaults (floor) → runtime metadata env vars → merged +//! user env_vars (last-wins) → reserved-key filtered. A separate +//! `config_file` tier tracks fields the harness reads from its config file +//! rather than the process env. +//! * [`resolve_effective_agent_env`] — assembles an `EffectiveAgentEnv` from +//! a record + personas + runtime catalog; no `AppHandle` dependency so it +//! is fully unit-testable. +//! * [`Requirement`] / [`RequirementSurface`] — structured predicates that +//! carry enough surface-discrimination for the UI to route each gap to the +//! right affordance (dropdown field vs env-var row vs CLI login step). +//! * [`AgentReadiness`] / [`agent_readiness`] — evaluates the effective env +//! against the requirements for the resolved runtime and returns `Ready` or +//! `NotReady(Vec)`. +//! +//! ## Env-assembly precedence (mirrors `spawn_agent_child`) +//! +//! 1. Baked build defaults (`baked_build_env()`) — injected first so the +//! layers above can override them. +//! 2. Runtime metadata env vars (`runtime_metadata_env_vars`) — provider / +//! model env keys derived from the record's `model`/`provider` fields and +//! the runtime's `model_env_var`/`provider_env_var`. +//! 3. Merged user env (`merged_user_env`) — the record's `env_vars` after +//! reserved-key and malformed-key filtering. Last-wins on collision. +//! +//! The config-file tier (Goose `~/.config/goose/config.yaml`) is tracked +//! separately because it is not part of the process env — the harness reads +//! it at startup. We do not evaluate it here; it is exposed for future +//! UI display only. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::managed_agents::{ + agent_env::baked_build_env, + config_bridge::read_goose_file_config, + discovery::{known_acp_runtime, resolve_command, KnownAcpRuntime}, + effective_agent_command, + env_vars::merged_user_env, + types::{ManagedAgentRecord, PersonaRecord}, +}; + +// ── EffectiveAgentEnv ───────────────────────────────────────────────────────── + +/// The resolved environment that a spawn of `record` would actually receive. +/// +/// Assembled from: baked build defaults (floor) → runtime metadata env vars +/// → merged user env_vars (last-wins) → reserved-key filtered. +/// +/// `config_file_path` is the harness config file path (if any) — not part of +/// the process env but relevant for display and future write-back dispatch. +/// `effective_command` is the resolved harness binary name (e.g. `"buzz-agent"`, +/// `"goose"`) after persona and override resolution. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveAgentEnv { + /// The process-env map the spawned harness would receive. + pub env: BTreeMap, + /// Harness config file path, if any (e.g. `~/.config/goose/config.yaml`). + pub config_file_path: Option<&'static str>, + /// The resolved harness binary name (e.g. `"buzz-agent"`, `"goose"`). + pub effective_command: String, +} + +/// Assemble the effective agent env from a record, personas, and optional +/// known-runtime metadata — without an `AppHandle` so it is unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record (model/provider/env_vars/…) +/// * `personas` — all current persona records (for persona-backed resolution) +/// * `runtime` — the `KnownAcpRuntime` for the effective command, if any +pub(crate) fn resolve_effective_agent_env( + record: &ManagedAgentRecord, + personas: &[PersonaRecord], + runtime: Option<&KnownAcpRuntime>, +) -> EffectiveAgentEnv { + let effective_command = effective_agent_command( + record.persona_id.as_deref(), + personas, + record.agent_command_override.as_deref(), + ); + + // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). + let mut env = baked_build_env(); + + // Layer 2: runtime metadata env vars (model / provider keys derived from + // the record's structured fields). + let effective_model = record.model.as_deref(); + let effective_provider = record.provider.as_deref(); + if let Some(rt) = runtime { + for (key, value) in super::runtime::runtime_metadata_env_vars( + rt.model_env_var, + rt.provider_env_var, + rt.provider_locked, + effective_model, + effective_provider, + ) { + env.insert(key.to_string(), value.to_string()); + } + } + + // Layer 3: merged user env (agent env_vars after reserved/malformed-key + // filtering; last-wins on collision). + let user_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + env.extend(user_env); + + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command, + } +} + +// ── Requirement types ───────────────────────────────────────────────────────── + +/// A single missing piece of configuration, tagged with the UI surface that +/// owns it so the UI can route each gap to the right affordance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub enum Requirement { + /// A normalized dropdown field (provider or model) that is missing. + /// Routes to the provider/model dropdown in the Edit Agent dialog. + NormalizedField { + /// Camel-case field name matching `NormalizedConfig` ("provider", "model"). + field: String, + }, + /// An env-backed credential that is absent from the effective env. + /// Routes to the env-var row editor in the Edit Agent dialog. + EnvKey { + /// The env var key name (e.g. `"ANTHROPIC_API_KEY"`). + key: String, + }, + /// A CLI authentication step that must be completed interactively. + /// Routes to a setup instruction panel in the Edit Agent dialog. + CliLogin { + /// Arguments for the login-status probe (e.g. `["claude", "auth", "status"]`). + probe_args: Vec, + /// Human-readable instruction for completing the login + /// (e.g. `"run \`codex login --with-api-key\`"`). + setup_copy: String, + }, +} + +impl Requirement { + /// Short label for logging/nudge copy. + pub(crate) fn label(&self) -> String { + match self { + Requirement::NormalizedField { field } => format!("missing {field}"), + Requirement::EnvKey { key } => format!("missing env {key}"), + Requirement::CliLogin { setup_copy, .. } => setup_copy.clone(), + } + } +} + +// ── AgentReadiness ──────────────────────────────────────────────────────────── + +/// Whether a managed agent has all required configuration to start. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AgentReadiness { + /// All required configuration is present — safe to spawn normally. + Ready, + /// One or more requirements are missing. + NotReady { + /// Surface-discriminated list of what is missing. + requirements: Vec, + }, +} + +impl AgentReadiness { + /// Returns `true` if the agent is ready to spawn. + pub(crate) fn is_ready(&self) -> bool { + matches!(self, AgentReadiness::Ready) + } + + /// Returns the missing requirements, or an empty slice if ready. + pub(crate) fn requirements(&self) -> &[Requirement] { + match self { + AgentReadiness::Ready => &[], + AgentReadiness::NotReady { requirements } => requirements, + } + } +} + +// ── agent_readiness ─────────────────────────────────────────────────────────── + +/// Evaluate whether a managed agent has all required configuration to start. +/// +/// Checks the `effective` env surface against the requirements for the +/// resolved runtime: +/// +/// * **buzz-agent / goose**: provider + model are required (both must be +/// present in the effective env or as structured fields). Additionally, +/// provider-specific credentials are required: +/// - `anthropic` → `ANTHROPIC_API_KEY` +/// - `openai` → `OPENAI_COMPAT_API_KEY` +/// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — +/// OAuth PKCE is the fallback) +/// * **claude**: a successful `claude auth status` probe. +/// * **codex**: a successful `codex login status` probe (checks the codex +/// credential store — NOT `OPENAI_API_KEY`). +/// * **unknown / custom command**: always `Ready` (no requirements known). +/// +/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in +/// `buzz-agent/src/config.rs:143` — it is an escape hatch for static tokens +/// but the normal path is OAuth PKCE. We intentionally do NOT mark the +/// token as required to avoid a false NotReady for users on OAuth. +pub(crate) fn agent_readiness(effective: &EffectiveAgentEnv) -> AgentReadiness { + let runtime = known_acp_runtime(&effective.effective_command); + let missing = collect_missing_requirements(effective, runtime); + if missing.is_empty() { + AgentReadiness::Ready + } else { + AgentReadiness::NotReady { + requirements: missing, + } + } +} + +/// Collect all missing requirements for the given effective env + runtime. +fn collect_missing_requirements( + effective: &EffectiveAgentEnv, + runtime: Option<&KnownAcpRuntime>, +) -> Vec { + let Some(rt) = runtime else { + // Unknown/custom command — no requirements to check. + return vec![]; + }; + + match rt.id { + "buzz-agent" => buzz_agent_requirements(effective), + "goose" => { + // Read the file config once at the call site so the inner fn is + // pure and unit-testable by injection. + let file_cfg = read_goose_file_config(); + goose_requirements(effective, file_cfg.as_ref()) + } + "claude" => cli_login_requirements( + &["claude", "auth", "status"], + "complete Claude Code authentication by running the Claude CLI", + ), + "codex" => cli_login_requirements( + &["codex", "login", "status"], + "run `codex login --with-api-key`", + ), + _ => vec![], + } +} + +/// Requirements for buzz-agent (provider + model + provider-specific creds). +fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { + let mut missing = Vec::new(); + + // Provider is required — maps to BUZZ_AGENT_PROVIDER in the effective env. + // An empty string is treated as absent: a key set to "" is not a valid + // provider and must not pass the readiness gate. + let provider = effective + .env + .get("BUZZ_AGENT_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if provider.is_none() { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + + // Model is required — maps to BUZZ_AGENT_MODEL in the effective env. + // Same empty-string treatment as provider. + let model = effective + .env + .get("BUZZ_AGENT_MODEL") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if model.is_none() { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + + // Provider-specific credential requirements. + // A key present with an empty value is treated as absent — matching the + // dialog's (envVars[key] ?? "").length === 0 emptiness check. + let env_key_missing = |key: &str| effective.env.get(key).map_or(true, |v| v.is_empty()); + match provider { + Some("anthropic") => { + if env_key_missing("ANTHROPIC_API_KEY") { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + } + Some("openai") => { + if env_key_missing("OPENAI_COMPAT_API_KEY") { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + } + Some("databricks") | Some("databricks_v2") => { + // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional + // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). + if env_key_missing("DATABRICKS_HOST") { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + } + _ => { + // Unknown provider or no provider yet — only the NormalizedField + // requirement above captures this gap. + } + } + + missing +} + +/// Requirements for goose (provider + model + provider-specific creds). +/// +/// Mirrors buzz-agent requirements but uses GOOSE_PROVIDER / GOOSE_MODEL. +/// +/// File-config tier: goose reads `~/.config/goose/config.yaml` at startup. +/// Requirements already satisfied there are silenced — we don't need to +/// require them from Buzz's env layer. The file layer only *silences* +/// requirements; it never injects values into the spawn env. +/// +/// `file_cfg` is injected by the caller (read once at `collect_missing_requirements`) +/// so this function is pure and unit-testable without touching disk. +fn goose_requirements( + effective: &EffectiveAgentEnv, + file_cfg: Option<&crate::managed_agents::config_bridge::RuntimeFileConfig>, +) -> Vec { + let mut missing = Vec::new(); + + // Empty string treated as absent — same as buzz_agent_requirements. + let provider = effective + .env + .get("GOOSE_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + + // Effective provider for credential checking: prefer env layer, then file. + let effective_provider = provider.or_else(|| { + file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + }); + + if provider.is_none() { + // Silenced if the file config provides a provider. + let file_provides_provider = file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_provider { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + } + + let model = effective + .env + .get("GOOSE_MODEL") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if model.is_none() { + // Silenced if the file config provides a model. + let file_provides_model = file_cfg + .as_ref() + .and_then(|c| c.model.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_model { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + } + + // Provider-specific credentials — same empty-string semantics as buzz-agent. + let env_key_missing = |key: &str| effective.env.get(key).map_or(true, |v| v.is_empty()); + // A credential key is also satisfied when the file config's `extra` map + // contains it (e.g. DATABRICKS_HOST set in the goose config file). + let file_key_present = |key: &str| -> bool { + file_cfg + .as_ref() + .map(|c| c.extra.get(key).map_or(false, |v| !v.is_empty())) + .unwrap_or(false) + }; + match effective_provider { + Some("anthropic") => { + if env_key_missing("ANTHROPIC_API_KEY") && !file_key_present("ANTHROPIC_API_KEY") { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + } + Some("openai") => { + if env_key_missing("OPENAI_COMPAT_API_KEY") + && !file_key_present("OPENAI_COMPAT_API_KEY") + { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + } + Some("databricks") | Some("databricks_v2") => { + if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + } + _ => {} + } + + missing +} + +/// Requirements for CLI-login runtimes (claude, codex). +/// +/// We probe the CLI's login-status command synchronously. These probes are +/// fast (<300ms) and the results are memoized by the caller for the session +/// lifetime if desired. +fn cli_login_requirements(probe_args: &[&str], setup_copy: &str) -> Vec { + // Resolve the binary through the full PATH-search + login-shell path so + // the probe works in a packaged macOS DMG where the GUI PATH lacks + // npm/homebrew directories (where `claude` / `codex` typically live). + // + // If the binary genuinely does not exist, stay NotReady — the user needs + // to install it. If it exists but is not on the GUI PATH, resolve_command + // finds it via the login-shell fallback. + let Some(binary_path) = resolve_command(probe_args[0]) else { + // Binary not found → not installed → NotReady. + return vec![Requirement::CliLogin { + probe_args: probe_args.iter().map(|s| s.to_string()).collect(), + setup_copy: setup_copy.to_string(), + }]; + }; + + // Run the probe at the resolved absolute path so the GUI-PATH gap is bypassed. + let logged_in = std::process::Command::new(&binary_path) + .args(&probe_args[1..]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if logged_in { + vec![] + } else { + vec![Requirement::CliLogin { + probe_args: probe_args.iter().map(|s| s.to_string()).collect(), + setup_copy: setup_copy.to_string(), + }] + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::discovery::known_acp_runtime_exact; + + /// Build a minimal `EffectiveAgentEnv` with the given env map and command. + fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + // ── buzz-agent tests ────────────────────────────────────────────────── + + #[test] + fn buzz_agent_missing_provider_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[("BUZZ_AGENT_MODEL", "claude-opus-4-5")]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "missing BUZZ_AGENT_PROVIDER should be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements should include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_missing_openai_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("BUZZ_AGENT_MODEL", "gpt-4o"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_anthropic_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + #[test] + fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + // No token present, no OAuth cache present → still Ready because we + // cannot evaluate OAuth state from the env map alone. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + // NOTE: no DATABRICKS_TOKEN + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "Databricks with HOST+model but no TOKEN should still be Ready (OAuth path)" + ); + } + + #[test] + fn buzz_agent_databricks_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + // NOTE: no DATABRICKS_HOST + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn buzz_agent_databricks_v2_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ( + "BUZZ_AGENT_MODEL", + "databricks/meta-llama-4-maverick-17b-instruct", + ), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + // ── goose tests ─────────────────────────────────────────────────────── + + #[test] + fn goose_missing_provider_returns_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env("goose", env_with(&[("GOOSE_MODEL", "claude-opus-4-5")])); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "missing GOOSE_PROVIDER with no file config must produce requirements" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_with_provider_and_model_and_key_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + // ── empty-string semantics ──────────────────────────────────────────── + // + // A key present with an empty value ("") must be treated as MISSING, to + // match the dialog's (envVars[key] ?? "").length === 0 emptiness check. + + #[test] + fn buzz_agent_empty_string_provider_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", ""), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_PROVIDER must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "provider".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_model_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", ""), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_MODEL must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_anthropic_key_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string ANTHROPIC_API_KEY must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_databricks_host_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string DATABRICKS_HOST must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn goose_empty_string_provider_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[("GOOSE_PROVIDER", ""), ("GOOSE_MODEL", "claude-opus-4-5")]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string GOOSE_PROVIDER must be treated as missing" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_empty_string_anthropic_key_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string ANTHROPIC_API_KEY must be treated as missing (goose)" + ); + assert!( + reqs.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "requirements must include ANTHROPIC_API_KEY; got {reqs:?}" + ); + } + + // ── codex tests ─────────────────────────────────────────────────────── + + #[test] + fn codex_not_ready_copy_does_not_mention_openai_api_key() { + // codex uses its own credential store via `codex login --with-api-key`. + // The nudge copy must NOT say "set OPENAI_API_KEY". + // We test the static not-logged-in path by constructing the requirement + // directly (the binary may or may not be installed in CI). + let reqs = cli_login_requirements( + &["codex", "login", "status"], + "run `codex login --with-api-key`", + ); + // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. + for req in &reqs { + if let Requirement::CliLogin { setup_copy, .. } = req { + assert!( + !setup_copy.contains("OPENAI_API_KEY"), + "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" + ); + assert!( + setup_copy.contains("codex login"), + "codex nudge copy should mention `codex login`; got: {setup_copy:?}" + ); + } + } + } + + // ── cli_login_requirements: resolve_command integration ───────────── + + #[test] + fn cli_login_requirements_missing_binary_is_not_ready() { + // A binary that cannot possibly exist on any system → binary not found + // → resolve_command returns None → function must return CliLogin + // requirement (NotReady), not panic or return Ready. + let reqs = cli_login_requirements( + &["__buzz_nonexistent_binary_abc123__", "status"], + "install the tool first", + ); + assert!( + !reqs.is_empty(), + "missing binary must produce a CliLogin requirement (NotReady)" + ); + assert!( + matches!(reqs[0], Requirement::CliLogin { .. }), + "requirement must be CliLogin; got {:?}", + reqs[0] + ); + } + + #[test] + fn cli_login_requirements_resolvable_binary_runs_probe_at_resolved_path() { + // `true` is a POSIX built-in available on every CI runner and always + // exits 0. Use it as a probe: probe_args = ["true", "--probe-arg"] + // → resolved path probed → exit 0 → logged_in = true → requirements + // is empty (Ready). This exercises the resolve_command fast path + // (binary found on PATH) + the probe-at-resolved-path branch. + // + // `true` is universally resolvable on POSIX/macOS/Linux CI, so we + // assert the ready (empty) outcome directly rather than hedging. + let reqs = cli_login_requirements( + &["true", "--probe-arg"], + "this should not show (true always succeeds)", + ); + assert!( + reqs.is_empty(), + "expected Ready (no requirements) when probe binary resolves and exits 0; \ + got {:?} — `true` is always on PATH on POSIX/macOS/Linux CI so this \ + must be empty", + reqs + ); + } + + // ── custom/unknown command ───────────────────────────────────────────── + + #[test] + fn unknown_command_is_always_ready() { + let env = make_env("my-custom-harness", BTreeMap::new()); + assert!( + agent_readiness(&env).is_ready(), + "unknown/custom command should always be Ready (no requirements)" + ); + } + + // ── AgentReadiness helpers ───────────────────────────────────────────── + + #[test] + fn agent_readiness_ready_has_empty_requirements() { + assert!(AgentReadiness::Ready.requirements().is_empty()); + } + + #[test] + fn agent_readiness_not_ready_exposes_requirements() { + let r = AgentReadiness::NotReady { + requirements: vec![Requirement::EnvKey { + key: "FOO".to_string(), + }], + }; + assert!(!r.is_ready()); + assert_eq!(r.requirements().len(), 1); + } + + // ── Requirement serialization ───────────────────────────────────────── + + #[test] + fn requirement_serializes_with_surface_tag() { + let r = Requirement::NormalizedField { + field: "provider".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "normalized_field"); + assert_eq!(json["field"], "provider"); + } + + #[test] + fn env_key_requirement_serializes_correctly() { + let r = Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "env_key"); + assert_eq!(json["key"], "ANTHROPIC_API_KEY"); + } + + #[test] + fn cli_login_requirement_serializes_correctly() { + let r = Requirement::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login --with-api-key`".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "cli_login"); + assert!(json["probe_args"].is_array()); + assert!(json["setup_copy"].as_str().unwrap().contains("codex login")); + } + + // ── resolve_effective_agent_env ───────────────────────────────────────── + + #[test] + fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { + // A record whose env_vars explicitly set provider/model must win over + // any baked defaults. In OSS test builds the baked map is empty, so + // this test validates the user-env layer is present in the output. + let mut env_vars = BTreeMap::new(); + env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); + env_vars.insert( + "BUZZ_AGENT_MODEL".to_string(), + "claude-opus-4-5".to_string(), + ); + + // Minimal record: only the fields resolve_effective_agent_env reads. + let record = crate::managed_agents::types::ManagedAgentRecord { + pubkey: "test-pubkey".to_string(), + name: "test-agent".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + mcp_toolsets: None, + env_vars, + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + relay_mesh: None, + }; + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime); + + // User env_vars must be present in the output (last-write-wins). + assert_eq!( + effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert_eq!( + effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("claude-opus-4-5") + ); + } +} + +// ── goose file-config–aware requirement tests ───────────────────────────── +// +// These tests call `goose_requirements` directly, injecting a synthetic +// `RuntimeFileConfig` so there is no disk I/O and tests are deterministic. + +#[cfg(test)] +mod goose_file_config_tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::config_bridge::RuntimeFileConfig; + + fn empty_env() -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn databricks_file_config() -> RuntimeFileConfig { + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + RuntimeFileConfig { + provider: Some("databricks_v2".to_string()), + model: Some("goose-claude-4-6-opus".to_string()), + extra, + ..Default::default() + } + } + + #[test] + fn goose_file_config_silences_databricks_host_requirement() { + // File has provider, model, and DATABRICKS_HOST — all requirements silenced. + let env = empty_env(); + let cfg = databricks_file_config(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + result.is_empty(), + "all requirements should be silenced by goose file config; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_env_empty_file_absent_still_not_ready() { + // No env, no file config → provider and model both required. + let env = empty_env(); + let result = goose_requirements(&env, None); + assert!( + result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider must be required when absent from both env and file" + ); + assert!( + result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model must be required when absent from both env and file" + ); + } + + #[test] + fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { + // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not + // in the file's `extra` map — it must still be required. + let cfg = RuntimeFileConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // Provider and model silenced. + assert!( + !result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider silenced by file config" + ); + assert!( + !result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model silenced by file config" + ); + // ANTHROPIC_API_KEY not in file extra → still required. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "ANTHROPIC_API_KEY must remain required when not in file extra" + ); + } + + #[test] + fn goose_env_provider_wins_over_file_provider_for_cred_check() { + // Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2). + // The env provider must win for credential checking. + let env = env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ]); + let cfg = databricks_file_config(); // has provider=databricks_v2 + let result = goose_requirements(&env, Some(&cfg)); + // anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "env provider=anthropic must require ANTHROPIC_API_KEY" + ); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "env provider=anthropic must NOT require DATABRICKS_HOST" + ); + } + + #[test] + fn goose_flat_databricks_host_in_file_config_silences_requirement() { + // Will's typical goose config: flat DATABRICKS_HOST at the top level, + // no active_provider — provider inferred as "databricks". + // The parser must store extra["DATABRICKS_HOST"] = value (canonical key), + // and goose_requirements must then silence the DATABRICKS_HOST requirement. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://block.cloud.databricks.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("goose-claude-4-5".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file). + assert!( + result.is_empty(), + "flat DATABRICKS_HOST in file config must silence all requirements; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_goose_provider_databricks_flat_host_silences_databricks_host() { + // GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST. + // The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("some-model".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "DATABRICKS_HOST must be silenced when canonical key is in file extra" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index de38c3b1bc..dbf3bc2301 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1747,6 +1747,92 @@ pub fn spawn_agent_child( if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } + + // ── Readiness check: set setup-payload if agent is not ready ───────────── + // + // Build the effective env the agent would have at start-time, run the + // readiness predicate, and if anything is missing, serialize the payload + // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup + // and enters the minimal setup-listener mode instead of the agent pool. + // + // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env + // cannot set it, but we also explicitly remove it after writing user env + // to guard against the parent-process environment. We then set it only + // when desktop has computed NotReady — the desktop is the sole readiness + // source and buzz-acp only transports the payload. + // + // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: + // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } + { + use crate::managed_agents::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, + }; + + let effective = resolve_effective_agent_env(record, &personas, runtime_meta); + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written further below in this function; + // ordering relative to that call is NOT what makes this safe — the + // reserved-key strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + if let Some(json) = setup_payload_json { + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + } + } // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an // override. When unset, the buzz-acp harness applies its own default // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e17a923b2c..8439daf2d2 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -327,6 +327,7 @@ pub struct CreateManagedAgentRequest { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, + pub provider: Option, pub mcp_toolsets: Option, /// Environment variables for this agent. Layered on top of persona env. #[serde(default)] diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 37e2e2178d..39b087d5c4 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -16,6 +16,7 @@ import { discoverManagedAgentPrereqs, getAgentConfigSurface, getManagedAgentLog, + getRuntimeFileConfig, installAcpRuntime, listManagedAgents, listRelayAgents, @@ -620,6 +621,32 @@ export function useAgentConfigSurface(pubkey: string | null) { }); } +export const runtimeFileConfigQueryKey = (runtimeId: string) => + ["runtime-file-config", runtimeId] as const; + +/** + * Query the file-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). + * + * Used by Create/Edit/Persona dialogs to know which requirements are already + * satisfied in the harness config file, so they can show "Set in goose config" + * rather than surfacing a false required-field marker. + * + * Enabled only when `runtimeId` is non-empty and the dialog is open. + */ +export function useRuntimeFileConfigQuery( + runtimeId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: runtimeFileConfigQueryKey(runtimeId), + queryFn: () => getRuntimeFileConfig(runtimeId), + enabled: (options?.enabled ?? true) && runtimeId.trim().length > 0, + staleTime: 30_000, + // File config rarely changes mid-session; no aggressive refetch needed. + refetchInterval: false, + }); +} + export function useTeamsQuery() { return useQuery({ queryKey: teamsQueryKey, diff --git a/desktop/src/features/agents/openEditAgentEvent.test.mjs b/desktop/src/features/agents/openEditAgentEvent.test.mjs new file mode 100644 index 0000000000..a0c203380e --- /dev/null +++ b/desktop/src/features/agents/openEditAgentEvent.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Provide a minimal window shim for the module's DOM event calls. +// Node 25 has CustomEvent but not window.addEventListener / dispatchEvent. +const _eventTarget = new EventTarget(); +globalThis.window = { + addEventListener: _eventTarget.addEventListener.bind(_eventTarget), + removeEventListener: _eventTarget.removeEventListener.bind(_eventTarget), + dispatchEvent: _eventTarget.dispatchEvent.bind(_eventTarget), +}; + +import { + consumePendingOpenEditAgent, + requestOpenEditAgent, + subscribeOpenEditAgent, +} from "./openEditAgentEvent.ts"; + +// ── consumePendingOpenEditAgent ─────────────────────────────────────────────── + +test("consumePendingOpenEditAgent_noPriorRequest_returnsFalse", () => { + // No request has been made for this pubkey — consume must return false. + assert.equal( + consumePendingOpenEditAgent("aabbccddeeff0011"), + false, + "consume with no pending request must return false", + ); +}); + +test("consumePendingOpenEditAgent_afterRequest_returnsTrue", () => { + const pubkey = "ddeeff00112233aa"; + requestOpenEditAgent(pubkey); + assert.equal( + consumePendingOpenEditAgent(pubkey), + true, + "consume immediately after request must return true", + ); +}); + +test("consumePendingOpenEditAgent_afterRequest_clearsState", () => { + const pubkey = "112233aabbccddee"; + requestOpenEditAgent(pubkey); + consumePendingOpenEditAgent(pubkey); + assert.equal( + consumePendingOpenEditAgent(pubkey), + false, + "second consume must return false — state cleared by first", + ); +}); + +test("consumePendingOpenEditAgent_wrongPubkey_returnsFalse", () => { + const pubkey = "aabbcc001122ddef"; + requestOpenEditAgent(pubkey); + const result = consumePendingOpenEditAgent("ffffffffffffffff"); + consumePendingOpenEditAgent(pubkey); // clean up + assert.equal( + result, + false, + "consume with non-matching pubkey must return false", + ); +}); + +// ── subscribeOpenEditAgent — live subscriber clears pending ─────────────────── + +test("subscribeOpenEditAgent_afterLiveHandle_consumeReturnsFalse", () => { + // Core Fix 2 invariant: after a live subscriber handles the event, + // consumePendingOpenEditAgent must return false (pending cleared). + const pubkey = "66778899aabbccdd"; + let handlerCalled = false; + + const unsubscribe = subscribeOpenEditAgent(pubkey, () => { + handlerCalled = true; + }); + + requestOpenEditAgent(pubkey); // fires synchronously via dispatchEvent + + unsubscribe(); + + assert.equal(handlerCalled, true, "handler must have been called"); + assert.equal( + consumePendingOpenEditAgent(pubkey), + false, + "pending must be cleared by live subscriber — not by consume", + ); +}); + +test("subscribeOpenEditAgent_differentPubkey_doesNotHandle", () => { + const subscribedPubkey = "aabbccdd11223344"; + const requestedPubkey = "ffffffffffffffff00000000"; + let handlerCalled = false; + + const unsubscribe = subscribeOpenEditAgent(subscribedPubkey, () => { + handlerCalled = true; + }); + + requestOpenEditAgent(requestedPubkey); + consumePendingOpenEditAgent(requestedPubkey); // clean up + unsubscribe(); + + assert.equal( + handlerCalled, + false, + "handler must not fire for a different pubkey", + ); +}); diff --git a/desktop/src/features/agents/openEditAgentEvent.ts b/desktop/src/features/agents/openEditAgentEvent.ts new file mode 100644 index 0000000000..8220b46d94 --- /dev/null +++ b/desktop/src/features/agents/openEditAgentEvent.ts @@ -0,0 +1,51 @@ +/** + * Global event for requesting that the Edit Agent dialog open for a specific + * agent pubkey. + * + * Pattern mirrors `openCreateAgentEvent.ts`. The card (or any caller outside + * a UserProfilePanel instance) dispatches the event; UserProfilePanel + * subscribes and opens the dialog when its current pubkey matches. + * + * Callers typically also call `openProfilePanel(pubkey)` from ProfilePanel- + * Context to ensure the panel is visible before the event fires. + */ + +const OPEN_EDIT_AGENT_EVENT = "buzz:open-edit-agent"; + +let pendingEditAgentPubkey: string | null = null; + +export function requestOpenEditAgent(pubkey: string) { + pendingEditAgentPubkey = pubkey; + window.dispatchEvent( + new CustomEvent(OPEN_EDIT_AGENT_EVENT, { detail: pubkey }), + ); +} + +export function consumePendingOpenEditAgent(pubkey: string): boolean { + if ( + pendingEditAgentPubkey !== null && + pendingEditAgentPubkey.toLowerCase() === pubkey.toLowerCase() + ) { + pendingEditAgentPubkey = null; + return true; + } + return false; +} + +export function subscribeOpenEditAgent( + pubkey: string, + handler: () => void, +): () => void { + function handleEvent(event: Event) { + const detail = (event as CustomEvent).detail; + if (detail.toLowerCase() === pubkey.toLowerCase()) { + pendingEditAgentPubkey = null; + handler(); + } + } + + window.addEventListener(OPEN_EDIT_AGENT_EVENT, handleEvent); + return () => { + window.removeEventListener(OPEN_EDIT_AGENT_EVENT, handleEvent); + }; +} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index de792e35dc..3f31d97156 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -7,6 +7,7 @@ import { useBackendProvidersQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, + useRuntimeFileConfigQuery, } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; import type { @@ -39,6 +40,20 @@ import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgent import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; import type { MeshServeTarget } from "@/shared/api/tauriMesh"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; +import { + requiredCredentialEnvKeys, + runtimeSupportsLlmProviderSelection, + shouldClearKnownModelForSelectionScope, + getProviderApiKeyEnvVar, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, +} from "./personaDialogPickers"; +import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; +import { + AgentModelField, + AgentProviderField, +} from "./personaProviderModelFields"; +import { usePersonaModelDiscovery } from "./usePersonaModelDiscovery"; export function CreateAgentDialog({ open, @@ -88,6 +103,15 @@ export function CreateAgentDialog({ React.useState(null); const [probeError, setProbeError] = React.useState(null); + // Local-mode LLM provider and model — structured state for the credential + // gate and live discovery. Only rendered when the runtime supports provider + // selection (buzz-agent / goose). + const [provider, setProvider] = React.useState(""); + const [model, setModel] = React.useState(""); + const [isCustomProviderEditing, setIsCustomProviderEditing] = + React.useState(false); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + // When `useMesh` is on, the agent runs buzz-agent against a member's // shared compute. The ACP runtime + backend selectors are hidden; runtime // fields are driven by `mesh_agent_preset(meshModelId)` and the submit @@ -131,6 +155,80 @@ export function CreateAgentDialog({ prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; + // Local mode: provider/model field visibility and live discovery. + // Create has no inherit checkbox — selectedRuntimeId IS the prospective runtime. + const llmProviderFieldVisible = + !isProviderMode && + !useMesh && + runtimeSupportsLlmProviderSelection(selectedRuntimeId); + const providerForDiscovery = llmProviderFieldVisible ? provider : ""; + + const { + discoveredModelOptions, + modelDiscoveryLoading, + modelDiscoveryStatus, + } = usePersonaModelDiscovery({ + envVars, + isCustomProviderEditing, + modelFieldVisible: llmProviderFieldVisible, + open, + provider: providerForDiscovery, + selectedRuntime: selectedRuntime ?? undefined, + }); + + // Full required credential key list for EnvVarsEditor amber locked rows — + // includes already-satisfied keys, not just missing ones. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + selectedRuntimeId, + { enabled: open }, + ); + // Credential keys satisfied by the runtime file config (e.g. goose config.yaml). + // These are shown as "Set in goose config" rows rather than amber required rows. + const fileSatisfiedEnvKeys = React.useMemo(() => { + if (!runtimeFileConfig) return [] as string[]; + const allKeys = requiredCredentialEnvKeys( + selectedRuntimeId, + runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", + ); + return allKeys.filter( + (key) => + (envVars[key] ?? "").length === 0 && + runtimeFileConfig.satisfiedEnvKeys.includes(key), + ); + }, [runtimeFileConfig, selectedRuntimeId, provider, envVars]); + + const requiredEnvKeys = React.useMemo( + () => + requiredCredentialEnvKeys( + selectedRuntimeId, + runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", + ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), + [selectedRuntimeId, provider, fileSatisfiedEnvKeys], + ); + + // Clear model when provider scope changes, mirroring EditAgentDialog. + React.useEffect(() => { + if ( + !open || + isCustomModelEditing || + !shouldClearKnownModelForSelectionScope({ + model, + provider: providerForDiscovery, + runtime: selectedRuntimeId, + }) + ) { + return; + } + setModel(""); + setIsCustomModelEditing(false); + }, [ + isCustomModelEditing, + model, + open, + providerForDiscovery, + selectedRuntimeId, + ]); + React.useEffect(() => { if (hasSyncedProviderSelection || providersQuery.isLoading) { return; @@ -249,6 +347,10 @@ export function CreateAgentDialog({ setProviderConfig({}); setProbedProvider(null); setProbeError(null); + setProvider(""); + setModel(""); + setIsCustomProviderEditing(false); + setIsCustomModelEditing(false); setUseMesh(false); setMeshModelId(""); setMeshClientError(null); @@ -266,24 +368,40 @@ export function CreateAgentDialog({ } function handleProviderChange(nextProviderId: string) { + const previousRuntimeId = selectedRuntimeId; setSelectedRuntimeId(nextProviderId); if (nextProviderId === "custom") { setShowAdvanced(true); - return; + } else { + const runtime = runtimes.find( + (candidate) => candidate.id === nextProviderId, + ); + if (runtime) { + setLastRuntime(nextProviderId); + setAgentCommand(runtime.command); + setAgentArgs(runtime.defaultArgs.join(",")); + setMcpCommand(runtime.mcpCommand ?? ""); + } } - const provider = runtimes.find( - (candidate) => candidate.id === nextProviderId, - ); - if (!provider) { - return; + // Clear model when switching to a runtime with a different model scope, + // and clear provider/model when switching away from provider-selection runtimes. + if ( + shouldClearModelForRuntimeChange(previousRuntimeId, nextProviderId) || + shouldClearKnownModelForSelectionScope({ + model, + provider, + runtime: nextProviderId, + }) + ) { + setModel(""); + setIsCustomModelEditing(false); + } + if (!runtimeSupportsLlmProviderSelection(nextProviderId)) { + setProvider(""); + setIsCustomProviderEditing(false); } - - setLastRuntime(nextProviderId); - setAgentCommand(provider.command); - setAgentArgs(provider.defaultArgs.join(",")); - setMcpCommand(provider.mcpCommand ?? ""); } function handleRunOnChange(value: string) { @@ -293,6 +411,52 @@ export function CreateAgentDialog({ setProbeError(null); } + // Provider dropdown handler for local-mode provider field — mirrors Edit. + function handleProviderDropdownChange(nextValue: string) { + if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) { + const previousApiKey = getProviderApiKeyEnvVar(provider); + if (previousApiKey) { + setEnvVars((current) => { + const next = { ...current }; + delete next[previousApiKey]; + return next; + }); + } + setIsCustomProviderEditing(true); + setProvider(""); + return; + } + + const nextProvider = + nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; + + // Clear the old API key when switching providers. + const previousApiKey = getProviderApiKeyEnvVar(provider); + const nextApiKey = getProviderApiKeyEnvVar(nextProvider); + if (previousApiKey && previousApiKey !== nextApiKey) { + setEnvVars((current) => { + const next = { ...current }; + delete next[previousApiKey]; + return next; + }); + } + + setIsCustomProviderEditing(false); + setProvider(nextProvider); + + if ( + !isCustomModelEditing && + shouldClearKnownModelForSelectionScope({ + model, + provider: nextProvider, + runtime: selectedRuntimeId, + }) + ) { + setModel(""); + setIsCustomModelEditing(false); + } + } + // Check provider config required fields are filled. const providerConfigComplete = React.useMemo(() => { if (!isProviderMode || !probedProvider?.config_schema) return true; @@ -407,7 +571,10 @@ export function CreateAgentDialog({ : undefined, systemPrompt: systemPrompt.trim() || undefined, envVars, - model: useMesh ? meshModelId.trim() || undefined : undefined, + model: useMesh + ? meshModelId.trim() || undefined + : model.trim() || undefined, + provider: !useMesh ? provider.trim() || undefined : undefined, relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined, spawnAfterCreate, // Relay-mesh agents need a freshly selected serve target to start; @@ -546,6 +713,32 @@ export function CreateAgentDialog({ /> ) : null} + {/* Local mode: structured provider + model fields for credential + gate and live discovery. Only when the runtime supports it. */} + {llmProviderFieldVisible ? ( + + ) : null} + {llmProviderFieldVisible ? ( + + ) : null} + { @@ -641,8 +834,10 @@ export function CreateAgentDialog({ diff --git a/desktop/src/features/agents/ui/EditAgentDialog.tsx b/desktop/src/features/agents/ui/EditAgentDialog.tsx index 7aa5b10731..e4155a5be1 100644 --- a/desktop/src/features/agents/ui/EditAgentDialog.tsx +++ b/desktop/src/features/agents/ui/EditAgentDialog.tsx @@ -2,11 +2,12 @@ import * as React from "react"; import { useAcpRuntimesQuery, + useAgentConfigSurface, usePersonasQuery, + useRuntimeFileConfigQuery, useUpdateManagedAgentMutation, } from "@/features/agents/hooks"; import type { - AcpRuntimeCatalogEntry, ManagedAgent, RespondToMode, UpdateManagedAgentInput, @@ -19,36 +20,31 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; import { - AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, - CUSTOM_MODEL_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, formatRuntimeOptionLabel, - getModelSelectValue, - getPersonaProviderOptions, getProviderApiKeyEnvVar, - hasPersonaModelOption, + isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, runtimeSupportsLlmProviderSelection, + requiredCredentialEnvKeys, shouldClearKnownModelForSelectionScope, sortPersonaRuntimes, type PersonaDropdownOption, - type PersonaModelOption, } from "./personaDialogPickers"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; -import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; +import { + AgentModelField, + AgentProviderField, +} from "./personaProviderModelFields"; import { CreateAgentBasicsFields, CreateAgentRuntimeFields, } from "./CreateAgentDialogSections"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; -import { - MODEL_DISCOVERY_LOADING_VALUE, - usePersonaModelDiscovery, -} from "./usePersonaModelDiscovery"; +import { usePersonaModelDiscovery } from "./usePersonaModelDiscovery"; export function EditAgentDialog({ agent, @@ -63,6 +59,7 @@ export function EditAgentDialog({ }) { const updateMutation = useUpdateManagedAgentMutation(); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); + const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; const [name, setName] = React.useState(agent.name); @@ -229,6 +226,90 @@ export function EditAgentDialog({ ); const providerForDiscovery = llmProviderFieldVisible ? provider : ""; + const normalizedConfig = configSurfaceQuery.data?.normalized; + const modelRequired = isMissingRequiredDropdownField( + normalizedConfig?.model, + model, + ); + const providerRequired = isMissingRequiredDropdownField( + normalizedConfig?.provider, + provider, + ); + + // The runtime id that will actually be active after submit. When inheriting, + // resolve from agent.agentCommand (the persona's runtime) using the same + // dual-match used at submit time — command path first, then id fallback for + // catalog entries where the adapter binary is missing (command:null). This + // single prospective id feeds BOTH the block-save gate (requiredEnvKeys) and + // the submit path so they never disagree on which runtime is being saved. + const prospectiveRuntimeId = React.useMemo(() => { + if (!inheritHarness) { + return selectedRuntime?.id ?? selectedRuntimeId; + } + return ( + runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) + ?.id ?? + runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ?? + "" + ); + }, [ + inheritHarness, + runtimes, + agent.agentCommand, + selectedRuntime?.id, + selectedRuntimeId, + ]); + + // Provider used for required-key validation — keyed off the PROSPECTIVE + // runtime, not the current dropdown. When the user transitions from a + // CLI-login pin (claude) to inherit a buzz-agent/goose persona, the current + // dropdown would suppress provider to "" (llmProviderFieldVisible=false), + // making requiredCredentialEnvKeys return [] and falsely unblocking the save. + // Using prospectiveRuntimeId here ensures the gate checks the credential + // requirements of the runtime that will actually be saved. + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + // Required credential env keys for the PROSPECTIVE post-submit runtime. + // Using the prospective id (not the current dropdown) ensures the gate + // validates what will actually be saved — in particular, on the inherit + // transition (claude→buzz-agent or buzz-agent→claude) the gate reflects + // the inherited runtime's requirements, not the old pin's. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + prospectiveRuntimeId, + { enabled: open }, + ); + // Credential keys satisfied by the runtime file config — shown as + // "Set in goose config" rows rather than amber required rows. + const fileSatisfiedEnvKeys = React.useMemo(() => { + if (!runtimeFileConfig) return [] as string[]; + const allKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + return allKeys.filter( + (key) => + (envVars[key] ?? "").length === 0 && + runtimeFileConfig.satisfiedEnvKeys.includes(key), + ); + }, [ + runtimeFileConfig, + prospectiveRuntimeId, + providerForRequiredKeys, + envVars, + ]); + + const requiredEnvKeys = React.useMemo( + () => + requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), + [prospectiveRuntimeId, providerForRequiredKeys, fileSatisfiedEnvKeys], + ); const { discoveredModelOptions, @@ -431,21 +512,10 @@ export function EditAgentDialog({ : undefined; // Derive the effective runtime at submit time — the one that will - // actually run AFTER submit. When pinned (inheritHarness=false), it's - // the live dropdown selection. When inheriting, match agent.agentCommand - // first by command (the normal path), then fall back to id-match for - // runtimes where the adapter is missing (command:null in the catalog). - // The id of a known runtime is stable even when its adapter binary is - // absent, so id-fallback lets us classify capability correctly without - // treating a "known adapter missing" as "completely unknown runtime." - const effectiveRuntimeIdForSubmit = inheritHarness - ? (runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) - ?.id ?? - // Fallback: id-based match for command:null catalog entries (adapter - // missing but runtime is known and its capability is still static). - runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ?? - "") - : (selectedRuntime?.id ?? selectedRuntimeId); + // actually run AFTER submit. This is the component-scope prospectiveRuntimeId, + // which is shared with the block-save gate (requiredEnvKeys) so both + // always agree on which runtime is being saved. + const effectiveRuntimeIdForSubmit = prospectiveRuntimeId; // Classify the effective runtime's provider capability as a tri-state so // the provider submit branch can distinguish "known-locked" (clear) from @@ -569,10 +639,11 @@ export function EditAgentDialog({ onModeChange={setRespondTo} /> - {llmProviderFieldVisible ? ( - @@ -720,183 +794,6 @@ export function EditAgentDialog({ ); } -function EditAgentModelField({ - disabled, - discoveredModelOptions, - isCustomModelEditing, - model, - modelDiscoveryLoading, - modelDiscoveryStatus, - onIsCustomModelEditingChange, - onModelChange, -}: { - disabled: boolean; - discoveredModelOptions: readonly PersonaModelOption[] | null; - isCustomModelEditing: boolean; - model: string; - modelDiscoveryLoading: boolean; - modelDiscoveryStatus: PersonaModelDiscoveryStatus | null; - onIsCustomModelEditingChange: (value: boolean) => void; - onModelChange: (value: string) => void; -}) { - const trimmedModel = model.trim(); - - // Mirror Persona: static options serve as the fallback when discovery hasn't - // returned yet. Discovered options are ADDITIVE — we never disable the picker - // or hide the custom input just because discovery returned null. - const staticModelOptions: readonly PersonaModelOption[] = [ - { id: "", label: "Default model" }, - ]; - const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions; - - // isModelCustom: true when the current model isn't in any known option set. - // We check discovered options (when available) or runtime-static options so - // a previously-saved custom model stays in custom mode even before discovery. - const isModelCustom = !hasPersonaModelOption( - effectiveModelOptions, - trimmedModel, - ); - - const modelSelectValue = getModelSelectValue({ - isCustomModelEditing, - isModelCustom, - model, - }); - - // The select is only disabled for mutation pending — never for missing discovery. - // Default/custom options remain usable regardless of discovery state. - const selectDisabled = disabled || modelDiscoveryLoading; - - // Show the custom model input whenever custom mode is active or the current - // model is already custom — not gated on discovery having returned. - const showCustomModelInput = isCustomModelEditing || isModelCustom; - - return ( -
- - - {showCustomModelInput ? ( - onModelChange(event.target.value)} - placeholder="Custom model ID" - value={model} - /> - ) : null} -

- {modelDiscoveryLoading - ? "Loading models..." - : modelDiscoveryStatus !== null - ? modelDiscoveryStatus.message - : discoveredModelOptions !== null - ? "Saved changes take effect on the next start." - : "Select a provider above to see available models."} -

-
- ); -} - -function EditAgentProviderField({ - disabled, - isCustomProviderEditing, - onProviderChange, - provider, - selectedRuntime, -}: { - disabled: boolean; - isCustomProviderEditing: boolean; - onProviderChange: (value: string) => void; - provider: string; - selectedRuntime: AcpRuntimeCatalogEntry | undefined; -}) { - const trimmedProvider = provider.trim(); - const providerOptions = getPersonaProviderOptions( - trimmedProvider, - selectedRuntime?.id ?? "", - ); - const providerSelectValue = isCustomProviderEditing - ? CUSTOM_PROVIDER_DROPDOWN_VALUE - : trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE; - - return ( -
- - - {isCustomProviderEditing ? ( - onProviderChange(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> - ) : null} -

- Changing the provider updates the available model list immediately. -

-
- ); -} - function envVarsChanged( a: Record, b: Record, diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.test.mjs b/desktop/src/features/agents/ui/EnvVarsEditor.test.mjs new file mode 100644 index 0000000000..0e7ccfb9f9 --- /dev/null +++ b/desktop/src/features/agents/ui/EnvVarsEditor.test.mjs @@ -0,0 +1,265 @@ +/** + * Unit tests for the EnvVarsEditor state helpers. + * + * Tests three invariants: + * + * 1. Pre-saved required key renders exactly once (toRows excludes skipKeys). + * 2. Type required value → add a normal var → required value survives in + * the emitted record (buildRecord merges required keys from value). + * 3. Provider/runtime switch (skipKeys change) triggers a row reprojection + * — the guard fires when skipKeys changes, even if value is unchanged. + * + * These are pure-logic tests — no React renderer needed. The transition tests + * (Invariant 3) exercise the real exported `skipKeysEqual` guard that controls + * whether the effect calls `setRows(toRows(value, skipKeys))`. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { toRows, toRecord, skipKeysEqual } from "./EnvVarsEditor.tsx"; + +// ── Invariant 1: toRows excludes skip keys ───────────────────────────────── + +test("toRows_presaved_required_key_excluded_from_rows", () => { + // A dialog opens with ANTHROPIC_API_KEY already set in value, and that key + // is in requiredKeys. toRows must NOT include it in the row list. + const value = { ANTHROPIC_API_KEY: "sk-abc", MY_VAR: "foo" }; + const skipKeys = new Set(["ANTHROPIC_API_KEY"]); + const rows = toRows(value, skipKeys); + + // MY_VAR should appear as a normal editable row. + assert.equal(rows.length, 1, "only non-skip keys should appear in rows"); + assert.equal(rows[0].key, "MY_VAR"); + assert.equal(rows[0].value, "foo"); +}); + +test("toRows_with_empty_value_and_required_key_produces_no_rows", () => { + // Dialog opens fresh, no user-set env vars, ANTHROPIC_API_KEY is required. + const value = { ANTHROPIC_API_KEY: "" }; + const skipKeys = new Set(["ANTHROPIC_API_KEY"]); + const rows = toRows(value, skipKeys); + assert.equal( + rows.length, + 0, + "required key with empty value should not enter rows", + ); +}); + +test("toRows_without_skip_keys_includes_all_entries", () => { + // Baseline: no skipKeys → behaviour is unchanged from the original. + const value = { FOO: "bar", BAZ: "qux" }; + const rows = toRows(value); + assert.equal(rows.length, 2); + const keys = rows.map((r) => r.key).sort(); + assert.deepEqual(keys, ["BAZ", "FOO"]); +}); + +test("toRows_file_satisfied_key_excluded_from_rows", () => { + // A file-satisfied key should also not appear in normal editable rows. + const value = { GOOSE_API_KEY: "from-config", USER_VAR: "hello" }; + const skipKeys = new Set(["GOOSE_API_KEY"]); + const rows = toRows(value, skipKeys); + assert.equal(rows.length, 1); + assert.equal(rows[0].key, "USER_VAR"); +}); + +// ── Invariant 2: emit preserves required-key values ─────────────────────── +// +// We test this via the pure helpers: build a row list (normal vars only), +// then simulate what buildRecord does — merge required-key values from +// value into toRecord(rows). This is the exact logic in buildRecord(). + +function buildRecord(rows, requiredKeys, value) { + const base = {}; + for (const key of requiredKeys) { + if (key in value) base[key] = value[key]; + } + return { ...base, ...toRecord(rows) }; +} + +test("buildRecord_preserves_required_key_value_when_normal_row_added", () => { + // Simulate: user typed ANTHROPIC_API_KEY="sk-abc" into the amber row + // (updateRequiredValue fired, value is now {ANTHROPIC_API_KEY:"sk-abc"}). + // Then user clicks "Add variable" → emit fires with rows=[{key:"",value:""}]. + // The emitted record must still contain ANTHROPIC_API_KEY. + const requiredKeys = ["ANTHROPIC_API_KEY"]; + const value = { ANTHROPIC_API_KEY: "sk-abc" }; + const rows = [{ id: "r1", key: "", value: "" }]; // new empty row + const record = buildRecord(rows, requiredKeys, value); + + // Empty-key rows are excluded by toRecord, so only ANTHROPIC_API_KEY survives. + assert.equal( + record.ANTHROPIC_API_KEY, + "sk-abc", + "required key value must survive in emitted record after adding a normal row", + ); +}); + +test("buildRecord_preserves_required_key_value_alongside_normal_rows", () => { + // User has typed a required key value AND has a normal env var row. + const requiredKeys = ["ANTHROPIC_API_KEY"]; + const value = { ANTHROPIC_API_KEY: "sk-xyz", MY_VAR: "foo" }; + // rows only contains MY_VAR (required key is excluded from rows). + const rows = [{ id: "r1", key: "MY_VAR", value: "foo" }]; + const record = buildRecord(rows, requiredKeys, value); + + assert.equal(record.ANTHROPIC_API_KEY, "sk-xyz", "required key preserved"); + assert.equal(record.MY_VAR, "foo", "normal row preserved"); + assert.equal(Object.keys(record).length, 2, "exactly two entries"); +}); + +test("buildRecord_normal_row_overrides_do_not_affect_required_key", () => { + // Normal row edits should not change the required key value. + const requiredKeys = ["ANTHROPIC_API_KEY"]; + const value = { ANTHROPIC_API_KEY: "sk-abc", EXISTING: "old" }; + const rows = [{ id: "r1", key: "EXISTING", value: "new" }]; + const record = buildRecord(rows, requiredKeys, value); + + assert.equal( + record.ANTHROPIC_API_KEY, + "sk-abc", + "required key unchanged by normal row edit", + ); + assert.equal(record.EXISTING, "new", "normal row update applied"); +}); + +test("buildRecord_required_key_not_in_value_is_omitted", () => { + // If the required key has never been set (not in value), it should not + // appear in the emitted record (no phantom empty entry). + const requiredKeys = ["ANTHROPIC_API_KEY"]; + const value = { MY_VAR: "hello" }; // ANTHROPIC_API_KEY not yet set + const rows = [{ id: "r1", key: "MY_VAR", value: "hello" }]; + const record = buildRecord(rows, requiredKeys, value); + + assert.equal( + "ANTHROPIC_API_KEY" in record, + false, + "unset required key must not appear in emitted record", + ); +}); + +// ── toRecord baseline ────────────────────────────────────────────────────── + +test("toRecord_skips_empty_key_rows", () => { + const rows = [ + { id: "a", key: "", value: "orphan" }, + { id: "b", key: "MY_VAR", value: "ok" }, + ]; + const record = toRecord(rows); + assert.equal("" in record, false, "empty-key row must be excluded"); + assert.equal(record.MY_VAR, "ok"); +}); + +test("toRecord_last_write_wins_on_duplicate_keys", () => { + const rows = [ + { id: "a", key: "FOO", value: "first" }, + { id: "b", key: "FOO", value: "second" }, + ]; + const record = toRecord(rows); + assert.equal(record.FOO, "second", "last duplicate wins"); + assert.equal(Object.keys(record).length, 1); +}); + +// ── Invariant 3: skipKeysEqual guard — transition detection ──────────────── +// +// The row-resync effect fires when `[value, skipKeys]` changes. The guard +// previously checked only `recordsEqual(lastEmitted, value)`. If `skipKeys` +// changed while `value` stayed equal to `lastEmitted`, the guard returned +// false and rows were NOT rebuilt — leaving a stale projection (duplicate +// or dropped key). The fix adds `skipKeysChanged = !skipKeysEqual(prev, next)` +// as a second trigger. +// +// These tests exercise the REAL exported `skipKeysEqual` function, which is +// exactly what the effect calls. They prove the guard fires on both transition +// directions, and that `toRows(value, newSkipKeys)` produces the correct rows +// after the rebuild. + +test("skipKeysEqual_detects_normal_to_required_transition", () => { + // Scenario: value = {ANTHROPIC_API_KEY:"sk"}, key starts as normal row. + // Provider switches → requiredKeys gains ANTHROPIC_API_KEY. + const prev = new Set(); // before switch: key is normal (not in skipKeys) + const next = new Set(["ANTHROPIC_API_KEY"]); // after switch: key is required + + // Guard must fire (skipKeys changed → !skipKeysEqual returns true). + assert.equal( + skipKeysEqual(prev, next), + false, + "normal→required transition must be detected", + ); + + // After rebuild: toRows with new skipKeys must EXCLUDE the now-required key. + const value = { ANTHROPIC_API_KEY: "sk", MY_VAR: "foo" }; + const rows = toRows(value, next); + const keyNames = rows.map((r) => r.key); + assert.equal( + keyNames.includes("ANTHROPIC_API_KEY"), + false, + "ANTHROPIC_API_KEY must not be in rows after normal→required transition", + ); + assert.equal( + keyNames.includes("MY_VAR"), + true, + "non-required key must still be in rows after transition", + ); +}); + +test("skipKeysEqual_detects_required_to_normal_transition", () => { + // Scenario: value = {ANTHROPIC_API_KEY:"sk"}, key starts as required row. + // Provider switches → requiredKeys loses ANTHROPIC_API_KEY. + const prev = new Set(["ANTHROPIC_API_KEY"]); // before switch: key is required + const next = new Set(); // after switch: key is now a normal row + + // Guard must fire (skipKeys changed). + assert.equal( + skipKeysEqual(prev, next), + false, + "required→normal transition must be detected", + ); + + // After rebuild: toRows with empty skipKeys must INCLUDE the key. + const value = { ANTHROPIC_API_KEY: "sk" }; + const rows = toRows(value, next); + assert.equal( + rows.length, + 1, + "key must appear as a normal row after required→normal", + ); + assert.equal(rows[0].key, "ANTHROPIC_API_KEY"); + assert.equal( + rows[0].value, + "sk", + "the key value must be preserved in the rebuilt row", + ); +}); + +test("skipKeysEqual_no_rebuild_when_keys_unchanged", () => { + // When skipKeys membership is identical (but different Set reference), the + // guard must NOT fire — avoids wasted re-render on every parent render. + const prev = new Set(["ANTHROPIC_API_KEY"]); + const next = new Set(["ANTHROPIC_API_KEY"]); // same membership, different ref + + assert.equal( + skipKeysEqual(prev, next), + true, + "identical membership must be equal (no spurious rebuild)", + ); +}); + +test("skipKeysEqual_empty_sets_are_equal", () => { + assert.equal( + skipKeysEqual(new Set(), new Set()), + true, + "two empty sets are equal", + ); +}); + +test("skipKeysEqual_different_sizes_are_not_equal", () => { + const a = new Set(["FOO", "BAR"]); + const b = new Set(["FOO"]); + assert.equal( + skipKeysEqual(a, b), + false, + "sets of different sizes are not equal", + ); +}); diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx index fa4cc543e3..ddce4ad6d2 100644 --- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx +++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx @@ -1,4 +1,4 @@ -import { Plus, X } from "lucide-react"; +import { AlertCircle, Lock, Plus, X } from "lucide-react"; import * as React from "react"; import { Button } from "@/shared/ui/button"; @@ -11,6 +11,60 @@ import { export type EnvVarsValue = Record; +/** + * Build a rows array from a value record, optionally skipping a set of keys. + * Exported for unit tests. + */ +export function toRows( + value: EnvVarsValue, + skipKeys: ReadonlySet = EMPTY_SET, +): Row[] { + return Object.entries(value) + .filter(([key]) => !skipKeys.has(key)) + .map(([key, val]) => ({ + id: crypto.randomUUID(), + key, + value: val, + })); +} + +/** + * Collapse an ordered row list back to a record, skipping rows with empty + * keys. Exported for unit tests. + */ +export function toRecord(rows: Row[]): EnvVarsValue { + const out: EnvVarsValue = {}; + for (const row of rows) { + // Empty key = user is mid-edit; skip it so we don't poison the record. + // Duplicate keys: last write wins (matches Command::env semantics). + if (row.key.length > 0) { + out[row.key] = row.value; + } + } + return out; +} + +// Module-private empty set constant so skipKeys defaults are allocation-free. +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * True iff two skip-key sets have the same membership. Used to detect a + * provider/runtime-switch transition where requiredKeys changed but `value` + * did not — without this, the row-resync effect's `recordsEqual` guard would + * silently skip rebuilding rows, leaving a stale projection (duplicate or + * missing key). Exported for unit tests. + */ +export function skipKeysEqual( + a: ReadonlySet, + b: ReadonlySet, +): boolean { + if (a.size !== b.size) return false; + for (const key of a) { + if (!b.has(key)) return false; + } + return true; +} + type EnvVarsEditorProps = { /** The current key/value map. */ value: EnvVarsValue; @@ -27,6 +81,22 @@ type EnvVarsEditorProps = { helperText?: string; /** Disables all editing. */ disabled?: boolean; + /** + * Env var keys that are required for the agent to start with the currently + * selected runtime + provider. Each key renders as a locked first-class row + * at the top of the editor: the key is pre-filled and read-only; the value + * is editable. If the value is already set in `value`, it is shown; otherwise + * the row is empty and marked with a "Required" badge so the user knows to + * fill it in. + */ + requiredKeys?: readonly string[]; + /** + * Env var keys that are required but already satisfied by the runtime's + * config file (e.g. `~/.config/goose/config.yaml`). These are shown as + * read-only informational rows with a "Set in goose config" annotation so + * the user knows the key is covered without needing to add it here. + */ + fileSatisfiedKeys?: readonly string[]; }; type Row = { id: string; key: string; value: string }; @@ -47,25 +117,64 @@ export function EnvVarsEditor({ label = "Environment variables", helperText, disabled = false, + requiredKeys = [], + fileSatisfiedKeys = [], }: EnvVarsEditorProps) { - // Local ordered row state. Synced from `value` on mount and when the - // parent supplies a value we did NOT just emit (e.g., dialog reopened - // with a different persona/agent). We track what we last emitted so a - // row with an empty key doesn't get wiped: emit returns {} for it, the - // parent's useState produces a new object reference, but `value` content - // matches our `lastEmitted`, so we skip the resync. - const [rows, setRows] = React.useState(() => toRows(value)); - const lastEmitted = React.useRef(toRecord(toRows(value))); + // Keys that render as their own special rows (required amber rows or + // file-satisfied read-only rows). These must NEVER enter `rows` state — + // they read/write `value` directly via `onChange`/`updateRequiredValue`. + // Keeping them out of rows is the invariant that prevents a pre-saved + // required key from appearing as a duplicate normal editable row. + const skipKeys = React.useMemo( + () => new Set([...requiredKeys, ...fileSatisfiedKeys]), + [requiredKeys, fileSatisfiedKeys], + ); + + // Local ordered row state — normal (non-special) keys only. Synced from + // `value` on mount and when the parent supplies a value we did NOT just + // emit (e.g., dialog reopened with a different persona/agent). We track + // what we last emitted so a row with an empty key doesn't get wiped: + // emit returns {} for it, the parent's useState produces a new object + // reference, but `value` content matches our `lastEmitted`, so we skip + // the resync. + // + // `lastEmitted` holds the FULL emitted record (normal rows + required-key + // values merged in), matching the shape of `value`, so `recordsEqual` can + // compare them on the same projection without special-casing. + const [rows, setRows] = React.useState(() => toRows(value, skipKeys)); + const lastEmitted = React.useRef(value); + // Track the previous skipKeys set so we can detect a skip-key-only + // transition (provider/runtime switch that changes requiredKeys while + // `value` stays equal to `lastEmitted.current`). Without this, the + // `recordsEqual` guard silently skips the row rebuild on such transitions, + // leaving a stale projection: a key that just became required still appears + // as a normal editable row (duplicate), or a key that just became normal + // is missing because it was excluded from rows (drop). + const prevSkipKeys = React.useRef>(skipKeys); React.useEffect(() => { - if (!recordsEqual(lastEmitted.current, value)) { + const skipKeysChanged = !skipKeysEqual(prevSkipKeys.current, skipKeys); + prevSkipKeys.current = skipKeys; + if (skipKeysChanged || !recordsEqual(lastEmitted.current, value)) { lastEmitted.current = value; - setRows(toRows(value)); + setRows(toRows(value, skipKeys)); } - }, [value]); + }, [value, skipKeys]); + + // Build the emitted record: normal rows + required-key values preserved + // from `value`. Required keys are never in `rows`, so `toRecord(rows)` + // would silently drop any required secret the user just typed unless we + // merge them back explicitly. + function buildRecord(nextRows: Row[]): EnvVarsValue { + const base: EnvVarsValue = {}; + for (const key of requiredKeys) { + if (key in value) base[key] = value[key]; + } + return { ...base, ...toRecord(nextRows) }; + } function emit(next: Row[]) { setRows(next); - const record = toRecord(next); + const record = buildRecord(next); lastEmitted.current = record; onChange(record); } @@ -82,6 +191,19 @@ export function EnvVarsEditor({ emit([...rows, { id: crypto.randomUUID(), key: "", value: "" }]); } + // Required rows render before the user-editable rows. They are NOT part of + // `rows` state (see skipKeys above). They read from / write to `value` + // directly via `onChange`, using their key as the stable identity. + // + // `lastEmitted.current` is updated BEFORE `onChange` so the resync effect + // (`recordsEqual(lastEmitted.current, value) === false`) does not trigger a + // `setRows(toRows(value, skipKeys))` after the parent re-renders. + function updateRequiredValue(key: string, newValue: string) { + const next = { ...value, [key]: newValue }; + lastEmitted.current = next; + onChange(next); + } + return (
@@ -91,7 +213,123 @@ export function EnvVarsEditor({ ) : null}
- {rows.length === 0 ? ( + {/* Required credential rows — shown first, key is read-only */} + {requiredKeys.map((key) => { + const currentValue = value[key] ?? ""; + const isMissing = currentValue.length === 0; + return ( +
+
+
+ + + {key} + + {isMissing ? ( + + + Required + + ) : null} +
+
+ + updateRequiredValue(key, event.target.value) + } + placeholder="value" + type="password" + value={currentValue} + /> +
+ {/* Spacer to align with the remove-button column */} +
+
+ {(() => { + const inheritedValue = inheritedFrom?.[key]; + return inheritedValue !== undefined ? ( +

+ Overrides {inheritedLabel} value{" "} + + {maskInherited(inheritedValue)} + +

+ ) : null; + })()} +
+ ); + })} + + {/* File-satisfied keys — required but set in the runtime config file */} + {fileSatisfiedKeys.map((key) => ( +
+
+
+ + + {key} + + + Set in goose config + +
+ {/* Spacer columns to align with required-key rows */} +
+ + •••••••• + +
+
+
+
+ ))} + + {/* User-managed rows */} + {rows.length === 0 && + requiredKeys.length === 0 && + fileSatisfiedKeys.length === 0 ? (

No variables set.

@@ -197,14 +435,6 @@ function maskInherited(value: string): string { return `••••${value.slice(-4)}`; } -function toRows(value: EnvVarsValue): Row[] { - return Object.entries(value).map(([key, val]) => ({ - id: crypto.randomUUID(), - key, - value: val, - })); -} - function recordsEqual(a: EnvVarsValue, b: EnvVarsValue): boolean { const aKeys = Object.keys(a); const bKeys = Object.keys(b); @@ -217,15 +447,3 @@ function recordsEqual(a: EnvVarsValue, b: EnvVarsValue): boolean { } return true; } - -function toRecord(rows: Row[]): EnvVarsValue { - const out: EnvVarsValue = {}; - for (const row of rows) { - // Empty key = user is mid-edit; skip it so we don't poison the record. - // Duplicate keys: last write wins (matches Command::env semantics). - if (row.key.length > 0) { - out[row.key] = row.value; - } - } - return out; -} diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 3d014210bf..a291930484 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -15,12 +15,16 @@ export function PersonaAdvancedFields({ namePoolText, onEnvVarsChange, onNamePoolTextChange, + requiredEnvKeys = [], + fileSatisfiedEnvKeys = [], }: { disabled: boolean; envVars: EnvVarsValue; namePoolText: string; onEnvVarsChange: (value: EnvVarsValue) => void; onNamePoolTextChange: (value: string) => void; + requiredEnvKeys?: readonly string[]; + fileSatisfiedEnvKeys?: readonly string[]; }) { return (
@@ -57,7 +61,9 @@ export function PersonaAdvancedFields({
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 5a813ec4aa..a108d52351 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -41,6 +41,7 @@ import { AUTO_PROVIDER_DROPDOWN_VALUE, CUSTOM_MODEL_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, + computeLocalModeGate, formatRuntimeOptionLabel, getDefaultLlmProviderLabel, getDefaultPersonaRuntime, @@ -53,6 +54,7 @@ import { hasPersonaModelOption, NO_RUNTIME_DROPDOWN_VALUE, providerRequiresExplicitModel, + requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, type PersonaDropdownOption, PERSONA_FIELD_CONTROL_CLASS, @@ -62,10 +64,12 @@ import { sortPersonaRuntimes, } from "./personaDialogPickers"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; +import { RequiredFieldLabel } from "./personaProviderModelFields"; import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; +import { useRuntimeFileConfigQuery } from "../hooks"; type PersonaDialogProps = { open: boolean; @@ -419,6 +423,35 @@ export function PersonaDialog({ : ""; const providerApiKeyFieldVisible = llmProviderFieldVisible && providerApiKeyConfig !== null; + // Required credential env keys for this runtime + provider combination. + // Used to show required markers on the LLM provider label and amber + // locked rows in the env vars editor. + // File-layer config for the selected runtime (e.g. goose config.yaml). + // Used to silence requirements already satisfied there. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { + enabled: open, + }); + const localModeGate = computeLocalModeGate({ + envVars, + isProviderMode: false, + model, + provider: trimmedProvider, + runtimeId: runtime, + runtimeFileConfig, + useMesh: false, + }); + // Required keys for EnvVarsEditor amber rows: exclude file-satisfied keys + // so they render in the "Set in goose config" row instead. + const requiredEnvKeys = requiredCredentialEnvKeys( + runtime, + trimmedProvider, + ).filter((key) => !localModeGate.fileSatisfiedEnvKeys.includes(key)); + // Provider required-ness is a static property of the runtime — it does not + // change based on whether the field is currently filled. Using the dynamic + // missingNormalizedFields check would flip the asterisk off once a value is + // selected, which is incoherent (required means required, not "required until + // satisfied"). runtimeSupportsLlmProviderSelection is the authoritative gate. + const providerIsRequired = runtimeSupportsLlmProviderSelection(runtime); const modelFieldVisible = runtime.trim().length > 0 || blankRuntimeModelProviderEditable; const isExplicitModelRequired = @@ -863,7 +896,7 @@ export function PersonaDialog({ className="text-sm font-medium text-foreground" htmlFor="persona-runtime" > - Provider + Agent runtime - + {!providerIsRequired ? ( + + Optional + + ) : null} + ) : null} diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs new file mode 100644 index 0000000000..e8e24370e5 --- /dev/null +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -0,0 +1,412 @@ +/** + * Unit tests for the CreateAgentDialog local-mode readiness gate. + * + * The gate computes whether required fields are present for the selected + * runtime: when missing, it surfaces field markers (isRequired) and env-key + * amber rows (EnvVarsEditor.requiredKeys), and the setup-listener nudge will + * fire after spawn. The gate NO LONGER blocks the create/save button — + * users can save with incomplete config and the nudge will guide them. + * + * On Create there is no inherit checkbox, so selectedRuntimeId IS the + * prospective runtime — no prospectiveRuntimeId hoist needed. + * + * The shared helper under test: + * computeLocalModeGate — pure function used by field isRequired and + * EnvVarsEditor.requiredKeys; canSubmit no longer + * reads gate.satisfied. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + computeLocalModeGate, + requiredCredentialEnvKeys, + runtimeSupportsLlmProviderSelection, +} from "./personaDialogPickers.tsx"; + +// ── Core predicate: provider-selection support ───────────────────────────── + +test("localMode_buzzAgent_supportsProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("buzz-agent"), + true, + "buzz-agent must support LLM provider selection", + ); +}); + +test("localMode_goose_supportsProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("goose"), + true, + "goose must support LLM provider selection", + ); +}); + +test("localMode_claude_doesNotSupportProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("claude"), + false, + "claude must NOT support LLM provider selection (CLI-login runtime)", + ); +}); + +test("localMode_custom_doesNotSupportProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("custom"), + false, + "custom runtime must NOT support LLM provider selection", + ); +}); + +// ── IMPORTANT 1: normalized field gate (provider + model) ───────────────── + +test("localMode_buzzAgent_emptyProvider_notSatisfied", () => { + // Scenario: user selects buzz-agent but leaves provider empty. + // Rust readiness requires BUZZ_AGENT_PROVIDER — empty = NotReady. + // The gate must report not-satisfied and surface the missing field marker, + // but does NOT block the save button. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("provider"), + "missing provider must be in missingNormalizedFields", + ); + assert.equal( + result.satisfied, + false, + "empty provider: gate not satisfied (marker shown); save button is still enabled", + ); +}); + +test("localMode_buzzAgent_emptyModel_notSatisfied", () => { + // Scenario: buzz-agent + anthropic + API key present, but model left empty. + // Rust readiness requires BUZZ_AGENT_MODEL — empty = NotReady. + // The gate surfaces the missing field marker; save button is still enabled. + const result = computeLocalModeGate({ + envVars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + isProviderMode: false, + model: "", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("model"), + "missing model must be in missingNormalizedFields", + ); + assert.equal( + result.satisfied, + false, + "empty model: gate not satisfied (marker shown); save button is still enabled", + ); +}); + +// ── Gate: buzz-agent / anthropic with missing key → markers shown ───────── + +test("localMode_buzzAgent_anthropic_missingKey_notSatisfied", () => { + // Scenario: user selects buzz-agent/anthropic + fills model, but hasn't + // supplied ANTHROPIC_API_KEY — the exact crash-loop case the nudge handles. + // Gate reports not-satisfied (required marker + env row shown); save allowed. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingEnvKeys.includes("ANTHROPIC_API_KEY"), + "ANTHROPIC_API_KEY must be in missingEnvKeys", + ); + assert.equal( + result.satisfied, + false, + "missing ANTHROPIC_API_KEY: gate not satisfied (marker + nudge shown); save still allowed", + ); +}); + +test("localMode_buzzAgent_anthropic_allRequired_present_allowed", () => { + // All three required fields present: provider, model, and credential key. + const result = computeLocalModeGate({ + envVars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "no missing normalized fields when provider and model are set", + ); + assert.deepEqual( + result.missingEnvKeys, + [], + "no missing env keys when ANTHROPIC_API_KEY is set", + ); + assert.equal( + result.satisfied, + true, + "all required fields present must allow create", + ); +}); + +// ── Gate: claude runtime (CLI-login) → NOT blocked ──────────────────────── + +test("localMode_claude_noRequiredFields_notBlocked", () => { + // Scenario: user selects claude. Claude uses CLI-login (out-of-band auth), + // runtimeSupportsLlmProviderSelection=false → no provider/model required, + // no credential keys required. The gate must not block. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "claude", + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "claude must have no required normalized fields", + ); + assert.deepEqual( + result.missingEnvKeys, + [], + "claude must return no required credential keys", + ); + assert.equal( + result.satisfied, + true, + "claude must NOT be blocked by the local-mode gate", + ); +}); + +// ── Gate: isProviderMode / useMesh bypass ───────────────────────────────── + +test("localMode_gate_bypassed_for_providerMode", () => { + // In provider mode, gate must be satisfied regardless of local fields. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: true, + model: "", + provider: "", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.equal( + result.satisfied, + true, + "provider mode must bypass the local-mode gate", + ); +}); + +test("localMode_gate_bypassed_for_meshMode", () => { + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "buzz-agent", + useMesh: true, + }); + + assert.equal( + result.satisfied, + true, + "relay-mesh mode must bypass the local-mode gate", + ); +}); + +// ── IMPORTANT 2: requiredEnvKeys surfaces correctly ─────────────────────── + +test("localMode_requiredEnvKeys_surfaces_anthropicKey", () => { + // requiredCredentialEnvKeys returns ALL required keys for the provider + // (including already-satisfied ones) — what EnvVarsEditor receives for + // its amber locked rows. Verify the full key list, not just missing keys. + const allKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + assert.ok( + allKeys.includes("ANTHROPIC_API_KEY"), + "requiredCredentialEnvKeys must include ANTHROPIC_API_KEY for buzz-agent/anthropic", + ); +}); + +test("localMode_requiredEnvKeys_gate_and_envVarsEditor_share_same_key_set", () => { + // The key the gate blocks on must equal the key EnvVarsEditor shows. + // computeLocalModeGate.missingEnvKeys ⊆ requiredCredentialEnvKeys output. + const gateResult = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + const fullKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + + for (const key of gateResult.missingEnvKeys) { + assert.ok( + fullKeys.includes(key), + `gate-missing key ${key} must appear in requiredCredentialEnvKeys output (EnvVarsEditor source)`, + ); + } +}); + +// ── Gate: provider selection drives required credential keys ────────────── + +test("localMode_providerSelection_drives_requiredKey", () => { + // Different provider selections must produce different required keys. + const anthropicGate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + const databricksGate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "databricks-meta-llama", + provider: "databricks", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + anthropicGate.missingEnvKeys.length > 0, + "anthropic must require at least one credential key", + ); + assert.ok( + databricksGate.missingEnvKeys.length > 0, + "databricks must require at least one credential key", + ); + assert.notDeepEqual( + anthropicGate.missingEnvKeys, + databricksGate.missingEnvKeys, + "different providers must require different keys", + ); +}); + +// ── File-config bridge tests ────────────────────────────────────────────── + +test("localMode_goose_databricksHost_satisfiedByFileConfig_notRequired", () => { + // Scenario: goose runtime, databricks_v2 provider, DATABRICKS_HOST in file. + // The gate should NOT flag DATABRICKS_HOST as missing — it's satisfied in goose config. + const fileConfig = { + provider: "databricks_v2", + model: "goose-claude-4-6-opus", + satisfiedEnvKeys: ["DATABRICKS_HOST"], + }; + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "goose-claude-4-6-opus", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: fileConfig, + useMesh: false, + }); + + assert.ok( + !result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must NOT appear in missingEnvKeys when satisfied by file config", + ); + assert.ok( + result.fileSatisfiedEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must appear in fileSatisfiedEnvKeys when set in goose config", + ); + assert.equal( + result.satisfied, + true, + "gate must be satisfied when all requirements are covered by env or file config", + ); +}); + +test("localMode_goose_databricksHost_noFileConfig_stillRequired", () => { + // Scenario: goose + databricks_v2, no file config present. + // DATABRICKS_HOST must still be required. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must be required when absent from both env and file config", + ); + assert.equal( + result.satisfied, + false, + "gate must NOT be satisfied when DATABRICKS_HOST is missing from env and file", + ); +}); + +test("localMode_goose_providerSatisfiedByFileConfig_noNormalizedFieldRequired", () => { + // Scenario: goose, no provider in Buzz env but file config has provider + model. + // Neither 'provider' nor 'model' should be required. + const fileConfig = { + provider: "anthropic", + model: "claude-opus-4-5", + satisfiedEnvKeys: [], + }; + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "goose", + runtimeFileConfig: fileConfig, + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "normalized fields must be empty when provider + model are in file config", + ); +}); + +test("localMode_goose_envPlusFileConfig_bothEmpty_stillRequired", () => { + // Scenario: goose, empty env, file config is null (no file). + // Both provider and model must be required. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("provider"), + "provider must be required when absent from both env and file", + ); + assert.ok( + result.missingNormalizedFields.includes("model"), + "model must be required when absent from both env and file", + ); + assert.equal(result.satisfied, false, "gate must not be satisfied"); +}); diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 857d07abfa..75cfe00eeb 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -4,6 +4,8 @@ import test from "node:test"; import { runtimeSupportsLlmProviderSelection, getPersonaProviderOptions, + requiredCredentialEnvKeys, + isMissingRequiredDropdownField, } from "./personaDialogPickers.tsx"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel.ts"; @@ -91,6 +93,28 @@ test("editAgent_providerOptions_includesCurrentIfCustom", () => { // for deciding whether to show options is sound: when discovery is null, we // fall back to staticModelOptions (length > 0), so we always have options. +test("editAgent_requiredDropdownField_onlyMarksMissingKnownField", async () => { + const { isMissingRequiredDropdownField } = await import( + "./personaDialogPickers.tsx" + ); + + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, ""), + true, + "missing required dropdown value must be marked required", + ); + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, "configured"), + false, + "configured required dropdown value must not show the missing-required mark", + ); + assert.equal( + isMissingRequiredDropdownField(null, ""), + false, + "unknown normalized field names are ignored because they do not map to a dropdown", + ); +}); + test("editAgent_modelFallback_staticOptionsWhenDiscoveryNull", () => { const staticModelOptions = [{ id: "", label: "Default model" }]; // Simulate: discoveredModelOptions === null → effectiveModelOptions is static fallback @@ -993,3 +1017,281 @@ test("editAgent_bugA_unknownCommandStillFallsBackToCustom", () => { "unknown command/id must still fall back to 'custom'", ); }); + +// ── requiredCredentialEnvKeys ────────────────────────────────────────────── +// +// Guards the provider-aware credential requirements surface so Phase 2 +// required env rows stay correct as providers and runtimes change. + +test("requiredCredentialEnvKeys: buzz-agent + anthropic → ANTHROPIC_API_KEY", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + assert.deepEqual(keys, ["ANTHROPIC_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + openai → OPENAI_COMPAT_API_KEY", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "openai"); + assert.deepEqual(keys, ["OPENAI_COMPAT_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + databricks → DATABRICKS_HOST only (no token)", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + assert.deepEqual(keys, ["DATABRICKS_HOST"]); + assert.ok( + !keys.includes("DATABRICKS_TOKEN"), + "DATABRICKS_TOKEN must not be required (OAuth PKCE is the normal auth path)", + ); +}); + +test("requiredCredentialEnvKeys: buzz-agent + databricks_v2 → DATABRICKS_HOST only", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "databricks_v2"); + assert.deepEqual(keys, ["DATABRICKS_HOST"]); +}); + +test("requiredCredentialEnvKeys: goose + anthropic → ANTHROPIC_API_KEY", () => { + const keys = requiredCredentialEnvKeys("goose", "anthropic"); + assert.deepEqual(keys, ["ANTHROPIC_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: goose + openai → OPENAI_COMPAT_API_KEY", () => { + const keys = requiredCredentialEnvKeys("goose", "openai"); + assert.deepEqual(keys, ["OPENAI_COMPAT_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + no provider → empty (provider not yet selected)", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: claude → empty (uses CLI login, not env keys)", () => { + const keys = requiredCredentialEnvKeys("claude", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: codex → empty (uses CLI login, not env keys)", () => { + const keys = requiredCredentialEnvKeys("codex", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: custom/unknown runtime → empty", () => { + const keys = requiredCredentialEnvKeys("my-custom-harness", "anthropic"); + assert.deepEqual(keys, []); +}); + +// ── Block-save gate: hasRequiredEnvKeyMissing logic ──────────────────────── +// +// The EditAgentDialog computes: +// hasRequiredEnvKeyMissing = requiredEnvKeys.some(k => (envVars[k] ?? "").length === 0) +// and folds it into canSubmit. These tests exercise that predicate directly. + +function hasRequiredEnvKeyMissing(requiredKeys, envVars) { + return requiredKeys.some((key) => (envVars[key] ?? "").length === 0); +} + +test("blockSave_buzzAgentAnthropicMissingKey_blocked", () => { + // Will's exact case: buzz-agent / anthropic / opus / no ANTHROPIC_API_KEY + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = {}; // key absent + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when ANTHROPIC_API_KEY is missing", + ); +}); + +test("blockSave_buzzAgentAnthropicKeyProvided_allowed", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = { ANTHROPIC_API_KEY: "sk-ant-test" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "save must be allowed when ANTHROPIC_API_KEY is present", + ); +}); + +test("blockSave_buzzAgentAnthropicEmptyStringKey_blocked", () => { + // Empty string is treated the same as absent — matches EnvVarsEditor isMissing + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = { ANTHROPIC_API_KEY: "" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when ANTHROPIC_API_KEY is empty string", + ); +}); + +test("blockSave_claudeNoCliLogin_notBlocked", () => { + // CLI-login runtimes have no dialog-fixable requirement — must never block + const requiredKeys = requiredCredentialEnvKeys("claude", ""); + const envVars = {}; // no keys set + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "claude save must NOT be blocked — CLI login is out-of-band", + ); +}); + +test("blockSave_codexNoCliLogin_notBlocked", () => { + const requiredKeys = requiredCredentialEnvKeys("codex", ""); + const envVars = {}; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "codex save must NOT be blocked — CLI login is out-of-band", + ); +}); + +test("blockSave_buzzAgentDatabricksMissingHost_blocked", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + const envVars = {}; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when DATABRICKS_HOST is missing", + ); +}); + +test("blockSave_buzzAgentDatabricksHostProvided_allowed", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + const envVars = { DATABRICKS_HOST: "https://my.databricks.instance" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "save must be allowed when DATABRICKS_HOST is present", + ); +}); + +// ── Block-save gate: isMissingRequiredDropdownField ──────────────────────── +// +// The EditAgentDialog also gates on modelRequired / providerRequired. +// These tests guard the isMissingRequiredDropdownField predicate used for both. + +test("blockSave_missingRequiredModel_blocked", () => { + // isRequired=true and value is empty → should block + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, ""), + true, + "canSubmit must be blocked when required model is unset", + ); +}); + +test("blockSave_requiredModelProvided_allowed", () => { + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, "claude-opus-4-5"), + false, + "canSubmit must be allowed when required model is set", + ); +}); + +test("blockSave_optionalModelEmpty_allowed", () => { + // isRequired=false → not a block-save condition + assert.equal( + isMissingRequiredDropdownField({ isRequired: false }, ""), + false, + "optional empty model must not block save", + ); +}); + +test("blockSave_nullField_allowed", () => { + // null/undefined field descriptor → not required, not blocked + assert.equal( + isMissingRequiredDropdownField(null, ""), + false, + "null field must not block save", + ); + assert.equal( + isMissingRequiredDropdownField(undefined, ""), + false, + "undefined field must not block save", + ); +}); + +// ── Block-save gate: inherit-runtime transition cases ────────────────────── +// +// When inheritHarness=true, prospectiveRuntimeId resolves from agent.agentCommand +// (the persona's runtime), not the current dropdown. These tests guard the two +// failure modes Thufir flagged: +// FALSE-ALLOW: claude pin → inherit buzz-agent persona → missing ANTHROPIC_API_KEY +// → must be BLOCKED (prospective runtime is buzz-agent/anthropic, key absent) +// FALSE-BLOCK: buzz-agent pin → inherit claude persona +// → must NOT be blocked (claude has no dialog-fixable credential requirement) + +test("blockSave_inheritTransition_claudePin_toBuzzAgentPersona_missingKey_blocked", () => { + // Scenario: agent is currently pinned to claude (CLI-login, llmProviderFieldVisible=false + // so providerForDiscovery="" in the component). The user checks "Inherit runtime + // from persona" where the persona uses buzz-agent/anthropic. + // prospectiveRuntimeId resolves to "buzz-agent"; providerForRequiredKeys must + // use the PROSPECTIVE runtime's provider-field visibility (buzz-agent supports + // provider selection) rather than the current locked runtime's suppression. + const prospectiveRuntimeId = "buzz-agent"; // resolved from persona's agentCommand + const provider = "anthropic"; // agent's configured provider (in envVars / state) + + // Mirror the component's providerForRequiredKeys computation: + // providerForRequiredKeys = runtimeSupportsLlmProviderSelection(prospectiveRuntimeId) + // ? provider : "" + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + const requiredKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + const envVars = {}; // ANTHROPIC_API_KEY absent + + // providerForRequiredKeys must be "anthropic" (buzz-agent supports selection) + // so requiredCredentialEnvKeys returns [ANTHROPIC_API_KEY] and save is blocked. + assert.equal( + providerForRequiredKeys, + "anthropic", + "providerForRequiredKeys must use the prospective runtime's visibility, not the locked current runtime", + ); + const missing = requiredKeys.some((key) => (envVars[key] ?? "").length === 0); + assert.equal( + missing, + true, + "inheriting buzz-agent/anthropic persona with no key must BLOCK save (false-allow prevented)", + ); +}); + +test("blockSave_inheritTransition_buzzAgentPin_toClaudePersona_notBlocked", () => { + // Scenario: agent is pinned to buzz-agent/anthropic. The user checks + // "Inherit runtime from persona" where the persona uses claude. + // prospectiveRuntimeId resolves to "claude"; claude doesn't support provider + // selection, so providerForRequiredKeys="" and requiredCredentialEnvKeys returns []. + const prospectiveRuntimeId = "claude"; // resolved from persona's agentCommand + const provider = "anthropic"; // agent's old provider (no longer relevant for claude) + + // Mirror the component's providerForRequiredKeys computation: + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + const requiredKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + const envVars = {}; // nothing set — claude doesn't require dialog credentials + + // providerForRequiredKeys must be "" (claude doesn't support provider selection) + assert.equal( + providerForRequiredKeys, + "", + "providerForRequiredKeys must be empty for CLI-login runtimes", + ); + const missing = requiredKeys.some((key) => (envVars[key] ?? "").length === 0); + assert.equal( + missing, + false, + "inheriting claude persona must NOT block save (false-block prevented — CLI login is out-of-band)", + ); + assert.equal( + requiredKeys.length, + 0, + "claude must return empty required keys — no dialog-fixable credential", + ); +}); diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index e3b51cfcb3..574dabff79 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -1,4 +1,5 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { RuntimeFileConfigSubset } from "@/shared/api/tauri"; export const PERSONA_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; @@ -75,6 +76,49 @@ function isKnownLlmProvider( return (KNOWN_LLM_PROVIDER_IDS as readonly string[]).includes(providerId); } +/** + * Returns the credential env-var keys that are required for a given + * runtime + provider combination. These are the keys that must be present + * in the agent's effective env for it to start successfully. + * + * Used by EnvVarsEditor to render first-class "required" rows that make the + * gap visible before the user tries to save or start the agent. + * + * Mirrors the Rust `readiness::buzz_agent_requirements` / + * `readiness::goose_requirements` logic — keep in sync. + */ +export function requiredCredentialEnvKeys( + runtimeId: string, + provider: string, +): readonly string[] { + const normalizedRuntime = runtimeId.trim(); + const normalizedProvider = provider.trim().toLowerCase(); + + // buzz-agent and goose both use provider-specific credentials. + if (normalizedRuntime === "buzz-agent" || normalizedRuntime === "goose") { + if (normalizedProvider === "anthropic") return ["ANTHROPIC_API_KEY"]; + if (normalizedProvider === "openai") return ["OPENAI_COMPAT_API_KEY"]; + if ( + normalizedProvider === "databricks" || + normalizedProvider === "databricks_v2" + ) { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + return ["DATABRICKS_HOST"]; + } + } + + // claude and codex handle auth via CLI login (not env keys) — those + // requirements are surfaced separately via the CliLogin surface. + return []; +} + +export function isMissingRequiredDropdownField( + field: { isRequired: boolean } | null | undefined, + value: string, +) { + return field?.isRequired === true && value.trim().length === 0; +} + export function runtimeSupportsLlmProviderSelection(runtimeId: string) { return runtimeId === "buzz-agent" || runtimeId === "goose"; } @@ -308,3 +352,108 @@ export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) { null ); } + +/** + * Pure local-mode readiness gate for Create (no existing agent, no config + * surface query). Returns the missing normalized fields (provider, model) and + * the missing credential env keys so the caller can derive `canSubmit`, + * field `isRequired`, and `EnvVarsEditor.requiredKeys` from the same source. + * + * Two classes of required field for provider-selection runtimes (buzz-agent, + * goose) — both required unconditionally per readiness.rs: + * 1. Normalized fields: provider + model (empty string = NotReady) + * 2. Credential env keys: provider-specific (e.g. ANTHROPIC_API_KEY) + * + * isProviderMode / useMesh modes are NOT subject to this gate — they have + * their own gates. Pass isProviderMode=true or useMesh=true to bypass. + */ +export function computeLocalModeGate({ + envVars, + isProviderMode, + model, + provider, + runtimeId, + runtimeFileConfig, + useMesh, +}: { + envVars: Record; + isProviderMode: boolean; + model: string; + provider: string; + runtimeId: string; + /** Optional file-layer config for the runtime (e.g. goose config.yaml). + * When provided, requirements already satisfied there are silenced. */ + runtimeFileConfig?: RuntimeFileConfigSubset | null; + useMesh: boolean; +}): { + /** Normalized field names that are required but empty ("provider", "model"). */ + missingNormalizedFields: string[]; + /** Credential env key names that are required but missing or empty. */ + missingEnvKeys: string[]; + /** Env keys that are not set in Buzz but are satisfied in the runtime's + * config file (e.g. "Set in goose config"). */ + fileSatisfiedEnvKeys: string[]; + /** True when the create button may be enabled (from this gate's perspective). */ + satisfied: boolean; +} { + if (isProviderMode || useMesh) { + return { + missingNormalizedFields: [], + missingEnvKeys: [], + fileSatisfiedEnvKeys: [], + satisfied: true, + }; + } + + const needsProviderSelection = runtimeSupportsLlmProviderSelection(runtimeId); + + // A normalized field is satisfied by the runtime file config when the file + // provides the value (provider or model). The file layer silences the + // requirement; the value is not injected into the Buzz env. + const fileProvider = runtimeFileConfig?.provider?.trim() ?? ""; + const fileModel = runtimeFileConfig?.model?.trim() ?? ""; + const fileSatisfiedKeys = new Set(runtimeFileConfig?.satisfiedEnvKeys ?? []); + + const missingNormalizedFields: string[] = []; + if (needsProviderSelection) { + if (provider.trim().length === 0 && fileProvider.length === 0) { + missingNormalizedFields.push("provider"); + } + if (model.trim().length === 0 && fileModel.length === 0) { + missingNormalizedFields.push("model"); + } + } + + // Credential keys depend on the selected provider (empty provider → no keys + // required beyond the normalized field gate above). + // Use the file provider as fallback when the env provider is empty, so + // credential requirements are computed correctly for file-config runtimes. + const effectiveProviderForKeys = needsProviderSelection + ? provider.trim() || fileProvider + : ""; + const providerForKeys = needsProviderSelection + ? effectiveProviderForKeys + : ""; + const requiredKeys = requiredCredentialEnvKeys(runtimeId, providerForKeys); + + const missingEnvKeys: string[] = []; + const fileSatisfiedEnvKeys: string[] = []; + for (const key of requiredKeys) { + if ((envVars[key] ?? "").length > 0) { + // Set in Buzz env — satisfied, no action. + } else if (fileSatisfiedKeys.has(key)) { + // Not in Buzz env but present in the runtime config file — silenced. + fileSatisfiedEnvKeys.push(key); + } else { + missingEnvKeys.push(key); + } + } + + return { + missingNormalizedFields, + missingEnvKeys, + fileSatisfiedEnvKeys, + satisfied: + missingNormalizedFields.length === 0 && missingEnvKeys.length === 0, + }; +} diff --git a/desktop/src/features/agents/ui/personaProviderModelFields.tsx b/desktop/src/features/agents/ui/personaProviderModelFields.tsx new file mode 100644 index 0000000000..dd2fd5af59 --- /dev/null +++ b/desktop/src/features/agents/ui/personaProviderModelFields.tsx @@ -0,0 +1,226 @@ +/** + * Shared provider and model field components for agent dialogs. + * + * Both CreateAgentDialog (local mode) and EditAgentDialog import these + * instead of duplicating the picker logic. + */ +import type * as React from "react"; + +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { Input } from "@/shared/ui/input"; +import { + AUTO_MODEL_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, + CUSTOM_MODEL_DROPDOWN_VALUE, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + getModelSelectValue, + getPersonaProviderOptions, + hasPersonaModelOption, + type PersonaModelOption, +} from "./personaDialogPickers"; +import { MODEL_DISCOVERY_LOADING_VALUE } from "./usePersonaModelDiscovery"; +import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; + +export function RequiredFieldLabel({ + children, + htmlFor, + isRequired, +}: { + children: React.ReactNode; + htmlFor: string; + isRequired: boolean; +}) { + return ( + + ); +} + +export function AgentModelField({ + disabled, + discoveredModelOptions, + isCustomModelEditing, + isRequired, + model, + modelDiscoveryLoading, + modelDiscoveryStatus, + onIsCustomModelEditingChange, + onModelChange, +}: { + disabled: boolean; + discoveredModelOptions: readonly PersonaModelOption[] | null; + isCustomModelEditing: boolean; + isRequired: boolean; + model: string; + modelDiscoveryLoading: boolean; + modelDiscoveryStatus: PersonaModelDiscoveryStatus | null; + onIsCustomModelEditingChange: (value: boolean) => void; + onModelChange: (value: string) => void; +}) { + const trimmedModel = model.trim(); + + // Mirror Persona: static options serve as the fallback when discovery hasn't + // returned yet. Discovered options are ADDITIVE — we never disable the picker + // or hide the custom input just because discovery returned null. + const staticModelOptions: readonly PersonaModelOption[] = [ + { id: "", label: "Default model" }, + ]; + const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions; + + // isModelCustom: true when the current model isn't in any known option set. + // We check discovered options (when available) or runtime-static options so + // a previously-saved custom model stays in custom mode even before discovery. + const isModelCustom = !hasPersonaModelOption( + effectiveModelOptions, + trimmedModel, + ); + + const modelSelectValue = getModelSelectValue({ + isCustomModelEditing, + isModelCustom, + model, + }); + + // The select is only disabled for mutation pending — never for missing discovery. + // Default/custom options remain usable regardless of discovery state. + const selectDisabled = disabled || modelDiscoveryLoading; + + // Show the custom model input whenever custom mode is active or the current + // model is already custom — not gated on discovery having returned. + const showCustomModelInput = isCustomModelEditing || isModelCustom; + + return ( +
+ + Model + + + {showCustomModelInput ? ( + onModelChange(event.target.value)} + placeholder="Custom model ID" + value={model} + /> + ) : null} +

+ {modelDiscoveryLoading + ? "Loading models..." + : modelDiscoveryStatus !== null + ? modelDiscoveryStatus.message + : discoveredModelOptions !== null + ? "Saved changes take effect on the next start." + : "Select a provider above to see available models."} +

+
+ ); +} + +export function AgentProviderField({ + disabled, + isCustomProviderEditing, + isRequired, + onProviderChange, + provider, + selectedRuntime, +}: { + disabled: boolean; + isCustomProviderEditing: boolean; + isRequired: boolean; + onProviderChange: (value: string) => void; + provider: string; + selectedRuntime: AcpRuntimeCatalogEntry | undefined; +}) { + const trimmedProvider = provider.trim(); + const providerOptions = getPersonaProviderOptions( + trimmedProvider, + selectedRuntime?.id ?? "", + ); + const providerSelectValue = isCustomProviderEditing + ? CUSTOM_PROVIDER_DROPDOWN_VALUE + : trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE; + + return ( +
+ + LLM provider + + + {isCustomProviderEditing ? ( + onProviderChange(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> + ) : null} +

+ Changing the provider updates the available model list immediately. +

+
+ ); +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 1a2018c923..2cfc3d2206 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -36,6 +36,7 @@ import { KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. @@ -417,6 +418,7 @@ export function formatTimelineMessages( renderKey: event.localKey ?? event.id, createdAt: event.created_at, pubkey: authorPubkey, + signerPubkey: normalizePubkey(event.pubkey), author, avatarUrl: getAuthorAvatarUrl({ authorPubkey, diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b98b1f249b..fb07b7e312 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -17,6 +17,13 @@ export type TimelineMessage = { renderKey?: string; createdAt: number; pubkey?: string; + /** + * Raw signer pubkey (`event.pubkey`), normalized to lowercase hex. + * Distinct from `pubkey`, which is the resolved display-author and may be + * overridden by `actor` or `p` tags. Use this field — not `pubkey` — for + * security-sensitive checks such as authenticating config-nudge cards. + */ + signerPubkey?: string; author: string; avatarUrl?: string | null; role?: string; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4efec8a66e..ac1cd93991 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -20,6 +20,7 @@ import { KIND_HUDDLE_STARTED, KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; +import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -323,6 +324,14 @@ export const MessageRow = React.memo( emojiOnly && "text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle", )} + // Only pass the author pubkey for agent-authored messages so + // config-nudge cards can authenticate the sender. Uses the + // raw event signer (signerPubkey) — not the tag-attributed + // display author — to prevent actor/p-tag spoofing. + configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey( + message, + resolvedAgentPubkeys, + )} content={message.body} customEmoji={customEmoji} imetaByUrl={imetaByUrl} diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs new file mode 100644 index 0000000000..10a7bd69a0 --- /dev/null +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs @@ -0,0 +1,130 @@ +/** + * Integration-seam tests for getConfigNudgeAuthorPubkey. + * + * These tests exercise the exact field-selection seam that was broken: + * formatTimelineMessages retains signerPubkey = raw event.pubkey while + * resolving message.pubkey to the tag-attributed author. A test that + * passes the author pubkey by hand (as the previous regression did) would + * not catch a regression that reverts MessageRow to using message.pubkey + * (the spoofable field) instead of signerPubkey. + * + * By constructing a real TimelineMessage via formatTimelineMessages and + * passing it to getConfigNudgeAuthorPubkey — the same helper MessageRow + * calls — we lock the actual seam. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatTimelineMessages } from "../lib/formatTimelineMessages.ts"; +import { getConfigNudgeAuthorPubkey } from "./configNudgeAuthPubkey.ts"; + +const CHANNEL_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +// The raw event signer is a human (not an agent). +const HUMAN_SIGNER = + "1111111111111111111111111111111111111111111111111111111111111111"; +// The attributed agent pubkey (appears in actor/p tag). +const AGENT_PUBKEY = + "2222222222222222222222222222222222222222222222222222222222222222"; + +const AGENT_PUBKEYS = new Set([AGENT_PUBKEY]); + +function makeEvent(overrides = {}) { + return { + id: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + pubkey: HUMAN_SIGNER, + kind: 9, + created_at: 1_700_000_000, + content: "**Fizz** needs configuration.\n\n```buzz:config-nudge\n{}\n```", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function format(event) { + return formatTimelineMessages([event], null, undefined, null); +} + +// ── Spoof-regression test ───────────────────────────────────────────────────── +// +// A human-signed kind:9 event carries an `actor` tag attributing it to the +// agent. formatTimelineMessages resolves message.pubkey = agent (via the +// actor tag) but retains signerPubkey = human (from raw event.pubkey). +// getConfigNudgeAuthorPubkey MUST return undefined — the human signer is +// not in the known-agent set, so the card must not render. + +test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => { + const event = makeEvent({ + pubkey: HUMAN_SIGNER, + tags: [ + ["h", CHANNEL_ID], + ["actor", AGENT_PUBKEY], + ], + }); + + const [msg] = format(event); + + // Verify the seam: pubkey resolves to agent (tag-attributed), signerPubkey + // is human (raw signer). If these are equal the test loses its meaning. + assert.equal( + msg.pubkey?.toLowerCase(), + AGENT_PUBKEY, + "attributed pubkey must be the agent (actor tag wins in resolveEventAuthorPubkey)", + ); + assert.notEqual( + msg.signerPubkey, + AGENT_PUBKEY, + "signerPubkey must NOT be the agent (event was signed by human)", + ); + + // The guard must reject: signer is human, not in AGENT_PUBKEYS. + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + undefined, + "human signer with actor-tag attribution to agent must NOT enable the card", + ); +}); + +// ── Positive case ───────────────────────────────────────────────────────────── +// +// A genuine kind:9 signed by the agent itself: getConfigNudgeAuthorPubkey +// must return the agent pubkey so MessageRow enables the card. + +test("signerIsAgent_genuine_returnsAgentPubkey", () => { + const event = makeEvent({ pubkey: AGENT_PUBKEY }); + + const [msg] = format(event); + + assert.equal( + msg.signerPubkey, + AGENT_PUBKEY, + "signerPubkey must be the agent when the event is signed by the agent", + ); + + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + AGENT_PUBKEY, + "genuine agent-signed kind:9 must enable the card", + ); +}); + +// ── Non-kind:9 is always excluded ───────────────────────────────────────────── +// +// KIND_STREAM_MESSAGE_V2 (40002) is a valid timeline-content kind but is NOT +// kind:9 — the helper must return undefined even when the signer is a known +// agent, because the config-nudge sentinel is only emitted by the setup-listener +// on kind:9 (KIND_STREAM_MESSAGE) events. + +test("nonKind9_agentSigner_returnsUndefined", () => { + // kind 40002 = KIND_STREAM_MESSAGE_V2: a valid timeline event, not kind:9. + const event = makeEvent({ pubkey: AGENT_PUBKEY, kind: 40002 }); + + const [msg] = format(event); + + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + undefined, + "non-kind:9 events must never enable the card even if signer is known agent", + ); +}); diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts new file mode 100644 index 0000000000..65582f9650 --- /dev/null +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts @@ -0,0 +1,31 @@ +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Returns the pubkey to use as `configNudgeAuthorPubkey` for a given message, + * or `undefined` when the config-nudge card path should be disabled. + * + * The card is enabled ONLY when: + * 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener + * wire format. + * 2. `message.signerPubkey` is set and is a known agent — authenticates + * against the raw event signer (NOT `message.pubkey`, which is the + * tag-attributed display author and can be spoofed via `actor`/`p` tags). + * + * Extracting this predicate as a pure helper lets tests exercise the exact + * signer-vs-attributed-author distinction with a real `TimelineMessage` from + * `formatTimelineMessages`, without a full React render harness. + */ +export function getConfigNudgeAuthorPubkey( + message: Pick, + resolvedAgentPubkeys: Set, +): string | undefined { + if ( + message.kind === KIND_STREAM_MESSAGE && + message.signerPubkey && + resolvedAgentPubkeys.has(message.signerPubkey) + ) { + return message.signerPubkey; + } + return undefined; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 359954c1d6..8b6a57f16b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -37,6 +37,10 @@ import { import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { + consumePendingOpenEditAgent, + subscribeOpenEditAgent, +} from "@/features/agents/openEditAgentEvent"; import { duplicatePersonaDialogState, editPersonaDialogState, @@ -148,6 +152,21 @@ export function UserProfilePanel({ [onTabChange], ); const [editAgentOpen, setEditAgentOpen] = React.useState(false); + + // Open the Edit Agent dialog when `requestOpenEditAgent(pubkey)` fires from + // a card or other non-panel surface (e.g. `ConfigNudgeCard`). Mirrors the + // `subscribeOpenCreateAgent` pattern in AgentsView. + React.useEffect(() => { + if (!pubkey) return; + // Consume any pending request that arrived before this panel mounted. + if (consumePendingOpenEditAgent(pubkey)) { + setEditAgentOpen(true); + } + // Subscribe for events that arrive while the panel is mounted. + return subscribeOpenEditAgent(pubkey, () => { + setEditAgentOpen(true); + }); + }, [pubkey]); const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 2f9b1524d0..c48d59cec5 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1257,6 +1257,32 @@ export async function putAgentSessionConfig( return invokeTauri("put_agent_session_config", { pubkey, payload }); } +/** File-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). */ +export type RuntimeFileConfigSubset = { + /** Provider set in the harness config file. */ + provider: string | null; + /** Model set in the harness config file. */ + model: string | null; + /** Credential env key names whose values are present in the file config. */ + satisfiedEnvKeys: string[]; +}; + +/** + * Get the file-layer config for a runtime so dialogs can show + * "Set in goose config" instead of surfacing a false required-field marker. + * Returns `null` when the runtime has no config file or it cannot be parsed. + */ +export async function getRuntimeFileConfig( + runtimeId: string, +): Promise { + return invokeTauri( + "get_runtime_file_config", + { + runtimeId, + }, + ); +} + type RawUpdateManagedAgentResponse = { agent: RawManagedAgent; profile_sync_error: string | null; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 426e1e468a..cc641f1934 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -468,6 +468,7 @@ export type CreateManagedAgentInput = { systemPrompt?: string; avatarUrl?: string; model?: string; + provider?: string; mcpToolsets?: string; envVars?: Record; spawnAfterCreate?: boolean; diff --git a/desktop/src/shared/lib/computeConfigNudge.ts b/desktop/src/shared/lib/computeConfigNudge.ts new file mode 100644 index 0000000000..25040713d4 --- /dev/null +++ b/desktop/src/shared/lib/computeConfigNudge.ts @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import type { ConfigNudgePayload } from "@/shared/lib/configNudge"; +import { extractConfigNudge } from "@/shared/lib/configNudge"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Pure helper that computes the active `ConfigNudgePayload` for a message body. + * + * Called by `MarkdownInner` inside a `useMemo`; when the return value is + * non-null the markdown prose node is suppressed (via `selectProseOrNudge`) + * and replaced by `ConfigNudgeCard`. + * + * Extracted into its own module so it can be imported and tested without + * pulling in `markdown.tsx`'s heavy dependency chain (Tauri, emoji-mart, etc.). + */ +export function computeConfigNudge( + content: string, + interactive: boolean, + configNudgeAuthorPubkey: string | undefined | null, +): ConfigNudgePayload | null { + if (!interactive || !configNudgeAuthorPubkey) return null; + const payload = extractConfigNudge(content); + if (payload === null) return null; + if ( + normalizePubkey(payload.agent_pubkey) !== + normalizePubkey(configNudgeAuthorPubkey) + ) { + return null; + } + return payload; +} + +/** + * Returns `markdownNode` when no trusted config-nudge payload is present, + * or `null` when the card should render instead. + * + * This is the single production copy of the prose-suppression branch. + * `MarkdownInner` calls this instead of an inline ternary so the test suite + * can import and exercise the exact same branch, making a suppression revert + * observable at unit-test time. + */ +export function selectProseOrNudge( + configNudge: ConfigNudgePayload | null, + markdownNode: ReactNode, +): ReactNode { + return configNudge === null ? markdownNode : null; +} diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs new file mode 100644 index 0000000000..adee297f75 --- /dev/null +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { extractConfigNudge, stripConfigNudgeSentinel } from "./configNudge.ts"; + +// Helper: build a fenced sentinel body containing the given payload. +function withSentinel(prose, payload) { + return `${prose}\n\n\`\`\`buzz:config-nudge\n${JSON.stringify(payload)}\n\`\`\``; +} + +const FIZZ_PUBKEY = "aabbccddeeff0011"; +const ATLAS_PUBKEY = "ddeeff00112233aa"; +const CODEX_PUBKEY = "112233aabbccddee"; + +// ── extractConfigNudge ──────────────────────────────────────────────────────── + +test("extractConfigNudge returns null when no sentinel present", () => { + assert.equal( + extractConfigNudge("**Fizz** needs configuration before it can respond."), + null, + ); +}); + +test("extractConfigNudge returns null for empty string", () => { + assert.equal(extractConfigNudge(""), null); +}); + +test("extractConfigNudge parses env_key requirement", () => { + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + const content = [ + "**Fizz** needs configuration before it can respond:", + "- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables", + "", + "Open Edit Agent in the Buzz app to set these.", + "", + "```buzz:config-nudge", + JSON.stringify(payload), + "```", + ].join("\n"); + + assert.deepEqual(extractConfigNudge(content), payload); +}); + +test("extractConfigNudge parses normalized_field requirement", () => { + const payload = { + agent_name: "Atlas", + agent_pubkey: ATLAS_PUBKEY, + requirements: [{ surface: "normalized_field", field: "provider" }], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses cli_login requirement", () => { + const payload = { + agent_name: "Codex", + agent_pubkey: CODEX_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["codex", "login", "status"], + setup_copy: "run `codex login --with-api-key`", + }, + ], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses multiple requirements of mixed types", () => { + const payload = { + agent_name: "Atlas", + agent_pubkey: ATLAS_PUBKEY, + requirements: [ + { surface: "normalized_field", field: "model" }, + { surface: "env_key", key: "OPENAI_API_KEY" }, + { + surface: "cli_login", + probe_args: ["codex", "login"], + setup_copy: "run `codex login`", + }, + ], + }; + const result = extractConfigNudge(withSentinel("prose", payload)); + assert.equal(result?.requirements.length, 3); + assert.equal(result?.agent_name, "Atlas"); + assert.equal(result?.agent_pubkey, ATLAS_PUBKEY); +}); + +test("extractConfigNudge returns null for malformed JSON", () => { + const content = "prose\n\n```buzz:config-nudge\nnot{valid}json\n```"; + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when JSON is valid but missing agent_name", () => { + const content = withSentinel("prose", { + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when JSON is valid but missing agent_pubkey", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + requirements: [], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when requirements contain unknown surface", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "unknown_surface", data: "x" }], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when requirements is not an array", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: "bad", + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge ignores regular code blocks with other language tags", () => { + const content = 'prose\n\n```json\n{"key":"val"}\n```'; + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge handles empty requirements array", () => { + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +// ── stripConfigNudgeSentinel ────────────────────────────────────────────────── + +test("stripConfigNudgeSentinel returns content unchanged when no sentinel", () => { + const content = "plain message body"; + assert.equal(stripConfigNudgeSentinel(content), content); +}); + +test("stripConfigNudgeSentinel strips the sentinel block", () => { + const prose = "**Fizz** needs configuration.\n\nOpen Edit Agent."; + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }; + const content = withSentinel(prose, payload); + const stripped = stripConfigNudgeSentinel(content); + assert.ok(!stripped.includes("buzz:config-nudge"), "sentinel must be gone"); + assert.ok(stripped.includes("needs configuration"), "prose must survive"); +}); + +test("stripConfigNudgeSentinel removes preceding blank line", () => { + const content = "prose\n\n```buzz:config-nudge\n{}\n```"; + const stripped = stripConfigNudgeSentinel(content); + // Should not end with multiple newlines — the blank line separator was eaten. + assert.ok(!stripped.endsWith("\n\n"), "trailing blank line must be trimmed"); +}); + +// ── Auth-gate invariants (Fix 1 — authenticate before rendering) ────────────── +// +// The full auth check lives in MarkdownInner's useMemo: it calls +// normalizePubkey(payload.agent_pubkey) !== normalizePubkey(configNudgeAuthorPubkey) +// and returns null when they don't match. The tests below verify: +// (a) extractConfigNudge still returns the payload regardless of the caller +// (extraction is pure; auth is responsibility of the renderer), +// (b) when agent_pubkey doesn't match the author, the comparison yields null +// (simulated inline to keep the test self-contained without React). + +import { normalizePubkey } from "./pubkey.ts"; + +/** + * Simulate the auth guard in MarkdownInner's configNudge useMemo. + * Returns the payload if it passes auth, null otherwise. + */ +function authGuardedExtract(content, configNudgeAuthorPubkey) { + if (!configNudgeAuthorPubkey) return null; + const payload = extractConfigNudge(content); + if (payload === null) return null; + if ( + normalizePubkey(payload.agent_pubkey) !== + normalizePubkey(configNudgeAuthorPubkey) + ) { + return null; + } + return payload; +} + +const FIZZ_PUBKEY_AUTH = "aabbccddeeff0011223344556677889900aabbcc"; +const OTHER_PUBKEY = "ffffffffffffffffffffffffffffffffffffffff"; + +function makeNudgeBody(agentPubkey) { + const payload = { + agent_name: "Fizz", + agent_pubkey: agentPubkey, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + return `**Fizz** needs configuration.\n\n\`\`\`buzz:config-nudge\n${JSON.stringify(payload)}\n\`\`\``; +} + +test("authGuard_noAuthorPubkey_returnsNull", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + assert.equal( + authGuardedExtract(body, null), + null, + "null configNudgeAuthorPubkey must yield null (card path off)", + ); +}); + +test("authGuard_undefinedAuthorPubkey_returnsNull", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + assert.equal( + authGuardedExtract(body, undefined), + null, + "undefined configNudgeAuthorPubkey must yield null (card path off)", + ); +}); + +test("authGuard_mismatchedAuthor_returnsNull", () => { + // Fence carries FIZZ_PUBKEY_AUTH but caller says the message author is OTHER_PUBKEY. + // The card must not render and the fence must NOT be stripped by the caller. + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + const result = authGuardedExtract(body, OTHER_PUBKEY); + assert.equal( + result, + null, + "mismatched agent_pubkey vs configNudgeAuthorPubkey must yield null", + ); + // Fence text must still be in the raw body (not stripped) — stripping only + // happens when configNudge !== null. + assert.ok( + body.includes("buzz:config-nudge"), + "fence must remain in body when auth guard returns null", + ); +}); + +test("authGuard_matchingAuthor_returnsPayload", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + const result = authGuardedExtract(body, FIZZ_PUBKEY_AUTH); + assert.notEqual(result, null, "matching author must yield the payload"); + assert.equal(result?.agent_pubkey, FIZZ_PUBKEY_AUTH); +}); + +test("authGuard_matchingAuthor_caseInsensitive", () => { + // normalizePubkey lowercases both sides; mixed-case must still match. + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH.toUpperCase()); + const result = authGuardedExtract(body, FIZZ_PUBKEY_AUTH.toLowerCase()); + assert.notEqual( + result, + null, + "case-insensitive pubkey comparison must pass auth", + ); +}); + +// ── Signer-vs-tag-attribution regression ───────────────────────────────────── +// +// Pass 2 finding: message.pubkey (display author) can be overridden by actor/p +// tags, so a human-signed event can carry the agent pubkey as its attributed +// author. The auth guard must check the RAW EVENT SIGNER (signerPubkey from +// formatTimelineMessages), not the display author. +// +// We simulate this by passing the HUMAN pubkey as configNudgeAuthorPubkey +// while the payload's agent_pubkey is the AGENT pubkey — as MessageRow now +// does (passes signerPubkey, not pubkey). +// +// If the guard were still using the tag-attributed pubkey (display author = +// agent pubkey), it would pass auth and return the payload — a forged card. +// With the signer check, the human signer != agent payload key → null. + +const HUMAN_PUBKEY = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; +const AGENT_PUBKEY_FOR_SPOOF = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + +test("authGuard_signerIsHuman_tagAttributedToAgent_returnsNull", () => { + // The event was signed by HUMAN_PUBKEY but its `actor` or first `p` tag + // attributes it to AGENT_PUBKEY_FOR_SPOOF (display author = agent). + // The payload's agent_pubkey matches the ATTRIBUTED pubkey, not the signer. + // MessageRow now passes signerPubkey (HUMAN) as configNudgeAuthorPubkey; + // the auth guard must reject since HUMAN != AGENT. + const body = makeNudgeBody(AGENT_PUBKEY_FOR_SPOOF); + + // Simulate what MessageRow does: pass the RAW SIGNER, not the display author. + const result = authGuardedExtract(body, HUMAN_PUBKEY); + assert.equal( + result, + null, + "raw signer is human; even if actor-tag attributes to agent, must yield null", + ); + // Fence must remain — not stripped — when auth fails. + assert.ok( + body.includes("buzz:config-nudge"), + "fence must remain in body when signer auth fails", + ); +}); + +// ── Render-suppression contract ─────────────────────────────────────────────── +// +// When `extractConfigNudge` returns a non-null payload, `markdown.tsx` MUST +// suppress the prose markdown node and render only the card: +// +// {configNudge === null ? markdownNode : null} +// +// This test pins that contract at the parse level. A revert to `{markdownNode}` +// (rendering both prose and card) would violate the invariants checked here: +// the prose text is still in the raw wire body and would re-appear alongside +// the card. If you break these assertions by changing `markdown.tsx` to render +// both, these tests must be updated AND the visual duplication re-approved. + +test("nudgePresent_extractNonNull_and_stripRemovesSentinel", () => { + const prose = + "**Fizz** needs configuration before it can respond:\n- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables\n\nOpen Edit Agent in the Buzz app to set these."; + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + const body = withSentinel(prose, payload); + + // 1. extractConfigNudge sees the sentinel → card renders, prose must be + // suppressed. If this returns null the card never renders at all. + const nudge = extractConfigNudge(body); + assert.notEqual( + nudge, + null, + "sentinel present → extractConfigNudge must return payload", + ); + + // 2. stripConfigNudgeSentinel removes the fence so whatever markdown node + // IS rendered doesn't also show the raw JSON block. The stripped string + // must NOT contain the sentinel open-fence marker. + const stripped = stripConfigNudgeSentinel(body); + assert.ok( + !stripped.includes("buzz:config-nudge"), + "stripped content must not contain the fence marker", + ); + + // 3. The prose IS still in the stripped string — that is intentional: the + // stripped string is what non-card clients display. On desktop the prose + // is suppressed by the `configNudge === null ? markdownNode : null` guard + // in markdown.tsx, NOT by stripping it from the string. If a future + // change strips the prose from the string here, that would break CLI + // fallback — this assertion guards against that regression too. + assert.ok( + stripped.includes("ANTHROPIC_API_KEY"), + "prose content must remain in stripped string for non-card client fallback", + ); +}); + +test("nudgeAbsent_extractNull_markdownNodeShown", () => { + // When there is no sentinel, extractConfigNudge returns null, meaning + // markdown.tsx renders `markdownNode` normally (no card, no suppression). + const plainBody = "Hello from an agent without any configuration sentinel."; + assert.equal( + extractConfigNudge(plainBody), + null, + "no sentinel → extractConfigNudge must return null so markdownNode is shown", + ); + // stripConfigNudgeSentinel on sentinel-free content is a no-op. + assert.equal( + stripConfigNudgeSentinel(plainBody), + plainBody, + "no sentinel → stripConfigNudgeSentinel must return content unchanged", + ); +}); diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts new file mode 100644 index 0000000000..627e0f6e8c --- /dev/null +++ b/desktop/src/shared/lib/configNudge.ts @@ -0,0 +1,133 @@ +/** + * Utilities for extracting and parsing the `buzz:config-nudge` sentinel that + * `buzz-acp`'s setup-listener appends to its kind:9 nudge body. + * + * Wire format (appended by `setup_mode.rs::nudge_body()`): + * + * ``` + * ```buzz:config-nudge + * {"agent_name":"…","agent_pubkey":"…","requirements":[…]} + * ``` + * ``` + * + * The prose above the fence is left untouched and used as a plaintext + * fallback for non-card clients. The desktop detects the sentinel here, + * strips it from the displayed markdown, and renders a `ConfigNudgeCard`. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A single missing requirement — mirrors the Rust `RequirementPayload` enum. */ +export type ConfigNudgeRequirement = + | { surface: "normalized_field"; field: string } + | { surface: "env_key"; key: string } + | { + surface: "cli_login"; + probe_args: string[]; + setup_copy: string; + }; + +/** + * The structured payload embedded in the `buzz:config-nudge` sentinel block. + * Mirrors the Rust `SetupPayload` struct. + */ +export type ConfigNudgePayload = { + agent_name: string; + /** Hex-encoded agent pubkey. Used by the desktop card action to open Edit Agent. */ + agent_pubkey: string; + requirements: ConfigNudgeRequirement[]; +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FENCE_OPEN = "```buzz:config-nudge"; +const FENCE_CLOSE = "```"; + +// ── Extractor ───────────────────────────────────────────────────────────────── + +/** + * Extract the `ConfigNudgePayload` from a message body, if present. + * + * Returns `null` when: + * - the sentinel fence is absent + * - the JSON inside is malformed + * - the parsed value doesn't match the expected shape + * + * Never throws — all errors are swallowed so this is safe to call in the + * render path. + */ +export function extractConfigNudge(content: string): ConfigNudgePayload | null { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return null; + + // The JSON starts on the line after the opening fence. + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) return null; + + // The JSON ends at the next closing ``` that appears on its own line. + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) return null; + + const json = content.slice(jsonStart + 1, closeIdx).trim(); + if (!json) return null; + + try { + const parsed: unknown = JSON.parse(json); + return isConfigNudgePayload(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Strip the `buzz:config-nudge` sentinel block (and any preceding blank line) + * from a message body. Returns the original string unchanged when no sentinel + * is present. + * + * Used so the prose fallback is rendered without the raw code block. + */ +export function stripConfigNudgeSentinel(content: string): string { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return content; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, openIdx); + if (closeIdx === -1) return content; + + const afterFence = closeIdx + `\n${FENCE_CLOSE}`.length; + // Trim a preceding blank line so the prose doesn't gain a trailing gap. + const prose = content.slice(0, openIdx).replace(/\n{2,}$/, "\n"); + return prose + content.slice(afterFence); +} + +// ── Type-guard ───────────────────────────────────────────────────────────────── + +function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + if (typeof r.surface !== "string") return false; + switch (r.surface) { + case "normalized_field": + return typeof r.field === "string"; + case "env_key": + return typeof r.key === "string"; + case "cli_login": + return ( + Array.isArray(r.probe_args) && + r.probe_args.every((a) => typeof a === "string") && + typeof r.setup_copy === "string" + ); + default: + return false; + } +} + +function isConfigNudgePayload(v: unknown): v is ConfigNudgePayload { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + return ( + typeof p.agent_name === "string" && + typeof p.agent_pubkey === "string" && + Array.isArray(p.requirements) && + p.requirements.every(isConfigNudgeRequirement) + ); +} diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx new file mode 100644 index 0000000000..767a83417a --- /dev/null +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -0,0 +1,125 @@ +import { AlertTriangle } from "lucide-react"; + +import { requestOpenEditAgent } from "@/features/agents/openEditAgentEvent"; +import type { ConfigNudgePayload } from "@/shared/lib/configNudge"; +import { cn } from "@/shared/lib/cn"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { + Attachment, + AttachmentActions, + AttachmentContent, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/shared/ui/attachment"; + +/** + * Stable key for a requirement row. The combination of surface + primary + * value uniquely identifies a requirement within a nudge payload. + * The fallback position index handles edge cases like two identical rows. + */ +function requirementKey( + req: ConfigNudgePayload["requirements"][number], + index: number, +): string { + switch (req.surface) { + case "env_key": + return `env_key:${req.key}:${index}`; + case "normalized_field": + return `normalized_field:${req.field}:${index}`; + case "cli_login": + return `cli_login:${req.probe_args.join(",")}:${index}`; + } +} + +/** + * Inline card rendered when the desktop detects a `buzz:config-nudge` + * sentinel in a kind:9 message body. + * + * Uses the `Attachment` primitive's built-in `state="error"` destructive-tint + * variant so it is visually distinct and consistent with other error states in + * the system. + * + * The card's trigger opens the Edit Agent dialog for the agent by: + * 1. Calling `openProfilePanel(pubkey)` (from ProfilePanelContext) to ensure + * the profile panel is visible. + * 2. Dispatching `requestOpenEditAgent(pubkey)` so that `UserProfilePanel` + * auto-opens the edit dialog once it mounts with that pubkey. + */ +export function ConfigNudgeCard({ + className, + nudge, +}: { + className?: string; + nudge: ConfigNudgePayload; +}) { + const { openProfilePanel } = useProfilePanel(); + + const handleOpen = () => { + openProfilePanel?.(nudge.agent_pubkey); + requestOpenEditAgent(nudge.agent_pubkey); + }; + + return ( + + + + + + {nudge.agent_name} needs configuration + +
+ {nudge.requirements.map((req, i) => ( + + ))} +
+
+ + + Edit Agent + + + +
+ ); +} + +function RequirementRow({ + requirement, +}: { + requirement: ConfigNudgePayload["requirements"][number]; +}) { + switch (requirement.surface) { + case "env_key": + return ( +
+ Set{" "} + + {requirement.key} + {" "} + in Edit Agent → Environment variables +
+ ); + case "normalized_field": + return ( +
+ Set the {requirement.field} field in Edit Agent + dropdowns +
+ ); + case "cli_login": + return ( +
+ {requirement.setup_copy} +
+ ); + } +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index ede8e86fee..9cae0bcbf3 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -656,3 +656,130 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => { assert.equal(kids[0].type, "inlineCode"); assert.equal(kids[0].value, "buzz://message?channel=c&id=m"); }); + +// ── selectProseOrNudge render-level guard ───────────────────────────────────── +// +// `MarkdownInner` calls `selectProseOrNudge(configNudge, markdownNode)` — +// the single production copy of the prose-suppression branch, exported from +// `computeConfigNudge.ts`. These tests import and call that exact function +// through a minimal stub so a revert that changes its behavior is caught +// at unit-test time. +// +// (The full `Markdown` component cannot be rendered in this environment: +// emoji-mart JSON imports crash the module loader before React runs.) + +import { + computeConfigNudge, + selectProseOrNudge, +} from "../lib/computeConfigNudge.ts"; +import { stripConfigNudgeSentinel } from "../lib/configNudge.ts"; + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const HUMAN_PUBKEY = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + +function nudgeBody(agentPubkey) { + return [ + "**Fizz** needs configuration before it can respond:", + "- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables", + "", + "Open Edit Agent in the Buzz app to set these.", + "", + "```buzz:config-nudge", + JSON.stringify({ + agent_name: "Fizz", + agent_pubkey: agentPubkey, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }), + "```", + ].join("\n"); +} + +// Minimal wrapper that calls the real production functions from +// `computeConfigNudge.ts` — `computeConfigNudge` to detect the payload and +// `selectProseOrNudge` for the prose-suppression branch — without importing +// any Tauri or context dependencies. +function GuardStub({ content, configNudgeAuthorPubkey }) { + const configNudge = computeConfigNudge( + content, + true, + configNudgeAuthorPubkey, + ); + const stripped = + configNudge !== null ? stripConfigNudgeSentinel(content) : content; + const markdownNode = React.createElement( + "div", + { "data-markdown-prose": "" }, + stripped, + ); + return React.createElement( + "div", + null, + selectProseOrNudge(configNudge, markdownNode), + configNudge !== null + ? React.createElement("div", { "data-config-nudge": "" }) + : null, + ); +} + +test("nudgeGuard_sentinelPresentMatchingAuthor_cardRenderedProseAbsent", () => { + const body = nudgeBody(AGENT_PUBKEY); + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: body, + configNudgeAuthorPubkey: AGENT_PUBKEY, + }), + ); + // Card placeholder rendered. + assert.ok( + html.includes("data-config-nudge"), + "data-config-nudge must be present when sentinel+author match", + ); + // Prose suppressed — the raw fallback text must NOT appear outside the card. + assert.ok( + !html.includes("needs configuration before it can respond"), + "prose must be absent when card renders", + ); + assert.ok( + !html.includes("data-markdown-prose"), + "markdownNode must be null (not rendered) when configNudge is non-null", + ); +}); + +test("nudgeGuard_sentinelPresentWrongAuthor_proseRenderedCardAbsent", () => { + // Sentinel present, but author pubkey is human — auth guard rejects, prose shown. + const body = nudgeBody(AGENT_PUBKEY); + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: body, + configNudgeAuthorPubkey: HUMAN_PUBKEY, + }), + ); + assert.ok( + !html.includes("data-config-nudge"), + "card must be absent when author pubkey does not match sentinel agent_pubkey", + ); + assert.ok( + html.includes("data-markdown-prose"), + "markdownNode must render when configNudge is null (auth mismatch)", + ); +}); + +test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => { + const plain = "Hello, this is a normal message with no sentinel."; + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: plain, + configNudgeAuthorPubkey: AGENT_PUBKEY, + }), + ); + assert.ok( + !html.includes("data-config-nudge"), + "card must be absent when no sentinel is present", + ); + assert.ok( + html.includes("data-markdown-prose"), + "markdownNode must render when no sentinel is present", + ); +}); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index f78fc7da33..8275c1b3c1 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -37,8 +37,13 @@ import remarkMentions from "@/shared/lib/remarkMentions"; import remarkSpoilers from "@/shared/lib/remarkSpoilers"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import { AttachmentGroup } from "@/shared/ui/attachment"; +import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; +import { + computeConfigNudge, + selectProseOrNudge, +} from "@/shared/lib/computeConfigNudge"; import { INLINE_CODE_CHIP_CLASS, MENTION_CHIP_BASE_CLASSES, @@ -1934,6 +1939,7 @@ function createMarkdownComponents( function MarkdownInner({ channelNames, className, + configNudgeAuthorPubkey, content, customEmoji, imetaByUrl, @@ -1974,6 +1980,10 @@ function MarkdownInner({ () => (interactive ? extractSupportedLinkPreviews(content) : []), [content, interactive], ); + const configNudge = React.useMemo( + () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), + [content, interactive, configNudgeAuthorPubkey], + ); const runtimeRef = useLatestRef({ agentMentionPubkeysByName, channels, @@ -2014,11 +2024,16 @@ function MarkdownInner({ let processedContent = content; - if (/^(?:\s{2}\n)+/.test(content)) { + // Note: stripping the sentinel here is intentionally omitted. When + // configNudge !== null, selectProseOrNudge() returns null — suppressing + // the prose node entirely — so processedContent is never rendered and + // stripConfigNudgeSentinel would be dead work on that path. + + if (/^(?:\s{2}\n)+/.test(processedContent)) { processedContent = `\u200B${processedContent}`; } - if (/(?:\s{2}\n)+$/.test(content)) { + if (/(?:\s{2}\n)+$/.test(processedContent)) { processedContent = `${processedContent}\u200B`; } @@ -2057,7 +2072,15 @@ function MarkdownInner({ )} > - {markdownNode} + {selectProseOrNudge(configNudge, markdownNode)} + {configNudge !== null ? ( + + + + ) : null} {resolvedLinkPreviews.length > 0 ? ( [0], diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts new file mode 100644 index 0000000000..00d3dba117 --- /dev/null +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -0,0 +1,298 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/agent-readiness"; + +// An existing buzz-agent managed agent for the Edit-dialog shot. +// Tyler's pubkey maps to gooseSurface in the mock bridge (runtimeId: "goose"), +// which supports LLM provider selection — the shared AgentProviderField / +// AgentModelField components render for it just as they do for buzz-agent. +const EDIT_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; + +/** + * Navigate to the agents view and open the Create Agent dialog via the + * "Custom agent" option, then fill a placeholder name. + */ +async function openCreateDialog(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByText("Custom agent").click(); + await page.getByTestId("agent-name-input").fill("Test Agent"); +} + +/** + * Wait for the provider field to become visible (buzz-agent auto-selected) + * then select the given provider value. + */ +async function selectProvider( + page: import("@playwright/test").Page, + provider: string, +) { + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-provider").selectOption(provider); +} + +/** + * Choose "Custom model..." from the model dropdown and fill a custom model id. + */ +async function setCustomModel( + page: import("@playwright/test").Page, + modelId: string, +) { + await page.locator("#agent-model").selectOption("__custom_model__"); + await page.getByLabel("Custom model ID").fill(modelId); +} + +/** + * Open the Edit Agent dialog for a seeded managed agent. + * Opens the agents view, clicks the agent card to open the profile panel, + * then clicks the Edit quick-action button. + */ +async function openEditDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + const agentButton = page.getByRole("button", { + name: `${agentName} agent profile`, + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("user-profile-edit-agent").click(); + + // Wait for the Edit dialog's provider field (goose runtime supports it). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); +} + +// Settle any in-flight CSS / Web Animations before capture. +async function settleAnimations(page: import("@playwright/test").Page) { + await page.evaluate(() => + Promise.all(document.getAnimations().map((a) => a.finished)), + ); +} + +test.describe("agent readiness gate screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + // Shot 01: buzz-agent selected, provider empty → required marker shown, save allowed. + test("01-create-buzzagent-empty-provider-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + + // Wait for buzz-agent to auto-select and the provider field to render. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + + // Provider empty → required marker shown; submit is now ENABLED. + // Wait up to 10 s for prereqsQuery to resolve (async even in mock env). + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/01-create-buzzagent-empty-provider-marker.png`, + }); + }); + + // Shot 02: buzz-agent + anthropic selected, model empty → required marker shown, save allowed. + test("02-create-buzzagent-empty-model-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + + // Model still empty → required marker shown; submit is now ENABLED. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/02-create-buzzagent-empty-model-marker.png`, + }); + }); + + // Shot 03: buzz-agent + anthropic + model set, ANTHROPIC_API_KEY missing → + // amber required row names the key, submit ENABLED (no longer blocked). + test("03-create-missing-credential-row", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + await setCustomModel(page, "claude-opus-4-5"); + + // Required row should name ANTHROPIC_API_KEY; submit is now ENABLED. + await expect(page.getByTestId("env-vars-required-key")).toHaveText( + "ANTHROPIC_API_KEY", + ); + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + + // Scroll the required row into view so it is visible in the screenshot. + await page.getByTestId("env-vars-required-key").scrollIntoViewIfNeeded(); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/03-create-missing-credential-row.png`, + }); + }); + + // Shot 04: all required fields satisfied → Create button enabled. + test("04-create-all-required-satisfied-enabled", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + await setCustomModel(page, "claude-opus-4-5"); + await page + .getByTestId("env-vars-required-value") + .fill("sk-test-api-key-for-e2e"); + + // All required fields satisfied → submit enabled. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 5_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/04-create-all-required-satisfied-enabled.png`, + }); + }); + + // Shot 05: claude runtime (CLI-login) — provider/model not required, submit enabled. + // Override the catalog to make claude fully available so it appears in the dropdown. + test("05-create-cli-login-runtime-no-provider-required", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: "buzz-dev-mcp", + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + }, + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "available", + command: "/usr/local/bin/claude-agent", + binary_path: "/usr/local/bin/claude-agent", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install the Claude Code ACP adapter via npm.", + install_instructions_url: + "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", + }, + ], + }); + + await openCreateDialog(page); + + // Wait for buzz-agent to auto-select (provider field visible), then + // switch to claude. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-runtime").selectOption("claude"); + + // Provider/model fields hidden for CLI-login runtimes. + await expect(page.locator("#agent-provider")).not.toBeVisible(); + // Submit enabled without provider/model. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 5_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/05-create-cli-login-runtime-no-provider-required.png`, + }); + }); + + // Shot 07: Edit dialog for an existing managed agent (goose runtime) showing + // the shared AgentProviderField / AgentModelField extraction. + test("07-edit-dialog-extracted-fields", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: EDIT_AGENT_PUBKEY, + name: "Tyler Agent", + status: "stopped" as const, + channelNames: ["agents"], + }, + ], + }); + + await openEditDialog(page, "Tyler Agent"); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/07-edit-dialog-extracted-fields.png`, + }); + }); + + // Shot 08: goose runtime, provider empty → required marker shown, save allowed (same as buzz-agent). + test("08-create-goose-empty-provider-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + + // Buzz-agent auto-selects first; wait for its provider field, then + // switch to goose to confirm its required-marker behavior is identical. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-runtime").selectOption("goose"); + + // Provider field still visible for goose (also a provider-selection runtime). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 5_000, + }); + // Required marker shown; submit is now ENABLED. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/08-create-goose-empty-provider-marker.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d504dd17f1..2c1f9ae1d5 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -122,6 +122,22 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.getByText("Custom agent").click(); await page.getByTestId("agent-name-input").fill(agentName); + + // The buzz-agent runtime requires provider + model selection. Wait for the + // provider field to appear (it renders once the ACP runtime catalog loads + // and the auto-select effect resolves to buzz-agent). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-provider").selectOption("anthropic"); + await page.locator("#agent-model").selectOption("__custom_model__"); + await page.getByLabel("Custom model ID").fill("claude-opus-4-5"); + // Supply a credential so the agent can actually run (create is no longer + // blocked by a missing key, but we include it for a realistic test). + await page + .getByTestId("env-vars-required-value") + .fill("sk-test-api-key-for-e2e"); + await page.getByRole("button", { name: "Advanced setup" }).click(); await page.getByTestId("agent-parallelism-input").fill("3"); await page From 429f54b4f45df0d5f4a52aafb8913e903ebdc6b3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 18:30:22 -0400 Subject: [PATCH 014/291] feat(archive): add agent turn-metric (kind 44200) local archive (#1555) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 20 +- desktop/src-tauri/build.rs | 8 + desktop/src-tauri/src/archive/mod.rs | 246 +++++++++++++++++- desktop/src-tauri/src/archive/pipeline.rs | 69 ++++- desktop/src-tauri/src/archive/store.rs | 203 +++++++++++++++ .../src/commands/agent_metric_archive.rs | 35 +++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + desktop/src/app/AppShell.tsx | 2 + .../agentMetricArchivePreference.ts | 71 +++++ .../ui/LocalArchiveSettingsCard.tsx | 142 +++++++++- .../useAgentMetricArchiveSeed.test.mjs | 222 ++++++++++++++++ .../useAgentMetricArchiveSeed.ts | 114 ++++++++ .../useObserverArchiveSeed.test.mjs | 47 ++-- .../local-archive/useObserverArchiveSeed.ts | 28 +- desktop/src/shared/api/tauriArchive.ts | 28 ++ desktop/src/shared/constants/kinds.ts | 1 + 17 files changed, 1170 insertions(+), 70 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_metric_archive.rs create mode 100644 desktop/src/features/local-archive/agentMetricArchivePreference.ts create mode 100644 desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs create mode 100644 desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f7890909d2..13fa7ddc0a 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -69,7 +69,25 @@ const overrides = new Map([ // E2E test-depth hardening added the owner_p content round-trip assert and // two empty-table drop asserts (~24 lines). Queued to split the test module // into archive/mod_tests.rs in a follow-up. - ["src-tauri/src/archive/mod.rs", 1465], + // agent-metric-archive PR added 4 new unit tests (owner_p+44200 routing, + // decrypt-success plaintext storage, decrypt-fail-closed, 24200 still + // ephemeral) + run_batch_sync_with_keys helper (~175 lines). Same test-growth + // category as above. Still queued to split. + // merge_save_subscription_kinds command + owner_p-kinds TOCTOU fix adds ~30 + // lines. Atomic merge to close the concurrent-seed race. Still queued to split. + // IMMEDIATE-tx fix: BEGIN IMMEDIATE replaces DEFERRED unchecked_transaction + // in merge_owner_p_kinds comment block (~5 lines). Still queued to split. + // doc-comment: mixed row-shape invariant on read_archived_events (~5 lines). + ["src-tauri/src/archive/mod.rs", 1705], + // archive/store.rs: merge_owner_p_kinds fn (read+union+upsert under a single + // SQLite tx) + 4 unit tests (create-when-none, adds-kind, idempotent, + // concurrent-interleave). Load-bearing TOCTOU fix for the owner_p shared row. + // Queued to split test module into store_tests.rs in a follow-up. + // IMMEDIATE-tx fix: replaces mislabeled sequential test with a real + // two-connection WAL regression test (tempfile + std::thread + Barrier, + // ~85 lines). The new test exercises the actual concurrent write path and + // fails fast if IMMEDIATE guard is removed. Still queued to split. + ["src-tauri/src/archive/store.rs", 1179], ["src-tauri/src/commands/agents.rs", 1437], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index cce1716dcc..022ef47684 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -14,6 +14,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -81,6 +82,13 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); } + // Presence-only flag: when set (any non-empty value), agent-turn-metric + // archive defaults to ON for the current identity on first run. OSS builds + // leave this unset → default OFF. + if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 300c54532b..d28c5ef8e1 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -5,9 +5,11 @@ //! //! Two access proof paths, chosen by event kind: //! -//! **Persistent scopes** (`channel_h`, `referenced_e`): the relay is the -//! source of truth. Candidates are grouped and re-queried via a batched -//! authed `/query`; only events the relay returns are inserted. +//! **Persistent scopes** (`channel_h`, `referenced_e`, and `owner_p`+44200): +//! the relay is the source of truth. Candidates are grouped and re-queried via +//! a batched authed `/query`; only events the relay returns are inserted. +//! For kind-44200 (agent turn metrics), content is decrypted at ingest and +//! stored as plaintext JSON — fail-closed (decrypt error → drop). //! //! **Ephemeral scope** (`owner_p`, kind 24200 observer frames): the relay //! never stores these, so `/query` cannot verify them. The relay's REQ-time @@ -32,6 +34,7 @@ use crate::relay::{query_relay, relay_ws_url_with_override}; // ── Constants ─────────────────────────────────────────────────────────────── const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; +const KIND_AGENT_TURN_METRIC: u16 = 44200; const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; // ── DB helpers ─────────────────────────────────────────────────────────────── @@ -143,12 +146,18 @@ pub async fn archive_events( // ── Phase 3: persist (sync) ────────────────────────────────────────────── let conn = open_db()?; + let owner_keys = { + let keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + keys_guard.clone() + // guard drops here + }; commit_archive( bucket_results, plan.ephemeral, plan.pre_dropped, &identity_pk, &relay_url, + &owner_keys, now, &conn, ) @@ -375,6 +384,32 @@ async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), St Ok(()) } +// ── merge_save_subscription_kinds ──────────────────────────────────────────── + +/// Atomically merge `kind` into the `owner_p` save subscription for the +/// current identity + relay. +/// +/// Reads the existing `kinds` array, unions in `kind`, and writes back — all +/// inside a single SQLite transaction. This prevents the TOCTOU race where two +/// concurrent seed hooks (observer seed + metric seed) each read an empty row +/// and the last writer clobbers the first. +/// +/// Called by both `useObserverArchiveSeed` (kind 24200) and +/// `useAgentMetricArchiveSeed` (kind 44200) instead of the former +/// list → merge-in-TS → create pattern. +#[tauri::command] +pub fn merge_save_subscription_kinds(state: State<'_, AppState>, kind: u32) -> Result<(), String> { + if kind > u32::from(u16::MAX) { + return Err(format!("kind {kind} is out of the valid range 0..=65535")); + } + + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + let conn = open_db()?; + store::merge_owner_p_kinds(&conn, &identity_pk, &relay_url, &identity_pk, kind, now) +} + // ── list_save_subscriptions ────────────────────────────────────────────────── /// List all save subscriptions for the current identity + relay. @@ -429,6 +464,11 @@ const DEFAULT_READ_LIMIT: i64 = 50; /// /// `kinds` is an optional filter; an empty array means "no kinds matched" /// (not "all kinds") — callers should pass `null`/`None` when they want all. +/// +/// Note: stored row payloads are not uniform — kind 44200 rows store the raw +/// metric payload JSON, while all other kinds store full Nostr Event JSON. A +/// caller doing `Event::from_json` on an unfiltered read must filter by kind +/// first (today's only reader filters `kinds: [24200]`). #[tauri::command] pub fn read_archived_events( state: State<'_, AppState>, @@ -519,6 +559,26 @@ mod tests { relay_url: &str, conn: &Connection, fake_relay_events: Vec, + ) -> ArchiveBatchResult { + let owner_keys = Keys::generate(); + run_batch_sync_with_keys( + candidates, + identity_pk, + relay_url, + conn, + fake_relay_events, + &owner_keys, + ) + } + + /// Like `run_batch_sync` but with a specific owner `Keys` for decrypt. + fn run_batch_sync_with_keys( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + owner_keys: &Keys, ) -> ArchiveBatchResult { let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); @@ -544,6 +604,7 @@ mod tests { plan.pre_dropped, identity_pk, relay_url, + owner_keys, 0, conn, ) @@ -1064,6 +1125,183 @@ mod tests { ); } + // ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── + + fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { + use buzz_core_pkg::agent_turn_metric::{ + encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, + }; + let owner_pk = owner_keys.public_key().to_hex(); + let payload = AgentTurnMetricPayload { + harness: "test-harness".to_string(), + model: Some("test-model".to_string()), + channel_id: None, + session_id: Some("sess-1".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T00:00:00Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cost_usd: Some(0.001), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: None, + delta_reliable: true, + stop_reason: None, + }; + let ciphertext = + encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(44200), &ciphertext) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + /// A kind-44200 event with `owner_p` scope must route to the persistent + /// (relay-query) path, NOT the ephemeral path. + #[test] + fn test_owner_p_44200_routes_to_persistent_path() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Subscription for kind 44200 under owner_p. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + // Must be in persistent buckets, NOT ephemeral list. + assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); + assert_eq!( + plan.ephemeral.len(), + 0, + "kind-44200 must NOT be on the ephemeral path" + ); + assert_eq!( + plan.buckets[0].scope_type_str, "owner_p", + "bucket scope_type must be owner_p" + ); + } + + /// A kind-24200 event with `owner_p` scope must still route to ephemeral. + #[test] + fn test_owner_p_24200_still_routes_to_ephemeral() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + assert_eq!( + plan.buckets.len(), + 0, + "kind-24200 must NOT land in a bucket" + ); + assert_eq!( + plan.ephemeral.len(), + 1, + "kind-24200 must be on the ephemeral path" + ); + } + + /// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. + #[test] + fn test_turn_metric_decrypt_success_stores_plaintext() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + + assert_eq!(result.persisted, 1, "event must be persisted"); + assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + + // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. + let raw_json: String = conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + // Plaintext JSON should be a valid object with "harness" key. + let parsed: serde_json::Value = + serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); + assert_eq!( + parsed["harness"], "test-harness", + "stored plaintext must decode to AgentTurnMetricPayload" + ); + // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). + assert_ne!( + raw_json, ev.content, + "stored content must differ from original ciphertext" + ); + } + + /// Decrypt fail: event is dropped, nothing written to the store (fail-closed). + #[test] + fn test_turn_metric_decrypt_fail_drops_fail_closed() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Register subscription under owner_pk so the event passes plan-phase, + // but use `wrong_keys` in commit so decrypt fails. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &wrong_keys, // wrong key → decrypt fails + ); + + assert_eq!( + result.persisted, 0, + "decrypt failure must not persist the event" + ); + assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "no rows must be written to archived_events on decrypt failure" + ); + } + // ── Real-relay integration tests ────────────────────────────────────────── // // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` @@ -1202,12 +1440,14 @@ mod tests { // Phase 3: persist (sync). Fresh connection, same file. let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + let owner_keys = state.keys.lock().unwrap().clone(); commit_archive( bucket_results, plan.ephemeral, plan.pre_dropped, &identity_pk, &relay_url, + &owner_keys, 0, &conn, ) diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index bcfbaf2fd3..4660a8fe3f 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -124,7 +124,15 @@ pub(super) fn plan_archive( continue; } - if cand.matched_scope.scope_type.is_ephemeral() { + // owner_p scope splits by kind: + // kind 24200 (observer frames) → ephemeral path (relay never stores them). + // kind 44200 (turn metrics) → persistent path (relay stores, #p-gated). + // Any other kind under owner_p follows the same ephemeral path as 24200 + // (conservative default for unknowns). + let is_ephemeral = cand.matched_scope.scope_type.is_ephemeral() + && raw_kind != super::KIND_AGENT_TURN_METRIC as u64; + + if is_ephemeral { ephemeral.push(Parsed { event, raw_json: cand.raw_event_json, @@ -188,6 +196,11 @@ pub(super) fn plan_archive( "#e": [&scope_value], "kinds": allowed_kinds, }), + "owner_p" => serde_json::json!({ + "ids": ids, + "#p": [&scope_value], + "kinds": allowed_kinds, + }), _ => { pre_dropped += group.len() as u32; continue; @@ -249,6 +262,7 @@ pub(super) fn commit_archive( pre_dropped: u32, identity_pk: &str, relay_url: &str, + owner_keys: &nostr::Keys, now: i64, conn: &Connection, ) -> Result { @@ -257,16 +271,19 @@ pub(super) fn commit_archive( // Collect writes; count drops first, then execute inside a single // transaction so event and scope rows are always committed atomically. - struct WriteRow<'a> { + // + // raw_json is owned so kind-44200 rows can store decrypted plaintext + // instead of the original NIP-44 ciphertext. + struct WriteRow { eid: String, kind: i64, pubkey: String, created_at: i64, - raw_json: &'a str, - scope_type: &'a str, - scope_value: &'a str, + raw_json: String, + scope_type: String, + scope_value: String, } - let mut writes: Vec> = Vec::new(); + let mut writes: Vec = Vec::new(); // ── Persistent path ────────────────────────────────────────────────────── for result in &bucket_results { @@ -293,7 +310,33 @@ pub(super) fn commit_archive( continue; } - // The relay returning this event for {ids, #h/#e, kinds} IS the + // For kind-44200 (agent turn metrics): decrypt at ingest and store + // the plaintext payload JSON so token-usage calculators can read + // the archive without needing the owner key. Fail-closed: if + // decrypt fails for any reason, drop the event — never store + // ciphertext or partial output. + let stored_json = + if p.event.kind.as_u16() as u64 == super::KIND_AGENT_TURN_METRIC as u64 { + match buzz_core_pkg::agent_turn_metric::decrypt_agent_turn_metric( + owner_keys, &p.event, + ) { + Ok(payload) => match serde_json::to_string(&payload) { + Ok(json) => json, + Err(_) => { + dropped += 1; + continue; + } + }, + Err(_) => { + dropped += 1; + continue; + } + } + } else { + p.raw_json.clone() + }; + + // The relay returning this event for {ids, #h/#e/#p, kinds} IS the // proof of scope membership. Use scope_value directly; no local // tag re-derivation (which would incorrectly drop h-less events // matched via the relay's StoredEvent.channel_id fallback). @@ -302,9 +345,9 @@ pub(super) fn commit_archive( kind: p.event.kind.as_u16() as i64, pubkey: p.event.pubkey.to_hex(), created_at: p.event.created_at.as_secs() as i64, - raw_json: &p.raw_json, - scope_type: &result.scope_type_str, - scope_value: &result.scope_value, + raw_json: stored_json, + scope_type: result.scope_type_str.clone(), + scope_value: result.scope_value.clone(), }); } } @@ -343,7 +386,7 @@ pub(super) fn commit_archive( w.kind, &w.pubkey, w.created_at, - w.raw_json, + &w.raw_json, now, )?; store::upsert_event_scope( @@ -351,8 +394,8 @@ pub(super) fn commit_archive( identity_pk, relay_url, &w.eid, - w.scope_type, - w.scope_value, + &w.scope_type, + &w.scope_value, now, )?; persisted += 1; diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index b70a96749e..72704105f2 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -195,6 +195,85 @@ pub fn has_save_subscription( Ok(count > 0) } +/// Atomically merge `new_kind` into the `owner_p` save subscription for the +/// given identity + relay + scope_value. +/// +/// Reads the current `kinds` array, unions in `new_kind`, and writes back — +/// all inside a single `BEGIN IMMEDIATE` SQLite transaction. `IMMEDIATE` +/// acquires the write lock at `BEGIN` (before the `SELECT`), so a second +/// concurrent caller blocks on `busy_timeout` (5000 ms, set by +/// `open_archive_db`) and then reads the first caller's committed row. This +/// guarantees the union is complete; a `DEFERRED` transaction would let two +/// concurrent callers both read the empty snapshot and produce a +/// `BUSY_SNAPSHOT` failure on the losing writer. +/// +/// If no row exists yet it is created with `[new_kind]`. `now` is used as +/// `created_at` only on insert (the conflict clause never updates it). +pub fn merge_owner_p_kinds( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_value: &str, + new_kind: u32, + now: i64, +) -> Result<(), String> { + // BEGIN IMMEDIATE takes the write lock before the SELECT so concurrent + // callers serialize here rather than racing to a BUSY_SNAPSHOT error. + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("merge_owner_p_kinds begin immediate: {e}"))?; + + let result = (|| -> Result<(), String> { + // Read the current kinds, if any. + let existing_json: Option = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("merge_owner_p_kinds read: {e}"))?; + + // Parse, union, re-serialize. + let mut kinds: Vec = match existing_json { + Some(ref json) => serde_json::from_str(json).unwrap_or_default(), + None => vec![], + }; + if !kinds.contains(&new_kind) { + kinds.push(new_kind); + } + let kinds_json = serde_json::to_string(&kinds) + .map_err(|e| format!("merge_owner_p_kinds serialize: {e}"))?; + + // Upsert — INSERT on first call, UPDATE kinds on subsequent. + conn.execute( + "INSERT INTO save_subscriptions + (identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at) + VALUES (?1, ?2, 'owner_p', ?3, ?4, ?5) + ON CONFLICT (identity_pubkey, relay_url, scope_type, scope_value) + DO UPDATE SET kinds = excluded.kinds", + params![identity_pubkey, relay_url, scope_value, kinds_json, now], + ) + .map_err(|e| format!("merge_owner_p_kinds upsert: {e}"))?; + + Ok(()) + })(); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("merge_owner_p_kinds commit: {e}"))?; + } else { + // Best-effort rollback; ignore the rollback error to surface the + // original error to the caller. + let _ = conn.execute_batch("ROLLBACK"); + } + + result +} + /// Return the `kinds` JSON string for a matching save subscription, or `None` /// if no subscription exists. pub fn get_subscription_kinds( @@ -524,6 +603,130 @@ mod tests { assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); } + // ── merge_owner_p_kinds ────────────────────────────────────────────────── + + #[test] + fn test_merge_owner_p_kinds_creates_row_when_none_exists() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [24200]); + } + + #[test] + fn test_merge_owner_p_kinds_adds_new_kind_to_existing_row() { + let conn = in_memory(); + // Seed with 24200 first. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + // Now merge 44200 in — must produce [24200, 44200]. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!(kinds.contains(&24200), "must still contain 24200"); + assert!(kinds.contains(&44200), "must now contain 44200"); + assert_eq!(kinds.len(), 2, "no duplicates"); + } + + #[test] + fn test_merge_owner_p_kinds_idempotent_on_existing_kind() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [44200], "no duplicates after idempotent call"); + } + + /// Two-connection WAL regression test for the BEGIN IMMEDIATE fix. + /// + /// Opens TWO separate connections to the same WAL file (mirroring the + /// real scenario where the observer-archive seed hook and the + /// metric-archive seed hook each open their own connection via + /// `open_archive_db`). Both threads call `merge_owner_p_kinds` + /// concurrently from an empty row. With `BEGIN IMMEDIATE` the losing + /// thread blocks on `busy_timeout` until the winner commits, then + /// reads the committed row and merges its kind in. Both calls must + /// resolve `Ok` and the final row must contain exactly `[24200, 44200]`. + /// + /// A `DEFERRED` transaction would produce `SQLITE_BUSY_SNAPSHOT` on the + /// loser (not retried by busy_timeout), causing one kind to be silently + /// dropped. This test fails in <10 ms if the IMMEDIATE guard is removed. + #[test] + fn test_merge_owner_p_kinds_two_conn_wal_both_kinds_survive() { + use std::sync::{Arc, Barrier}; + use std::thread; + use tempfile::NamedTempFile; + + // A real file DB is required for WAL mode (in-memory dbs don't support + // shared-cache WAL across multiple connections in the same process). + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Initialise schema on the file DB via conn-A so both threads see it. + let init_conn = open_archive_db(&db_path).unwrap(); + drop(init_conn); + + // Barrier ensures both threads are inside `merge_owner_p_kinds` before + // either one issues `BEGIN IMMEDIATE`, maximising the race window. + let barrier = Arc::new(Barrier::new(2)); + + let path_a = db_path.clone(); + let path_b = db_path.clone(); + let bar_a = Arc::clone(&barrier); + let bar_b = Arc::clone(&barrier); + + let handle_observer = thread::spawn(move || { + let conn = open_archive_db(&path_a).unwrap(); + bar_a.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1) + }); + + let handle_metric = thread::spawn(move || { + let conn = open_archive_db(&path_b).unwrap(); + bar_b.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2) + }); + + let res_observer = handle_observer.join().expect("observer thread panicked"); + let res_metric = handle_metric.join().expect("metric thread panicked"); + + assert!( + res_observer.is_ok(), + "observer seed must succeed: {:?}", + res_observer + ); + assert!( + res_metric.is_ok(), + "metric seed must succeed: {:?}", + res_metric + ); + + // Verify the final row contains both kinds. + let verify_conn = open_archive_db(&db_path).unwrap(); + let subs = list_save_subscriptions(&verify_conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1, "exactly one owner_p row"); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!( + kinds.contains(&24200), + "observer kind 24200 must survive concurrent metric seed; got {:?}", + kinds + ); + assert!( + kinds.contains(&44200), + "metric kind 44200 must be present after concurrent seed; got {:?}", + kinds + ); + assert_eq!( + kinds.len(), + 2, + "exactly two kinds, no duplicates; got {:?}", + kinds + ); + } + // ── Archived events ────────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/commands/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs new file mode 100644 index 0000000000..43cfc7b082 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -0,0 +1,35 @@ +//! Build-time flag for agent-turn-metric archive default. +//! +//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time +//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` +//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 +//! (agent turn metrics) on first run for the current identity. +//! +//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in +//! manually via the Local Archive settings card. + +/// Returns `true` when an internal build has agent-turn-metric archive +/// default-on. +/// +/// The frontend calls this once at startup to decide whether to seed the +/// `owner_p` [44200] save subscription. The result is stable for the lifetime +/// of the binary — it is baked at compile time. +#[tauri::command] +pub fn agent_metric_archive_default_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { + // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT + // baked in), this must return false. + assert!( + !agent_metric_archive_default_enabled(), + "expected false in OSS/test build" + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e4298bcb05..68f5e1e786 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_config; mod agent_discovery; +mod agent_metric_archive; mod agent_models; mod agent_providers; mod agent_settings; @@ -42,6 +43,7 @@ mod workspace; pub use agent_config::*; pub use agent_discovery::*; +pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9c1f7c2972..f062ec467d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -616,8 +616,10 @@ pub fn run() { relay_reconnect_hook, relay_reconnect_hook_configured, observer_archive_default_enabled, + agent_metric_archive_default_enabled, archive::archive_events, archive::create_save_subscription, + archive::merge_save_subscription_kinds, archive::list_save_subscriptions, archive::delete_save_subscription, archive::read_archived_events, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 022ae67e84..be7cf268df 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -51,6 +51,7 @@ import { import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; +import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -151,6 +152,7 @@ export function AppShell() { useAgentsDataRefresh(); useArchiveSync(); useObserverArchiveSeed(identityQuery.data?.pubkey); + useAgentMetricArchiveSeed(identityQuery.data?.pubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/local-archive/agentMetricArchivePreference.ts b/desktop/src/features/local-archive/agentMetricArchivePreference.ts new file mode 100644 index 0000000000..092d53ef01 --- /dev/null +++ b/desktop/src/features/local-archive/agentMetricArchivePreference.ts @@ -0,0 +1,71 @@ +/** + * Persists whether the user has made an explicit choice about the + * agent-turn-metric archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on workspace switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + */ + +const KEY_PREFIX = "buzz:agent-metric-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Returns `true` if the user has already made an explicit choice for this + * identity (either opted in or opted out). When `false`, the seeding path + * may fire. + */ +export function hasExplicitAgentMetricArchiveChoice( + identityPubkey: string, +): boolean { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + return window.localStorage.getItem(storageKey(identityPubkey)) !== null; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitAgentMetricArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (create_save_subscription is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitAgentMetricArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index 85d078cd69..5bf59574be 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -9,7 +9,10 @@ import { type SaveSubscription, type ScopeType, } from "@/shared/api/tauriArchive"; -import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { + KIND_AGENT_OBSERVER_FRAME, + KIND_AGENT_TURN_METRIC, +} from "@/shared/constants/kinds"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; @@ -21,6 +24,7 @@ import { } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; +import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; import { buildSubscriptionRequest, @@ -42,6 +46,9 @@ function scopeLabel( return channelNameById.get(sub.scopeValue) ?? sub.scopeValue; } if (sub.scopeType === "owner_p") { + if (sub.kinds.includes(KIND_AGENT_TURN_METRIC)) { + return "My agents' turn metrics"; + } return "My agent session frames"; } return sub.scopeValue; @@ -97,6 +104,50 @@ function ObserverArchiveSection({ ); } +// ── Agent-turn-metric archive section ──────────────────────────────────────── + +type AgentMetricSectionProps = { + enabled: boolean; + toggling: boolean; + onToggle: (checked: boolean) => void; +}; + +function AgentMetricArchiveSection({ + enabled, + toggling, + onToggle, +}: AgentMetricSectionProps) { + return ( +
+

Agent turn metrics

+ + +
+ +

+ Saves kind {KIND_AGENT_TURN_METRIC} turn-metric events addressed + to your pubkey. Stored as plaintext in your local archive so + token-usage calculators can read them directly. +

+
+ +
+
+
+ ); +} + // ── Add-subscription form ───────────────────────────────────────────────────── type KindChecklistProps = { @@ -331,6 +382,7 @@ export function LocalArchiveSettingsCard() { const [deletingKey, setDeletingKey] = React.useState(null); const [isAddingOpen, setIsAddingOpen] = React.useState(false); const [observerToggling, setObserverToggling] = React.useState(false); + const [metricToggling, setMetricToggling] = React.useState(false); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -381,24 +433,42 @@ export function LocalArchiveSettingsCard() { [reload], ); - const observerEnabled = subs.some((s) => s.scopeType === "owner_p"); + const observerEnabled = subs.some( + (s) => + s.scopeType === "owner_p" && s.kinds.includes(KIND_AGENT_OBSERVER_FRAME), + ); + const metricEnabled = subs.some( + (s) => + s.scopeType === "owner_p" && s.kinds.includes(KIND_AGENT_TURN_METRIC), + ); const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; setObserverToggling(true); try { - if (checked) { - await createSaveSubscription("owner_p", pubkey, [ - KIND_AGENT_OBSERVER_FRAME, - ]); - setExplicitObserverArchiveChoice(pubkey, true); - toast.success("Observer feed archive enabled."); + // The owner_p row is keyed by (scope_type, scope_value) — both observer + // (24200) and metric (44200) share the same row. Merge kinds atomically: + // read current kinds, add or remove 24200, upsert the result. + const currentKinds = + subs + .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) + ?.kinds.filter((k) => k !== KIND_AGENT_OBSERVER_FRAME) ?? []; + const nextKinds = checked + ? [...currentKinds, KIND_AGENT_OBSERVER_FRAME] + : currentKinds; + + if (nextKinds.length > 0) { + await createSaveSubscription("owner_p", pubkey, nextKinds); } else { await deleteSaveSubscription("owner_p", pubkey); - setExplicitObserverArchiveChoice(pubkey, false); - toast.success("Observer feed archive disabled."); } + setExplicitObserverArchiveChoice(pubkey, checked); + toast.success( + checked + ? "Observer feed archive enabled." + : "Observer feed archive disabled.", + ); await reload(); } catch (err) { toast.error( @@ -410,10 +480,51 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, reload], + [pubkey, subs, reload], ); - // Non-observer subscriptions shown in the active-subscriptions list. + const handleMetricToggle = React.useCallback( + async (checked: boolean) => { + if (!pubkey) return; + setMetricToggling(true); + try { + // Same row as observer — merge 44200 in or out. + const currentKinds = + subs + .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) + ?.kinds.filter((k) => k !== KIND_AGENT_TURN_METRIC) ?? []; + const nextKinds = checked + ? [...currentKinds, KIND_AGENT_TURN_METRIC] + : currentKinds; + + if (nextKinds.length > 0) { + await createSaveSubscription("owner_p", pubkey, nextKinds); + } else { + await deleteSaveSubscription("owner_p", pubkey); + } + setExplicitAgentMetricArchiveChoice(pubkey, checked); + toast.success( + checked + ? "Agent turn metric archive enabled." + : "Agent turn metric archive disabled.", + ); + await reload(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : "Failed to update agent metric archive.", + ); + } finally { + setMetricToggling(false); + } + }, + [pubkey, subs, reload], + ); + + // Non-owner_p subscriptions shown in the active-subscriptions list. + // observer (24200) and metric (44200) owner_p subs each have their own + // dedicated section above. const channelSubs = subs.filter((s) => s.scopeType !== "owner_p"); return ( @@ -431,6 +542,13 @@ export function LocalArchiveSettingsCard() { toggling={observerToggling} /> + {/* Agent-turn-metric archive — dedicated first-class section */} + void handleMetricToggle(checked)} + toggling={metricToggling} + /> + {/* Channel subscriptions */}

diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs new file mode 100644 index 0000000000..6a832b4bd3 --- /dev/null +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs @@ -0,0 +1,222 @@ +/** + * Tests for useAgentMetricArchiveSeed seeding logic. + * + * Mirrors the pattern in useObserverArchiveSeed.test.mjs — drives the async + * seed logic via the deps-injection interface, no React required. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Fake deps factory ──────────────────────────────────────────────────────── + +function makeDeps({ + defaultOn = false, + hasExplicitChoice = false, + mergeShouldFail = false, +} = {}) { + const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; + + return { + calls, + agentMetricArchiveDefaultEnabled: async () => defaultOn, + mergeSaveSubscriptionKinds: async (kind) => { + if (mergeShouldFail) throw new Error("merge failed"); + calls.mergeSaveSubscriptionKinds.push({ kind }); + }, + hasExplicitChoice: (_pubkey) => hasExplicitChoice, + setExplicitChoice: (pubkey, enabled) => { + calls.setExplicitChoice.push({ pubkey, enabled }); + }, + }; +} + +// Minimal re-implementation of the seeding logic from useAgentMetricArchiveSeed.ts. +// Kept in sync with the source by structural mirroring. +const KIND_AGENT_TURN_METRIC = 44200; + +async function runSeed(pubkey, deps) { + if (!pubkey) return; + if (deps.hasExplicitChoice(pubkey)) return; + + let defaultOn; + try { + defaultOn = await deps.agentMetricArchiveDefaultEnabled(); + } catch { + return; + } + + if (!defaultOn) return; + + try { + await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); + } catch { + return; // transient failure — do NOT set explicit choice + } + + deps.setExplicitChoice(pubkey, true); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test("test_internal_build_unset_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.mergeSaveSubscriptionKinds.length, + 1, + "should call mergeSaveSubscriptionKinds once", + ); + const call = deps.calls.mergeSaveSubscriptionKinds[0]; + assert.equal(call.kind, 44200); +}); + +test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.setExplicitChoice.length, + 1, + "should persist explicit choice after successful seed", + ); + assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123"); + assert.equal(deps.calls.setExplicitChoice[0].enabled, true); +}); + +test("test_explicit_choice_set_does_not_reseed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.mergeSaveSubscriptionKinds.length, + 0, + "should not call mergeSaveSubscriptionKinds when explicit choice is already set", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not update explicit choice when already set", + ); +}); + +test("test_oss_build_does_not_seed", async () => { + const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.mergeSaveSubscriptionKinds.length, + 0, + "should not call mergeSaveSubscriptionKinds in OSS build", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not persist explicit choice in OSS build", + ); +}); + +test("test_merge_failure_does_not_persist_explicit_choice", async () => { + const deps = makeDeps({ + defaultOn: true, + hasExplicitChoice: false, + mergeShouldFail: true, + }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should NOT persist explicit choice after a transient merge failure", + ); +}); + +test("test_empty_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("", deps); + + assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); + +test("test_undefined_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed(undefined, deps); + + assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); + +// ── Concurrent-interleave test ─────────────────────────────────────────────── +// +// Verifies the scenario Paul identified: on an internal-build first run with +// both flags on and no prior owner_p row, the observer and metric seeds race. +// With the old TS-side list+merge+create pattern the interleave could be: +// +// 1. observer seed: await list() → [] +// 2. metric seed: await list() → [] (row not yet written) +// 3. observer writes [24200] +// 4. metric writes [44200] → clobbers 24200 +// +// The new pattern delegates the merge to Rust under a single SQLite tx. +// Here we model that by tracking a shared "db state" and verifying that +// running both seeds concurrently (Promise.all) leaves both kinds present. + +test("test_concurrent_seeds_both_kinds_survive", async () => { + // Shared in-memory "db" — the atomic merge impl would serialize via SQLite + // write lock; here we simulate by letting calls accumulate and checking + // the final state. + const db = new Set(); // kinds present after all merges + + function makeConcurrentDeps(defaultOn = true) { + return { + agentMetricArchiveDefaultEnabled: async () => defaultOn, + // Simulates the atomic merge: each call simply adds its kind to the set, + // regardless of what was there before (atomicity guarantee). + mergeSaveSubscriptionKinds: async (kind) => { + db.add(kind); + }, + hasExplicitChoice: (_pubkey) => false, + setExplicitChoice: () => {}, + }; + } + + const observerDeps = makeConcurrentDeps(); + const metricDeps = makeConcurrentDeps(); + + // Replace the kind constant in the observer runSeed equivalent with 24200. + async function runObserverSeed(pubkey, deps) { + if (!pubkey) return; + if (deps.hasExplicitChoice(pubkey)) return; + let defaultOn; + try { + defaultOn = await deps.agentMetricArchiveDefaultEnabled(); + } catch { + return; + } + if (!defaultOn) return; + try { + await deps.mergeSaveSubscriptionKinds(24200); + } catch { + return; + } + deps.setExplicitChoice(pubkey, true); + } + + // Run both seeds concurrently — no await between them. + await Promise.all([ + runObserverSeed("pubkey123", observerDeps), + runSeed("pubkey123", metricDeps), + ]); + + assert.ok( + db.has(24200), + "observer kind 24200 must be present after concurrent seeds", + ); + assert.ok( + db.has(44200), + "metric kind 44200 must be present after concurrent seeds", + ); + assert.equal(db.size, 2, "exactly two kinds, no extras"); +}); diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts new file mode 100644 index 0000000000..520d0e6603 --- /dev/null +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts @@ -0,0 +1,114 @@ +/** + * First-run seeding for agent-turn-metric archive. + * + * When an internal build has `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` set and + * the current identity has not yet made an explicit choice, this hook + * auto-creates an `owner_p` save subscription including kind 44200 agent turn + * metrics, scoped to the current identity's pubkey. + * + * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently + * running observer seed (24200) cannot clobber this kind — the union happens + * under a single SQLite transaction regardless of await ordering. + * + * OSS builds return `false` from `agent_metric_archive_default_enabled` → + * no-op. After any explicit user action (seeding or opt-out), the localStorage + * flag prevents re-seeding on subsequent starts. + */ + +import * as React from "react"; + +import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; +import { + mergeSaveSubscriptionKinds, + agentMetricArchiveDefaultEnabled, +} from "@/shared/api/tauriArchive"; +import { + hasExplicitAgentMetricArchiveChoice, + setExplicitAgentMetricArchiveChoice, +} from "./agentMetricArchivePreference"; + +/** + * Deps interface for testing. Production callers pass nothing. + */ +export interface AgentMetricArchiveSeedDeps { + agentMetricArchiveDefaultEnabled: () => Promise; + mergeSaveSubscriptionKinds: (kind: number) => Promise; + hasExplicitChoice: (pubkey: string) => boolean; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; +} + +const defaultDeps: AgentMetricArchiveSeedDeps = { + agentMetricArchiveDefaultEnabled, + mergeSaveSubscriptionKinds, + hasExplicitChoice: hasExplicitAgentMetricArchiveChoice, + setExplicitChoice: setExplicitAgentMetricArchiveChoice, +}; + +/** + * Seed the agent-turn-metric archive subscription for `pubkey` once per + * identity per device on internal builds. + * + * @param pubkey - current identity pubkey. When undefined (identity not yet + * loaded), the hook waits until it becomes available. + * @param deps - optional dep-injection for tests. + */ +export function useAgentMetricArchiveSeed( + pubkey: string | undefined, + deps: AgentMetricArchiveSeedDeps = defaultDeps, +): void { + React.useEffect(() => { + if (!pubkey) return; + + // Already made an explicit choice for this identity — never re-seed. + if (deps.hasExplicitChoice(pubkey)) return; + + let cancelled = false; + + async function maybeSeed(): Promise { + // pubkey is checked above but TypeScript doesn't narrow across the async + // boundary — re-guard here so the call below is type-safe. + if (!pubkey) return; + + let defaultOn: boolean; + try { + defaultOn = await deps.agentMetricArchiveDefaultEnabled(); + } catch (err) { + console.warn("[useAgentMetricArchiveSeed] flag check failed:", err); + return; + } + + if (cancelled) return; + + if (!defaultOn) { + // OSS build (flag off): don't persist a choice — leave null so seeding + // can still fire if this identity later runs an internal build. + return; + } + + // Internal build + no prior choice → auto-seed via atomic DB merge. + try { + await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); + } catch (err) { + console.warn( + "[useAgentMetricArchiveSeed] mergeSaveSubscriptionKinds failed:", + err, + ); + // Do NOT set the localStorage flag — a transient failure (relay + // unreachable, archive DB not yet initialized) should retry on next + // startup rather than permanently suppress seeding. + return; + } + + if (cancelled) return; + + // Persist the explicit choice so this never re-fires. + deps.setExplicitChoice(pubkey, true); + } + + void maybeSeed(); + + return () => { + cancelled = true; + }; + }, [pubkey, deps]); +} diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index 506f91c6af..54128282df 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -19,16 +19,16 @@ import test from "node:test"; function makeDeps({ defaultOn = false, hasExplicitChoice = false, - createShouldFail = false, + mergeShouldFail = false, } = {}) { - const calls = { createSaveSubscription: [], setExplicitChoice: [] }; + const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; return { calls, observerArchiveDefaultEnabled: async () => defaultOn, - createSaveSubscription: async (scopeType, scopeValue, kinds) => { - if (createShouldFail) throw new Error("create failed"); - calls.createSaveSubscription.push({ scopeType, scopeValue, kinds }); + mergeSaveSubscriptionKinds: async (kind) => { + if (mergeShouldFail) throw new Error("merge failed"); + calls.mergeSaveSubscriptionKinds.push({ kind }); }, hasExplicitChoice: (_pubkey) => hasExplicitChoice, setExplicitChoice: (pubkey, enabled) => { @@ -40,6 +40,8 @@ function makeDeps({ // Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts. // Kept in sync with the source by structural mirroring (not bytewise copy) — // change the source and update this accordingly. +const KIND_AGENT_OBSERVER_FRAME = 24200; + async function runSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; @@ -54,7 +56,7 @@ async function runSeed(pubkey, deps) { if (!defaultOn) return; try { - await deps.createSaveSubscription("owner_p", pubkey, [24200]); + await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); } catch { return; // transient failure — do NOT set explicit choice } @@ -69,14 +71,12 @@ test("test_internal_build_unset_seeds_owner_p_subscription", async () => { await runSeed("pubkey123", deps); assert.equal( - deps.calls.createSaveSubscription.length, + deps.calls.mergeSaveSubscriptionKinds.length, 1, - "should call createSaveSubscription once", + "should call mergeSaveSubscriptionKinds once", ); - const call = deps.calls.createSaveSubscription[0]; - assert.equal(call.scopeType, "owner_p"); - assert.equal(call.scopeValue, "pubkey123"); - assert.deepEqual(call.kinds, [24200]); + const call = deps.calls.mergeSaveSubscriptionKinds[0]; + assert.equal(call.kind, 24200); }); test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { @@ -97,9 +97,9 @@ test("test_explicit_choice_set_does_not_reseed", async () => { await runSeed("pubkey123", deps); assert.equal( - deps.calls.createSaveSubscription.length, + deps.calls.mergeSaveSubscriptionKinds.length, 0, - "should not call createSaveSubscription when explicit choice is already set", + "should not call mergeSaveSubscriptionKinds when explicit choice is already set", ); assert.equal( deps.calls.setExplicitChoice.length, @@ -113,9 +113,9 @@ test("test_oss_build_does_not_seed", async () => { await runSeed("pubkey123", deps); assert.equal( - deps.calls.createSaveSubscription.length, + deps.calls.mergeSaveSubscriptionKinds.length, 0, - "should not call createSaveSubscription in OSS build", + "should not call mergeSaveSubscriptionKinds in OSS build", ); assert.equal( deps.calls.setExplicitChoice.length, @@ -124,23 +124,18 @@ test("test_oss_build_does_not_seed", async () => { ); }); -test("test_create_failure_does_not_persist_explicit_choice", async () => { +test("test_merge_failure_does_not_persist_explicit_choice", async () => { const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false, - createShouldFail: true, + mergeShouldFail: true, }); await runSeed("pubkey123", deps); - assert.equal( - deps.calls.createSaveSubscription.length, - 0, - "createSaveSubscription should throw (called internally but errors)", - ); assert.equal( deps.calls.setExplicitChoice.length, 0, - "should NOT persist explicit choice after a transient create failure", + "should NOT persist explicit choice after a transient merge failure", ); }); @@ -148,7 +143,7 @@ test("test_empty_pubkey_does_nothing", async () => { const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); await runSeed("", deps); - assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); assert.equal(deps.calls.setExplicitChoice.length, 0); }); @@ -156,6 +151,6 @@ test("test_undefined_pubkey_does_nothing", async () => { const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); await runSeed(undefined, deps); - assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); assert.equal(deps.calls.setExplicitChoice.length, 0); }); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 6bb9bc326b..f547493da1 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -2,9 +2,13 @@ * First-run seeding for observer-feed archive. * * When an internal build has `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` set and the - * current identity has not yet made an explicit choice, this hook auto-creates - * an `owner_p` save subscription (kind 24200 observer frames, scoped to the - * current identity's pubkey). + * current identity has not yet made an explicit choice, this hook + * auto-creates an `owner_p` save subscription including kind 24200 observer + * frames, scoped to the current identity's pubkey. + * + * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently + * running metric seed (44200) cannot clobber this kind — the union happens + * under a single SQLite transaction regardless of await ordering. * * OSS builds return `false` from `observer_archive_default_enabled` → no-op. * After any explicit user action (seeding or opt-out), the localStorage flag @@ -15,7 +19,7 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; import { - createSaveSubscription, + mergeSaveSubscriptionKinds, observerArchiveDefaultEnabled, } from "@/shared/api/tauriArchive"; import { @@ -28,18 +32,14 @@ import { */ export interface ObserverArchiveSeedDeps { observerArchiveDefaultEnabled: () => Promise; - createSaveSubscription: ( - scopeType: "owner_p", - scopeValue: string, - kinds: number[], - ) => Promise; + mergeSaveSubscriptionKinds: (kind: number) => Promise; hasExplicitChoice: (pubkey: string) => boolean; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { observerArchiveDefaultEnabled, - createSaveSubscription, + mergeSaveSubscriptionKinds, hasExplicitChoice: hasExplicitObserverArchiveChoice, setExplicitChoice: setExplicitObserverArchiveChoice, }; @@ -85,14 +85,12 @@ export function useObserverArchiveSeed( return; } - // Internal build + no prior choice → auto-seed. + // Internal build + no prior choice → auto-seed via atomic DB merge. try { - await deps.createSaveSubscription("owner_p", pubkey, [ - KIND_AGENT_OBSERVER_FRAME, - ]); + await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); } catch (err) { console.warn( - "[useObserverArchiveSeed] createSaveSubscription failed:", + "[useObserverArchiveSeed] mergeSaveSubscriptionKinds failed:", err, ); // Do NOT set the localStorage flag — a transient failure (relay diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 4940ec4212..e701ea1cf7 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -98,6 +98,34 @@ export async function observerArchiveDefaultEnabled(): Promise { return invokeTauri("observer_archive_default_enabled"); } +/** + * Returns `true` when the build has agent-turn-metric archive default-on. + * + * Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time; + * OSS builds never set it, so this returns `false`. The frontend calls this + * once at startup to decide whether to auto-seed an `owner_p` [44200] + * subscription. + */ +export async function agentMetricArchiveDefaultEnabled(): Promise { + return invokeTauri("agent_metric_archive_default_enabled"); +} + +/** + * Atomically merge `kind` into the `owner_p` save subscription for the + * current identity + relay. + * + * Performs a read-modify-write inside a single SQLite transaction on the Rust + * side, so concurrent callers (e.g. observer seed + metric seed racing on + * first run) cannot clobber each other's kind. + * + * Called by both `useObserverArchiveSeed` and `useAgentMetricArchiveSeed` + * instead of the former list → merge-in-TS → create pattern. + */ +export async function mergeSaveSubscriptionKinds(kind: number): Promise { + await invokeTauri("merge_save_subscription_kinds", { kind }); + notifySubscriptionChange(); +} + /** * Create a save subscription. * Runs an access probe on the backend (channel membership, event readability). diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index d59624233c..c16fcb4711 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -43,6 +43,7 @@ export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; +export const KIND_AGENT_TURN_METRIC = 44200; export const KIND_MESH_STATUS_REPORT = 24620; export const KIND_MESH_CONNECT_REQUEST = 24621; export const KIND_MESH_CALL_ME_NOW = 24622; From 484bede7597814dd7dc322d1f72f69c08545ef42 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 18:45:05 -0400 Subject: [PATCH 015/291] fix(archive): atomic remove-kind path + split test modules (#1562) Signed-off-by: Will Pfleger Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> --- desktop/scripts/check-file-sizes.mjs | 35 +- desktop/src-tauri/src/archive/mod.rs | 1232 +---------------- desktop/src-tauri/src/archive/mod_tests.rs | 1199 ++++++++++++++++ desktop/src-tauri/src/archive/store.rs | 759 ++-------- desktop/src-tauri/src/archive/store_tests.rs | 730 ++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../ui/LocalArchiveSettingsCard.tsx | 38 +- desktop/src/shared/api/tauriArchive.ts | 18 + 8 files changed, 2082 insertions(+), 1930 deletions(-) create mode 100644 desktop/src-tauri/src/archive/mod_tests.rs create mode 100644 desktop/src-tauri/src/archive/store_tests.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 13fa7ddc0a..015f050fe3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -60,34 +60,13 @@ const overrides = new Map([ // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config // commands add ~40 lines. Queued to split. // branch cut; override bumped to cover the merged total. Queued to split. - // archive/mod.rs carries the full test module: 899 unit tests + 4 real-relay - // integration tests (ignored, live-relay only, wrapped in - // #[cfg(not(target_os = "windows"))] mod real_relay). The test module is the - // source of the overage — production logic is ~408 lines. The fix-round added - // 2 regression tests (F2: out-of-range kind + F3: atomicity invariant). - // read_archived_events Tauri command added ~39 lines (Phase 1 read-back). - // E2E test-depth hardening added the owner_p content round-trip assert and - // two empty-table drop asserts (~24 lines). Queued to split the test module - // into archive/mod_tests.rs in a follow-up. - // agent-metric-archive PR added 4 new unit tests (owner_p+44200 routing, - // decrypt-success plaintext storage, decrypt-fail-closed, 24200 still - // ephemeral) + run_batch_sync_with_keys helper (~175 lines). Same test-growth - // category as above. Still queued to split. - // merge_save_subscription_kinds command + owner_p-kinds TOCTOU fix adds ~30 - // lines. Atomic merge to close the concurrent-seed race. Still queued to split. - // IMMEDIATE-tx fix: BEGIN IMMEDIATE replaces DEFERRED unchecked_transaction - // in merge_owner_p_kinds comment block (~5 lines). Still queued to split. - // doc-comment: mixed row-shape invariant on read_archived_events (~5 lines). - ["src-tauri/src/archive/mod.rs", 1705], - // archive/store.rs: merge_owner_p_kinds fn (read+union+upsert under a single - // SQLite tx) + 4 unit tests (create-when-none, adds-kind, idempotent, - // concurrent-interleave). Load-bearing TOCTOU fix for the owner_p shared row. - // Queued to split test module into store_tests.rs in a follow-up. - // IMMEDIATE-tx fix: replaces mislabeled sequential test with a real - // two-connection WAL regression test (tempfile + std::thread + Barrier, - // ~85 lines). The new test exercises the actual concurrent write path and - // fails fast if IMMEDIATE guard is removed. Still queued to split. - ["src-tauri/src/archive/store.rs", 1179], + // archive/mod_tests.rs carries the full test module for archive/mod.rs: + // unit tests + 4 real-relay integration tests (ignored, live-relay only). + // Production logic in mod.rs is now ~527 lines (under 1000). mod_tests.rs + // is test-only content; the override covers the test growth accumulated + // across the local-archive + agent-metric-archive PR series. store_tests.rs + // (~731 lines) is under 1000 so needs no override. + ["src-tauri/src/archive/mod_tests.rs", 1208], ["src-tauri/src/commands/agents.rs", 1437], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index d28c5ef8e1..c5fd77003d 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -410,6 +410,32 @@ pub fn merge_save_subscription_kinds(state: State<'_, AppState>, kind: u32) -> R store::merge_owner_p_kinds(&conn, &identity_pk, &relay_url, &identity_pk, kind, now) } +// ── remove_save_subscription_kind ──────────────────────────────────────────── + +/// Atomically remove `kind` from the `owner_p` save subscription for the +/// current identity + relay. +/// +/// Mirrors `merge_save_subscription_kinds`: reads existing kinds, removes +/// `kind`, then deletes the row if the list becomes empty or updates it +/// otherwise. Uses `BEGIN IMMEDIATE` to prevent the same snapshot race as the +/// merge path. +/// +/// Called by both `useObserverArchiveSeed` / `handleObserverToggle` (kind +/// 24200) and the metric equivalents (kind 44200) on toggle-OFF, replacing the +/// former TS-side read-modify-overwrite + whole-row `deleteSaveSubscription` +/// which would drop the *other* kind if `subs` state was stale. +#[tauri::command] +pub fn remove_save_subscription_kind(state: State<'_, AppState>, kind: u32) -> Result<(), String> { + if kind > u32::from(u16::MAX) { + return Err(format!("kind {kind} is out of the valid range 0..=65535")); + } + + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::remove_owner_p_kind(&conn, &identity_pk, &relay_url, &identity_pk, kind) +} + // ── list_save_subscriptions ────────────────────────────────────────────────── /// List all save subscriptions for the current identity + relay. @@ -498,1207 +524,5 @@ pub fn read_archived_events( // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; - use pipeline::BucketWithResult; - use rusqlite::Connection; - use uuid::Uuid; - - // ── Helpers ────────────────────────────────────────────────────────────── - - fn in_memory() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.pragma_update(None, "journal_mode", "WAL").unwrap(); - conn.pragma_update(None, "busy_timeout", 5000).unwrap(); - conn.execute_batch(super::store::SCHEMA).unwrap(); - conn - } - - fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event { - let owner_pk = owner_keys.public_key().to_hex(); - let agent_pk = agent_keys.public_key().to_hex(); - let tags = vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["agent", &agent_pk]).unwrap(), - Tag::parse(["frame", frame_type]).unwrap(), - ]; - EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) - .tags(tags) - .sign_with_keys(agent_keys) - .unwrap() - } - - fn add_sub( - conn: &Connection, - identity_pk: &str, - relay_url: &str, - scope_type: &str, - scope_value: &str, - kinds_json: &str, - ) { - store::upsert_save_subscription( - conn, - identity_pk, - relay_url, - scope_type, - scope_value, - kinds_json, - 0, - ) - .unwrap(); - } - - /// Run the full archive pipeline synchronously with a fake relay response. - /// - /// Calls `plan_archive` → injects fake relay events → `commit_archive`. - /// This mirrors `archive_events` without the async relay calls. - fn run_batch_sync( - candidates: Vec, - identity_pk: &str, - relay_url: &str, - conn: &Connection, - fake_relay_events: Vec, - ) -> ArchiveBatchResult { - let owner_keys = Keys::generate(); - run_batch_sync_with_keys( - candidates, - identity_pk, - relay_url, - conn, - fake_relay_events, - &owner_keys, - ) - } - - /// Like `run_batch_sync` but with a specific owner `Keys` for decrypt. - fn run_batch_sync_with_keys( - candidates: Vec, - identity_pk: &str, - relay_url: &str, - conn: &Connection, - fake_relay_events: Vec, - owner_keys: &Keys, - ) -> ArchiveBatchResult { - let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); - - // Synthesize BucketWithResult from the fake relay response. - let fake_ids: std::collections::HashSet = - fake_relay_events.iter().map(|e| e.id.to_hex()).collect(); - let bucket_results: Vec = plan - .buckets - .into_iter() - .map(|b| BucketWithResult { - scope_type_str: b.scope_type_str, - scope_value: b.scope_value, - allowed_kinds: b.allowed_kinds, - group: b.group, - returned_ids: fake_ids.clone(), - relay_failed: false, - }) - .collect(); - - commit_archive( - bucket_results, - plan.ephemeral, - plan.pre_dropped, - identity_pk, - relay_url, - owner_keys, - 0, - conn, - ) - .unwrap() - } - - fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate { - ArchiveCandidate { - raw_event_json: event.as_json(), - matched_scope: MatchedScope { - scope_type, - scope_value: scope_value.to_string(), - }, - } - } - - // ── Ephemeral validator — individual condition rejection ────────────────── - - #[test] - fn test_ephemeral_validator_accepts_valid_frame() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - assert!( - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url) - .is_ok() - ); - } - - #[test] - fn test_ephemeral_validator_rejects_wrong_kind() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = EventBuilder::new(Kind::TextNote, "hello") - .tags(vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), - Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), - ]) - .sign_with_keys(&agent_keys) - .unwrap(); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("kind")); - } - - #[test] - fn test_ephemeral_validator_rejects_missing_p_tag() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) - .tags(vec![ - Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), - Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), - ]) - .sign_with_keys(&agent_keys) - .unwrap(); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("#p")); - } - - #[test] - fn test_ephemeral_validator_rejects_missing_agent_tag() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) - .tags(vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), - ]) - .sign_with_keys(&agent_keys) - .unwrap(); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("agent")); - } - - #[test] - fn test_ephemeral_validator_rejects_control_frame() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = make_observer_frame(&owner_keys, &agent_keys, "control"); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("telemetry")); - } - - #[test] - fn test_ephemeral_validator_rejects_wrong_author() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let other_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) - .tags(vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), - Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), - ]) - .sign_with_keys(&other_keys) // wrong signer - .unwrap(); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("author")); - } - - #[test] - fn test_ephemeral_validator_rejects_no_subscription() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // Deliberately do NOT add a subscription. - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("owner_p subscription")); - } - - #[test] - fn test_ephemeral_validator_rejects_kind_not_in_subscription() { - // Subscription exists but kinds = [1] (not 24200) — must be rejected. - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let result = - validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); - assert!(result.is_err()); - let msg = result.unwrap_err(); - assert!( - msg.contains("24200"), - "expected kind 24200 in error, got: {msg}" - ); - } - - // ── archive pipeline — persistent path ─────────────────────────────────── - - #[test] - fn test_persistent_channel_h_persists_when_relay_returns_event() { - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); - - let ev = EventBuilder::new(Kind::Custom(9), "msg") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; - - // Fake relay returns the event (simulates relay proof). - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); - assert_eq!(result.persisted, 1); - assert_eq!(result.dropped, 0); - - // Confirm the event is in the store. - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); - let scope_count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0)) - .unwrap(); - assert_eq!(scope_count, 1); - } - - #[test] - fn test_persistent_channel_h_drops_when_relay_does_not_return_event() { - // Relay returns empty — event not proven accessible. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); - - let ev = EventBuilder::new(Kind::Custom(9), "msg") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; - - // Fake relay returns nothing. - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]); - assert_eq!(result.persisted, 0); - assert_eq!(result.dropped, 1); - } - - #[test] - fn test_persistent_drops_when_no_subscription() { - // No subscription at all — drop before even querying. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - // Intentionally no subscription. - - let ev = EventBuilder::new(Kind::Custom(9), "msg") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; - - // Fake relay would return the event, but no sub → dropped. - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); - assert_eq!(result.persisted, 0); - assert_eq!(result.dropped, 1); - } - - #[test] - fn test_persistent_drops_kind_not_in_subscription() { - // Subscription is for kind 9 only; event is kind 7 (reaction). - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); - - // kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter - let ev = EventBuilder::new(Kind::Reaction, "+") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; - - // Fake relay returns the event (simulates relay proof via StoredEvent.channel_id), - // but kind 7 is not in the subscription's kinds list. - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); - assert_eq!(result.persisted, 0); - assert_eq!(result.dropped, 1); - } - - #[test] - fn test_persistent_h_less_event_persists_when_relay_returns_it() { - // An h-less event (e.g. reaction kind:7) that the relay returns under - // the scoped #h filter (via StoredEvent.channel_id fallback) must be - // persisted. The local tag scanner would have dropped it; the scoped - // filter proof must not. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]"); - - // Build a reaction without an `h` tag — local re-derivation would drop it. - let ev = EventBuilder::new(Kind::Reaction, "+") - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; - - // Fake relay returns it (relay used StoredEvent.channel_id to match). - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); - assert_eq!(result.persisted, 1); - assert_eq!(result.dropped, 0); - - // Scope row uses bucket's scope_value, not a local-derived value. - let scope_val: String = conn - .query_row( - "SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(scope_val, chan); - } - - #[test] - fn test_persistent_referenced_e_persists_when_relay_returns_event() { - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let ref_id = "a".repeat(64); - add_sub( - &conn, - &identity_pk, - relay_url, - "referenced_e", - &ref_id, - "[9]", - ); - - let ev = EventBuilder::new(Kind::Custom(9), "reply") - .tags(vec![Tag::parse(["e", &ref_id]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)]; - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); - assert_eq!(result.persisted, 1); - assert_eq!(result.dropped, 0); - } - - #[test] - fn test_mixed_batch_persisted_and_dropped_counted_exactly() { - // Two channel_h candidates: relay only returns one. dropped must be 1. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-abc"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); - - let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![ - candidate(&ev1, ScopeType::ChannelH, chan), - candidate(&ev2, ScopeType::ChannelH, chan), - ]; - - // Fake relay only returns ev1. - let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]); - assert_eq!(result.persisted, 1); - assert_eq!(result.dropped, 1); - } - - // ── archive pipeline — ephemeral path ──────────────────────────────────── - - #[test] - fn test_ephemeral_path_persists_valid_frame() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; - - // Fake relay returns nothing (not consulted for ephemeral path). - let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); - assert_eq!(result.persisted, 1); - assert_eq!(result.dropped, 0); - } - - #[test] - fn test_ephemeral_path_drops_kind_not_in_subscription() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // kinds = [1], not [24200] - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); - - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; - - let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); - assert_eq!(result.persisted, 0); - assert_eq!(result.dropped, 1); - } - - // ── Invalid input dropped ───────────────────────────────────────────────── - - #[test] - fn test_malformed_json_is_dropped() { - let result = Event::from_json("not json at all"); - assert!(result.is_err()); - } - - #[test] - fn test_tampered_event_fails_verify_id() { - let keys = Keys::generate(); - let mut ev_json: serde_json::Value = serde_json::from_str( - &EventBuilder::new(Kind::TextNote, "ok") - .sign_with_keys(&keys) - .unwrap() - .as_json(), - ) - .unwrap(); - ev_json["content"] = serde_json::Value::String("tampered".into()); - let tampered = ev_json.to_string(); - let ev = Event::from_json(&tampered).unwrap(); - assert!(!ev.verify_id()); - } - - // ── F2: out-of-range kind ───────────────────────────────────────────────── - - #[test] - fn test_out_of_range_kind_is_dropped() { - // kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200 - // via `v as u16`, so without the raw-kind check the validator would - // reason about 24200 while the persisted raw_json still says 89736. - // The fix rejects it before Event::from_json. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let owner_pk = &identity_pk; - // Build a valid kind-24200 frame, then mutate only the raw kind to 89736. - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let pk = owner_keys.public_key().to_hex(); - add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]"); - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap(); - raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536)); - let bad_json = raw.to_string(); - - let cand = ArchiveCandidate { - raw_event_json: bad_json, - matched_scope: MatchedScope { - scope_type: ScopeType::OwnerP, - scope_value: pk.clone(), - }, - }; - let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]); - assert_eq!(result.persisted, 0, "out-of-range kind must be dropped"); - assert_eq!(result.dropped, 1); - // No event row must exist. - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 0); - let _ = owner_pk; // silence unused-var - } - - // ── F3: transactional atomicity ─────────────────────────────────────────── - - #[test] - fn test_commit_archive_rolls_back_when_scope_write_would_fail() { - // Verify the split-schema invariant: if we simulate a mid-batch - // failure (by putting the DB into a state where the scope table is - // missing), the event row must NOT survive. - // - // We can't actually make upsert_event_scope fail on a healthy DB, so - // we verify the positive side: with a healthy DB, both rows are always - // written together or neither is. We confirm via run_batch_sync that - // after a full successful commit both tables have matching row counts. - let conn = in_memory(); - let keys = Keys::generate(); - let identity_pk = keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - let chan = "chan-txn"; - add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); - - let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") - .tags(vec![Tag::parse(["h", chan]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let cands = vec![ - candidate(&ev1, ScopeType::ChannelH, chan), - candidate(&ev2, ScopeType::ChannelH, chan), - ]; - let result = run_batch_sync( - cands, - &identity_pk, - relay_url, - &conn, - vec![ev1.clone(), ev2.clone()], - ); - assert_eq!(result.persisted, 2); - assert_eq!(result.dropped, 0); - - // Every event row must have exactly one corresponding scope row. - let event_count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - let scope_count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| { - r.get(0) - }) - .unwrap(); - assert_eq!( - event_count, scope_count, - "every event row must have a matching scope row — split-schema invariant" - ); - } - - // ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── - - fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { - use buzz_core_pkg::agent_turn_metric::{ - encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, - }; - let owner_pk = owner_keys.public_key().to_hex(); - let payload = AgentTurnMetricPayload { - harness: "test-harness".to_string(), - model: Some("test-model".to_string()), - channel_id: None, - session_id: Some("sess-1".to_string()), - turn_id: Some("turn-1".to_string()), - turn_seq: Some(1), - timestamp: "2026-07-01T00:00:00Z".to_string(), - turn: Some(TokenCounts { - input_tokens: Some(100), - output_tokens: Some(50), - total_tokens: Some(150), - cost_usd: Some(0.001), - cache_read_tokens: None, - cache_write_tokens: None, - }), - cumulative: None, - delta_reliable: true, - stop_reason: None, - }; - let ciphertext = - encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); - let tags = vec![ - Tag::parse(["p", &owner_pk]).unwrap(), - Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), - ]; - EventBuilder::new(Kind::Custom(44200), &ciphertext) - .tags(tags) - .sign_with_keys(agent_keys) - .unwrap() - } - - /// A kind-44200 event with `owner_p` scope must route to the persistent - /// (relay-query) path, NOT the ephemeral path. - #[test] - fn test_owner_p_44200_routes_to_persistent_path() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // Subscription for kind 44200 under owner_p. - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - - let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); - - // Must be in persistent buckets, NOT ephemeral list. - assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); - assert_eq!( - plan.ephemeral.len(), - 0, - "kind-44200 must NOT be on the ephemeral path" - ); - assert_eq!( - plan.buckets[0].scope_type_str, "owner_p", - "bucket scope_type must be owner_p" - ); - } - - /// A kind-24200 event with `owner_p` scope must still route to ephemeral. - #[test] - fn test_owner_p_24200_still_routes_to_ephemeral() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); - - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - - let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); - - assert_eq!( - plan.buckets.len(), - 0, - "kind-24200 must NOT land in a bucket" - ); - assert_eq!( - plan.ephemeral.len(), - 1, - "kind-24200 must be on the ephemeral path" - ); - } - - /// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. - #[test] - fn test_turn_metric_decrypt_success_stores_plaintext() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - let result = run_batch_sync_with_keys( - vec![cand], - &owner_pk, - relay_url, - &conn, - vec![ev.clone()], - &owner_keys, - ); - - assert_eq!(result.persisted, 1, "event must be persisted"); - assert_eq!(result.dropped, 0, "no drops on successful decrypt"); - - // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. - let raw_json: String = conn - .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) - .unwrap(); - // Plaintext JSON should be a valid object with "harness" key. - let parsed: serde_json::Value = - serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); - assert_eq!( - parsed["harness"], "test-harness", - "stored plaintext must decode to AgentTurnMetricPayload" - ); - // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). - assert_ne!( - raw_json, ev.content, - "stored content must differ from original ciphertext" - ); - } - - /// Decrypt fail: event is dropped, nothing written to the store (fail-closed). - #[test] - fn test_turn_metric_decrypt_fail_drops_fail_closed() { - let conn = in_memory(); - let owner_keys = Keys::generate(); - let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail - let agent_keys = Keys::generate(); - let owner_pk = owner_keys.public_key().to_hex(); - let relay_url = "wss://relay.example"; - // Register subscription under owner_pk so the event passes plan-phase, - // but use `wrong_keys` in commit so decrypt fails. - add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); - - let ev = make_turn_metric_event(&owner_keys, &agent_keys); - let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); - let result = run_batch_sync_with_keys( - vec![cand], - &owner_pk, - relay_url, - &conn, - vec![ev.clone()], - &wrong_keys, // wrong key → decrypt fails - ); - - assert_eq!( - result.persisted, 0, - "decrypt failure must not persist the event" - ); - assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); - - let event_count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!( - event_count, 0, - "no rows must be written to archived_events on decrypt failure" - ); - } - - // ── Real-relay integration tests ────────────────────────────────────────── - // - // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` - // and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that - // are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at - // test-binary launch before any test runs. The guard compiles the whole - // cluster out of the Windows test binary. These tests are `#[ignore]` and - // require a live relay anyway — there is no Windows relay in CI. - // - // Run (Linux/macOS only): - // - // RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \ - // archive::tests::real_relay -- --ignored --nocapture - // - // The relay must be running with a Postgres backend (same docker compose - // as the existing e2e suite). Each test creates its own keypair + channel so - // concurrent runs don't interfere. - - #[cfg(not(target_os = "windows"))] - mod real_relay { - use super::*; - use crate::app_state::build_app_state; - use std::path::Path; - - fn relay_ws_url_from_env() -> String { - std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) - } - - fn relay_http_base() -> String { - relay_ws_url_from_env() - .replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() - } - - /// Build a test AppState wired with specific identity keys and relay URL. - /// Mirrors production `archive_events`: the same `query_buckets` call path - /// is exercised, including NIP-98 signing inside `query_relay`. - fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { - let state = build_app_state(); - *state.keys.lock().unwrap() = keys; - *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); - state - } - - /// Submit a signed event to the relay via `POST /events`. - /// The staging relay accepts events without NIP-98; this is only used for - /// test setup (publishing events that the archive pipeline will later query). - async fn submit_event_to_relay(ev: &Event) -> serde_json::Value { - let http = reqwest::Client::new(); - let resp = http - .post(format!("{}/events", relay_http_base())) - .header("X-Pubkey", ev.pubkey.to_hex()) - .header("Content-Type", "application/json") - .body(ev.as_json()) - .send() - .await - .expect("submit event to relay"); - assert!( - resp.status().is_success(), - "relay event submit failed: {}", - resp.status() - ); - resp.json().await.expect("parse submit response") - } - - /// Create an open channel on the relay. Returns the channel UUID string. - async fn create_relay_channel(keys: &Keys) -> String { - let channel_id = Uuid::new_v4().to_string(); - let ev = EventBuilder::new(Kind::Custom(9007), "") - .tags(vec![ - Tag::parse(["h", &channel_id]).unwrap(), - Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(), - Tag::parse(["channel_type", "stream"]).unwrap(), - Tag::parse(["visibility", "open"]).unwrap(), - ]) - .sign_with_keys(keys) - .unwrap(); - let resp = submit_event_to_relay(&ev).await; - assert!( - resp["accepted"].as_bool().unwrap_or(false), - "channel creation not accepted: {resp}" - ); - channel_id - } - - /// Open a file-backed archive DB at `path` and insert one save subscription. - fn file_db_with_subscription( - path: &Path, - identity_pk: &str, - relay_url: &str, - scope_type: &str, - scope_value: &str, - kinds_json: &str, - ) { - let conn = store::open_archive_db(path).expect("open file archive db"); - add_sub( - &conn, - identity_pk, - relay_url, - scope_type, - scope_value, - kinds_json, - ); - // conn drops here — file is flushed before the caller reopens it - } - - /// Run plan → real relay query (via the production `query_buckets` path, - /// including NIP-98 signing) → commit, using a file-backed archive DB. - /// - /// Mirrors the open/drop/query/reopen pattern of production `archive_events`: - /// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`). - /// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed. - /// 3. Reopen DB for `commit_archive`. - /// - /// Returns `ArchiveBatchResult`; caller reopens the file for row assertions. - async fn run_batch_real_relay( - candidates: Vec, - state: &AppState, - db_path: &Path, - ) -> ArchiveBatchResult { - let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); - let relay_url = crate::relay::relay_ws_url_with_override(state); - - // Phase 1: plan (sync). Connection dropped before any .await. - let plan = { - let conn = store::open_archive_db(db_path).expect("open archive db for plan"); - plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap() - // conn drops here - }; - - // Phase 2: relay queries (async) — no Connection in scope. - // Uses the real `query_buckets` path: query_relay → NIP-98 signed /query. - let bucket_results = query_buckets(plan.buckets, state).await; - - // Phase 3: persist (sync). Fresh connection, same file. - let conn = store::open_archive_db(db_path).expect("open archive db for commit"); - let owner_keys = state.keys.lock().unwrap().clone(); - commit_archive( - bucket_results, - plan.ephemeral, - plan.pre_dropped, - &identity_pk, - &relay_url, - &owner_keys, - 0, - &conn, - ) - .unwrap() - } - - /// Happy path: publish a kind:9 message to a channel, then run the archive - /// pipeline against the real relay. Asserts exact event + scope rows in a - /// file-backed SQLite archive, reopened after commit to prove persistence. - #[tokio::test] - #[ignore] - async fn test_real_relay_channel_h_happy_path_persists_event() { - let keys = Keys::generate(); - let relay_url = relay_ws_url_from_env(); - let state = make_test_app_state(keys.clone(), &relay_url); - let identity_pk = keys.public_key().to_hex(); - - // Create a channel and publish a kind:9 message. - let channel_id = create_relay_channel(&keys).await; - let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive") - .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let submit_resp = submit_event_to_relay(&msg_ev).await; - assert!( - submit_resp["accepted"].as_bool().unwrap_or(false), - "message not accepted: {submit_resp}" - ); - - // Give the relay a moment to persist. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - - // File-backed archive DB with a channel_h subscription. - let tmp = tempfile::tempdir().expect("tempdir"); - let db_path = tmp.path().join("archive.db"); - file_db_with_subscription( - &db_path, - &identity_pk, - &relay_url, - "channel_h", - &channel_id, - "[9]", - ); - - let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; - let result = run_batch_real_relay(cands, &state, &db_path).await; - - assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}"); - assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}"); - - // Reopen the same file to assert exact row counts — proves file-backed persistence. - let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); - - let event_count: i64 = read_conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(event_count, 1, "archived_events should have 1 row"); - - let scope_count: i64 = read_conn - .query_row( - "SELECT COUNT(*) FROM archived_event_scopes \ - WHERE scope_type = 'channel_h' AND scope_value = ?1", - [&channel_id], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row"); - - // Confirm the stored raw_json round-trips to the original event. - let raw_json: String = read_conn - .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) - .unwrap(); - let stored_ev = Event::from_json(&raw_json).unwrap(); - assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex()); - - println!( - "✓ real relay: event {} archived under channel_h:{}", - msg_ev.id.to_hex(), - channel_id - ); - println!(" archived_events: {event_count} row(s)"); - println!(" archived_event_scopes: {scope_count} row(s)"); - } - - /// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9 - /// channel message. The relay stores the event, but the archive's kind filter drops it - /// because 9 ∉ {1059}. - #[tokio::test] - #[ignore] - async fn test_real_relay_kind_mismatch_drops_event() { - let keys = Keys::generate(); - let relay_url = relay_ws_url_from_env(); - let state = make_test_app_state(keys.clone(), &relay_url); - let identity_pk = keys.public_key().to_hex(); - - let channel_id = create_relay_channel(&keys).await; - // Publish a kind:9 channel message — relay accepts it, but the subscription's - // kind filter is [1059] so the archive pipeline must drop it. - let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg") - .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - let submit_resp = submit_event_to_relay(&msg_ev).await; - assert!( - submit_resp["accepted"].as_bool().unwrap_or(false), - "message not accepted: {submit_resp}" - ); - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - - // Subscription allows kinds=[1059] only — kind:9 must be dropped. - let tmp = tempfile::tempdir().expect("tempdir"); - let db_path = tmp.path().join("archive.db"); - file_db_with_subscription( - &db_path, - &identity_pk, - &relay_url, - "channel_h", - &channel_id, - "[1059]", - ); - - let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; - let result = run_batch_real_relay(cands, &state, &db_path).await; - - assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped"); - assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1"); - - // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. - let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); - let event_count: i64 = read_conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!( - event_count, 0, - "nothing should be archived on kind-mismatch" - ); - - println!( - "✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])" - ); - } - - /// No-subscription drop: event arrives but no save_subscription row exists. - #[tokio::test] - #[ignore] - async fn test_real_relay_no_subscription_drops_event() { - let keys = Keys::generate(); - let relay_url = relay_ws_url_from_env(); - let state = make_test_app_state(keys.clone(), &relay_url); - - let channel_id = create_relay_channel(&keys).await; - let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped") - .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) - .sign_with_keys(&keys) - .unwrap(); - submit_event_to_relay(&msg_ev).await; - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - - // Empty file-backed archive DB — no subscription row. - // plan_archive drops the whole group because no subscription matches. - let tmp = tempfile::tempdir().expect("tempdir"); - let db_path = tmp.path().join("archive.db"); - store::open_archive_db(&db_path).expect("init empty archive db"); - // conn from init drops here; DB file exists but has no subscription rows - - let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; - let result = run_batch_real_relay(cands, &state, &db_path).await; - - assert_eq!(result.persisted, 0, "no-sub: should be dropped"); - assert_eq!(result.dropped, 1, "no-sub: drop count should be 1"); - - // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. - let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); - let event_count: i64 = read_conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!( - event_count, 0, - "nothing should be archived on no-subscription" - ); - - println!("✓ real relay: no-subscription correctly dropped event"); - } - - /// Owner_p ephemeral path: a locally-built valid 24200 frame is archived - /// without a relay query (ephemeral events are never stored on the relay). - #[tokio::test] - #[ignore] - async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() { - let owner_keys = Keys::generate(); - let agent_keys = Keys::generate(); - let relay_url = relay_ws_url_from_env(); - let state = make_test_app_state(owner_keys.clone(), &relay_url); - let identity_pk = owner_keys.public_key().to_hex(); - - // Build a valid kind:24200 observer frame addressed to the owner. - let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); - - // File-backed archive DB with an owner_p subscription for kind 24200. - let tmp = tempfile::tempdir().expect("tempdir"); - let db_path = tmp.path().join("archive.db"); - file_db_with_subscription( - &db_path, - &identity_pk, - &relay_url, - "owner_p", - &identity_pk, - "[24200]", - ); - - // owner_p candidates bypass the relay entirely — query_buckets gets an - // empty bucket list and the ephemeral path handles the frame locally. - let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)]; - let result = run_batch_real_relay(cands, &state, &db_path).await; - - assert_eq!( - result.persisted, 1, - "owner_p: valid frame should be persisted" - ); - assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped"); - - // Reopen the same file to assert exact row counts. - let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); - - let event_count: i64 = read_conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(event_count, 1); - - let scope_count: i64 = read_conn - .query_row( - "SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(scope_count, 1); - - // Confirm the stored raw_json round-trips to the original frame. - let raw_json: String = read_conn - .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) - .unwrap(); - let stored_ev = Event::from_json(&raw_json).unwrap(); - assert_eq!(stored_ev.id.to_hex(), ev.id.to_hex()); - - println!( - "✓ real relay: owner_p ephemeral frame {} archived", - ev.id.to_hex() - ); - println!(" archived_events: {event_count} row(s)"); - println!(" archived_event_scopes: {scope_count} row(s)"); - } - } -} +#[path = "mod_tests.rs"] +mod mod_tests; diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs new file mode 100644 index 0000000000..21b29cae8c --- /dev/null +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -0,0 +1,1199 @@ +//! Unit and integration tests for `archive/mod.rs`. +//! +//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. + +use super::pipeline::BucketWithResult; +use super::*; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use rusqlite::Connection; +use uuid::Uuid; + +// ── Helpers ────────────────────────────────────────────────────────────── + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(super::store::SCHEMA).unwrap(); + conn +} + +fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event { + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_pk]).unwrap(), + Tag::parse(["frame", frame_type]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() +} + +fn add_sub( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, +) { + store::upsert_save_subscription( + conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + 0, + ) + .unwrap(); +} + +/// Run the full archive pipeline synchronously with a fake relay response. +/// +/// Calls `plan_archive` → injects fake relay events → `commit_archive`. +/// This mirrors `archive_events` without the async relay calls. +fn run_batch_sync( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, +) -> ArchiveBatchResult { + let owner_keys = Keys::generate(); + run_batch_sync_with_keys( + candidates, + identity_pk, + relay_url, + conn, + fake_relay_events, + &owner_keys, + ) +} + +/// Like `run_batch_sync` but with a specific owner `Keys` for decrypt. +fn run_batch_sync_with_keys( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + owner_keys: &Keys, +) -> ArchiveBatchResult { + let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); + + // Synthesize BucketWithResult from the fake relay response. + let fake_ids: std::collections::HashSet = + fake_relay_events.iter().map(|e| e.id.to_hex()).collect(); + let bucket_results: Vec = plan + .buckets + .into_iter() + .map(|b| BucketWithResult { + scope_type_str: b.scope_type_str, + scope_value: b.scope_value, + allowed_kinds: b.allowed_kinds, + group: b.group, + returned_ids: fake_ids.clone(), + relay_failed: false, + }) + .collect(); + + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + identity_pk, + relay_url, + owner_keys, + 0, + conn, + ) + .unwrap() +} + +fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate { + ArchiveCandidate { + raw_event_json: event.as_json(), + matched_scope: MatchedScope { + scope_type, + scope_value: scope_value.to_string(), + }, + } +} + +// ── Ephemeral validator — individual condition rejection ────────────────── + +#[test] +fn test_ephemeral_validator_accepts_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + assert!( + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url).is_ok() + ); +} + +#[test] +fn test_ephemeral_validator_rejects_wrong_kind() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::TextNote, "hello") + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("kind")); +} + +#[test] +fn test_ephemeral_validator_rejects_missing_p_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("#p")); +} + +#[test] +fn test_ephemeral_validator_rejects_missing_agent_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent")); +} + +#[test] +fn test_ephemeral_validator_rejects_control_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, "control"); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("telemetry")); +} + +#[test] +fn test_ephemeral_validator_rejects_wrong_author() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let other_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&other_keys) // wrong signer + .unwrap(); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("author")); +} + +#[test] +fn test_ephemeral_validator_rejects_no_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Deliberately do NOT add a subscription. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("owner_p subscription")); +} + +#[test] +fn test_ephemeral_validator_rejects_kind_not_in_subscription() { + // Subscription exists but kinds = [1] (not 24200) — must be rejected. + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("24200"), + "expected kind 24200 in error, got: {msg}" + ); +} + +// ── archive pipeline — persistent path ─────────────────────────────────── + +#[test] +fn test_persistent_channel_h_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Confirm the event is in the store. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0)) + .unwrap(); + assert_eq!(scope_count, 1); +} + +#[test] +fn test_persistent_channel_h_drops_when_relay_does_not_return_event() { + // Relay returns empty — event not proven accessible. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns nothing. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_drops_when_no_subscription() { + // No subscription at all — drop before even querying. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + // Intentionally no subscription. + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay would return the event, but no sub → dropped. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_drops_kind_not_in_subscription() { + // Subscription is for kind 9 only; event is kind 7 (reaction). + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + // kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter + let ev = EventBuilder::new(Kind::Reaction, "+") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof via StoredEvent.channel_id), + // but kind 7 is not in the subscription's kinds list. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +#[test] +fn test_persistent_h_less_event_persists_when_relay_returns_it() { + // An h-less event (e.g. reaction kind:7) that the relay returns under + // the scoped #h filter (via StoredEvent.channel_id fallback) must be + // persisted. The local tag scanner would have dropped it; the scoped + // filter proof must not. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]"); + + // Build a reaction without an `h` tag — local re-derivation would drop it. + let ev = EventBuilder::new(Kind::Reaction, "+") + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns it (relay used StoredEvent.channel_id to match). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Scope row uses bucket's scope_value, not a local-derived value. + let scope_val: String = conn + .query_row( + "SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_val, chan); +} + +#[test] +fn test_persistent_referenced_e_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let ref_id = "a".repeat(64); + add_sub( + &conn, + &identity_pk, + relay_url, + "referenced_e", + &ref_id, + "[9]", + ); + + let ev = EventBuilder::new(Kind::Custom(9), "reply") + .tags(vec![Tag::parse(["e", &ref_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)]; + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); +} + +#[test] +fn test_mixed_batch_persisted_and_dropped_counted_exactly() { + // Two channel_h candidates: relay only returns one. dropped must be 1. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + + // Fake relay only returns ev1. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 1); +} + +// ── archive pipeline — ephemeral path ──────────────────────────────────── + +#[test] +fn test_ephemeral_path_persists_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + // Fake relay returns nothing (not consulted for ephemeral path). + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); +} + +#[test] +fn test_ephemeral_path_drops_kind_not_in_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // kinds = [1], not [24200] + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); +} + +// ── Invalid input dropped ───────────────────────────────────────────────── + +#[test] +fn test_malformed_json_is_dropped() { + let result = Event::from_json("not json at all"); + assert!(result.is_err()); +} + +#[test] +fn test_tampered_event_fails_verify_id() { + let keys = Keys::generate(); + let mut ev_json: serde_json::Value = serde_json::from_str( + &EventBuilder::new(Kind::TextNote, "ok") + .sign_with_keys(&keys) + .unwrap() + .as_json(), + ) + .unwrap(); + ev_json["content"] = serde_json::Value::String("tampered".into()); + let tampered = ev_json.to_string(); + let ev = Event::from_json(&tampered).unwrap(); + assert!(!ev.verify_id()); +} + +// ── F2: out-of-range kind ───────────────────────────────────────────────── + +#[test] +fn test_out_of_range_kind_is_dropped() { + // kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200 + // via `v as u16`, so without the raw-kind check the validator would + // reason about 24200 while the persisted raw_json still says 89736. + // The fix rejects it before Event::from_json. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let owner_pk = &identity_pk; + // Build a valid kind-24200 frame, then mutate only the raw kind to 89736. + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let pk = owner_keys.public_key().to_hex(); + add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap(); + raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536)); + let bad_json = raw.to_string(); + + let cand = ArchiveCandidate { + raw_event_json: bad_json, + matched_scope: MatchedScope { + scope_type: ScopeType::OwnerP, + scope_value: pk.clone(), + }, + }; + let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0, "out-of-range kind must be dropped"); + assert_eq!(result.dropped, 1); + // No event row must exist. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + let _ = owner_pk; // silence unused-var +} + +// ── F3: transactional atomicity ─────────────────────────────────────────── + +#[test] +fn test_commit_archive_rolls_back_when_scope_write_would_fail() { + // Verify the split-schema invariant: if we simulate a mid-batch + // failure (by putting the DB into a state where the scope table is + // missing), the event row must NOT survive. + // + // We can't actually make upsert_event_scope fail on a healthy DB, so + // we verify the positive side: with a healthy DB, both rows are always + // written together or neither is. We confirm via run_batch_sync that + // after a full successful commit both tables have matching row counts. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-txn"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + let result = run_batch_sync( + cands, + &identity_pk, + relay_url, + &conn, + vec![ev1.clone(), ev2.clone()], + ); + assert_eq!(result.persisted, 2); + assert_eq!(result.dropped, 0); + + // Every event row must have exactly one corresponding scope row. + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + event_count, scope_count, + "every event row must have a matching scope row — split-schema invariant" + ); +} + +// ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── + +fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { + use buzz_core_pkg::agent_turn_metric::{ + encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, + }; + let owner_pk = owner_keys.public_key().to_hex(); + let payload = AgentTurnMetricPayload { + harness: "test-harness".to_string(), + model: Some("test-model".to_string()), + channel_id: None, + session_id: Some("sess-1".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T00:00:00Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cost_usd: Some(0.001), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: None, + delta_reliable: true, + stop_reason: None, + }; + let ciphertext = + encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(44200), &ciphertext) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() +} + +/// A kind-44200 event with `owner_p` scope must route to the persistent +/// (relay-query) path, NOT the ephemeral path. +#[test] +fn test_owner_p_44200_routes_to_persistent_path() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Subscription for kind 44200 under owner_p. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + // Must be in persistent buckets, NOT ephemeral list. + assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); + assert_eq!( + plan.ephemeral.len(), + 0, + "kind-44200 must NOT be on the ephemeral path" + ); + assert_eq!( + plan.buckets[0].scope_type_str, "owner_p", + "bucket scope_type must be owner_p" + ); +} + +/// A kind-24200 event with `owner_p` scope must still route to ephemeral. +#[test] +fn test_owner_p_24200_still_routes_to_ephemeral() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + assert_eq!( + plan.buckets.len(), + 0, + "kind-24200 must NOT land in a bucket" + ); + assert_eq!( + plan.ephemeral.len(), + 1, + "kind-24200 must be on the ephemeral path" + ); +} + +/// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. +#[test] +fn test_turn_metric_decrypt_success_stores_plaintext() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + + assert_eq!(result.persisted, 1, "event must be persisted"); + assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + + // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. + let raw_json: String = conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + // Plaintext JSON should be a valid object with "harness" key. + let parsed: serde_json::Value = + serde_json::from_str(&raw_json).expect("stored raw_json must be valid JSON"); + assert_eq!( + parsed["harness"], "test-harness", + "stored plaintext must decode to AgentTurnMetricPayload" + ); + // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). + assert_ne!( + raw_json, ev.content, + "stored content must differ from original ciphertext" + ); +} + +/// Decrypt fail: event is dropped, nothing written to the store (fail-closed). +#[test] +fn test_turn_metric_decrypt_fail_drops_fail_closed() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Register subscription under owner_pk so the event passes plan-phase, + // but use `wrong_keys` in commit so decrypt fails. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &wrong_keys, // wrong key → decrypt fails + ); + + assert_eq!( + result.persisted, 0, + "decrypt failure must not persist the event" + ); + assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "no rows must be written to archived_events on decrypt failure" + ); +} + +// ── Real-relay integration tests ────────────────────────────────────────── +// +// Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` +// and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that +// are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at +// test-binary launch before any test runs. The guard compiles the whole +// cluster out of the Windows test binary. These tests are `#[ignore]` and +// require a live relay anyway — there is no Windows relay in CI. +// +// Run (Linux/macOS only): +// +// RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \ +// archive::tests::real_relay -- --ignored --nocapture +// +// The relay must be running with a Postgres backend (same docker compose +// as the existing e2e suite). Each test creates its own keypair + channel so +// concurrent runs don't interfere. + +#[cfg(not(target_os = "windows"))] +mod real_relay { + use super::*; + use crate::app_state::build_app_state; + use std::path::Path; + + fn relay_ws_url_from_env() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) + } + + fn relay_http_base() -> String { + relay_ws_url_from_env() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + + /// Build a test AppState wired with specific identity keys and relay URL. + /// Mirrors production `archive_events`: the same `query_buckets` call path + /// is exercised, including NIP-98 signing inside `query_relay`. + fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); + state + } + + /// Submit a signed event to the relay via `POST /events`. + /// The staging relay accepts events without NIP-98; this is only used for + /// test setup (publishing events that the archive pipeline will later query). + async fn submit_event_to_relay(ev: &Event) -> serde_json::Value { + let http = reqwest::Client::new(); + let resp = http + .post(format!("{}/events", relay_http_base())) + .header("X-Pubkey", ev.pubkey.to_hex()) + .header("Content-Type", "application/json") + .body(ev.as_json()) + .send() + .await + .expect("submit event to relay"); + assert!( + resp.status().is_success(), + "relay event submit failed: {}", + resp.status() + ); + resp.json().await.expect("parse submit response") + } + + /// Create an open channel on the relay. Returns the channel UUID string. + async fn create_relay_channel(keys: &Keys) -> String { + let channel_id = Uuid::new_v4().to_string(); + let ev = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = submit_event_to_relay(&ev).await; + assert!( + resp["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {resp}" + ); + channel_id + } + + /// Open a file-backed archive DB at `path` and insert one save subscription. + fn file_db_with_subscription( + path: &Path, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + let conn = store::open_archive_db(path).expect("open file archive db"); + add_sub( + &conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + ); + // conn drops here — file is flushed before the caller reopens it + } + + /// Run plan → real relay query (via the production `query_buckets` path, + /// including NIP-98 signing) → commit, using a file-backed archive DB. + /// + /// Mirrors the open/drop/query/reopen pattern of production `archive_events`: + /// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`). + /// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed. + /// 3. Reopen DB for `commit_archive`. + /// + /// Returns `ArchiveBatchResult`; caller reopens the file for row assertions. + async fn run_batch_real_relay( + candidates: Vec, + state: &AppState, + db_path: &Path, + ) -> ArchiveBatchResult { + let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(state); + + // Phase 1: plan (sync). Connection dropped before any .await. + let plan = { + let conn = store::open_archive_db(db_path).expect("open archive db for plan"); + plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap() + // conn drops here + }; + + // Phase 2: relay queries (async) — no Connection in scope. + // Uses the real `query_buckets` path: query_relay → NIP-98 signed /query. + let bucket_results = query_buckets(plan.buckets, state).await; + + // Phase 3: persist (sync). Fresh connection, same file. + let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + let owner_keys = state.keys.lock().unwrap().clone(); + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + &owner_keys, + 0, + &conn, + ) + .unwrap() + } + + /// Happy path: publish a kind:9 message to a channel, then run the archive + /// pipeline against the real relay. Asserts exact event + scope rows in a + /// file-backed SQLite archive, reopened after commit to prove persistence. + #[tokio::test] + #[ignore] + async fn test_real_relay_channel_h_happy_path_persists_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + // Create a channel and publish a kind:9 message. + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + + // Give the relay a moment to persist. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // File-backed archive DB with a channel_h subscription. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[9]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}"); + assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}"); + + // Reopen the same file to assert exact row counts — proves file-backed persistence. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1, "archived_events should have 1 row"); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes \ + WHERE scope_type = 'channel_h' AND scope_value = ?1", + [&channel_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row"); + + // Confirm the stored raw_json round-trips to the original event. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex()); + + println!( + "✓ real relay: event {} archived under channel_h:{}", + msg_ev.id.to_hex(), + channel_id + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + + /// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9 + /// channel message. The relay stores the event, but the archive's kind filter drops it + /// because 9 ∉ {1059}. + #[tokio::test] + #[ignore] + async fn test_real_relay_kind_mismatch_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + let channel_id = create_relay_channel(&keys).await; + // Publish a kind:9 channel message — relay accepts it, but the subscription's + // kind filter is [1059] so the archive pipeline must drop it. + let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Subscription allows kinds=[1059] only — kind:9 must be dropped. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[1059]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped"); + assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on kind-mismatch" + ); + + println!( + "✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])" + ); + } + + /// No-subscription drop: event arrives but no save_subscription row exists. + #[tokio::test] + #[ignore] + async fn test_real_relay_no_subscription_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + submit_event_to_relay(&msg_ev).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Empty file-backed archive DB — no subscription row. + // plan_archive drops the whole group because no subscription matches. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + store::open_archive_db(&db_path).expect("init empty archive db"); + // conn from init drops here; DB file exists but has no subscription rows + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "no-sub: should be dropped"); + assert_eq!(result.dropped, 1, "no-sub: drop count should be 1"); + + // Belt-and-suspenders: confirm the on-disk archive is genuinely empty. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "nothing should be archived on no-subscription" + ); + + println!("✓ real relay: no-subscription correctly dropped event"); + } + + /// Owner_p ephemeral path: a locally-built valid 24200 frame is archived + /// without a relay query (ephemeral events are never stored on the relay). + #[tokio::test] + #[ignore] + async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(owner_keys.clone(), &relay_url); + let identity_pk = owner_keys.public_key().to_hex(); + + // Build a valid kind:24200 observer frame addressed to the owner. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + + // File-backed archive DB with an owner_p subscription for kind 24200. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "owner_p", + &identity_pk, + "[24200]", + ); + + // owner_p candidates bypass the relay entirely — query_buckets gets an + // empty bucket list and the ephemeral path handles the frame locally. + let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!( + result.persisted, 1, + "owner_p: valid frame should be persisted" + ); + assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped"); + + // Reopen the same file to assert exact row counts. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1); + + // Confirm the stored raw_json round-trips to the original frame. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), ev.id.to_hex()); + + println!( + "✓ real relay: owner_p ephemeral frame {} archived", + ev.id.to_hex() + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } +} diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index 72704105f2..821d9e661c 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -274,6 +274,93 @@ pub fn merge_owner_p_kinds( result } +/// Atomically remove `kind` from the `owner_p` save subscription for the +/// given identity + relay + scope_value. +/// +/// Reads the current `kinds` array, removes `kind` if present, then: +/// - if the resulting list is **empty**, deletes the `owner_p` row entirely +/// (keeping parity with the UI behavior where the last kind off removes the +/// subscription row). +/// - otherwise, updates `kinds` to the reduced list. +/// +/// Uses `BEGIN IMMEDIATE` for the same reason as `merge_owner_p_kinds`: the +/// write lock is acquired before the SELECT so concurrent callers serialize +/// rather than racing to a `BUSY_SNAPSHOT` error. +/// +/// No-op (Ok) if the row does not exist or the kind is not present. +pub fn remove_owner_p_kind( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_value: &str, + kind: u32, +) -> Result<(), String> { + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("remove_owner_p_kind begin immediate: {e}"))?; + + let result = (|| -> Result<(), String> { + // Read the current kinds, if any. + let existing_json: Option = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("remove_owner_p_kind read: {e}"))?; + + let existing_json = match existing_json { + Some(j) => j, + // Row doesn't exist — nothing to remove. + None => return Ok(()), + }; + + let mut kinds: Vec = serde_json::from_str(&existing_json).unwrap_or_default(); + kinds.retain(|&k| k != kind); + + if kinds.is_empty() { + // Last kind removed — delete the row entirely. + conn.execute( + "DELETE FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value], + ) + .map_err(|e| format!("remove_owner_p_kind delete: {e}"))?; + } else { + let kinds_json = serde_json::to_string(&kinds) + .map_err(|e| format!("remove_owner_p_kind serialize: {e}"))?; + conn.execute( + "UPDATE save_subscriptions + SET kinds = ?4 + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = 'owner_p' + AND scope_value = ?3", + params![identity_pubkey, relay_url, scope_value, kinds_json], + ) + .map_err(|e| format!("remove_owner_p_kind update: {e}"))?; + } + + Ok(()) + })(); + + if result.is_ok() { + conn.execute_batch("COMMIT") + .map_err(|e| format!("remove_owner_p_kind commit: {e}"))?; + } else { + let _ = conn.execute_batch("ROLLBACK"); + } + + result +} + /// Return the `kinds` JSON string for a matching save subscription, or `None` /// if no subscription exists. pub fn get_subscription_kinds( @@ -506,673 +593,5 @@ pub fn gc_orphaned_events( // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - fn in_memory() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.pragma_update(None, "journal_mode", "WAL").unwrap(); - conn.pragma_update(None, "busy_timeout", 5000).unwrap(); - conn.execute_batch(SCHEMA).unwrap(); - conn - } - - // ── Schema init ────────────────────────────────────────────────────────── - - #[test] - fn test_schema_init_creates_all_tables() { - let conn = in_memory(); - // Verify all three tables exist by inserting a row in each. - conn.execute( - "INSERT INTO save_subscriptions VALUES ('pk','relay','channel_h','abc','[1]',0)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO archived_events VALUES ('pk','relay','id1',1,'author',0,'{}',0)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO archived_event_scopes VALUES ('pk','relay','id1','channel_h','abc',0)", - [], - ) - .unwrap(); - } - - #[test] - fn test_schema_init_is_idempotent() { - // Running SCHEMA twice must not error. - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(SCHEMA).unwrap(); - conn.execute_batch(SCHEMA).unwrap(); - } - - // ── Save subscriptions ─────────────────────────────────────────────────── - - #[test] - fn test_upsert_save_subscription_inserts_and_updates_kinds() { - let conn = in_memory(); - upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - assert_eq!(subs.len(), 1); - assert_eq!(subs[0].kinds, "[1]"); - - // Update kinds. - upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1,6]", 2).unwrap(); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - assert_eq!(subs.len(), 1); - assert_eq!(subs[0].kinds, "[1,6]"); - } - - #[test] - fn test_list_save_subscriptions_scoped_to_identity_and_relay() { - let conn = in_memory(); - upsert_save_subscription(&conn, "pk1", "wss://r1", "channel_h", "a", "[1]", 1).unwrap(); - upsert_save_subscription(&conn, "pk2", "wss://r1", "channel_h", "b", "[1]", 2).unwrap(); - upsert_save_subscription(&conn, "pk1", "wss://r2", "channel_h", "c", "[1]", 3).unwrap(); - - let subs = list_save_subscriptions(&conn, "pk1", "wss://r1").unwrap(); - assert_eq!(subs.len(), 1); - assert_eq!(subs[0].scope_value, "a"); - } - - #[test] - fn test_delete_save_subscription_removes_row() { - let conn = in_memory(); - upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); - let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc").unwrap(); - assert!(deleted); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - assert!(subs.is_empty()); - } - - #[test] - fn test_delete_save_subscription_returns_false_when_not_found() { - let conn = in_memory(); - let deleted = - delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "nope").unwrap(); - assert!(!deleted); - } - - #[test] - fn test_has_save_subscription_true_and_false() { - let conn = in_memory(); - upsert_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk", "[24200]", 1).unwrap(); - assert!(has_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk").unwrap()); - assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); - } - - // ── merge_owner_p_kinds ────────────────────────────────────────────────── - - #[test] - fn test_merge_owner_p_kinds_creates_row_when_none_exists() { - let conn = in_memory(); - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - assert_eq!(subs.len(), 1); - let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); - assert_eq!(kinds, [24200]); - } - - #[test] - fn test_merge_owner_p_kinds_adds_new_kind_to_existing_row() { - let conn = in_memory(); - // Seed with 24200 first. - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); - // Now merge 44200 in — must produce [24200, 44200]. - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - assert_eq!(subs.len(), 1); - let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); - assert!(kinds.contains(&24200), "must still contain 24200"); - assert!(kinds.contains(&44200), "must now contain 44200"); - assert_eq!(kinds.len(), 2, "no duplicates"); - } - - #[test] - fn test_merge_owner_p_kinds_idempotent_on_existing_kind() { - let conn = in_memory(); - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); - let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); - let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); - assert_eq!(kinds, [44200], "no duplicates after idempotent call"); - } - - /// Two-connection WAL regression test for the BEGIN IMMEDIATE fix. - /// - /// Opens TWO separate connections to the same WAL file (mirroring the - /// real scenario where the observer-archive seed hook and the - /// metric-archive seed hook each open their own connection via - /// `open_archive_db`). Both threads call `merge_owner_p_kinds` - /// concurrently from an empty row. With `BEGIN IMMEDIATE` the losing - /// thread blocks on `busy_timeout` until the winner commits, then - /// reads the committed row and merges its kind in. Both calls must - /// resolve `Ok` and the final row must contain exactly `[24200, 44200]`. - /// - /// A `DEFERRED` transaction would produce `SQLITE_BUSY_SNAPSHOT` on the - /// loser (not retried by busy_timeout), causing one kind to be silently - /// dropped. This test fails in <10 ms if the IMMEDIATE guard is removed. - #[test] - fn test_merge_owner_p_kinds_two_conn_wal_both_kinds_survive() { - use std::sync::{Arc, Barrier}; - use std::thread; - use tempfile::NamedTempFile; - - // A real file DB is required for WAL mode (in-memory dbs don't support - // shared-cache WAL across multiple connections in the same process). - let db_file = NamedTempFile::new().unwrap(); - let db_path = db_file.path().to_path_buf(); - - // Initialise schema on the file DB via conn-A so both threads see it. - let init_conn = open_archive_db(&db_path).unwrap(); - drop(init_conn); - - // Barrier ensures both threads are inside `merge_owner_p_kinds` before - // either one issues `BEGIN IMMEDIATE`, maximising the race window. - let barrier = Arc::new(Barrier::new(2)); - - let path_a = db_path.clone(); - let path_b = db_path.clone(); - let bar_a = Arc::clone(&barrier); - let bar_b = Arc::clone(&barrier); - - let handle_observer = thread::spawn(move || { - let conn = open_archive_db(&path_a).unwrap(); - bar_a.wait(); // sync: both threads ready - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1) - }); - - let handle_metric = thread::spawn(move || { - let conn = open_archive_db(&path_b).unwrap(); - bar_b.wait(); // sync: both threads ready - merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2) - }); - - let res_observer = handle_observer.join().expect("observer thread panicked"); - let res_metric = handle_metric.join().expect("metric thread panicked"); - - assert!( - res_observer.is_ok(), - "observer seed must succeed: {:?}", - res_observer - ); - assert!( - res_metric.is_ok(), - "metric seed must succeed: {:?}", - res_metric - ); - - // Verify the final row contains both kinds. - let verify_conn = open_archive_db(&db_path).unwrap(); - let subs = list_save_subscriptions(&verify_conn, "pk", "wss://r").unwrap(); - assert_eq!(subs.len(), 1, "exactly one owner_p row"); - let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); - assert!( - kinds.contains(&24200), - "observer kind 24200 must survive concurrent metric seed; got {:?}", - kinds - ); - assert!( - kinds.contains(&44200), - "metric kind 44200 must be present after concurrent seed; got {:?}", - kinds - ); - assert_eq!( - kinds.len(), - 2, - "exactly two kinds, no duplicates; got {:?}", - kinds - ); - } - - // ── Archived events ────────────────────────────────────────────────────── - - #[test] - fn test_upsert_archived_event_is_idempotent() { - let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - // Second call must not error or duplicate. - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); - } - - // ── Many-to-many scope rows ────────────────────────────────────────────── - - #[test] - fn test_one_event_gets_multiple_scope_rows() { - let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 200).unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "evref", 200).unwrap(); - // Idempotent second insert. - upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 201).unwrap(); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM archived_event_scopes WHERE id = 'id1'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 2); - } - - // ── GC ─────────────────────────────────────────────────────────────────── - - #[test] - fn test_gc_removes_event_when_last_scope_deleted() { - let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); - // Delete the only scope row manually. - conn.execute("DELETE FROM archived_event_scopes WHERE id = 'id1'", []) - .unwrap(); - let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); - assert_eq!(removed, 1); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_gc_leaves_event_with_remaining_scope() { - let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "ref", 200).unwrap(); - // Delete only one scope row. - conn.execute( - "DELETE FROM archived_event_scopes WHERE scope_type = 'referenced_e'", - [], - ) - .unwrap(); - let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); - assert_eq!(removed, 0); - } - - // ── read_archived_events ───────────────────────────────────────────────── - - fn seed_events(conn: &Connection) { - // Three events in scope "channel_h/chan1" for identity "pk"/"wss://r". - // created_at: 300 (newest), 200, 100 (oldest). - for (id, kind, created_at, raw) in &[ - ("e1", 9i64, 300i64, r#"{"id":"e1","created_at":300}"#), - ("e2", 9i64, 200i64, r#"{"id":"e2","created_at":200}"#), - ("e3", 42i64, 100i64, r#"{"id":"e3","created_at":100}"#), - ] { - upsert_archived_event( - conn, - "pk", - "wss://r", - id, - *kind, - "author", - *created_at, - raw, - 999, - ) - .unwrap(); - upsert_event_scope(conn, "pk", "wss://r", id, "channel_h", "chan1", 999).unwrap(); - } - // One event in a different scope — must never appear in chan1 results. - upsert_archived_event( - conn, - "pk", - "wss://r", - "e4", - 9, - "author", - 250, - r#"{"id":"e4"}"#, - 999, - ) - .unwrap(); - upsert_event_scope(conn, "pk", "wss://r", "e4", "channel_h", "chan2", 999).unwrap(); - } - - #[test] - fn test_read_archived_events_returns_newest_first() { - let conn = in_memory(); - seed_events(&conn); - let rows = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 10, - ) - .unwrap(); - assert_eq!(rows.len(), 3); - // Newest first: e1 (300), e2 (200), e3 (100). - let ids: Vec<&str> = rows - .iter() - .map(|r| { - if r.contains("\"e1\"") { - "e1" - } else if r.contains("\"e2\"") { - "e2" - } else { - "e3" - } - }) - .collect(); - assert_eq!(ids, ["e1", "e2", "e3"]); - } - - #[test] - fn test_read_archived_events_keyset_cursor_excludes_at_boundary() { - let conn = in_memory(); - seed_events(&conn); - // Compound cursor at e1 (created_at=300, id="e1"): excludes e1 itself, - // returns e2 and e3. - let rows = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - Some(300), - Some("e1"), - 10, - ) - .unwrap(); - assert_eq!(rows.len(), 2); - assert!(rows.iter().all(|r| !r.contains("\"e1\""))); - } - - #[test] - fn test_read_archived_events_keyset_cursor_advances_correctly() { - let conn = in_memory(); - seed_events(&conn); - // Page 1: before=None/None, limit=2 → e1, e2. - let page1 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 2, - ) - .unwrap(); - assert_eq!(page1.len(), 2); - // Page 2: compound cursor at e2 (created_at=200, id="e2") → e3 only. - let page2 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - Some(200), - Some("e2"), - 2, - ) - .unwrap(); - assert_eq!(page2.len(), 1); - assert!(page2[0].contains("\"e3\"")); - // No overlap between pages. - assert!(page1.iter().all(|r| !r.contains("\"e3\""))); - } - - #[test] - fn test_read_archived_events_kind_filter() { - let conn = in_memory(); - seed_events(&conn); - // Only kind 9 (e1 and e2); e3 is kind 42. - let rows = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - Some(&[9]), - None, - None, - 10, - ) - .unwrap(); - assert_eq!(rows.len(), 2); - assert!(rows.iter().all(|r| !r.contains("\"e3\""))); - } - - #[test] - fn test_read_archived_events_scope_isolation() { - let conn = in_memory(); - seed_events(&conn); - // chan2 has only e4; chan1 results must not include e4. - let chan1 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 10, - ) - .unwrap(); - assert!(chan1.iter().all(|r| !r.contains("\"e4\""))); - - let chan2 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan2", - None, - None, - None, - 10, - ) - .unwrap(); - assert_eq!(chan2.len(), 1); - assert!(chan2[0].contains("\"e4\"")); - } - - #[test] - fn test_read_archived_events_identity_isolation() { - let conn = in_memory(); - seed_events(&conn); - // Different identity — must see no rows. - let rows = read_archived_events( - &conn, - "pk2", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 10, - ) - .unwrap(); - assert!(rows.is_empty()); - } - - #[test] - fn test_read_archived_events_relay_isolation() { - let conn = in_memory(); - seed_events(&conn); - // Different relay — must see no rows. - let rows = read_archived_events( - &conn, - "pk", - "wss://other", - "channel_h", - "chan1", - None, - None, - None, - 10, - ) - .unwrap(); - assert!(rows.is_empty()); - } - - #[test] - fn test_read_archived_events_empty_result() { - let conn = in_memory(); - let rows = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "nope", - None, - None, - None, - 10, - ) - .unwrap(); - assert!(rows.is_empty()); - } - - #[test] - fn test_read_archived_events_limit_respected() { - let conn = in_memory(); - seed_events(&conn); - let rows = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 1, - ) - .unwrap(); - assert_eq!(rows.len(), 1); - // Must be the newest (e1, created_at=300). - assert!(rows[0].contains("\"e1\"")); - } - - #[test] - fn test_read_archived_events_no_duplicates_across_pages() { - let conn = in_memory(); - seed_events(&conn); - let page1 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - None, - None, - 2, - ) - .unwrap(); - // Compound cursor at e2 (the oldest in page1: created_at=200, id="e2"). - let page2 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "chan1", - None, - Some(200), - Some("e2"), - 2, - ) - .unwrap(); - // All event ids across both pages are unique. - let all: Vec<_> = page1.iter().chain(page2.iter()).collect(); - assert_eq!(all.len(), 3); // 2 + 1 = 3 total, no duplication. - } - - /// Regression for the scalar-cursor same-second skip defect (Thufir IMPORTANT). - /// - /// The writer stores `created_at` in whole seconds, so two events can share - /// the same timestamp. The sort order is `(created_at DESC, id DESC)`, so - /// a page split exactly at a same-second boundary leaves one sibling on each - /// side. With only `created_at < before` the second-page sibling would be - /// permanently excluded. The compound `(created_at < ?) OR (created_at = ? - /// AND id < ?)` predicate mirrors the sort key exactly and avoids the skip. - #[test] - fn test_read_archived_events_same_second_cursor_no_skip() { - let conn = in_memory(); - // Two events share created_at=1000. Sort order: "z" (id "z") > "a" (id "a"), - // so ORDER BY created_at DESC, id DESC yields: ("z", 1000) first, ("a", 1000) second. - // A third event has created_at=500. - for (id, kind, created_at, raw) in &[ - ("z", 9i64, 1000i64, r#"{"id":"z","created_at":1000}"#), - ("a", 9i64, 1000i64, r#"{"id":"a","created_at":1000}"#), - ("old", 9i64, 500i64, r#"{"id":"old","created_at":500}"#), - ] { - upsert_archived_event( - &conn, - "pk", - "wss://r", - id, - *kind, - "author", - *created_at, - raw, - 999, - ) - .unwrap(); - upsert_event_scope(&conn, "pk", "wss://r", id, "channel_h", "same_sec", 999).unwrap(); - } - - // Page 1: limit=1 → should return ("z", 1000) only (newest by compound sort). - let page1 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "same_sec", - None, - None, - None, - 1, - ) - .unwrap(); - assert_eq!(page1.len(), 1); - assert!(page1[0].contains("\"z\""), "page1 must be the 'z' row"); - - // Page 2: compound cursor at ("z", 1000). - // With a scalar cursor (created_at < 1000), row "a" would be SKIPPED. - // With the compound cursor, "a" must appear on page 2. - let page2 = read_archived_events( - &conn, - "pk", - "wss://r", - "channel_h", - "same_sec", - None, - Some(1000), - Some("z"), - 2, - ) - .unwrap(); - // Must contain "a" (same-second sibling) and "old" (strictly older). - assert_eq!(page2.len(), 2, "page2 must return both remaining rows"); - assert!( - page2.iter().any(|r| r.contains("\"a\"")), - "same-second sibling 'a' must not be skipped" - ); - assert!( - page2.iter().any(|r| r.contains("\"old\"")), - "'old' row must appear on page2" - ); - // No overlap with page1. - assert!(page2.iter().all(|r| !r.contains("\"z\""))); - } -} +#[path = "store_tests.rs"] +mod store_tests; diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs new file mode 100644 index 0000000000..18a80af608 --- /dev/null +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -0,0 +1,730 @@ +//! Unit tests for `archive/store.rs`. +//! +//! Kept in a sibling file so `store.rs` stays under the 1000-line gate; +//! `#[path]`-included from there. + +use super::*; + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn +} + +// ── Schema init ────────────────────────────────────────────────────────── + +#[test] +fn test_schema_init_creates_all_tables() { + let conn = in_memory(); + // Verify all three tables exist by inserting a row in each. + conn.execute( + "INSERT INTO save_subscriptions VALUES ('pk','relay','channel_h','abc','[1]',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_events VALUES ('pk','relay','id1',1,'author',0,'{}',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_event_scopes VALUES ('pk','relay','id1','channel_h','abc',0)", + [], + ) + .unwrap(); +} + +#[test] +fn test_schema_init_is_idempotent() { + // Running SCHEMA twice must not error. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); +} + +// ── Save subscriptions ─────────────────────────────────────────────────── + +#[test] +fn test_upsert_save_subscription_inserts_and_updates_kinds() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1]"); + + // Update kinds. + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1,6]", 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1,6]"); +} + +#[test] +fn test_list_save_subscriptions_scoped_to_identity_and_relay() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk1", "wss://r1", "channel_h", "a", "[1]", 1).unwrap(); + upsert_save_subscription(&conn, "pk2", "wss://r1", "channel_h", "b", "[1]", 2).unwrap(); + upsert_save_subscription(&conn, "pk1", "wss://r2", "channel_h", "c", "[1]", 3).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk1", "wss://r1").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].scope_value, "a"); +} + +#[test] +fn test_delete_save_subscription_removes_row() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc").unwrap(); + assert!(deleted); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); +} + +#[test] +fn test_delete_save_subscription_returns_false_when_not_found() { + let conn = in_memory(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "nope").unwrap(); + assert!(!deleted); +} + +#[test] +fn test_has_save_subscription_true_and_false() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk", "[24200]", 1).unwrap(); + assert!(has_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk").unwrap()); + assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); +} + +// ── merge_owner_p_kinds ────────────────────────────────────────────────── + +#[test] +fn test_merge_owner_p_kinds_creates_row_when_none_exists() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [24200]); +} + +#[test] +fn test_merge_owner_p_kinds_adds_new_kind_to_existing_row() { + let conn = in_memory(); + // Seed with 24200 first. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + // Now merge 44200 in — must produce [24200, 44200]. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!(kinds.contains(&24200), "must still contain 24200"); + assert!(kinds.contains(&44200), "must now contain 44200"); + assert_eq!(kinds.len(), 2, "no duplicates"); +} + +#[test] +fn test_merge_owner_p_kinds_idempotent_on_existing_kind() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [44200], "no duplicates after idempotent call"); +} + +/// Two-connection WAL regression test for the BEGIN IMMEDIATE fix. +/// +/// Opens TWO separate connections to the same WAL file (mirroring the +/// real scenario where the observer-archive seed hook and the +/// metric-archive seed hook each open their own connection via +/// `open_archive_db`). Both threads call `merge_owner_p_kinds` +/// concurrently from an empty row. With `BEGIN IMMEDIATE` the losing +/// thread blocks on `busy_timeout` until the winner commits, then +/// reads the committed row and merges its kind in. Both calls must +/// resolve `Ok` and the final row must contain exactly `[24200, 44200]`. +/// +/// A `DEFERRED` transaction would produce `SQLITE_BUSY_SNAPSHOT` on the +/// loser (not retried by busy_timeout), causing one kind to be silently +/// dropped. This test fails in <10 ms if the IMMEDIATE guard is removed. +#[test] +fn test_merge_owner_p_kinds_two_conn_wal_both_kinds_survive() { + use std::sync::{Arc, Barrier}; + use std::thread; + use tempfile::NamedTempFile; + + // A real file DB is required for WAL mode (in-memory dbs don't support + // shared-cache WAL across multiple connections in the same process). + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_path_buf(); + + // Initialise schema on the file DB via conn-A so both threads see it. + let init_conn = open_archive_db(&db_path).unwrap(); + drop(init_conn); + + // Barrier ensures both threads are inside `merge_owner_p_kinds` before + // either one issues `BEGIN IMMEDIATE`, maximising the race window. + let barrier = Arc::new(Barrier::new(2)); + + let path_a = db_path.clone(); + let path_b = db_path.clone(); + let bar_a = Arc::clone(&barrier); + let bar_b = Arc::clone(&barrier); + + let handle_observer = thread::spawn(move || { + let conn = open_archive_db(&path_a).unwrap(); + bar_a.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1) + }); + + let handle_metric = thread::spawn(move || { + let conn = open_archive_db(&path_b).unwrap(); + bar_b.wait(); // sync: both threads ready + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2) + }); + + let res_observer = handle_observer.join().expect("observer thread panicked"); + let res_metric = handle_metric.join().expect("metric thread panicked"); + + assert!( + res_observer.is_ok(), + "observer seed must succeed: {:?}", + res_observer + ); + assert!( + res_metric.is_ok(), + "metric seed must succeed: {:?}", + res_metric + ); + + // Verify the final row contains both kinds. + let verify_conn = open_archive_db(&db_path).unwrap(); + let subs = list_save_subscriptions(&verify_conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1, "exactly one owner_p row"); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert!( + kinds.contains(&24200), + "observer kind 24200 must survive concurrent metric seed; got {:?}", + kinds + ); + assert!( + kinds.contains(&44200), + "metric kind 44200 must be present after concurrent seed; got {:?}", + kinds + ); + assert_eq!( + kinds.len(), + 2, + "exactly two kinds, no duplicates; got {:?}", + kinds + ); +} + +// ── remove_owner_p_kind ────────────────────────────────────────────────── + +#[test] +fn test_remove_owner_p_kind_removes_one_kind_leaving_other() { + let conn = in_memory(); + // Seed both kinds. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 2).unwrap(); + + // Remove 44200 — 24200 must survive. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1, "row must still exist"); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!( + kinds, + [24200], + "only 24200 remains after removing 44200; got {kinds:?}" + ); +} + +#[test] +fn test_remove_owner_p_kind_deletes_row_when_last_kind_removed() { + let conn = in_memory(); + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 44200, 1).unwrap(); + + // Remove the only kind — row must be deleted. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!( + subs.is_empty(), + "row must be deleted when last kind removed" + ); +} + +#[test] +fn test_remove_owner_p_kind_noop_when_row_absent() { + let conn = in_memory(); + // No row exists — must succeed silently. + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 24200).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); +} + +#[test] +fn test_remove_owner_p_kind_noop_when_kind_absent() { + let conn = in_memory(); + // Row exists with only 24200 — removing 44200 must leave row unchanged. + merge_owner_p_kinds(&conn, "pk", "wss://r", "mypk", 24200, 1).unwrap(); + remove_owner_p_kind(&conn, "pk", "wss://r", "mypk", 44200).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + let kinds: Vec = serde_json::from_str(&subs[0].kinds).unwrap(); + assert_eq!(kinds, [24200]); +} + +// ── Archived events ────────────────────────────────────────────────────── + +#[test] +fn test_upsert_archived_event_is_idempotent() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + // Second call must not error or duplicate. + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +// ── Many-to-many scope rows ────────────────────────────────────────────── + +#[test] +fn test_one_event_gets_multiple_scope_rows() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "evref", 200).unwrap(); + // Idempotent second insert. + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 201).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE id = 'id1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); +} + +// ── GC ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_gc_removes_event_when_last_scope_deleted() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + // Delete the only scope row manually. + conn.execute("DELETE FROM archived_event_scopes WHERE id = 'id1'", []) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 1); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_gc_leaves_event_with_remaining_scope() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "ref", 200).unwrap(); + // Delete only one scope row. + conn.execute( + "DELETE FROM archived_event_scopes WHERE scope_type = 'referenced_e'", + [], + ) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 0); +} + +// ── read_archived_events ───────────────────────────────────────────────── + +fn seed_events(conn: &Connection) { + // Three events in scope "channel_h/chan1" for identity "pk"/"wss://r". + // created_at: 300 (newest), 200, 100 (oldest). + for (id, kind, created_at, raw) in &[ + ("e1", 9i64, 300i64, r#"{"id":"e1","created_at":300}"#), + ("e2", 9i64, 200i64, r#"{"id":"e2","created_at":200}"#), + ("e3", 42i64, 100i64, r#"{"id":"e3","created_at":100}"#), + ] { + upsert_archived_event( + conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", id, "channel_h", "chan1", 999).unwrap(); + } + // One event in a different scope — must never appear in chan1 results. + upsert_archived_event( + conn, + "pk", + "wss://r", + "e4", + 9, + "author", + 250, + r#"{"id":"e4"}"#, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", "e4", "channel_h", "chan2", 999).unwrap(); +} + +#[test] +fn test_read_archived_events_returns_newest_first() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 3); + // Newest first: e1 (300), e2 (200), e3 (100). + let ids: Vec<&str> = rows + .iter() + .map(|r| { + if r.contains("\"e1\"") { + "e1" + } else if r.contains("\"e2\"") { + "e2" + } else { + "e3" + } + }) + .collect(); + assert_eq!(ids, ["e1", "e2", "e3"]); +} + +#[test] +fn test_read_archived_events_keyset_cursor_excludes_at_boundary() { + let conn = in_memory(); + seed_events(&conn); + // Compound cursor at e1 (created_at=300, id="e1"): excludes e1 itself, + // returns e2 and e3. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(300), + Some("e1"), + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e1\""))); +} + +#[test] +fn test_read_archived_events_keyset_cursor_advances_correctly() { + let conn = in_memory(); + seed_events(&conn); + // Page 1: before=None/None, limit=2 → e1, e2. + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + assert_eq!(page1.len(), 2); + // Page 2: compound cursor at e2 (created_at=200, id="e2") → e3 only. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + assert_eq!(page2.len(), 1); + assert!(page2[0].contains("\"e3\"")); + // No overlap between pages. + assert!(page1.iter().all(|r| !r.contains("\"e3\""))); +} + +#[test] +fn test_read_archived_events_kind_filter() { + let conn = in_memory(); + seed_events(&conn); + // Only kind 9 (e1 and e2); e3 is kind 42. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + Some(&[9]), + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e3\""))); +} + +#[test] +fn test_read_archived_events_scope_isolation() { + let conn = in_memory(); + seed_events(&conn); + // chan2 has only e4; chan1 results must not include e4. + let chan1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(chan1.iter().all(|r| !r.contains("\"e4\""))); + + let chan2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan2", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(chan2.len(), 1); + assert!(chan2[0].contains("\"e4\"")); +} + +#[test] +fn test_read_archived_events_identity_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different identity — must see no rows. + let rows = read_archived_events( + &conn, + "pk2", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_relay_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different relay — must see no rows. + let rows = read_archived_events( + &conn, + "pk", + "wss://other", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_empty_result() { + let conn = in_memory(); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "nope", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); +} + +#[test] +fn test_read_archived_events_limit_respected() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + // Must be the newest (e1, created_at=300). + assert!(rows[0].contains("\"e1\"")); +} + +#[test] +fn test_read_archived_events_no_duplicates_across_pages() { + let conn = in_memory(); + seed_events(&conn); + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + // Compound cursor at e2 (the oldest in page1: created_at=200, id="e2"). + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + // All event ids across both pages are unique. + let all: Vec<_> = page1.iter().chain(page2.iter()).collect(); + assert_eq!(all.len(), 3); // 2 + 1 = 3 total, no duplication. +} + +/// Regression for the scalar-cursor same-second skip defect (Thufir IMPORTANT). +/// +/// The writer stores `created_at` in whole seconds, so two events can share +/// the same timestamp. The sort order is `(created_at DESC, id DESC)`, so +/// a page split exactly at a same-second boundary leaves one sibling on each +/// side. With only `created_at < before` the second-page sibling would be +/// permanently excluded. The compound `(created_at < ?) OR (created_at = ? +/// AND id < ?)` predicate mirrors the sort key exactly and avoids the skip. +#[test] +fn test_read_archived_events_same_second_cursor_no_skip() { + let conn = in_memory(); + // Two events share created_at=1000. Sort order: "z" (id "z") > "a" (id "a"), + // so ORDER BY created_at DESC, id DESC yields: ("z", 1000) first, ("a", 1000) second. + // A third event has created_at=500. + for (id, kind, created_at, raw) in &[ + ("z", 9i64, 1000i64, r#"{"id":"z","created_at":1000}"#), + ("a", 9i64, 1000i64, r#"{"id":"a","created_at":1000}"#), + ("old", 9i64, 500i64, r#"{"id":"old","created_at":500}"#), + ] { + upsert_archived_event( + &conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", id, "channel_h", "same_sec", 999).unwrap(); + } + + // Page 1: limit=1 → should return ("z", 1000) only (newest by compound sort). + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(page1.len(), 1); + assert!(page1[0].contains("\"z\""), "page1 must be the 'z' row"); + + // Page 2: compound cursor at ("z", 1000). + // With a scalar cursor (created_at < 1000), row "a" would be SKIPPED. + // With the compound cursor, "a" must appear on page 2. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + Some(1000), + Some("z"), + 2, + ) + .unwrap(); + // Must contain "a" (same-second sibling) and "old" (strictly older). + assert_eq!(page2.len(), 2, "page2 must return both remaining rows"); + assert!( + page2.iter().any(|r| r.contains("\"a\"")), + "same-second sibling 'a' must not be skipped" + ); + assert!( + page2.iter().any(|r| r.contains("\"old\"")), + "'old' row must appear on page2" + ); + // No overlap with page1. + assert!(page2.iter().all(|r| !r.contains("\"z\""))); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f062ec467d..4e4629f919 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -620,6 +620,7 @@ pub fn run() { archive::archive_events, archive::create_save_subscription, archive::merge_save_subscription_kinds, + archive::remove_save_subscription_kind, archive::list_save_subscriptions, archive::delete_save_subscription, archive::read_archived_events, diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index 5bf59574be..6bc1325330 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -6,6 +6,8 @@ import { createSaveSubscription, deleteSaveSubscription, listSaveSubscriptions, + mergeSaveSubscriptionKinds, + removeSaveSubscriptionKind, type SaveSubscription, type ScopeType, } from "@/shared/api/tauriArchive"; @@ -447,21 +449,10 @@ export function LocalArchiveSettingsCard() { if (!pubkey) return; setObserverToggling(true); try { - // The owner_p row is keyed by (scope_type, scope_value) — both observer - // (24200) and metric (44200) share the same row. Merge kinds atomically: - // read current kinds, add or remove 24200, upsert the result. - const currentKinds = - subs - .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) - ?.kinds.filter((k) => k !== KIND_AGENT_OBSERVER_FRAME) ?? []; - const nextKinds = checked - ? [...currentKinds, KIND_AGENT_OBSERVER_FRAME] - : currentKinds; - - if (nextKinds.length > 0) { - await createSaveSubscription("owner_p", pubkey, nextKinds); + if (checked) { + await mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); } else { - await deleteSaveSubscription("owner_p", pubkey); + await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME); } setExplicitObserverArchiveChoice(pubkey, checked); toast.success( @@ -480,7 +471,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, subs, reload], + [pubkey, reload], ); const handleMetricToggle = React.useCallback( @@ -488,19 +479,10 @@ export function LocalArchiveSettingsCard() { if (!pubkey) return; setMetricToggling(true); try { - // Same row as observer — merge 44200 in or out. - const currentKinds = - subs - .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) - ?.kinds.filter((k) => k !== KIND_AGENT_TURN_METRIC) ?? []; - const nextKinds = checked - ? [...currentKinds, KIND_AGENT_TURN_METRIC] - : currentKinds; - - if (nextKinds.length > 0) { - await createSaveSubscription("owner_p", pubkey, nextKinds); + if (checked) { + await mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } else { - await deleteSaveSubscription("owner_p", pubkey); + await removeSaveSubscriptionKind(KIND_AGENT_TURN_METRIC); } setExplicitAgentMetricArchiveChoice(pubkey, checked); toast.success( @@ -519,7 +501,7 @@ export function LocalArchiveSettingsCard() { setMetricToggling(false); } }, - [pubkey, subs, reload], + [pubkey, reload], ); // Non-owner_p subscriptions shown in the active-subscriptions list. diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index e701ea1cf7..4c927373cd 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -126,6 +126,24 @@ export async function mergeSaveSubscriptionKinds(kind: number): Promise { notifySubscriptionChange(); } +/** + * Atomically remove `kind` from the `owner_p` save subscription for the + * current identity + relay. + * + * Mirrors `mergeSaveSubscriptionKinds`: reads existing kinds, removes `kind`, + * then deletes the row if the list becomes empty or updates it otherwise. + * Uses `BEGIN IMMEDIATE` on the Rust side for the same reason as the merge + * path — concurrent toggle-OFF callers serialize rather than racing. + * + * Called by toggle-OFF handlers in `LocalArchiveSettingsCard` for both + * kind 24200 and kind 44200, replacing the former TS-side read-modify-overwrite + * that would drop the *other* kind if `subs` state was stale. + */ +export async function removeSaveSubscriptionKind(kind: number): Promise { + await invokeTauri("remove_save_subscription_kind", { kind }); + notifySubscriptionChange(); +} + /** * Create a save subscription. * Runs an access probe on the backend (channel membership, event readability). From 75b52ad7e54ca7f930656e756910a08d0f146b82 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 18:57:47 -0400 Subject: [PATCH 016/291] chore(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 (#1563) Signed-off-by: Will Pfleger Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1198e7f0f0..afb474a2dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,9 +1662,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From 5f7f774ea13c8061a7288eb5da30d86686c797a3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 6 Jul 2026 19:15:02 -0400 Subject: [PATCH 017/291] chore(release): release Buzz Desktop version 0.3.45 (#1561) --- CHANGELOG.md | 18 ++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f33395bc..bdf4c9c5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v0.3.45 + +- chore(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#1563](https://github.com/block/buzz/pull/1563)) ([`75b52ad7e`](https://github.com/block/buzz/commit/75b52ad7e54ca7f930656e756910a08d0f146b82)) +- fix(archive): atomic remove-kind path + split test modules ([#1562](https://github.com/block/buzz/pull/1562)) ([`484bede75`](https://github.com/block/buzz/commit/484bede7597814dd7dc322d1f72f69c08545ef42)) +- feat(archive): add agent turn-metric (kind 44200) local archive ([#1555](https://github.com/block/buzz/pull/1555)) ([`429f54b4f`](https://github.com/block/buzz/commit/429f54b4f45df0d5f4a52aafb8913e903ebdc6b3)) +- feat(desktop,buzz-acp): add harness-agnostic config bridge and setup-listener mode ([#1411](https://github.com/block/buzz/pull/1411)) ([`d8355d318`](https://github.com/block/buzz/commit/d8355d318853f85ffaf53dc7b5215bc998b460c4)) +- fix(desktop): stop edit-channel dialog hanging on "Saving..." ([#1557](https://github.com/block/buzz/pull/1557)) ([`76806cb1f`](https://github.com/block/buzz/commit/76806cb1f654650a412b7f0368f495062a9ee224)) +- feat(archive): add local-save archive with observer-feed default-on option ([#1442](https://github.com/block/buzz/pull/1442)) ([`711078807`](https://github.com/block/buzz/commit/711078807777db77960404e51ffd862c08a33ded)) +- feat(desktop): add Slack-like Drafts inbox with persistence and image fix ([#1539](https://github.com/block/buzz/pull/1539)) ([`4c598dcef`](https://github.com/block/buzz/commit/4c598dcef577bb204f9666eaac5b95743a0f8736)) +- fix(setup): fix syntax error in seed-local-community.sh ([#1547](https://github.com/block/buzz/pull/1547)) ([`3729a6515`](https://github.com/block/buzz/commit/3729a6515617abdae03117d9713dfeb7e1ff74c2)) +- feat(desktop): add right-click context menu to workspace rail ([#1552](https://github.com/block/buzz/pull/1552)) ([`0e87998f8`](https://github.com/block/buzz/commit/0e87998f8fcac0ff1612d661cbd505a63b51c550)) +- fix(desktop): restore saved window geometry on launch ([#1554](https://github.com/block/buzz/pull/1554)) ([`e790c9828`](https://github.com/block/buzz/commit/e790c9828f1ae85ce7618d06e67e78732be4221b)) +- feat(desktop): add typeahead search to persona model dropdown ([#1542](https://github.com/block/buzz/pull/1542)) ([`3b43743e1`](https://github.com/block/buzz/commit/3b43743e1f2acd192705a80629ceadfa7a737831)) +- fix(desktop): publish new agent profiles to active relay ([#1546](https://github.com/block/buzz/pull/1546)) ([`346efe08b`](https://github.com/block/buzz/commit/346efe08b3ca6e847b6c9c2f843cdb8834db123c)) +- chore(desktop): use dot access for host header assignment ([#1548](https://github.com/block/buzz/pull/1548)) ([`ccfdf45bc`](https://github.com/block/buzz/commit/ccfdf45bc5dd61ef7d59644293d84deed24e1789)) +- fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal ([#1508](https://github.com/block/buzz/pull/1508)) ([`c2ee4d162`](https://github.com/block/buzz/commit/c2ee4d162753a3338dc434f85c80a82eb284b3d2)) + + ## v0.3.44 - fix(desktop): keep thread-summary badges mounted through scrollback prepends ([#1533](https://github.com/block/buzz/pull/1533)) ([`6abf614fd`](https://github.com/block/buzz/commit/6abf614fd4eb512cf2e4175f17dc09892a8b472d)) diff --git a/desktop/package.json b/desktop/package.json index 7a1cf8f5f1..f20f345614 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.3.44", + "version": "0.3.45", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5684429877..4a404f7b7a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -885,7 +885,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.3.44" +version = "0.3.45" dependencies = [ "anyhow", "atomic-write-file", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1791cf5313..1d7f93deff 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.3.44" +version = "0.3.45" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 5e622d5945..42392149aa 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.3.44", + "version": "0.3.45", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 36ac5243ff8aed4401d3453a115afc4a603fccf0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 6 Jul 2026 16:31:22 -0700 Subject: [PATCH 018/291] feat(channel list): add persistent channel sort toggle (#1505) Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> --- desktop/playwright.config.ts | 1 + .../lib/channelSortPreference.test.mjs | 256 ++++++++++++++++++ .../sidebar/lib/channelSortPreference.ts | 159 +++++++++++ .../sidebar/lib/dmSidebarSort.test.mjs | 55 +++- .../src/features/sidebar/lib/dmSidebarSort.ts | 48 +++- .../sidebar/lib/useChannelSortPreference.ts | 86 ++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 121 ++++++++- .../sidebar/ui/ChannelSortDropdown.tsx | 72 +++++ .../sidebar/ui/CustomChannelSection.tsx | 11 + desktop/tests/e2e/channel-sort.spec.ts | 171 ++++++++++++ 10 files changed, 957 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelSortPreference.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelSortPreference.ts create mode 100644 desktop/src/features/sidebar/lib/useChannelSortPreference.ts create mode 100644 desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx create mode 100644 desktop/tests/e2e/channel-sort.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 75de302e2e..b1133386a9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/channel-sort.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs new file mode 100644 index 0000000000..ac558075f0 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_SORT_MODE, + DEFAULT_STORE, + parseChannelSortPayload, + sectionSortGroupKey, + sortChannelsForSidebar, + sortModeForGroup, + stripOrphanedSectionModes, +} from "./channelSortPreference.ts"; + +function makeChannel(id, name, lastMessageAt = null) { + return { + archivedAt: null, + channelType: "stream", + description: "", + id, + isMember: true, + lastMessageAt, + memberCount: 2, + memberPubkeys: [], + name, + participantPubkeys: [], + participants: [], + purpose: null, + topic: null, + ttlDeadline: null, + ttlSeconds: null, + visibility: "open", + }; +} + +// ── parseChannelSortPayload ────────────────────────────────────────────────── + +test("parseChannelSortPayload: valid per-group payload", () => { + assert.deepEqual( + parseChannelSortPayload({ + version: 1, + groups: { channels: "recent", dms: "alpha" }, + }), + { version: 1, groups: { channels: "recent", dms: "alpha" } }, + ); +}); + +test("parseChannelSortPayload: empty groups is valid", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1, groups: {} }), { + version: 1, + groups: {}, + }); +}); + +test("parseChannelSortPayload: unknown modes are filtered out", () => { + assert.deepEqual( + parseChannelSortPayload({ + version: 1, + groups: { channels: "zorp", forums: "recent", dms: 42 }, + }), + { version: 1, groups: { forums: "recent" } }, + ); +}); + +test("parseChannelSortPayload: missing/invalid groups falls back to empty", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1 }), { + version: 1, + groups: {}, + }); + assert.deepEqual(parseChannelSortPayload({ version: 1, groups: ["x"] }), { + version: 1, + groups: {}, + }); +}); + +test("parseChannelSortPayload: wrong version returns null", () => { + assert.equal( + parseChannelSortPayload({ version: 2, groups: { channels: "alpha" } }), + null, + ); +}); + +test("parseChannelSortPayload: non-object input returns null", () => { + assert.equal(parseChannelSortPayload(null), null); + assert.equal(parseChannelSortPayload("alpha"), null); + assert.equal(parseChannelSortPayload(42), null); +}); + +// ── sortModeForGroup / defaults ────────────────────────────────────────────── + +test("default sort mode is alpha and default store has no overrides", () => { + assert.equal(DEFAULT_SORT_MODE, "alpha"); + assert.deepEqual(DEFAULT_STORE.groups, {}); +}); + +test("sortModeForGroup: unset group falls back to alpha", () => { + assert.equal(sortModeForGroup(DEFAULT_STORE, "channels"), "alpha"); + assert.equal(sortModeForGroup(DEFAULT_STORE, "dms"), "alpha"); +}); + +test("sortModeForGroup: set groups are independent", () => { + const store = { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("abc")]: "recent" }, + }; + assert.equal(sortModeForGroup(store, "channels"), "recent"); + assert.equal(sortModeForGroup(store, sectionSortGroupKey("abc")), "recent"); + assert.equal(sortModeForGroup(store, "forums"), "alpha"); + assert.equal(sortModeForGroup(store, sectionSortGroupKey("xyz")), "alpha"); +}); + +test("sectionSortGroupKey: namespaced by section id", () => { + assert.equal(sectionSortGroupKey("abc"), "section:abc"); +}); + +// ── stripOrphanedSectionModes ──────────────────────────────────────────────── + +test("stripOrphanedSectionModes: drops modes for deleted sections", () => { + const store = { + version: 1, + groups: { + channels: "recent", + [sectionSortGroupKey("live")]: "recent", + [sectionSortGroupKey("deleted")]: "alpha", + }, + }; + assert.deepEqual(stripOrphanedSectionModes(store, ["live"]), { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, + }); +}); + +test("stripOrphanedSectionModes: fixed groups survive with no live sections", () => { + const store = { + version: 1, + groups: { + starred: "recent", + channels: "alpha", + forums: "recent", + dms: "recent", + [sectionSortGroupKey("gone")]: "recent", + }, + }; + assert.deepEqual(stripOrphanedSectionModes(store, []), { + version: 1, + groups: { + starred: "recent", + channels: "alpha", + forums: "recent", + dms: "recent", + }, + }); +}); + +test("stripOrphanedSectionModes: returns same reference when nothing is stale", () => { + const store = { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, + }; + assert.equal(stripOrphanedSectionModes(store, ["live", "other"]), store); +}); + +test("stripOrphanedSectionModes: does not mutate the input store", () => { + const store = { + version: 1, + groups: { [sectionSortGroupKey("gone")]: "recent" }, + }; + stripOrphanedSectionModes(store, []); + assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" }); +}); + +// ── sortChannelsForSidebar ─────────────────────────────────────────────────── + +test("alpha: sorts by name with id tie-breaker", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("2", "zeta"), + makeChannel("1", "alpha"), + makeChannel("b", "same"), + makeChannel("a", "same"), + ], + "alpha", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["1", "a", "b", "2"], + ); +}); + +test("recent: newest last message first", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("old", "old", "2026-01-01T00:00:00Z"), + makeChannel("new", "new", "2026-06-01T00:00:00Z"), + makeChannel("mid", "mid", "2026-03-01T00:00:00Z"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["new", "mid", "old"], + ); +}); + +test("recent: channels without activity sink to bottom alphabetically", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("quiet-z", "zzz"), + makeChannel("active", "active", "2026-06-01T00:00:00Z"), + makeChannel("quiet-a", "aaa"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["active", "quiet-a", "quiet-z"], + ); +}); + +test("recent: equal timestamps fall back to name then id", () => { + const ts = "2026-06-01T00:00:00Z"; + const sorted = sortChannelsForSidebar( + [ + makeChannel("b", "same", ts), + makeChannel("a", "same", ts), + makeChannel("c", "aardvark", ts), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["c", "a", "b"], + ); +}); + +test("recent: unparseable timestamps are treated as no activity", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("bad", "bad", "not-a-date"), + makeChannel("good", "good", "2026-06-01T00:00:00Z"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["good", "bad"], + ); +}); + +test("does not mutate the input array", () => { + const input = [makeChannel("b", "bbb"), makeChannel("a", "aaa")]; + sortChannelsForSidebar(input, "alpha"); + assert.deepEqual( + input.map((c) => c.id), + ["b", "a"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts new file mode 100644 index 0000000000..ff8b9677ee --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -0,0 +1,159 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { Channel } from "@/shared/api/types"; + +const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; + +export type ChannelSortMode = "alpha" | "recent"; + +/** + * Key identifying a sidebar grouping that carries its own sort preference. + * Fixed groups use their name; custom sections use `section:`. + */ +export type ChannelSortGroupKey = + | "starred" + | "channels" + | "forums" + | "dms" + | `section:${string}`; + +export type ChannelSortStore = { + version: 1; + groups: Record; +}; + +export const DEFAULT_SORT_MODE: ChannelSortMode = "alpha"; + +export const DEFAULT_STORE: ChannelSortStore = Object.freeze({ + version: 1, + groups: {}, +}); + +export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey { + return `section:${sectionId}`; +} + +/** + * Returns the localStorage key for the sidebar channel sort preferences. + * + * When `relayUrl` is provided the key is scoped to that relay (normalized via + * the same `normalizeRelayUrl` used by all relay-scoped local stores) so + * preferences don't bleed across workspaces/relays. + */ +export function storageKey(pubkey: string, relayUrl?: string): string { + if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; + const normalized = normalizeRelayUrl(relayUrl); + // Encode the normalized relay so it can't contain the `:` delimiter. + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; +} + +/** + * Drops per-section sort modes whose custom section no longer exists so + * deleted sections don't leave stale `section:` keys in localStorage + * forever. Fixed group keys (starred/channels/forums/dms) are always kept. + * Returns the same store reference when nothing needs stripping. + */ +export function stripOrphanedSectionModes( + store: ChannelSortStore, + liveSectionIds: Iterable, +): ChannelSortStore { + const liveKeys = new Set( + [...liveSectionIds].map((id) => sectionSortGroupKey(id)), + ); + const kept = Object.entries(store.groups).filter( + ([key]) => !key.startsWith("section:") || liveKeys.has(key), + ); + if (kept.length === Object.keys(store.groups).length) return store; + return { ...store, groups: Object.fromEntries(kept) }; +} + +export function parseChannelSortPayload( + json: unknown, +): ChannelSortStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1) return null; + const groups: Record = + typeof obj.groups === "object" && + obj.groups !== null && + !Array.isArray(obj.groups) + ? Object.fromEntries( + Object.entries(obj.groups as Record).filter( + (entry): entry is [string, ChannelSortMode] => + entry[1] === "alpha" || entry[1] === "recent", + ), + ) + : {}; + return { version: 1, groups }; +} + +export function readChannelSortStore( + pubkey: string, + relayUrl?: string, +): ChannelSortStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl)); + if (!raw) return DEFAULT_STORE; + return parseChannelSortPayload(JSON.parse(raw)) ?? DEFAULT_STORE; + } catch { + return DEFAULT_STORE; + } +} + +export function writeChannelSortStore( + pubkey: string, + store: ChannelSortStore, + relayUrl?: string, +): boolean { + try { + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify(store), + ); + return true; + } catch { + return false; + } +} + +export function sortModeForGroup( + store: ChannelSortStore, + group: ChannelSortGroupKey, +): ChannelSortMode { + return store.groups[group] ?? DEFAULT_SORT_MODE; +} + +function channelRecencyMs(channel: Channel): number | null { + if (!channel.lastMessageAt) return null; + const ms = Date.parse(channel.lastMessageAt); + return Number.isFinite(ms) ? ms : null; +} + +export function compareChannelsByName(left: Channel, right: Channel): number { + return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); +} + +/** + * Sorts a single sidebar grouping's channels by the selected mode. + * + * `alpha` orders by name (id tie-breaker). `recent` orders by last message + * time, newest first; channels without any message activity sink to the + * bottom in alphabetical order so quiet channels stay stable and findable. + */ +export function sortChannelsForSidebar( + channels: Channel[], + mode: ChannelSortMode, +): Channel[] { + if (mode === "alpha") { + return [...channels].sort(compareChannelsByName); + } + return [...channels].sort((left, right) => { + const leftMs = channelRecencyMs(left); + const rightMs = channelRecencyMs(right); + if (leftMs !== null && rightMs !== null && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftMs !== null && rightMs === null) return -1; + if (leftMs === null && rightMs !== null) return 1; + return compareChannelsByName(left, right); + }); +} diff --git a/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs b/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs index b371804543..f16e80f01e 100644 --- a/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs +++ b/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs @@ -1,16 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { sortDmChannelsByLabel } from "./dmSidebarSort.ts"; +import { + sortDmChannelsByLabel, + sortDmChannelsForSidebar, +} from "./dmSidebarSort.ts"; -function makeDm(id, name) { +function makeDm(id, name, lastMessageAt = null) { return { archivedAt: null, channelType: "dm", description: "", id, isMember: true, - lastMessageAt: null, + lastMessageAt, memberCount: 2, memberPubkeys: [], name, @@ -70,3 +73,49 @@ test("uses channel id as a deterministic tie breaker", () => { ["a", "b"], ); }); + +test("sortDmChannelsForSidebar: alpha mode matches label sort", () => { + const sorted = sortDmChannelsForSidebar( + [makeDm("b", "Zed"), makeDm("a", "Amy")], + {}, + "alpha", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["a", "b"], + ); +}); + +test("sortDmChannelsForSidebar: recent mode puts newest message first", () => { + const sorted = sortDmChannelsForSidebar( + [ + makeDm("old", "Amy", "2026-01-01T00:00:00Z"), + makeDm("new", "Zed", "2026-06-01T00:00:00Z"), + ], + {}, + "recent", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["new", "old"], + ); +}); + +test("sortDmChannelsForSidebar: recent mode sinks quiet DMs in label order", () => { + const sorted = sortDmChannelsForSidebar( + [ + makeDm("quiet-z", "Zed"), + makeDm("active", "Amy", "2026-06-01T00:00:00Z"), + makeDm("quiet-a", "Bea"), + ], + {}, + "recent", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["active", "quiet-a", "quiet-z"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/dmSidebarSort.ts b/desktop/src/features/sidebar/lib/dmSidebarSort.ts index d372d9e543..34ef1b93f7 100644 --- a/desktop/src/features/sidebar/lib/dmSidebarSort.ts +++ b/desktop/src/features/sidebar/lib/dmSidebarSort.ts @@ -1,14 +1,52 @@ import type { Channel } from "@/shared/api/types"; +import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference"; + +function compareDmChannelsByLabel( + left: Channel, + right: Channel, + channelLabels: Record, +): number { + const leftLabel = channelLabels[left.id] ?? left.name; + const rightLabel = channelLabels[right.id] ?? right.name; + return leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id); +} export function sortDmChannelsByLabel( channels: Channel[], channelLabels: Record, ) { + return [...channels].sort((left, right) => + compareDmChannelsByLabel(left, right, channelLabels), + ); +} + +function dmRecencyMs(channel: Channel): number | null { + if (!channel.lastMessageAt) return null; + const ms = Date.parse(channel.lastMessageAt); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Sorts sidebar DMs by the selected mode. `alpha` keeps the existing + * resolved-label ordering; `recent` orders by last message time, newest + * first, with quiet DMs sinking to the bottom in label order. + */ +export function sortDmChannelsForSidebar( + channels: Channel[], + channelLabels: Record, + mode: ChannelSortMode, +) { + if (mode === "alpha") { + return sortDmChannelsByLabel(channels, channelLabels); + } return [...channels].sort((left, right) => { - const leftLabel = channelLabels[left.id] ?? left.name; - const rightLabel = channelLabels[right.id] ?? right.name; - return ( - leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id) - ); + const leftMs = dmRecencyMs(left); + const rightMs = dmRecencyMs(right); + if (leftMs !== null && rightMs !== null && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftMs !== null && rightMs === null) return -1; + if (leftMs === null && rightMs !== null) return 1; + return compareDmChannelsByLabel(left, right, channelLabels); }); } diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts new file mode 100644 index 0000000000..6b23c1b3b1 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { + DEFAULT_STORE, + readChannelSortStore, + sortModeForGroup, + storageKey, + stripOrphanedSectionModes, + writeChannelSortStore, + type ChannelSortGroupKey, + type ChannelSortMode, + type ChannelSortStore, +} from "./channelSortPreference"; + +/** + * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so + * they don't bleed across identities or workspaces (same scoping as channel + * sections). Each sidebar grouping (starred, channels, forums, dms, and each + * custom section) carries its own saved Recent/A–Z mode; unset groups default + * to A–Z. Mirrors changes made in other windows via the storage event. + * + * When `liveSectionIds` is provided, writes also prune `section:` entries + * whose custom section no longer exists, so deleted sections don't leave + * stale keys in localStorage. + */ +export function useChannelSortPreference( + pubkey: string | undefined, + relayUrl?: string, + liveSectionIds?: string[], +): { + sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; + setSortModeFor: (group: ChannelSortGroupKey, mode: ChannelSortMode) => void; +} { + const [store, setStore] = React.useState(() => { + if (!pubkey) return DEFAULT_STORE; + return readChannelSortStore(pubkey, relayUrl); + }); + + React.useEffect(() => { + if (!pubkey) { + setStore(DEFAULT_STORE); + return; + } + setStore(readChannelSortStore(pubkey, relayUrl)); + }, [pubkey, relayUrl]); + + React.useEffect(() => { + if (!pubkey) return; + const key = storageKey(pubkey, relayUrl); + const handler = (e: StorageEvent) => { + if (e.key !== key) return; + setStore(readChannelSortStore(pubkey, relayUrl)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey, relayUrl]); + + const sortModeFor = React.useCallback( + (group: ChannelSortGroupKey) => sortModeForGroup(store, group), + [store], + ); + + const setSortModeFor = React.useCallback( + (group: ChannelSortGroupKey, mode: ChannelSortMode) => { + if (!pubkey) return; + setStore((prev) => { + const withUpdate: ChannelSortStore = { + ...prev, + groups: { ...prev.groups, [group]: mode }, + }; + // Prune sort modes left behind by deleted custom sections on write so + // the stored map can't grow unboundedly with stale `section:` keys. + const next = liveSectionIds + ? stripOrphanedSectionModes(withUpdate, liveSectionIds) + : withUpdate; + if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; + return next; + }); + }, + [pubkey, relayUrl, liveSectionIds], + ); + + return { sortModeFor, setSortModeFor }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 98bbe2644a..a4f3b4d6a5 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -15,7 +15,12 @@ import { } from "@/features/sidebar/lib/useChannelSections"; import { useActiveWorkingChannelsById } from "@/features/sidebar/lib/useActiveWorkingChannelsById"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; -import { sortDmChannelsByLabel } from "@/features/sidebar/lib/dmSidebarSort"; +import { sortDmChannelsForSidebar } from "@/features/sidebar/lib/dmSidebarSort"; +import { + sectionSortGroupKey, + sortChannelsForSidebar, +} from "@/features/sidebar/lib/channelSortPreference"; +import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { @@ -32,6 +37,7 @@ import { ChannelGroupSection, CustomChannelSection, } from "@/features/sidebar/ui/CustomChannelSection"; +import { ChannelSortDropdown } from "@/features/sidebar/ui/ChannelSortDropdown"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; @@ -349,6 +355,17 @@ export function AppSidebar({ unassignChannel, } = useChannelSections(currentPubkey, activeWorkspace?.relayUrl); + const sectionIds = React.useMemo( + () => channelSections.map((s) => s.id), + [channelSections], + ); + + const { sortModeFor, setSortModeFor } = useChannelSortPreference( + currentPubkey, + activeWorkspace?.relayUrl, + sectionIds, + ); + const [createSectionState, setCreateSectionState] = React.useState<{ open: boolean; pendingChannelId: string | null; @@ -360,11 +377,6 @@ export function AppSidebar({ const { requestLeaveChannel, dialog: leaveChannelDialog } = useLeaveChannelDialog(); - const sectionIds = React.useMemo( - () => channelSections.map((s) => s.id), - [channelSections], - ); - const streamChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "stream"), [channels], @@ -387,15 +399,33 @@ export function AppSidebar({ unassigned.push(channel); } } - return { bySection, unassigned }; - }, [streamChannels, channelSections, channelAssignments, starredChannelIds]); + // Apply each grouping's own sort preference; section membership itself + // is untouched. + for (const sectionId of Object.keys(bySection)) { + bySection[sectionId] = sortChannelsForSidebar( + bySection[sectionId], + sortModeFor(sectionSortGroupKey(sectionId)), + ); + } + return { + bySection, + unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), + }; + }, [ + streamChannels, + channelSections, + channelAssignments, + starredChannelIds, + sortModeFor, + ]); const starredChannels = React.useMemo(() => { if (!starredChannelIds || starredChannelIds.size === 0) return []; - return streamChannels.filter((channel) => - starredChannelIds.has(channel.id), + return sortChannelsForSidebar( + streamChannels.filter((channel) => starredChannelIds.has(channel.id)), + sortModeFor("starred"), ); - }, [streamChannels, starredChannelIds]); + }, [streamChannels, starredChannelIds, sortModeFor]); const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { @@ -419,8 +449,12 @@ export function AppSidebar({ ); const forumChannels = React.useMemo( - () => channels.filter((channel) => channel.channelType === "forum"), - [channels], + () => + sortChannelsForSidebar( + channels.filter((channel) => channel.channelType === "forum"), + sortModeFor("forums"), + ), + [channels, sortModeFor], ); const directMessages = React.useMemo( () => channels.filter((channel) => channel.channelType === "dm"), @@ -442,8 +476,13 @@ export function AppSidebar({ profileDisplayName: profile?.displayName, }); const sortedDirectMessages = React.useMemo( - () => sortDmChannelsByLabel(directMessages, dmChannelLabels), - [directMessages, dmChannelLabels], + () => + sortDmChannelsForSidebar( + directMessages, + dmChannelLabels, + sortModeFor("dms"), + ), + [directMessages, dmChannelLabels, sortModeFor], ); const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, @@ -561,6 +600,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={starredChannels} + leadingHeaderAction={ + + setSortModeFor("starred", mode) + } + sortMode={sortModeFor("starred")} + testId="channel-sort-trigger-starred" + /> + } listTestId="starred-list" onMarkAllRead={() => { for (const channel of starredChannels) { @@ -612,6 +661,22 @@ export function AppSidebar({ assignments={channelAssignments} isFirst={idx === 0} isLast={idx === channelSections.length - 1} + leadingHeaderAction={ + + setSortModeFor( + sectionSortGroupKey(section.id), + mode, + ) + } + sortMode={sortModeFor( + sectionSortGroupKey(section.id), + )} + testId={`channel-sort-trigger-section-${section.id}`} + visibilityClassName="" + /> + } onToggleCollapsed={() => toggleCollapsedSection(section.id) } @@ -650,6 +715,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sectionBuckets.unassigned} + leadingHeaderAction={ + + setSortModeFor("channels", mode) + } + sortMode={sortModeFor("channels")} + testId="channel-sort-trigger-channels" + /> + } listTestId="stream-list" onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} @@ -684,6 +759,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={forumChannels} + leadingHeaderAction={ + + setSortModeFor("forums", mode) + } + sortMode={sortModeFor("forums")} + testId="channel-sort-trigger-forums" + /> + } listTestId="forum-list" onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} @@ -703,6 +788,12 @@ export function AppSidebar({ + setSortModeFor("dms", mode)} + sortMode={sortModeFor("dms")} + testId="channel-sort-trigger-dms" + /> + + + onSortModeChange(value as ChannelSortMode)} + value={sortMode} + > + {SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 77c1a363ca..d0e4874055 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -19,6 +19,8 @@ import { Trash2, } from "lucide-react"; +import type * as React from "react"; + import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { ContextMenu, @@ -254,6 +256,7 @@ function SectionHeaderActions({ browseAriaLabel, createAriaLabel, hasUnread, + leadingAction, onBrowseClick, onCreateClick, onMarkAllRead, @@ -261,12 +264,14 @@ function SectionHeaderActions({ browseAriaLabel?: string; createAriaLabel: string; hasUnread?: boolean; + leadingAction?: React.ReactNode; onBrowseClick?: () => void; onCreateClick?: () => void; onMarkAllRead?: () => void; }) { return (
+ {leadingAction} {hasUnread && onMarkAllRead ? ( + + Revert to original + + ) : null} + {onToggleSpoiler ? ( + + + + onToggleSpoiler(attachment.url) + } + pressed={isSpoilered} + > + + + + + {isSpoilered ? "Remove spoiler" : "Mark as spoiler"} + + + ) : null} + {canEdit ? ( + + + + + Draw on image + + ) : null} + + + Close + +
+ ) : null} + + + + + + + + Remove attachment + +

+ + ); +}); + /** * Thumbnail previews for uploaded attachments in the composer. * Each attachment shows as a small image with a remove button and @@ -60,7 +365,11 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onEditSave, onRemove, + onRevert, + originalUrlByUrl, + onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { if (attachments.length === 0 && !isUploading) return null; @@ -85,16 +394,6 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ const isVideo = attachment.type.startsWith("video/"); const isImage = attachment.type.startsWith("image/"); const isFile = !isVideo && !isImage; - const isSpoilered = spoileredUrls?.has(attachment.url) ?? false; - const thumbUrl = attachment.thumb - ? rewriteRelayUrl(attachment.thumb) - : rewriteRelayUrl(attachment.url); - const videoPosterUrl = attachment.image - ? rewriteRelayUrl(attachment.image) - : attachment.thumb - ? rewriteRelayUrl(attachment.thumb) - : undefined; - const mediaStyle = composerMediaStyle(); // Generic file: compact chip with a file icon + filename, plus the // same remove button. No lightbox (nothing to preview). @@ -113,13 +412,13 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ transition={{ type: "spring", stiffness: 500, damping: 30 }} className="group relative" > -
+
{label}
- + - - Remove attachment - - + ); })} {isUploading && @@ -210,7 +491,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
{onCancelUpload && preview.id >= 0 ? ( - + - - {isVideo ? ( - // biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available - -
- ); -} diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index a5a87eda05..da23c396e8 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -27,7 +27,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ }: ComposerEmojiPickerProps) { return ( - + + ))} +
+ + + + + + Undo (⌘Z) + + + + + + Redo (⇧⌘Z) + +
+ + + +
+ + {saveError ? ( +

+ {saveError} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index 63d41145e6..a605b3dcd9 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react"; import { Bold, Code, + HatGlasses, Italic, Link, List, @@ -28,11 +29,6 @@ type FormattingToolbarProps = { onLinkButton?: () => void; }; -export type SpoilerToggleState = { - emptySelection: boolean; - nextSpoilered?: boolean; -}; - type ActiveStates = { bold: boolean; italic: boolean; @@ -43,6 +39,7 @@ type ActiveStates = { bulletList: boolean; orderedList: boolean; blockquote: boolean; + spoiler: boolean; }; function getActiveStates(editor: Editor): ActiveStates { @@ -56,6 +53,7 @@ function getActiveStates(editor: Editor): ActiveStates { bulletList: editor.isActive("bulletList"), orderedList: editor.isActive("orderedList"), blockquote: editor.isActive("blockquote"), + spoiler: isSpoilerFormattingActive(editor), }; } @@ -77,12 +75,17 @@ function documentRangeForEmptySelection(editor: Editor): { return from < to ? { from, to } : null; } -export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState { - const emptySelection = editor.state.selection.empty; +/** + * Toggles the text spoiler mark. With a selection, toggles the mark on the + * selected range; with an empty selection, applies/removes spoiler across the + * whole document. Text-only — media spoilers are toggled per-attachment in + * the attachment lightbox. + */ +export function toggleSpoilerFormatting(editor: Editor): void { const range = documentRangeForEmptySelection(editor); if (!range) { editor.chain().focus().toggleMark(SPOILER_MARK_NAME).run(); - return { emptySelection }; + return; } const cursorPosition = editor.state.selection.from; @@ -94,16 +97,14 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState { ); if (rangeSpoilerState === "no-markable-content") { chain.setTextSelection(cursorPosition).run(); - return { emptySelection }; + return; } - const nextSpoilered = rangeSpoilerState !== "fully-spoiled"; - if (nextSpoilered) { + if (rangeSpoilerState !== "fully-spoiled") { chain.setMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run(); } else { chain.unsetMark(SPOILER_MARK_NAME).setTextSelection(cursorPosition).run(); } - return { emptySelection, nextSpoilered }; } /** @@ -202,6 +203,11 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ editor?.chain().focus().toggleBlockquote().run(); }, [editor]); + const toggleSpoiler = React.useCallback(() => { + if (!editor) return; + toggleSpoilerFormatting(editor); + }, [editor]); + if (!editor || !activeStates) return null; const items = [ @@ -264,12 +270,18 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ action: toggleBlockquote, active: activeStates.blockquote, }, + { + icon: HatGlasses, + label: "Spoiler", + action: toggleSpoiler, + active: activeStates.spoiler, + }, ] as const; return (
{items.map((item) => ( - +
@@ -966,9 +954,7 @@ function MessageComposerImpl({ onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} - onSpoilerToggle={handleComposerSpoilerToggle} sendDisabled={sendDisabled} - spoilerActive={spoileredAttachmentUrls.size > 0} /> diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 6da440c11a..1c8087e992 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -1,25 +1,12 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; -import { - ALargeSmall, - ArrowUp, - AtSign, - HatGlasses, - Paperclip, - X, -} from "lucide-react"; +import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react"; import { Button } from "@/shared/ui/button"; -import { cn } from "@/shared/lib/cn"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; -import { - FormattingToolbar, - isSpoilerFormattingActive, - type SpoilerToggleState, - toggleSpoilerFormatting, -} from "./FormattingToolbar"; +import { FormattingToolbar } from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; /** Spring for enter/exit of button groups — all fire simultaneously. */ @@ -46,9 +33,7 @@ export const MessageComposerToolbar = React.memo( onLinkButton, onOpenMentionPicker, onPaperclip, - onSpoilerToggle, sendDisabled, - spoilerActive, }: { composerDisabled: boolean; editor: Editor | null; @@ -65,38 +50,8 @@ export const MessageComposerToolbar = React.memo( onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; - onSpoilerToggle?: (state: SpoilerToggleState) => void; sendDisabled: boolean; - spoilerActive?: boolean; }) { - const [spoilerFormattingActive, setSpoilerFormattingActive] = - React.useState(() => - editor ? isSpoilerFormattingActive(editor) : false, - ); - - React.useEffect(() => { - if (!editor) { - setSpoilerFormattingActive(false); - return; - } - - const update = () => { - setSpoilerFormattingActive(isSpoilerFormattingActive(editor)); - }; - update(); - editor.on("transaction", update); - return () => { - editor.off("transaction", update); - }; - }, [editor]); - - const isSpoilerActive = spoilerFormattingActive || Boolean(spoilerActive); - - const handleSpoilerClick = React.useCallback(() => { - if (!editor) return; - onSpoilerToggle?.(toggleSpoilerFormatting(editor)); - }, [editor, onSpoilerToggle]); - return (
- + - - Spoiler - - + -
{ - event.preventDefault(); - event.stopPropagation(); - applyAvatarUrl(); - }} - > - - setIsAvatarUrlInputFocused(false)} - onChange={(event) => setAvatarUrlDraft(event.target.value)} - onFocus={() => setIsAvatarUrlInputFocused(true)} - placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"} - spellCheck={false} - value={avatarUrlDraft} - /> - - {hasAvatarUrlDraft ? ( - - - - ) : null} - -
- {hasAvatar && onClearAvatar ? ( - - ) : null} + + + + {avatarMenuContent} + {uploadErrorMessage ? (

diff --git a/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs b/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs index 11f8ad36ea..4495872d07 100644 --- a/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs +++ b/desktop/src/features/agents/ui/managedAgentAvatar.test.mjs @@ -21,6 +21,16 @@ test("resolveManagedAgentAvatarUrl uploads data image URIs", async () => { assert.equal(uploaded, "https://relay.example/avatar.png"); }); +test("resolveManagedAgentAvatarUrl passes emoji svg data URLs through", async () => { + const emojiUrl = + "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E"; + const uploaded = await resolveManagedAgentAvatarUrl(emojiUrl, async () => { + throw new Error("should not upload inline emoji svg data URLs"); + }); + + assert.equal(uploaded, emojiUrl); +}); + test("resolveManagedAgentAvatarUrl passes non-data URLs through", async () => { const uploaded = await resolveManagedAgentAvatarUrl( " https://relay.example/already-hosted.png ", diff --git a/desktop/src/features/agents/ui/managedAgentAvatar.ts b/desktop/src/features/agents/ui/managedAgentAvatar.ts index f699b57073..971cf07646 100644 --- a/desktop/src/features/agents/ui/managedAgentAvatar.ts +++ b/desktop/src/features/agents/ui/managedAgentAvatar.ts @@ -21,6 +21,14 @@ export async function resolveManagedAgentAvatarUrl( return resolvedAvatarUrl; } + // Emoji avatars are stored as inline, percent-encoded SVG data URLs + // (`data:image/svg+xml,%3C...`) — the same self-contained form profile + // persists. They are not base64 and must not be run through `atob`/upload; + // pass them through unchanged so the emoji survives agent creation. + if (!isBase64DataUri(resolvedAvatarUrl)) { + return resolvedAvatarUrl; + } + try { const [, b64] = resolvedAvatarUrl.split(",", 2); if (!b64) { @@ -39,6 +47,11 @@ async function defaultUploadMediaBytes(data: number[], filename?: string) { return uploadMediaBytes(data, filename); } +function isBase64DataUri(dataUri: string) { + const header = dataUri.slice(0, dataUri.indexOf(",")); + return header.includes(";base64"); +} + function safeFallbackAvatarUrl(avatarUrl: string | null | undefined) { const trimmed = avatarUrl?.trim() || undefined; return trimmed?.startsWith("data:image/") ? undefined : trimmed; diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts index 65acde7b19..b7e506f61a 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts @@ -430,6 +430,86 @@ export function dataTransferHasImage(dataTransfer: DataTransfer | null) { ); } +function normalizedWheelDeltaY(event: WheelEvent, scrollElement: HTMLElement) { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + return event.deltaY * 16; + } + + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return event.deltaY * scrollElement.clientHeight; + } + + return event.deltaY; +} + +function clampScrollTop(scrollElement: HTMLElement, scrollTop: number) { + const maxScrollTop = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight, + ); + return Math.max(0, Math.min(maxScrollTop, scrollTop)); +} + +// Wheel events are retargeted to the emoji-mart host outside its shadow root, +// so the browser does not reliably apply the default scroll to `.scroll`. +function installEmojiMartWheelScroll(shadowRoot: ShadowRoot) { + let removeWheelListener: (() => void) | null = null; + let currentScrollElement: HTMLElement | null = null; + + function connectScrollElement() { + const nextScrollElement = shadowRoot.querySelector(".scroll"); + + if (nextScrollElement === currentScrollElement) { + return; + } + + removeWheelListener?.(); + currentScrollElement = nextScrollElement; + + if (!nextScrollElement) { + removeWheelListener = null; + return; + } + + const handleWheel = (event: WheelEvent) => { + if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) { + return; + } + + const deltaY = normalizedWheelDeltaY(event, nextScrollElement); + const nextScrollTop = clampScrollTop( + nextScrollElement, + nextScrollElement.scrollTop + deltaY, + ); + + if (nextScrollTop === nextScrollElement.scrollTop) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + nextScrollElement.scrollTop = nextScrollTop; + }; + + nextScrollElement.addEventListener("wheel", handleWheel, { + passive: false, + }); + removeWheelListener = () => { + nextScrollElement.removeEventListener("wheel", handleWheel); + }; + } + + connectScrollElement(); + + const observer = new MutationObserver(connectScrollElement); + observer.observe(shadowRoot, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + removeWheelListener?.(); + }; +} + export function useEmojiMartStyles( containerRef: React.RefObject, enabled: boolean, @@ -440,6 +520,7 @@ export function useEmojiMartStyles( } let animationFrame = 0; + let removeWheelScroll: (() => void) | null = null; const installEmojiMartStyles = () => { const host = containerRef.current?.querySelector("em-emoji-picker"); @@ -456,12 +537,15 @@ export function useEmojiMartStyles( style.textContent = EMOJI_MART_SHADOW_CSS; shadowRoot.appendChild(style); } + + removeWheelScroll ??= installEmojiMartWheelScroll(shadowRoot); }; animationFrame = window.requestAnimationFrame(installEmojiMartStyles); return () => { window.cancelAnimationFrame(animationFrame); + removeWheelScroll?.(); }; }, [containerRef, enabled]); } diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 35303470c5..bf704f57f2 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -230,6 +230,49 @@ test("built-in personas are used from the catalog dialog", async ({ page }) => { await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); +test("agent avatar emoji picker scrolls inside its popover", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "New agent" }).click(); + + await expect(page.getByTestId("persona-dialog")).toBeVisible(); + await page.getByLabel("Add avatar").click(); + await page.getByRole("tab", { name: "Emoji" }).click(); + await expect(page.locator("em-emoji-picker")).toBeVisible(); + + await page.waitForFunction(() => { + const picker = document.querySelector("em-emoji-picker"); + const scroll = picker?.shadowRoot?.querySelector(".scroll"); + return ( + scroll instanceof HTMLElement && scroll.scrollHeight > scroll.clientHeight + ); + }); + + const before = await page.locator("em-emoji-picker").evaluate((picker) => { + const scroll = picker.shadowRoot?.querySelector(".scroll"); + return scroll instanceof HTMLElement ? scroll.scrollTop : -1; + }); + + const box = await page.locator("em-emoji-picker").boundingBox(); + if (!box) { + throw new Error("Could not measure emoji picker bounds."); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, 500); + + await expect + .poll(async () => + page.locator("em-emoji-picker").evaluate((picker) => { + const scroll = picker.shadowRoot?.querySelector(".scroll"); + return scroll instanceof HTMLElement ? scroll.scrollTop : -1; + }), + ) + .toBeGreaterThan(before); +}); + test("agent catalog can reopen from the populated library header", async ({ page, }) => { From d94561795a48af743278bfa1e912b894228312f3 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 7 Jul 2026 15:29:02 +0100 Subject: [PATCH 026/291] =?UTF-8?q?feat(desktop):=20unify=20sidebar=20sect?= =?UTF-8?q?ion=20actions=20into=20a=20per-section=20=E2=8B=AE=20menu=20(#1?= =?UTF-8?q?577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- .../src/features/sidebar/ui/AppSidebar.tsx | 103 +-- .../sidebar/ui/ChannelContextMenu.tsx | 284 ++++++++ .../sidebar/ui/ChannelSortDropdown.tsx | 72 -- .../sidebar/ui/CustomChannelSection.tsx | 665 +++++++++--------- .../features/sidebar/ui/SidebarSection.tsx | 2 +- .../sidebar/ui/sidebarMenuHelpers.tsx | 35 + desktop/tests/e2e/channel-sort.spec.ts | 8 +- desktop/tests/e2e/channels.spec.ts | 30 +- .../tests/e2e/identity-archive-hide.spec.ts | 7 +- desktop/tests/e2e/integration.spec.ts | 11 +- desktop/tests/e2e/smoke.spec.ts | 4 +- desktop/tests/e2e/stream.spec.ts | 5 +- desktop/tests/helpers/bridge.ts | 19 + 13 files changed, 731 insertions(+), 514 deletions(-) create mode 100644 desktop/src/features/sidebar/ui/ChannelContextMenu.tsx delete mode 100644 desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx create mode 100644 desktop/src/features/sidebar/ui/sidebarMenuHelpers.tsx diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index a4f3b4d6a5..ba148c606f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,6 +1,4 @@ // biome-ignore format: keep compact to stay within file size limit -import { MessageCirclePlus } from "lucide-react"; - import * as React from "react"; import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; @@ -36,8 +34,9 @@ import { SidebarSection } from "@/features/sidebar/ui/SidebarSection"; import { ChannelGroupSection, CustomChannelSection, + SectionActionsMenu, + SectionQuickAction, } from "@/features/sidebar/ui/CustomChannelSection"; -import { ChannelSortDropdown } from "@/features/sidebar/ui/ChannelSortDropdown"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; @@ -47,10 +46,6 @@ import { SidebarLoadingContent, useSidebarLoadingShape, } from "@/features/sidebar/ui/sidebarLoadingSkeleton"; -import { - SECTION_ACTION_VISIBILITY_CLASS, - SECTION_ICON_BUTTON_CLASS, -} from "@/features/sidebar/ui/sidebarSectionStyles"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; @@ -592,7 +587,6 @@ export function AppSidebar({ <> {starredChannels.length > 0 ? ( unreadChannelIds.has(c.id), )} @@ -600,16 +594,9 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={starredChannels} - leadingHeaderAction={ - - setSortModeFor("starred", mode) - } - sortMode={sortModeFor("starred")} - testId="channel-sort-trigger-starred" - /> - } + sortMode={sortModeFor("starred")} + onSortModeChange={(mode) => setSortModeFor("starred", mode)} + actionsTestId="section-actions-starred" listTestId="starred-list" onMarkAllRead={() => { for (const channel of starredChannels) { @@ -661,21 +648,9 @@ export function AppSidebar({ assignments={channelAssignments} isFirst={idx === 0} isLast={idx === channelSections.length - 1} - leadingHeaderAction={ - - setSortModeFor( - sectionSortGroupKey(section.id), - mode, - ) - } - sortMode={sortModeFor( - sectionSortGroupKey(section.id), - )} - testId={`channel-sort-trigger-section-${section.id}`} - visibilityClassName="" - /> + sortMode={sortModeFor(sectionSortGroupKey(section.id))} + onSortModeChange={(mode) => + setSortModeFor(sectionSortGroupKey(section.id), mode) } onToggleCollapsed={() => toggleCollapsedSection(section.id) @@ -707,27 +682,23 @@ export function AppSidebar({ /> ))} 0} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sectionBuckets.unassigned} - leadingHeaderAction={ - - setSortModeFor("channels", mode) - } - sortMode={sortModeFor("channels")} - testId="channel-sort-trigger-channels" - /> + sortMode={sortModeFor("channels")} + onSortModeChange={(mode) => + setSortModeFor("channels", mode) } + actionsTestId="section-actions-channels" listTestId="stream-list" onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} + showQuickCreate onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} @@ -753,22 +724,15 @@ export function AppSidebar({ 0} isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={forumChannels} - leadingHeaderAction={ - - setSortModeFor("forums", mode) - } - sortMode={sortModeFor("forums")} - testId="channel-sort-trigger-forums" - /> - } + sortMode={sortModeFor("forums")} + onSortModeChange={(mode) => setSortModeFor("forums", mode)} + actionsTestId="section-actions-forums" listTestId="forum-list" onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} @@ -788,25 +752,18 @@ export function AppSidebar({ - setSortModeFor("dms", mode)} - sortMode={sortModeFor("dms")} - testId="channel-sort-trigger-dms" + setIsNewDmOpen(true)} + testId="section-actions-dms-quick-create" + /> + setIsNewDmOpen(true)} + sortMode={sortModeFor("dms")} + onSortModeChange={(mode) => setSortModeFor("dms", mode)} /> -

} dmParticipantsByChannelId={dmParticipantsByChannelId} diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx new file mode 100644 index 0000000000..b965aed086 --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -0,0 +1,284 @@ +import { + Bell, + BellOff, + Check, + CheckCircle2, + CircleDot, + Copy, + LogOut, + Plus, + Star, + StarOff, +} from "lucide-react"; + +import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; +import { + ContextMenuIconSlot, + deferMenuAction, +} from "@/features/sidebar/ui/sidebarMenuHelpers"; +import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; +import type { Channel } from "@/shared/api/types"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@/shared/ui/context-menu"; + +function MoveToSectionSubmenu({ + channelId, + sections, + assignments, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, +}: { + channelId: string; + sections: ChannelSection[]; + assignments: Record; + onAssignChannel: (channelId: string, sectionId: string) => void; + onUnassignChannel: (channelId: string) => void; + onCreateSectionForChannel: (channelId: string) => void; +}) { + const currentSectionId = assignments[channelId]; + + return ( + + + + Move to section + + + {sections.map((section) => ( + + deferMenuAction(() => onAssignChannel(channelId, section.id)) + } + > + + {currentSectionId === section.id ? ( + + ) : section.icon ? ( + + ) : null} + + {section.name} + + ))} + {sections.length > 0 ? : null} + + deferMenuAction(() => onCreateSectionForChannel(channelId)) + } + > + + + + New section... + + {currentSectionId ? ( + deferMenuAction(() => onUnassignChannel(channelId))} + > + + Remove from section + + ) : null} + + + ); +} + +/** + * The channel/DM context menu's Copy actions, grouped under a single + * "Copy" submenu (channel name / channel ID). + */ +function CopyChannelSubmenu({ channel }: { channel: Channel }) { + return ( + + + + + + Copy + + + + copyTextToClipboard( + channel.name, + "Channel name copied to clipboard", + ) + } + > + Copy channel name + + + copyTextToClipboard(channel.id, "Channel ID copied to clipboard") + } + > + Copy channel ID + + + + ); +} + +export function ChannelContextMenuItems({ + channel, + hasUnread, + isMuted, + isStarred, + sections, + assignments, + onMarkChannelRead, + onMarkChannelUnread, + onMuteChannel, + onUnmuteChannel, + onStarChannel, + onUnstarChannel, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, + onLeaveChannel, +}: { + channel: Channel; + hasUnread: boolean; + isMuted?: boolean; + isStarred?: boolean; + sections?: ChannelSection[]; + assignments?: Record; + onMarkChannelRead?: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onMarkChannelUnread?: (channelId: string) => void; + onMuteChannel?: (channelId: string) => void; + onUnmuteChannel?: (channelId: string) => void; + onStarChannel?: (channelId: string) => void; + onUnstarChannel?: (channelId: string) => void; + onAssignChannel?: (channelId: string, sectionId: string) => void; + onUnassignChannel?: (channelId: string) => void; + onCreateSectionForChannel?: (channelId: string) => void; + onLeaveChannel?: (channel: Channel) => void; +}) { + const showStar = Boolean(onStarChannel && onUnstarChannel); + const showReadToggle = hasUnread + ? Boolean(onMarkChannelRead) + : Boolean(onMarkChannelUnread); + const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel); + const showMove = Boolean( + sections && + assignments && + onAssignChannel && + onUnassignChannel && + onCreateSectionForChannel, + ); + + return ( + <> + + {showMove ? ( + {})} + onUnassignChannel={onUnassignChannel ?? (() => {})} + onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})} + /> + ) : null} + {showReadToggle ? : null} + {hasUnread && onMarkChannelRead ? ( + + deferMenuAction(() => + onMarkChannelRead(channel.id, channel.lastMessageAt), + ) + } + > + + + + Mark as read + + ) : !hasUnread && onMarkChannelUnread ? ( + + deferMenuAction(() => onMarkChannelUnread(channel.id)) + } + > + + + + Mark unread + + ) : null} + {showMuteToggle || showStar ? : null} + {showMuteToggle ? ( + isMuted ? ( + + deferMenuAction(() => onUnmuteChannel?.(channel.id)) + } + > + + + + Unmute channel + + ) : ( + deferMenuAction(() => onMuteChannel?.(channel.id))} + > + + + + Mute channel + + ) + ) : null} + {showStar ? ( + isStarred ? ( + + deferMenuAction(() => onUnstarChannel?.(channel.id)) + } + > + + + + Unstar channel + + ) : ( + deferMenuAction(() => onStarChannel?.(channel.id))} + > + + + + Star channel + + ) + ) : null} + {onLeaveChannel ? ( + <> + + deferMenuAction(() => onLeaveChannel(channel))} + > + + + + Leave channel + + + ) : null} + + ); +} diff --git a/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx b/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx deleted file mode 100644 index 7a76d22d0a..0000000000 --- a/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { ArrowUpDown } from "lucide-react"; - -import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference"; -import { - SECTION_ACTION_VISIBILITY_CLASS, - SECTION_ICON_BUTTON_CLASS, -} from "@/features/sidebar/ui/sidebarSectionStyles"; -import { cn } from "@/shared/lib/cn"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; - -const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [ - { value: "recent", label: "Recent" }, - { value: "alpha", label: "A–Z" }, -]; - -/** - * Section-header dropdown for a single sidebar grouping's sort preference. - * Every grouping (Starred, each custom section, Channels, Forums, DMs) - * carries its own control and saved mode; grouping boundaries are untouched. - */ -export function ChannelSortDropdown({ - groupLabel, - sortMode, - onSortModeChange, - testId, - visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS, -}: { - groupLabel: string; - sortMode: ChannelSortMode; - onSortModeChange: (mode: ChannelSortMode) => void; - testId?: string; - visibilityClassName?: string; -}) { - const activeLabel = - SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "A–Z"; - const ariaLabel = `Sort ${groupLabel}: ${activeLabel}`; - - return ( - - - - - - onSortModeChange(value as ChannelSortMode)} - value={sortMode} - > - {SORT_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - ); -} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index d0e4874055..2021f7eca1 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -1,37 +1,38 @@ import { ArrowDown, ArrowUp, - Bell, - BellOff, - Check, + ArrowUpDown, CheckCheck, - CheckCircle2, ChevronDown, - CircleDot, - Clipboard, - Copy, - GripVertical, - LogOut, + EllipsisVertical, Pencil, Plus, - Star, - StarOff, Trash2, } from "lucide-react"; +import { useRef } from "react"; import type * as React from "react"; -import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shared/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { SidebarGroup, SidebarGroupContent, @@ -40,6 +41,8 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection"; +import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; +import { deferMenuAction } from "@/features/sidebar/ui/sidebarMenuHelpers"; import { DraggableChannelRow, DroppableSectionBody, @@ -64,262 +67,264 @@ const SECTION_LABEL_CHEVRON_CLASS = const SECTION_LABEL_CHEVRON_ICON_CLASS = "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2"; -function MoveToSectionSubmenu({ - channelId, - sections, - assignments, - onAssignChannel, - onUnassignChannel, - onCreateSectionForChannel, -}: { - channelId: string; - sections: ChannelSection[]; - assignments: Record; - onAssignChannel: (channelId: string, sectionId: string) => void; - onUnassignChannel: (channelId: string) => void; - onCreateSectionForChannel: (channelId: string) => void; -}) { - const currentSectionId = assignments[channelId]; +const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [ + { value: "recent", label: "Recent" }, + { value: "alpha", label: "A–Z" }, +]; +/** + * A single always-visible "+" quick action shown at the right edge of a + * section header, to the left of the ⋮ menu. Used for the most common + * per-section create action (New channel, New message) while every other + * action stays folded into {@link SectionActionsMenu}. + */ +export function SectionQuickAction({ + label, + onClick, + testId, + icon: Icon = Plus, + visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS, +}: { + label: string; + onClick: () => void; + testId?: string; + icon?: typeof Plus; + visibilityClassName?: string; +}) { return ( - - Move to section - - {sections.map((section) => ( - onAssignChannel(channelId, section.id)} - > - {currentSectionId === section.id ? ( - - ) : section.icon ? ( - - ) : ( - - )} - {section.name} - - ))} - {sections.length > 0 ? : null} - onCreateSectionForChannel(channelId)}> - - New section... - - {currentSectionId ? ( - onUnassignChannel(channelId)}> - Remove from section - - ) : null} - - + ); } -export function ChannelContextMenuItems({ - channel, +/** + * The single "more actions" menu shown at the right edge of every sidebar + * section header (Starred, Channels, Forums, Direct messages, and each custom + * section). Items render only when their handler is provided; a Sort radio + * group is appended whenever a sort preference is supplied. + */ +export function SectionActionsMenu({ + sectionLabel, + testId, + visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS, hasUnread, - isMuted, - isStarred, - sections, - assignments, - onMarkChannelRead, - onMarkChannelUnread, - onMuteChannel, - onUnmuteChannel, - onStarChannel, - onUnstarChannel, - onAssignChannel, - onUnassignChannel, - onCreateSectionForChannel, - onLeaveChannel, -}: { - channel: Channel; - hasUnread: boolean; - isMuted?: boolean; - isStarred?: boolean; - sections?: ChannelSection[]; - assignments?: Record; - onMarkChannelRead?: ( - channelId: string, - lastMessageAt: string | null | undefined, - ) => void; - onMarkChannelUnread?: (channelId: string) => void; - onMuteChannel?: (channelId: string) => void; - onUnmuteChannel?: (channelId: string) => void; - onStarChannel?: (channelId: string) => void; - onUnstarChannel?: (channelId: string) => void; - onAssignChannel?: (channelId: string, sectionId: string) => void; - onUnassignChannel?: (channelId: string) => void; - onCreateSectionForChannel?: (channelId: string) => void; - onLeaveChannel?: (channel: Channel) => void; -}) { - const showStar = Boolean(onStarChannel && onUnstarChannel); - const showReadToggle = hasUnread - ? Boolean(onMarkChannelRead) - : Boolean(onMarkChannelUnread); - return ( - <> - {showStar ? ( - isStarred ? ( - onUnstarChannel?.(channel.id)}> - - Unstar channel - - ) : ( - onStarChannel?.(channel.id)}> - - Star channel - - ) - ) : null} - {showStar && showReadToggle ? : null} - {hasUnread && onMarkChannelRead ? ( - onMarkChannelRead(channel.id, channel.lastMessageAt)} - > - - Mark as read - - ) : !hasUnread && onMarkChannelUnread ? ( - onMarkChannelUnread(channel.id)}> - - Mark unread - - ) : null} - {onMuteChannel && onUnmuteChannel ? ( - <> - - {isMuted ? ( - onUnmuteChannel(channel.id)}> - - Unmute channel - - ) : ( - onMuteChannel(channel.id)}> - - Mute channel - - )} - - ) : null} - {sections && - assignments && - onAssignChannel && - onUnassignChannel && - onCreateSectionForChannel ? ( - <> - - - - ) : null} - - - copyTextToClipboard(channel.name, "Channel name copied to clipboard") - } - > - - Copy channel name - - - copyTextToClipboard(channel.id, "Channel ID copied to clipboard") - } - > - - Copy channel ID - - {onLeaveChannel ? ( - <> - - onLeaveChannel(channel)} - > - - Leave channel - - - ) : null} - - ); -} - -function SectionHeaderActions({ - browseAriaLabel, - createAriaLabel, - hasUnread, - leadingAction, - onBrowseClick, - onCreateClick, onMarkAllRead, + onBrowse, + browseLabel, + onCreate, + createLabel, + onNewMessage, + newMessageLabel, + onRenameSection, + onMoveSectionUp, + onMoveSectionDown, + onDeleteSection, + isFirstSection, + isLastSection, + sortMode, + onSortModeChange, }: { - browseAriaLabel?: string; - createAriaLabel: string; + sectionLabel: string; + testId?: string; + visibilityClassName?: string; hasUnread?: boolean; - leadingAction?: React.ReactNode; - onBrowseClick?: () => void; - onCreateClick?: () => void; onMarkAllRead?: () => void; + onBrowse?: () => void; + browseLabel?: string; + onCreate?: () => void; + createLabel?: string; + onNewMessage?: () => void; + newMessageLabel?: string; + onRenameSection?: () => void; + onMoveSectionUp?: () => void; + onMoveSectionDown?: () => void; + onDeleteSection?: () => void; + isFirstSection?: boolean; + isLastSection?: boolean; + sortMode?: ChannelSortMode; + onSortModeChange?: (mode: ChannelSortMode) => void; +}) { + const triggerRef = useRef(null); + const showSectionManagement = Boolean(onRenameSection || onDeleteSection); + const showSort = Boolean(sortMode && onSortModeChange); + + return ( + + + + + { + event.preventDefault(); + triggerRef.current?.blur(); + }} + > + {hasUnread && onMarkAllRead ? ( + deferMenuAction(onMarkAllRead)}> + + Mark all as read + + ) : null} + {onNewMessage ? ( + deferMenuAction(onNewMessage)}> + + {newMessageLabel ?? "New message"} + + ) : null} + {onBrowse ? ( + deferMenuAction(onBrowse)}> + + {browseLabel ?? "Browse channels"} + + ) : null} + {onCreate ? ( + deferMenuAction(onCreate)}> + + {createLabel ?? "Create channel"} + + ) : null} + {showSectionManagement ? ( + <> + {onRenameSection ? ( + deferMenuAction(onRenameSection)} + > + + Rename section + + ) : null} + {onMoveSectionUp ? ( + deferMenuAction(onMoveSectionUp)} + > + + Move up + + ) : null} + {onMoveSectionDown ? ( + deferMenuAction(onMoveSectionDown)} + > + + Move down + + ) : null} + + ) : null} + {showSort ? ( + <> + + + + + Sort + + + + onSortModeChange?.(value as ChannelSortMode) + } + value={sortMode} + > + {SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + + ) : null} + {onDeleteSection ? ( + <> + + deferMenuAction(onDeleteSection)} + > + + Delete section + + + ) : null} + + + ); +} + +function ChannelSectionHeader({ + contentId, + isCollapsed, + onToggleCollapsed, + title, + actions, +}: { + contentId: string; + isCollapsed: boolean; + onToggleCollapsed: () => void; + title: string; + actions: React.ReactNode; }) { return ( -
- {leadingAction} - {hasUnread && onMarkAllRead ? ( +
+ - ) : null} - {onBrowseClick ? ( - - ) : null} - {onCreateClick ? ( - - ) : null} + +
+ {actions} +
); } export function ChannelGroupSection({ - browseAriaLabel, - createAriaLabel, + browseLabel, + createLabel, draggable, groupClassName, hasUnread, @@ -327,16 +332,19 @@ export function ChannelGroupSection({ isActiveChannel, activeWorkingByChannelId, items, - leadingHeaderAction, listTestId, onBrowseClick, onCreateClick, + showQuickCreate, onMarkAllRead, onMarkChannelRead, onMarkChannelUnread, onSelectChannel, onToggleCollapsed, selectedChannelId, + sortMode, + onSortModeChange, + actionsTestId, title, unreadChannelCounts, unreadChannelIds, @@ -353,18 +361,18 @@ export function ChannelGroupSection({ onUnstarChannel, onLeaveChannel, }: { - browseAriaLabel?: string; - createAriaLabel: string; + browseLabel?: string; + createLabel?: string; draggable?: boolean; groupClassName?: string; isCollapsed: boolean; isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; items: Channel[]; - leadingHeaderAction?: React.ReactNode; listTestId: string; onBrowseClick?: () => void; onCreateClick?: () => void; + showQuickCreate?: boolean; onMarkChannelRead: ( channelId: string, lastMessageAt: string | null | undefined, @@ -373,6 +381,9 @@ export function ChannelGroupSection({ onSelectChannel: (channelId: string) => void; onToggleCollapsed: () => void; selectedChannelId: string | null; + sortMode?: ChannelSortMode; + onSortModeChange?: (mode: ChannelSortMode) => void; + actionsTestId?: string; title: string; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; @@ -462,36 +473,37 @@ export function ChannelGroupSection({ -
- - - - -
+ ) : null} + + + } + /> {!isCollapsed ? ( {channelList} ) : null} @@ -519,7 +531,8 @@ export function CustomChannelSection({ assignments, isFirst, isLast, - leadingHeaderAction, + sortMode, + onSortModeChange, onToggleCollapsed, onSelectChannel, onMarkChannelRead, @@ -553,7 +566,8 @@ export function CustomChannelSection({ assignments: Record; isFirst: boolean; isLast: boolean; - leadingHeaderAction?: React.ReactNode; + sortMode?: ChannelSortMode; + onSortModeChange?: (mode: ChannelSortMode) => void; onToggleCollapsed: () => void; onSelectChannel: (channelId: string) => void; onMarkChannelRead: ( @@ -603,13 +617,6 @@ export function CustomChannelSection({ onClick={onToggleCollapsed} type="button" > -