Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 83 additions & 215 deletions apps/daemon/src/collab/vela-workspace-context.ts

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions apps/daemon/src/collab/workspace-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import { resolveEffectiveVelaConsoleOrigin } from '../integrations/vela-console-

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

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

/** Fallback billing state derived from lifecycle, used when a dev payload omits
* it. Production always carries B's authoritative `billingState`. */
/** Fallback billing state derived from lifecycle, used when a dev payload or
* membership-directory context does not carry a billing projection. */
function billingStateForLifecycle(lifecycle: WorkspaceLifecycleState): WorkspaceBillingState {
switch (lifecycle) {
case 'active':
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/src/collab/workspace-exact-context-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface Entry {

const DEFAULT_REALTIME_TTL_MS = 5 * 60_000;

/** Exact-workspace cache for Vela's authenticated `/workspaces/current` read. */
/** Exact-workspace cache for an authenticated membership-directory projection. */
export function createWorkspaceExactContextCache(
options: WorkspaceExactContextCacheOptions,
): WorkspaceExactContextCache {
Expand Down
17 changes: 8 additions & 9 deletions apps/daemon/src/collab/workspace-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// call, per the B-line explicit-workspace handoff. B's account-level Active
// Workspace is shared mutable state across a user's devices; a daemon task
// that re-read it per tick could silently flip workspaces mid-flight. The
// client therefore pins its own scope with a fixed priority and only lets the
// server's Active Workspace apply when it genuinely has no opinion:
// client therefore pins its own scope with a fixed priority and never asks the
// server to infer a workspace from account-global state:
//
// 1. `explicit` — the id this specific call was asked to target;
// 2. `projectWorkspaceId` — the workspace a project belongs to (its shared
Expand All @@ -13,19 +13,18 @@
// (workspace-selection.json);
// 4. `envWorkspaceId` — a VELA_WORKSPACE_ID inherited from the spawn
// environment;
// 5. none — send no header; the server resolves its stored
// Active Workspace (`source: 'server-current'`).
// 5. none — unresolved; the caller must fail closed or skip
// the workspace-scoped operation.
//
// The resolver is pure: it never invents an id and never mutates server state
// (resource calls must NOT PUT /workspaces/current — only an explicit user
// switch does).
// The resolver is pure: it never invents an id and never reads or mutates
// server-side workspace-selection state.

export type WorkspaceScopeSource =
| 'explicit'
| 'project'
| 'local-selection'
| 'environment'
| 'server-current';
| 'unresolved';

export interface WorkspaceScope {
workspaceId?: string;
Expand All @@ -48,5 +47,5 @@ export function resolveWorkspaceScope(inputs: WorkspaceScopeInputs): WorkspaceSc
if (local) return { workspaceId: local, source: 'local-selection' };
const env = inputs.envWorkspaceId?.trim();
if (env) return { workspaceId: env, source: 'environment' };
return { source: 'server-current' };
return { source: 'unresolved' };
}
42 changes: 33 additions & 9 deletions apps/daemon/src/routes/collab-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,8 @@ export interface RegisterCollabContextRoutesDeps {
context: WorkspaceCollabContext,
) => Promise<CollabCloudMemberDirectoryEntry[]>;
/**
* Legacy local selection store retained for compatibility wiring. Data-plane
* routes do not read or mutate it; each tab carries its exact Workspace and
* member identity on the request.
* Client-local restart default. Data-plane routes never use it as authority;
* each tab continues to carry its exact Workspace and member identity.
*/
activeWorkspace?: {
get(): string | null;
Expand Down Expand Up @@ -549,7 +548,21 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
fetchWorkspaceDirectory: async () => directory,
configuredEnv: configuredEnv(),
});
const activeWorkspaceId = claimed.ok ? claimed.context.workspaceId : null;
const savedWorkspaceId = deps.activeWorkspace?.get()?.trim() || null;
const savedWorkspaceIsVisible = Boolean(
savedWorkspaceId
&& items.some(
(item) =>
item.workspaceId === savedWorkspaceId
&& item.memberStatus === 'active'
&& item.lifecycleState !== 'deleted',
),
);
if (savedWorkspaceId && !savedWorkspaceIsVisible) {
await deps.activeWorkspace?.clear().catch(() => undefined);
Comment thread
lefarcen marked this conversation as resolved.
Outdated
}
let activeWorkspaceId = savedWorkspaceIsVisible ? savedWorkspaceId : null;
if (claimed.ok) activeWorkspaceId = claimed.context.workspaceId;
const body: WorkspaceDirectoryResponse = { items, activeWorkspaceId };
res.json(body);
});
Expand Down Expand Up @@ -588,8 +601,8 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
// Matching on the id alone would let a listed-but-removed membership (or a
// deleted workspace) through, and this entry is also what gets synthesized
// into the response below — so an unfiltered match could describe a
// workspace the caller no longer holds. Same predicate the provider's own
// `resolvePinnedWorkspace` uses.
// workspace the caller no longer holds. This matches the context provider's
// directory-selection predicate.
const selected = directory.find(
(item) =>
item.workspaceId === workspaceId &&
Expand All @@ -601,9 +614,10 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
return res.status(404).json({ error: 'workspace_not_visible' });
}

// Choosing a workspace is tab-local. The membership directory above is the
// authorization; neither this compatibility endpoint nor any data-plane
// route writes a daemon-global active Workspace.
// The membership directory above is the authorization. Persist the choice
// only as this client's next-start default; data-plane routes continue to
// require the exact Workspace/member pair on every request, so another tab
// already operating in a different workspace keeps its own scope.
//
// This used to PUT B's account-level active workspace first and fail the
// user's click (502) when that write did not take. That row is keyed by app
Expand All @@ -628,6 +642,16 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
return res.status(404).json({ error: 'workspace_no_longer_available' });
}
const resolved = context ?? workspaceContextFromDirectoryItem(selected, configuredEnv());
try {
await deps.activeWorkspace?.set(workspaceId);
Comment thread
lefarcen marked this conversation as resolved.
} catch {
return sendApiError(
res,
500,
'INTERNAL_ERROR',
'failed to persist the selected workspace',
);
}
// Warm this exact workspace's cold caches before responding, but never
// await them — a slow upstream must not delay the tab-local selection.
deps.onWorkspaceSwitched?.(workspaceId);
Expand Down
4 changes: 3 additions & 1 deletion apps/daemon/src/routes/collab-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ export function createCollabPresenceCloudClient(
const workspaceId =
context?.workspaceId?.trim() || workspaceScopeFor(projectId)?.trim() || '';
if (!workspaceId) {
throw new Error('explicit workspace scope is required');
throw Object.assign(new Error('workspace scope is unavailable'), {
code: 'workspace_scope_unavailable',
});
}
return workspaceId;
};
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3436,6 +3436,7 @@ export async function startServer({
const workspaceContext = withLastKnownWorkspaceContext(
createWorkspaceContextProviderFromEnv(process.env, {
configuredEnv: configuredAmrEnv,
fetchWorkspaceDirectory,
getActiveWorkspaceId: () => activeWorkspace.get(),
setLocalSelection: (workspaceId: string) => activeWorkspace.set(workspaceId),
// Only called after the membership directory CONFIRMS the pinned
Expand Down
5 changes: 3 additions & 2 deletions apps/daemon/tests/collab-context-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ describe('collab context routes', () => {
});
});

it('keeps workspace selection request-local and does not mutate the daemon active pin', async () => {
it('persists the restart default after verifying the request-local selection', async () => {
const setActive = vi.fn(async () => {});
const api = await startContextServer({
activeWorkspace: {
Expand Down Expand Up @@ -443,7 +443,8 @@ describe('collab context routes', () => {
workspaceId: 'ws-b',
workspaceMemberId: 'wm-b',
});
expect(setActive).not.toHaveBeenCalled();
expect(setActive).toHaveBeenCalledOnce();
expect(setActive).toHaveBeenCalledWith('ws-b');
});
});

Expand Down
14 changes: 14 additions & 0 deletions apps/daemon/tests/collab-presence-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,4 +1225,18 @@ describe('createCollabPresenceCloudClient', () => {
'leave:p1:ws-for-p1',
]);
});

it('fails closed instead of issuing a headerless cloud call without a scope', () => {
const transport = {
heartbeatPresence: vi.fn(async () => []),
listPresence: vi.fn(async () => []),
leavePresence: vi.fn(async () => []),
};
const cloud = createCollabPresenceCloudClient(transport, () => undefined);

expect(() => cloud!.listPresence('p1')).toThrow(expect.objectContaining({
code: 'workspace_scope_unavailable',
}));
expect(transport.listPresence).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions apps/daemon/tests/collab/active-workspace-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ describe('observed active team workspace snapshot', () => {
});

describe('active workspace selection generation', () => {
it('restores the last selection after the store is recreated', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-'));
roots.push(root);
const firstRun = createActiveWorkspaceSelectionStore(root);

await firstRun.set('workspace-last-used');

const restarted = createActiveWorkspaceSelectionStore(root);
expect(restarted.get()).toBe('workspace-last-used');
});

it('notifies subscribers after persisted selection changes', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-'));
roots.push(root);
Expand Down
Loading
Loading