Skip to content

Commit 9ad9236

Browse files
committed
fix(workspace): reuse directory authority for context reads
1 parent 03ef8d1 commit 9ad9236

5 files changed

Lines changed: 94 additions & 24 deletions

File tree

apps/daemon/src/collab/vela-workspace-context.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ const PROVIDER_MODES = new Set<WorkspaceProviderMode>(['platform_credits', 'pers
7777
interface VelaWorkspaceContextOptions {
7878
/** Injectable for tests. */
7979
fetch?: typeof fetch;
80+
/** Reuse the daemon's account-scoped directory authority broker. */
81+
fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>;
8082
/** Injectable for tests; defaults to reading ~/.amr/config.json + env. */
8183
readSession?: typeof readVelaControlApiContext;
8284
/** Settings-backed AMR environment used by the daemon's agent launcher. */
@@ -230,9 +232,19 @@ export function createVelaWorkspaceContextProvider(
230232
const fetchImpl = options.fetch ?? fetch;
231233
const readSession = options.readSession ?? readVelaControlApiContext;
232234
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
235+
type VelaSession = NonNullable<ReturnType<typeof readVelaControlApiContext>>;
233236
const configuredEnv = () => typeof options.configuredEnv === 'function'
234237
? options.configuredEnv()
235238
: (options.configuredEnv ?? {});
239+
const readWorkspaceDirectory = (
240+
session: VelaSession,
241+
selectedEnv: Record<string, string>,
242+
) => options.fetchWorkspaceDirectory?.() ?? fetchVelaWorkspaceDirectory({
243+
fetch: fetchImpl,
244+
readSession: () => session,
245+
configuredEnv: selectedEnv,
246+
timeoutMs,
247+
});
236248

237249
/** Pick the best default membership out of an already-fetched directory list. */
238250
function selectDefaultCandidate(
@@ -269,12 +281,7 @@ export function createVelaWorkspaceContextProvider(
269281
const session = readSession(process.env, selectedEnv);
270282
if (!session || !session.controlKey || !session.apiUrl) return null;
271283
try {
272-
const directory = await fetchVelaWorkspaceDirectory({
273-
fetch: fetchImpl,
274-
readSession: () => session,
275-
configuredEnv: selectedEnv,
276-
timeoutMs,
277-
});
284+
const directory = await readWorkspaceDirectory(session, selectedEnv);
278285
// A failed directory read confirms nothing. In particular, never evict
279286
// the local pin on a timeout, auth outage, or non-2xx response.
280287
if (!directory.ok) return null;
@@ -323,12 +330,7 @@ export function createVelaWorkspaceContextProvider(
323330
const workspaceId = req.workspaceId.trim();
324331
if (!session || !session.controlKey || !session.apiUrl || !workspaceId) return null;
325332
try {
326-
const directory = await fetchVelaWorkspaceDirectory({
327-
fetch: fetchImpl,
328-
readSession: () => session,
329-
configuredEnv: selectedEnv,
330-
timeoutMs,
331-
});
333+
const directory = await readWorkspaceDirectory(session, selectedEnv);
332334
if (!directory.ok) return null;
333335
const item = directory.items.find(
334336
(entry) =>
@@ -906,7 +908,11 @@ export function createWorkspaceContextProviderFromEnv(
906908
env: NodeJS.ProcessEnv = process.env,
907909
options: Pick<
908910
VelaWorkspaceContextOptions,
909-
'configuredEnv' | 'getActiveWorkspaceId' | 'setLocalSelection' | 'clearLocalSelection'
911+
| 'configuredEnv'
912+
| 'fetchWorkspaceDirectory'
913+
| 'getActiveWorkspaceId'
914+
| 'setLocalSelection'
915+
| 'clearLocalSelection'
910916
> = {},
911917
): WorkspaceContextProvider {
912918
if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') {

apps/daemon/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3436,6 +3436,7 @@ export async function startServer({
34363436
const workspaceContext = withLastKnownWorkspaceContext(
34373437
createWorkspaceContextProviderFromEnv(process.env, {
34383438
configuredEnv: configuredAmrEnv,
3439+
fetchWorkspaceDirectory,
34393440
getActiveWorkspaceId: () => activeWorkspace.get(),
34403441
setLocalSelection: (workspaceId: string) => activeWorkspace.set(workspaceId),
34413442
// Only called after the membership directory CONFIRMS the pinned

apps/daemon/tests/vela-workspace-context.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,33 @@ describe('createVelaWorkspaceContextProvider explicit local scope', () => {
11261126
expect(context?.planId).toBeNull();
11271127
});
11281128

1129+
it('routes current and exact reads through the injected directory authority broker', async () => {
1130+
const fetchWorkspaceDirectory = vi.fn(async () => ({
1131+
ok: true as const,
1132+
items: [B_DIRECTORY_ITEM],
1133+
}));
1134+
const directFetch = vi.fn(async () => {
1135+
throw new Error('direct directory fetch must stay behind the broker');
1136+
}) as unknown as typeof fetch;
1137+
const provider = createVelaWorkspaceContextProvider({
1138+
fetch: directFetch,
1139+
fetchWorkspaceDirectory,
1140+
readSession: () => SESSION,
1141+
getActiveWorkspaceId: () => B_DIRECTORY_ITEM.workspaceId,
1142+
});
1143+
1144+
await expect(provider.current({})).resolves.toMatchObject({
1145+
workspaceId: B_DIRECTORY_ITEM.workspaceId,
1146+
});
1147+
await expect(provider.resolveExact?.({
1148+
workspaceId: B_DIRECTORY_ITEM.workspaceId,
1149+
})).resolves.toMatchObject({
1150+
workspaceId: B_DIRECTORY_ITEM.workspaceId,
1151+
});
1152+
expect(fetchWorkspaceDirectory).toHaveBeenCalledTimes(2);
1153+
expect(directFetch).not.toHaveBeenCalled();
1154+
});
1155+
11291156
it('does not bootstrap when the directory returns 401', async () => {
11301157
const fetchImpl = vi.fn(async () => jsonResponse(401, { error: 'unauthenticated' })) as unknown as typeof fetch;
11311158
const provider = createVelaWorkspaceContextProvider({ fetch: fetchImpl, readSession: () => SESSION });

apps/web/src/components/EntryNavRail.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,8 @@ export type WorkspaceInviteTarget =
469469
* Direct invites and billing recovery are separate capabilities. A Personal
470470
* Free owner (or a full Team owner) can still enter Vela's upgrade/seat flow
471471
* without direct invite capability, but an admin never acquires billing power
472-
* from role alone. Unknown seat state fails closed until the context refresh
473-
* supplies an authoritative answer.
472+
* from role alone. Unknown capacity remains usable for a member with explicit
473+
* invite permission; the invite API is still the authority if the plan is full.
474474
*/
475475
export function canAccessWorkspaceInviteFlow(
476476
context: WorkspaceCollabContext | null | undefined,
@@ -494,25 +494,37 @@ export function canAccessWorkspaceInviteFlow(
494494
if (context.workspaceType === 'personal') return canInviteMembers;
495495

496496
const isSeatFull = workspaceSeatFull(context);
497-
if (isSeatFull === undefined) return false;
497+
if (isSeatFull === undefined) return canInviteMembers;
498498
if (!isSeatFull) return canInviteMembers;
499499
return context.role === 'owner' && canManageBilling;
500500
}
501501

502502
function workspaceSeatFull(
503503
context: WorkspaceCollabContext,
504504
): boolean | undefined {
505+
// Directory-derived contexts use 0/0 because the removed account-global
506+
// context projection no longer supplies seat accounting. That pair means
507+
// unknown, not a proven zero-seat plan. Explicit invite permission may still
508+
// open the form; the invite API remains the capacity authority.
509+
if (
510+
context.seatSummary?.seatLimit === 0
511+
&& context.seatSummary.usedSeats === 0
512+
) {
513+
return undefined;
514+
}
505515
const availableSeats = context.seatSummary?.availableSeats;
506516
if (availableSeats !== undefined) return availableSeats <= 0;
507517
return context.seatSummary?.isSeatFull;
508518
}
509519

510520
/**
511-
* Chooses the first safe invite surface. The local form is only valid when a
512-
* team is positively known to have direct invite capability and capacity.
513-
* Personal, Free-plan, and full-seat owner states go to Vela, whose dashboard
514-
* owns the authoritative upgrade/seat/invite decision. Missing routing or seat
515-
* data fails closed.
521+
* Chooses the first safe invite surface. The local form requires direct invite
522+
* capability and no proof that the team is already full; unknown capacity is
523+
* resolved by the invite API when the form is submitted.
524+
* Personal, Free-plan, and proven full-seat owner states go to Vela, whose
525+
* dashboard owns the authoritative upgrade/seat/invite decision. Unknown seat
526+
* data stays on the local permission-gated flow and lets the invite API return
527+
* an authoritative capacity result.
516528
*/
517529
export function resolveWorkspaceInviteTarget(
518530
context: WorkspaceCollabContext | null | undefined,
@@ -525,7 +537,7 @@ export function resolveWorkspaceInviteTarget(
525537
if (
526538
context.workspaceType === 'team' &&
527539
!needsTeamUpgrade &&
528-
workspaceSeatFull(context) === false &&
540+
workspaceSeatFull(context) !== true &&
529541
context.permissions.canInviteMembers === true
530542
) {
531543
return { kind: 'local' };

apps/web/tests/components/EntryNavRail.invite-seat-gate.test.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,35 @@ describe('EntryNavRail workspace-switcher invite target (recvqgbyLNk4eE)', () =>
144144
expect(openSpy).not.toHaveBeenCalled();
145145
});
146146

147-
it('fails closed while the team seat state is unknown', () => {
147+
it('keeps the permission-gated local invite when team seat state is unknown', () => {
148+
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
148149
renderRail(teamContextWithUnknownSeats());
149150

150151
fireEvent.click(screen.getByTestId('workspace-switcher'));
151-
expect(menu().queryByRole('menuitem', { name: // })).toBeNull();
152+
fireEvent.click(menu().getByRole('menuitem', { name: // }));
153+
154+
expect(screen.getByRole('dialog')).toBeTruthy();
155+
expect(openSpy).not.toHaveBeenCalled();
156+
});
157+
158+
it('treats the directory-derived zero/zero seat sentinel as unknown', () => {
159+
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
160+
const context = teamContext(3);
161+
renderRail({
162+
...context,
163+
role: 'admin',
164+
seatSummary: { seatLimit: 0, usedSeats: 0, availableSeats: 0, isSeatFull: true },
165+
permissions: {
166+
...context.permissions,
167+
canManageBilling: false,
168+
},
169+
} as WorkspaceCollabContext);
170+
171+
fireEvent.click(screen.getByTestId('workspace-switcher'));
172+
fireEvent.click(menu().getByRole('menuitem', { name: // }));
173+
174+
expect(screen.getByRole('dialog')).toBeTruthy();
175+
expect(openSpy).not.toHaveBeenCalled();
152176
});
153177

154178
it('hides the invite entry when neither local capacity nor a safe Vela URL exists', () => {

0 commit comments

Comments
 (0)