Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
22 changes: 15 additions & 7 deletions apps/daemon/src/collab/active-workspace-selection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import path from 'node:path';

interface ActiveWorkspaceSelectionFile {
Expand Down Expand Up @@ -87,20 +88,27 @@ export function createActiveWorkspaceSelectionStore(
async set(workspaceId: string) {
const next = workspaceId.trim();
if (!next) throw new Error('workspaceId is required');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.promises.writeFile(
tempPath,
JSON.stringify({ workspaceId: next }, null, 2),
'utf8',
);
await fs.promises.rename(tempPath, filePath);
} catch (error) {
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
cached = next;
generation += 1;
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(
filePath,
JSON.stringify({ workspaceId: next }, null, 2),
'utf8',
);
notify(next);
},
async clear() {
await fs.promises.rm(filePath, { force: true });
cached = null;
generation += 1;
await fs.promises.rm(filePath, { force: true });
notify(null);
},
subscribe(listener) {
Expand Down
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' };
}
55 changes: 43 additions & 12 deletions apps/daemon/src/routes/collab-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
WorkspaceInvalidationSsePayload,
WorkspaceTeamProjectsResponse,
} from '@open-design/contracts';
import { workspaceSeatCapacityState } from '@open-design/contracts';
import {
parseWorkspaceCollabContext,
type WorkspaceContextProvider,
Expand Down Expand Up @@ -173,9 +174,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 @@ -261,15 +261,21 @@ function workspaceGroupProperties(
context: WorkspaceCollabContext,
): Record<string, unknown> {
const planId = context.planId?.trim().toLowerCase();
const seatSummary = context.seatSummary;
const seatState = workspaceSeatCapacityState(seatSummary);
return {
workspace_type: context.workspaceType,
workspace_lifecycle: context.lifecycleState,
billing_state: context.billingState,
plan_bucket: !planId || planId === 'free' ? 'free' : 'paid',
provider_mode: context.providerMode,
seat_limit: context.seatSummary.seatLimit,
member_count: context.seatSummary.usedSeats,
seat_state: context.seatSummary.isSeatFull ? 'full' : 'available',
...(seatState !== 'unknown'
? {
seat_limit: seatSummary.seatLimit,
member_count: seatSummary.usedSeats,
}
: {}),
seat_state: seatState,
};
}

Expand Down Expand Up @@ -549,7 +555,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 +608,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 +621,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 +649,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
85 changes: 83 additions & 2 deletions apps/daemon/tests/collab-context-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
buildWorkspacePermissions,
buildWorkspaceSeatSummary,
Expand All @@ -16,15 +19,18 @@ import {
resolveWorkspaceSettingsUrl,
} from '../src/collab/workspace-context.js';
import { createWorkspaceBillingRuntimeCoordinator } from '../src/collab/workspace-billing-runtime.js';
import { createActiveWorkspaceSelectionStore } from '../src/collab/active-workspace-selection.js';

let server: http.Server | null = null;
const roots: string[] = [];

afterEach(async () => {
if (server) {
const toClose = server;
server = null;
await new Promise<void>((resolve) => toClose.close(() => resolve()));
}
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});

/** The minimal payload a dev/demo run PUTs — only enum + identity fields. */
Expand Down Expand Up @@ -255,6 +261,34 @@ describe('collab context routes', () => {
expect(observeWorkspace.mock.calls[0]?.[2]).not.toHaveProperty('workspaceMemberId');
});

it('observes directory-only seat capacity as unknown without synthetic counts', async () => {
const observeWorkspace = vi.fn();
const api = await startContextServer({
observeWorkspace,
fetchWorkspaceDirectory: async () => ({
ok: true,
items: [TEAM_DIRECTORY_ITEM],
}),
});

const response = await api.req('/api/workspace/context', {
headers: TEAM_HEADERS,
});

expect(response.status).toBe(200);
expect(response.body.context.seatSummary).toMatchObject({
seatLimit: 0,
usedSeats: 0,
});
expect(observeWorkspace).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ workspaceId: 'wm-1' }),
expect.objectContaining({ seat_state: 'unknown' }),
);
expect(observeWorkspace.mock.calls[0]?.[2]).not.toHaveProperty('seat_limit');
expect(observeWorkspace.mock.calls[0]?.[2]).not.toHaveProperty('member_count');
});

it('clears dev enrichment but retains directory-authorized exact context', async () => {
const api = await startContextServer({
fetchWorkspaceDirectory: async () => ({
Expand Down Expand Up @@ -412,7 +446,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 +477,54 @@ describe('collab context routes', () => {
workspaceId: 'ws-b',
workspaceMemberId: 'wm-b',
});
expect(setActive).not.toHaveBeenCalled();
expect(setActive).toHaveBeenCalledOnce();
expect(setActive).toHaveBeenCalledWith('ws-b');
});

it('keeps the previous directory default when selection persistence fails', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-route-'));
roots.push(root);
const activeWorkspace = createActiveWorkspaceSelectionStore(root);
await activeWorkspace.set('ws-a');
await rm(root, { recursive: true });
await writeFile(root, 'not a directory', 'utf8');
const directoryItems = [
{
workspaceId: 'ws-a',
workspaceName: 'Workspace A',
workspaceType: 'team' as const,
workspaceMemberId: 'wm-a',
role: 'member' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
},
{
workspaceId: 'ws-b',
workspaceName: 'Workspace B',
workspaceType: 'team' as const,
workspaceMemberId: 'wm-b',
role: 'owner' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
},
];
const api = await startContextServer({
activeWorkspace,
fetchWorkspaceDirectory: async () => ({ ok: true, items: directoryItems }),
});

const failedSwitch = await api.req('/api/workspace/active', {
method: 'PUT',
body: { workspaceId: 'ws-b', workspaceMemberId: 'wm-b' },
});
const directory = await api.req('/api/workspace/directory');

expect(failedSwitch.status).toBe(500);
expect(activeWorkspace.get()).toBe('ws-a');
expect(directory).toEqual({
status: 200,
body: { items: directoryItems, activeWorkspaceId: 'ws-a' },
});
});
});

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();
});
});
Loading
Loading