Skip to content

Commit e8c450b

Browse files
committed
fix(workspace): restore the last selection on restart
1 parent d176b0d commit e8c450b

26 files changed

Lines changed: 413 additions & 829 deletions

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

Lines changed: 72 additions & 210 deletions
Large diffs are not rendered by default.

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import { resolveEffectiveVelaConsoleOrigin } from '../integrations/vela-console-
1515

1616
// The daemon's single B-integration point . Presence + sync need the
1717
// caller's workspace identity (workspaceMemberId + role + lifecycle). In
18-
// production this provider verifies the request's auth against the B service and
19-
// returns B's CurrentWorkspaceContext for that user; until B is reachable, the
20-
// dev provider below holds an in-memory context that a demo/tools-dev run can
21-
// set. Swapping the provider is the only change when B ships — routes and the
22-
// web client stay put.
18+
// production this provider verifies the signed-in identity against B's
19+
// workspace membership directory, then resolves the daemon's locally persisted
20+
// workspace selection; until B is reachable, the dev provider below holds an
21+
// in-memory context that a demo/tools-dev run can set. Swapping the provider is
22+
// the only change when B ships — routes and the web client stay put.
2323

2424
export interface WorkspaceContextRequest {
2525
/** The caller's bearer token (a real provider verifies this against B). */
@@ -163,8 +163,8 @@ const BILLING_STATES: ReadonlySet<WorkspaceBillingState> = new Set([
163163
'locked',
164164
]);
165165

166-
/** Fallback billing state derived from lifecycle, used when a dev payload omits
167-
* it. Production always carries B's authoritative `billingState`. */
166+
/** Fallback billing state derived from lifecycle, used when a dev payload or
167+
* membership-directory context does not carry a billing projection. */
168168
function billingStateForLifecycle(lifecycle: WorkspaceLifecycleState): WorkspaceBillingState {
169169
switch (lifecycle) {
170170
case 'active':

apps/daemon/src/collab/workspace-exact-context-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ interface Entry {
4848

4949
const DEFAULT_REALTIME_TTL_MS = 5 * 60_000;
5050

51-
/** Exact-workspace cache for Vela's authenticated `/workspaces/current` read. */
51+
/** Exact-workspace cache for an authenticated membership-directory projection. */
5252
export function createWorkspaceExactContextCache(
5353
options: WorkspaceExactContextCacheOptions,
5454
): WorkspaceExactContextCache {

apps/daemon/src/collab/workspace-scope.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// call, per the B-line explicit-workspace handoff. B's account-level Active
33
// Workspace is shared mutable state across a user's devices; a daemon task
44
// that re-read it per tick could silently flip workspaces mid-flight. The
5-
// client therefore pins its own scope with a fixed priority and only lets the
6-
// server's Active Workspace apply when it genuinely has no opinion:
5+
// client therefore pins its own scope with a fixed priority and never asks the
6+
// server to infer a workspace from account-global state:
77
//
88
// 1. `explicit` — the id this specific call was asked to target;
99
// 2. `projectWorkspaceId` — the workspace a project belongs to (its shared
@@ -13,19 +13,18 @@
1313
// (workspace-selection.json);
1414
// 4. `envWorkspaceId` — a VELA_WORKSPACE_ID inherited from the spawn
1515
// environment;
16-
// 5. none — send no header; the server resolves its stored
17-
// Active Workspace (`source: 'server-current'`).
16+
// 5. none — unresolved; the caller must fail closed or skip
17+
// the workspace-scoped operation.
1818
//
19-
// The resolver is pure: it never invents an id and never mutates server state
20-
// (resource calls must NOT PUT /workspaces/current — only an explicit user
21-
// switch does).
19+
// The resolver is pure: it never invents an id and never reads or mutates
20+
// server-side workspace-selection state.
2221

2322
export type WorkspaceScopeSource =
2423
| 'explicit'
2524
| 'project'
2625
| 'local-selection'
2726
| 'environment'
28-
| 'server-current';
27+
| 'unresolved';
2928

3029
export interface WorkspaceScope {
3130
workspaceId?: string;
@@ -48,5 +47,5 @@ export function resolveWorkspaceScope(inputs: WorkspaceScopeInputs): WorkspaceSc
4847
if (local) return { workspaceId: local, source: 'local-selection' };
4948
const env = inputs.envWorkspaceId?.trim();
5049
if (env) return { workspaceId: env, source: 'environment' };
51-
return { source: 'server-current' };
50+
return { source: 'unresolved' };
5251
}

apps/daemon/src/routes/collab-context.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,8 @@ export interface RegisterCollabContextRoutesDeps {
173173
context: WorkspaceCollabContext,
174174
) => Promise<CollabCloudMemberDirectoryEntry[]>;
175175
/**
176-
* Legacy local selection store retained for compatibility wiring. Data-plane
177-
* routes do not read or mutate it; each tab carries its exact Workspace and
178-
* member identity on the request.
176+
* Client-local restart default. Data-plane routes never use it as authority;
177+
* each tab continues to carry its exact Workspace and member identity.
179178
*/
180179
activeWorkspace?: {
181180
get(): string | null;
@@ -549,7 +548,21 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
549548
fetchWorkspaceDirectory: async () => directory,
550549
configuredEnv: configuredEnv(),
551550
});
552-
const activeWorkspaceId = claimed.ok ? claimed.context.workspaceId : null;
551+
const savedWorkspaceId = deps.activeWorkspace?.get()?.trim() || null;
552+
const savedWorkspaceIsVisible = Boolean(
553+
savedWorkspaceId
554+
&& items.some(
555+
(item) =>
556+
item.workspaceId === savedWorkspaceId
557+
&& item.memberStatus === 'active'
558+
&& item.lifecycleState !== 'deleted',
559+
),
560+
);
561+
if (savedWorkspaceId && !savedWorkspaceIsVisible) {
562+
await deps.activeWorkspace?.clear().catch(() => undefined);
563+
}
564+
let activeWorkspaceId = savedWorkspaceIsVisible ? savedWorkspaceId : null;
565+
if (claimed.ok) activeWorkspaceId = claimed.context.workspaceId;
553566
const body: WorkspaceDirectoryResponse = { items, activeWorkspaceId };
554567
res.json(body);
555568
});
@@ -588,8 +601,8 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
588601
// Matching on the id alone would let a listed-but-removed membership (or a
589602
// deleted workspace) through, and this entry is also what gets synthesized
590603
// into the response below — so an unfiltered match could describe a
591-
// workspace the caller no longer holds. Same predicate the provider's own
592-
// `resolvePinnedWorkspace` uses.
604+
// workspace the caller no longer holds. This matches the context provider's
605+
// directory-selection predicate.
593606
const selected = directory.find(
594607
(item) =>
595608
item.workspaceId === workspaceId &&
@@ -601,9 +614,10 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
601614
return res.status(404).json({ error: 'workspace_not_visible' });
602615
}
603616

604-
// Choosing a workspace is tab-local. The membership directory above is the
605-
// authorization; neither this compatibility endpoint nor any data-plane
606-
// route writes a daemon-global active Workspace.
617+
// The membership directory above is the authorization. Persist the choice
618+
// only as this client's next-start default; data-plane routes continue to
619+
// require the exact Workspace/member pair on every request, so another tab
620+
// already operating in a different workspace keeps its own scope.
607621
//
608622
// This used to PUT B's account-level active workspace first and fail the
609623
// user's click (502) when that write did not take. That row is keyed by app
@@ -628,6 +642,16 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
628642
return res.status(404).json({ error: 'workspace_no_longer_available' });
629643
}
630644
const resolved = context ?? workspaceContextFromDirectoryItem(selected, configuredEnv());
645+
try {
646+
await deps.activeWorkspace?.set(workspaceId);
647+
} catch {
648+
return sendApiError(
649+
res,
650+
500,
651+
'INTERNAL_ERROR',
652+
'failed to persist the selected workspace',
653+
);
654+
}
631655
// Warm this exact workspace's cold caches before responding, but never
632656
// await them — a slow upstream must not delay the tab-local selection.
633657
deps.onWorkspaceSwitched?.(workspaceId);

apps/daemon/src/routes/collab-presence.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,9 @@ export function createCollabPresenceCloudClient(
307307
const workspaceId =
308308
context?.workspaceId?.trim() || workspaceScopeFor(projectId)?.trim() || '';
309309
if (!workspaceId) {
310-
throw new Error('explicit workspace scope is required');
310+
throw Object.assign(new Error('workspace scope is unavailable'), {
311+
code: 'workspace_scope_unavailable',
312+
});
311313
}
312314
return workspaceId;
313315
};

apps/daemon/tests/collab-presence-routes.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,4 +1225,18 @@ describe('createCollabPresenceCloudClient', () => {
12251225
'leave:p1:ws-for-p1',
12261226
]);
12271227
});
1228+
1229+
it('fails closed instead of issuing a headerless cloud call without a scope', () => {
1230+
const transport = {
1231+
heartbeatPresence: vi.fn(async () => []),
1232+
listPresence: vi.fn(async () => []),
1233+
leavePresence: vi.fn(async () => []),
1234+
};
1235+
const cloud = createCollabPresenceCloudClient(transport, () => undefined);
1236+
1237+
expect(() => cloud!.listPresence('p1')).toThrow(expect.objectContaining({
1238+
code: 'workspace_scope_unavailable',
1239+
}));
1240+
expect(transport.listPresence).not.toHaveBeenCalled();
1241+
});
12281242
});

apps/daemon/tests/collab/active-workspace-selection.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,17 @@ describe('observed active team workspace snapshot', () => {
165165
});
166166

167167
describe('active workspace selection generation', () => {
168+
it('restores the last selection after the store is recreated', async () => {
169+
const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-'));
170+
roots.push(root);
171+
const firstRun = createActiveWorkspaceSelectionStore(root);
172+
173+
await firstRun.set('workspace-last-used');
174+
175+
const restarted = createActiveWorkspaceSelectionStore(root);
176+
expect(restarted.get()).toBe('workspace-last-used');
177+
});
178+
168179
it('notifies subscribers after persisted selection changes', async () => {
169180
const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-'));
170181
roots.push(root);

0 commit comments

Comments
 (0)