Files
buzz/desktop/src/features/communities/useCommunityInit.ts
Duncan 9bac71b65b fix(workspace): represent applied-but-blocked as a truthful third state
Provider-access reconciliation runs in the post-commit section of
apply_workspace, after relay/keys/scope have committed. The previous
.await? propagated any reconciliation failure as a command Err, causing
the frontend to treat the workspace as unapplied and clear appliedKey —
while the new scope was already active. This contradicted the
applied/degraded contract and the useCommunityInit error-handling
assumption.

Fix: replace .await? with a match that returns WorkspaceApplyResult
with applied: true and blocked: Some(reason) on reconciliation failure.
Dependent post-commit steps (event sync, agent restore) remain
unreached on this path, preserving #4053's fail-closed intent. The
frontend parks on the loading gate with a truthful error and the same
retry-by-reapply semantics as the catch block.

Changes:
- scope.rs: add blocked: Option<String> field to WorkspaceApplyResult,
  add applied_but_blocked() constructor, document the three states
- workspace.rs: replace .await? with match; return applied_but_blocked
  on reconciliation failure; update command doc comment
- useCommunityInit.ts: handle blocked after the !applied branch; update
  catch-block comment (reconcile failures no longer arrive as Err)
- tauri.ts: add blocked?: string | null to ApplyWorkspaceResult type
- e2eBridge.ts: add blocked: null to apply_workspace mock result
- runtime_commands_tests.rs: add three new tests covering the new state
  and the three-state distinction

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-06 19:00:21 -04:00

