diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index cef8f2dd40..37f3f92618 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -126,6 +126,7 @@ import { retryUnavailableAmrBalanceGate, type AmrBalanceGateScope, } from '../runtime/amr-balance-gate'; +import { awaitHomeAmrGateWorkspaceContext } from '../runtime/home-amr-gate-context'; import { HomeView, seedHomeComposerPrompt } from './HomeView'; import { entryStrategyRoutingFields } from './entry-strategy-routing'; import { EntryBlankState } from './EntryBlankState'; @@ -1496,18 +1497,26 @@ export function EntryShell({ let amrGatePrecheckPassed = false; if (isAmrSend) { // PRODUCT INVARIANT: Send never starts Workspace identity discovery. - // Billing consumes the shell's current in-memory snapshot; if it has not - // arrived yet, the existing account-scoped gate is used. The daemon's - // ordinary project-create route is local and does not need live Workspace - // authority. Account/scope generation checks below only prevent a result - // from being reused after the user switches identity while the balance + // Billing consumes the shell's current in-memory snapshot. While that + // read is still in flight the send waits for it (bounded) instead of + // gating on the account wallet — that fallback showed a Team member the + // personal upgrade dialog; see `awaitHomeAmrGateWorkspaceContext`. The + // legacy account-scoped gate remains for a shell that has settled + // without a workspace identity. The daemon's ordinary project-create + // route is local and does not need live Workspace authority. + // Account/scope generation checks below only prevent a result from + // being reused after the user switches identity while the balance // request or dialog is in flight. for (let scopeAttempt = 0; scopeAttempt < 2; scopeAttempt += 1) { + const settledWorkspace = await awaitHomeAmrGateWorkspaceContext( + () => workspaceContextStateRef.current, + ); + if (settledWorkspace.kind === 'unsettled') { + handoff.rollback({ notice: t('home.amrGateUnavailable') }); + return false; + } const gateAccountGeneration = currentWorkspaceAccountGeneration(); - const gateWorkspaceState = workspaceContextStateRef.current; - const gateWorkspaceContext = gateWorkspaceState.failure === 'unsupported' - ? null - : workspaceResourceReadContext(gateWorkspaceState); + const gateWorkspaceContext = settledWorkspace.context; const gateWorkspaceIdentity = workspaceIdentityCacheKey(gateWorkspaceContext); const gateScope = amrBalanceGateScopeForWorkspaceContext(gateWorkspaceContext); let gate = await retryUnavailableAmrBalanceGate( diff --git a/apps/web/src/runtime/home-amr-gate-context.ts b/apps/web/src/runtime/home-amr-gate-context.ts new file mode 100644 index 0000000000..554b7292ee --- /dev/null +++ b/apps/web/src/runtime/home-amr-gate-context.ts @@ -0,0 +1,67 @@ +import type { WorkspaceCollabContext } from '@open-design/contracts'; + +import { + workspaceResourceReadContext, + type WorkspaceContextState, +} from '../collab/useWorkspaceContext'; + +/** + * How long a Home send waits for the shell's workspace identity read to + * settle before giving up on gating the run. A cold context read completes + * well inside this; a read that never completes is Cloud being unreachable. + */ +export const HOME_AMR_GATE_CONTEXT_SETTLE_MS = 6_000; +const POLL_MS = 50; + +export type HomeAmrGateWorkspaceContext = + | { kind: 'settled'; state: WorkspaceContextState; context: WorkspaceCollabContext | null } + | { kind: 'unsettled' }; + +/** + * The workspace context a Home OpenDesign Cloud send may gate its wallet on. + * + * INVARIANT: a send never gates on the account wallet while the shell's + * workspace identity read is still in flight. The balance gate without a + * scope reads the ACCOUNT (personal) wallet and, with no context to name an + * audience, shows the owner's upgrade dialog. A Team member who clicks Send + * before the identity read has settled (cold start, a workspace switch or a + * sign-in still resolving) would therefore be told to upgrade a personal plan + * for a Team wallet they cannot manage — the dialog the in-project gate, which + * always has its project's scope, never shows them (OPEND-3300 follow-up). + * + * So: the exact read identity, when the shell has one; the legacy account path + * only when the shell has SETTLED without one (an old daemon with no workspace + * endpoint, or an authoritative "no workspace"); and while the read is in + * flight, wait for it — bounded, because Home has no queue to park a send in. + * + * Send still never STARTS identity discovery: this only observes the read the + * shell already has in flight. + */ +export async function awaitHomeAmrGateWorkspaceContext( + read: () => WorkspaceContextState, + options: { deadlineMs?: number } = {}, +): Promise { + const deadlineMs = options.deadlineMs ?? HOME_AMR_GATE_CONTEXT_SETTLE_MS; + const startedAt = Date.now(); + for (;;) { + const state = read(); + const context = state.failure === 'unsupported' + ? null + : workspaceResourceReadContext(state); + if (context || !workspaceIdentityReadInFlight(state)) { + return { kind: 'settled', state, context }; + } + if (Date.now() - startedAt >= deadlineMs) return { kind: 'unsettled' }; + await new Promise((resolve) => { + globalThis.setTimeout(resolve, POLL_MS); + }); + } +} + +/** The shell has announced an identity read it has not heard back from. */ +export function workspaceIdentityReadInFlight( + state: Pick, +): boolean { + if (state.failure === 'unsupported') return false; + return Boolean(state.loading) || Boolean(state.identityChangePending); +} diff --git a/apps/web/tests/components/EntryShell.amr-pending-handoff.test.tsx b/apps/web/tests/components/EntryShell.amr-pending-handoff.test.tsx index 07f2c1a940..c0008ef4be 100644 --- a/apps/web/tests/components/EntryShell.amr-pending-handoff.test.tsx +++ b/apps/web/tests/components/EntryShell.amr-pending-handoff.test.tsx @@ -32,6 +32,7 @@ import type { } from '../../src/components/EntryShell'; import { I18nProvider } from '../../src/i18n'; import { checkAmrBalanceGate } from '../../src/runtime/amr-balance-gate'; +import { HOME_AMR_GATE_CONTEXT_SETTLE_MS } from '../../src/runtime/home-amr-gate-context'; import type { AgentInfo, AppConfig } from '../../src/types'; import { EntryShellWithGateHost } from '../helpers/entry-shell-gate-host'; import { setHomeHeroPrompt } from '../helpers/home-hero-lexical'; @@ -393,3 +394,147 @@ describe('OPEND-3300 · an empty in-memory wallet blocks on Home before the hand expect(h.rollback).not.toHaveBeenCalled(); }); }); + +/** + * OPEND-3300 follow-up · a Team member with no billing permission at $0 is + * told to ask the workspace owner — on Home exactly as in the project. A + * tester (member, `canManageBilling=false`, Team wallet $0) got the personal + * upgrade dialog instead: with the shell's workspace identity read still in + * flight at the click, the gate had no scope, read the ACCOUNT wallet and + * named the owner audience. The send now waits for the read the shell already + * has in flight (`awaitHomeAmrGateWorkspaceContext`) and never gates the + * account wallet while it is. + */ +describe('OPEND-3300 · a Team member at $0 is asked to find the owner on Home', () => { + function teamMemberContext(): WorkspaceCollabContext { + const role = 'member' as const; + const lifecycleState = 'active' as const; + return { + workspaceId: 'ws-team-3300', + workspaceType: 'team', + workspaceMemberId: 'wm-team-3300-member', + role, + memberStatus: 'active', + lifecycleState, + billingState: 'active', + planId: 'team_pro', + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 2 }), + permissions: buildWorkspacePermissions({ role, lifecycleState }), + teamId: 'ws-team-3300', + teamName: 'Acme Design', + workspaceName: 'Acme Design', + }; + } + + const teamScope = { + workspaceType: 'team', + workspaceId: 'ws-team-3300', + workspaceMemberId: 'wm-team-3300-member', + }; + + /** + * The shell's reads. `holdIdentity` keeps the directory and context reads + * pending until `releaseIdentity()` — the shape of a click that lands + * before a cold start or a workspace switch has resolved. + */ + function installFetch(workspace: WorkspaceCollabContext, options: { holdIdentity?: boolean } = {}) { + let releaseIdentity: () => void = () => undefined; + const identityReleased = new Promise((resolve) => { + releaseIdentity = resolve; + }); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/api/workspace/directory')) { + if (options.holdIdentity) await identityReleased; + return jsonResponse(workspaceDirectoryFixture([workspace])); + } + if (url.endsWith('/api/workspace/context')) { + if (options.holdIdentity) await identityReleased; + return jsonResponse({ context: workspace }); + } + if (url.includes('/api/workspace/billing?')) { + // No in-memory reading: the verdict comes from the confirmed gate. + return jsonResponse({ summary: null, workspaceBalance: null }); + } + if (url.endsWith('/api/workspace/projects/team')) return jsonResponse({ projects: [] }); + if (url.endsWith('/api/plugins')) return jsonResponse({ plugins: [] }); + if (url.endsWith('/api/mcp/servers')) return jsonResponse({ servers: [] }); + if (url.endsWith('/api/community/discord')) return jsonResponse({ stale: true }); + if (url.endsWith('/api/github/open-design')) return jsonResponse({ stale: true }); + return jsonResponse({}); + }) as typeof fetch; + return { releaseIdentity: () => releaseIdentity() }; + } + + beforeEach(() => { + globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver; + window.sessionStorage.clear(); + window.history.replaceState(null, '', '/'); + resetWorkspaceContextCache(); + resetWorkspaceBillingCache(); + resetTeamProjectsCache(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + globalThis.fetch = originalFetch; + globalThis.ResizeObserver = originalResizeObserver; + mockedCheckAmrBalanceGate.mockReset(); + resetWorkspaceContextCache(); + resetWorkspaceBillingCache(); + resetTeamProjectsCache(); + }); + + it('settled member identity: the Team wallet is gated and the owner dialog shows, never the upgrade one', async () => { + installFetch(teamMemberContext()); + const h = harness('amr'); + h.setGate({ kind: 'hard', reason: 'insufficient', snapshot: emptyWallet() }); + // Let the identity read settle the way it does before a user can type. + await waitFor(() => expect(screen.getByTestId('home-hero-input')).toBeTruthy()); + await submitHome('Design the onboarding flow.'); + await screen.findByTestId('amr-balance-owner-dialog'); + expect(screen.queryByTestId('amr-balance-dialog')).toBeNull(); + expect(mockedCheckAmrBalanceGate.mock.calls[0]?.[0]).toEqual(teamScope); + expect(h.onCreateProject).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTestId('amr-balance-owner-dismiss')); + await waitFor(() => expect(h.rollback).toHaveBeenCalledTimes(1)); + expect(h.rollback).toHaveBeenCalledWith(); + }); + + it('identity read still in flight at the click: the send waits for it and gates the Team wallet, not the account', async () => { + const reads = installFetch(teamMemberContext(), { holdIdentity: true }); + const h = harness('amr'); + h.setGate({ kind: 'hard', reason: 'insufficient', snapshot: emptyWallet() }); + await submitHome('Design the onboarding flow.'); + await waitFor(() => expect(h.onBeginProjectCreation).toHaveBeenCalledTimes(1)); + // Nothing may be gated yet: the shell has not heard which workspace pays. + await new Promise((resolve) => { setTimeout(resolve, 150); }); + expect(mockedCheckAmrBalanceGate).not.toHaveBeenCalled(); + expect(screen.queryByTestId('amr-balance-dialog')).toBeNull(); + reads.releaseIdentity(); + await screen.findByTestId('amr-balance-owner-dialog'); + expect(screen.queryByTestId('amr-balance-dialog')).toBeNull(); + expect(mockedCheckAmrBalanceGate.mock.calls[0]?.[0]).toEqual(teamScope); + expect(h.onCreateProject).not.toHaveBeenCalled(); + }); + + it('identity read that never settles: the send returns to Home with the balance notice and gates nothing', async () => { + installFetch(teamMemberContext(), { holdIdentity: true }); + const h = harness('amr'); + await submitHome('Design the onboarding flow.'); + await waitFor(() => expect(h.onBeginProjectCreation).toHaveBeenCalledTimes(1)); + vi.useFakeTimers(); + await vi.advanceTimersByTimeAsync(HOME_AMR_GATE_CONTEXT_SETTLE_MS + 200); + vi.useRealTimers(); + await waitFor(() => expect(h.rollback).toHaveBeenCalledTimes(1)); + expect(h.rollback).toHaveBeenCalledWith({ + notice: 'Couldn\'t confirm your OpenDesign Cloud balance. Try sending again.', + }); + expect(mockedCheckAmrBalanceGate).not.toHaveBeenCalled(); + expect(screen.queryByTestId('amr-balance-dialog')).toBeNull(); + expect(screen.queryByTestId('amr-balance-owner-dialog')).toBeNull(); + expect(h.onCreateProject).not.toHaveBeenCalled(); + }); +});