From 94b96752c9696c6957d1c4c526d2abc8c09bd4a0 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:39:33 -0600 Subject: [PATCH] desktop: hydrate managed agents before Welcome provisioning Make the owner-scoped persona sync backfill awaitable and run it before Welcome onboarding inspects local managed agents. This lets a second installation reconstruct relay-canonical identities before deciding whether to mint replacements. Co-authored-by: Carl Signed-off-by: Wes --- .../src/features/agents/lib/usePersonaSync.ts | 29 +++++++++++++------ .../features/onboarding/welcomeGuide.test.mjs | 25 ++++++++++++++++ .../src/features/onboarding/welcomeGuide.ts | 14 +++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index cc34adb45b..484638bb28 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -22,6 +22,23 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; +export async function hydratePersonaSync( + pubkey: string, + relayUrl: string, + onCancelled: () => boolean = () => false, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: PERSONA_SYNC_KINDS, + authors: [pubkey], + limit: 500, + }); + for (const event of events) { + if (onCancelled()) return; + if (event.pubkey !== pubkey) continue; + await reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl); + } +} + // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: // one-shot backfill of existing heads + tombstones, then a live subscription. // Returns a disposer that closes the live subscription. Extracted from the hook @@ -48,15 +65,9 @@ export function startPersonaSync( // One-shot backfill of existing heads + tombstones (closes the fresh-start // gap that live-only subscription + reconnect-replay cannot recover). - void relayClient - .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) - .then((events) => { - if (onCancelled()) return; - for (const event of events) reconcile(event); - }) - .catch((error) => { - console.warn("[usePersonaSync] backfill failed:", error); - }); + void hydratePersonaSync(pubkey, relayUrl, onCancelled).catch((error) => { + console.warn("[usePersonaSync] backfill failed:", error); + }); let unsub: (() => Promise) | null = null; void relayClient diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index b3def930f1..6720d3f971 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { activateWelcomeTeamPersonasSequentially, buildWelcomeStarterCreateInput, + hydrateWelcomeTeamAgentsBeforeProvision, LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT, pickWelcomeGuideAgent, pickWelcomeGuideAgentForRelay, @@ -15,6 +16,30 @@ import { WELCOME_TEAM_STARTERS, } from "./welcomeGuide.ts"; +test("Welcome provisioning awaits relay hydration before inspecting agents", async () => { + const calls = []; + await hydrateWelcomeTeamAgentsBeforeProvision( + "wss://relay.example///", + async () => { + calls.push("identity"); + return { pubkey: PUB_A }; + }, + async (pubkey, relayUrl) => { + calls.push(`hydrate:${pubkey}:${relayUrl}`); + }, + ); + assert.deepEqual(calls, ["identity", `hydrate:${PUB_A}:wss://relay.example`]); +}); + +test("Welcome provisioning skips hydration without a relay scope", async () => { + let called = false; + await hydrateWelcomeTeamAgentsBeforeProvision(null, async () => { + called = true; + return { pubkey: PUB_A }; + }); + assert.equal(called, false); +}); + const PUB_A = "a".repeat(64); const PUB_B = "b".repeat(64); const PUB_C = "c".repeat(64); diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index aa8deb7c19..c2f727c93d 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -1,3 +1,4 @@ +import { hydratePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, @@ -11,6 +12,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; import type { AcpRuntime, @@ -53,6 +55,17 @@ function normalizeRelayUrl(relayUrl: string | null | undefined) { return relayUrl?.trim().replace(/\/+$/, "") ?? null; } +export async function hydrateWelcomeTeamAgentsBeforeProvision( + relayUrl: string | null | undefined, + resolveIdentity: typeof getIdentity = getIdentity, + hydrate: typeof hydratePersonaSync = hydratePersonaSync, +): Promise { + const normalizedRelay = normalizeRelayUrl(relayUrl); + if (!normalizedRelay) return; + const identity = await resolveIdentity(); + await hydrate(identity.pubkey, normalizedRelay); +} + function isAgentScopedToRelay(agent: ManagedAgent, relayUrl?: string | null) { const targetRelayUrl = normalizeRelayUrl(relayUrl); if (!targetRelayUrl) { @@ -269,6 +282,7 @@ async function provisionWelcomeTeam( channelId: string, relayUrl?: string | null, ): Promise { + await hydrateWelcomeTeamAgentsBeforeProvision(relayUrl); const existingAgents = await listManagedAgents(); await ensureWelcomeTeamPersonasActive(); const [personas, runtimeCatalog, globalConfig] = await Promise.all([