370 lines
14 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
import { isMacPlatform } from "@/shared/lib/platform";
import { toast } from "sonner";
import { relayClient } from "@/shared/api/relayClient";
import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate";
import {
applyCommunity,
autoConnectDefaultRelayEnabled,
getDefaultRelayUrl,
} from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import {
clearAllDrafts,
initDraftStore,
} from "@/features/messages/lib/useDrafts";
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore";
import {
resetActiveAgentTurnsStore,
saveActiveAgentTurnsForCommunity,
restoreActiveAgentTurnsForCommunity,
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import {
initFirstCommunity,
shouldAutoConnectDefaultRelay,
} from "./communityStorage";
import type { Community } from "./types";
/**
* Tear down all community-scoped module singletons so the new
* community starts with a clean slate. Hook-managed singletons
* (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are
* destroyed via effect cleanup and do not need entries here.
* See AGENTS.md "Community Switching" for the full contract.
*/
function resetCommunityState({
resetAvatarState,
}: {
resetAvatarState: boolean;
}): void {
relayClient.disconnect();
resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
if (isTauri() && isMacPlatform()) {
void clearTrayAgentActivity();
}
if (resetAvatarState) {
resetAvatarProfileSync();
resetAvatarPresentations();
}
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetVideoPlayerState();
resetRenderScopedReactionHydration();
resetBackgroundMediaUploads();
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetLinkPreviewTitleCache();
}
type CommunityInitResult =
| { isReady: true; needsSetup: false; appliedKey: string }
| {
isReady: false;
needsSetup: true;
defaultRelayUrl: string;
}
| { isReady: false; needsSetup: false; appliedKey: string | null }
| { isReady: false; needsSetup: false; appliedKey: null; error: string };
/**
* Applies the active community config to the Tauri backend and resets
* all community-scoped module singletons when the community changes.
*
* Returns a discriminated union — only render the app after the
* community is applied. When `needsSetup` is true, the caller
* should show a first-run welcome screen.
*/
export function useCommunityInit(
activeCommunity: Community | null,
communityKey: string,
isSharedIdentity: boolean,
suppressAutoConnect = false,
): CommunityInitResult {
const [result, setResult] = useState<CommunityInitResult>({
isReady: false,
needsSetup: false,
appliedKey: null,
});
// Track whether this is the initial mount or a community switch.
// On the initial mount we skip resetting singletons (they're fresh).
const hasInitializedRef = useRef(false);
// Track the previously-applied community ID so we can save its turn state
// before resetting when the user switches to a different community.
const prevCommunityIdRef = useRef<string | null>(null);
// Deferred avatar work owns the relay captured when it was queued. A
// same-relay reconnect during onboarding must not cancel that work, while an
// actual relay boundary must clear both the queue and its presentation probe.
const appliedRelayUrlRef = useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
let cancelled = false;
async function init() {
if (!activeCommunity) {
if (hasInitializedRef.current) {
if (prevCommunityIdRef.current) {
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
prevCommunityIdRef.current = null;
}
resetCommunityState({ resetAvatarState: true });
appliedRelayUrlRef.current = null;
hasInitializedRef.current = false;
}
try {
const defaultRelayUrl = await getDefaultRelayUrl();
const autoConnectDefaultRelay =
await autoConnectDefaultRelayEnabled();
// Internal builds explicitly opt into treating their reviewed default
// relay as the first community. Public builds retain community
// selection even when BUZZ_RELAY_URL is overridden at runtime.
if (
!suppressAutoConnect &&
(isSharedIdentity ||
(autoConnectDefaultRelay &&
shouldAutoConnectDefaultRelay(defaultRelayUrl)))
) {
const identity = await getIdentity();
if (cancelled) return;
const community = initFirstCommunity(
defaultRelayUrl,
identity.pubkey,
);
if (community && !cancelled) {
window.location.reload();
return;
}
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
return;
}
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
} catch {
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl: "ws://localhost:3000",
});
}
}
return;
}
// Mark this community config as pending while it is applied to the
// backend. App.tsx also checks appliedKey against the active communityKey,
// which prevents rendering community-scoped UI for a new community until
// that exact config has finished applying.
setResult({
isReady: false,
needsSetup: false,
appliedKey: communityKey,
});
// On community switch (not initial mount), reset module singletons
// so the new tree starts with a clean slate.
if (hasInitializedRef.current) {
// Save the outgoing community's turn state before wiping the store so
// timers survive a round-trip (A → B → A keeps A's elapsed time).
if (prevCommunityIdRef.current) {
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
// Null out immediately so a rapid community switch (A→B→C before
// B's applyCommunity resolves) doesn't re-save the now-empty
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
}
hasInitializedRef.current = true;
appliedRelayUrlRef.current = activeCommunity.relayUrl;
// Apply community config to the Tauri backend.
//
// Note: we deliberately do NOT pass an nsec here. The persisted
// `identity.key` file (resolved at startup by `resolve_persisted_identity`,
// and updated atomically by `import_identity`) is the single source of
// truth for the active key. Older builds stored the nsec in localStorage
// and re-applied it on every reload, which silently overwrote any
// imported key. `loadCommunities()` strips lingering `nsec` fields from
// legacy entries; this site refuses to apply one even if present.
try {
const applyResult = await applyCommunity(
activeCommunity.relayUrl,
undefined,
activeCommunity.token,
activeCommunity.reposDir,
getOverrides().agentManagedProfiles === true,
);
if (!applyResult.applied) {
// Drain failed; old scope is still active. Treat as a fatal apply
// error: park on the loading gate so the user can retry by switching
// workspaces again.
const reason = applyResult.degraded.join("; ");
console.error(
"[useCommunityInit] workspace apply blocked by drain failure:",
reason,
);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error: `Workspace switch failed (agents could not stop): ${reason}`,
});
}
return;
}
if (applyResult.blocked != null) {
// Scope committed (applied: true) but post-commit provider-access
// reconciliation failed. The new workspace IS active, but dependent
// steps (event sync, agent restore) were skipped to preserve
// fail-closed behavior. Park on the loading gate so the user can
// retry by re-applying the workspace — same semantics as the catch
// block below, but truthfully reflecting that the workspace committed.
console.error(
"[useCommunityInit] workspace applied but blocked by provider reconciliation failure:",
applyResult.blocked,
);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error: `Workspace applied but provider access configuration failed: ${applyResult.blocked}`,
});
}
return;
}
// Workspace applied. Surface any post-commit degradation as a
// user-visible warning toast — the workspace IS active, but some
// best-effort post-commit steps failed (nest, event-sync, restore).
if (applyResult.degraded.length > 0) {
const reason = applyResult.degraded.join("; ");
console.warn(
"[useCommunityInit] workspace applied with degradation:",
applyResult.degraded,
);
toast.warning("Workspace applied with partial failures", {
description: reason,
duration: 8000,
});
}
} catch (error) {
// A bad `repos_dir` no longer reaches here — `apply_workspace` treats
// it as non-fatal (relay/keys apply, bad value not persisted, REPOS
// falls back to a real dir, a `repos-dir-error` toast surfaces it) and
// returns Ok, so the app boots into a working state where the user can
// fix the value in community settings. Provider-access reconciliation
// failures also no longer reach here — they arrive as `applied: true` +
// `blocked: string` and are handled above. This catch now only fires on
// a genuine relay/key apply failure (e.g. an invalid nsec or a poisoned
// lock). For those, marking the community ready would render
// community-scoped UI against a backend that never applied — park on
// the loading gate (isReady:false, no appliedKey) instead.
console.error("Failed to apply community to backend:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? error.message
: "Failed to apply community configuration",
});
}
return;
}
if (!cancelled) {
// Refresh relay-derived media state only after the backend has installed
// this community's relay override. On cold launch, mediaUrl.ts may have
// eagerly cached the default relay origin before applyCommunity ran;
// leaving that stale value makes authenticated relay media look external
// and bypass the localhost proxy.
resetMediaCaches();
try {
const identity = await getIdentity();
if (cancelled) return;
initDraftStore(identity.pubkey, activeCommunity.relayUrl);
} catch (err) {
if (cancelled) return;
console.error(
"[useCommunityInit] getIdentity failed, draft store uninitialized:",
err,
);
}
// Restore any turn state saved for this community (a prior A→B round-
// trip). This runs after applyCommunity succeeds and before the app
// renders so components see the restored timers on first render.
restoreActiveAgentTurnsForCommunity(activeCommunity.id);
// Prime the ref so the NEXT switch saves this community's state.
prevCommunityIdRef.current = activeCommunity.id;
setResult({
isReady: true,
needsSetup: false,
appliedKey: communityKey,
});
}
}
void init();
return () => {
cancelled = true;
};
}, [
activeCommunity?.id,
activeCommunity?.relayUrl,
activeCommunity?.token,
activeCommunity?.reposDir,
isSharedIdentity,
suppressAutoConnect,
communityKey,
]);
return result;
}