diff --git a/application/single_app/config.py b/application/single_app/config.py index 9d6d17910..bfc800eaf 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.305" +VERSION = "0.261.306" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index ec304d463..8d9d4dea8 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -21,6 +21,7 @@ import json import logging +import time import uuid from flask import current_app, jsonify, request, session, url_for @@ -128,6 +129,7 @@ from functions_public_workspaces import ( find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings, + visible_public_workspace_ids_from_user_settings, ) from functions_prompt_variables import ( MAX_REQUEST_BYTES as PROMPT_FILL_MAX_REQUEST_BYTES, @@ -954,53 +956,88 @@ def v2_bootstrap(): if not user_id: return jsonify({"error": "User not authenticated"}), 401 + # Per-section timings, emitted as one trace per request. The Cosmos calls behind + # this payload are instrumented, but most of its time is spent in Python between + # them, which no dependency span covers. + timer = BootstrapPhaseTimer() try: settings = get_settings() + timer.mark("settings") public_settings = sanitize_settings_for_user(settings) + timer.mark("sanitize_settings") user_settings = get_user_settings(user_id) user_settings_dict = ( user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} ) + timer.mark("user_settings") current_user_info = get_current_user_info() or {} session_user = session.get("user") or {} current_user_roles = session_user.get("roles", []) or [] + timer.mark("user_info") + measure = timer.measure per_user_overrides = { - "enable_chat_file_uploads": is_chat_file_upload_enabled_for_user( - settings, current_user_roles + "enable_chat_file_uploads": measure( + "chat_file_uploads", + is_chat_file_upload_enabled_for_user, + settings, + current_user_roles, ), - "allow_user_workflows": is_user_workflows_enabled_for_user( - settings, user_roles=current_user_roles + "allow_user_workflows": measure( + "user_workflows", + is_user_workflows_enabled_for_user, + settings, + user_roles=current_user_roles, ), - "enable_workflow_ai_assistant": is_workflow_assistant_enabled_for_user( - settings, user_roles=current_user_roles + "enable_workflow_ai_assistant": measure( + "workflow_assistant", + is_workflow_assistant_enabled_for_user, + settings, + user_roles=current_user_roles, ), # Only hides Ask AI; each assist route re-checks the scope permission. - "enable_agent_ai_assistant": is_agent_assistant_enabled(settings), - "enable_action_ai_assistant": is_action_assistant_enabled(settings), + "enable_agent_ai_assistant": measure( + "agent_assistant", is_agent_assistant_enabled, settings + ), + "enable_action_ai_assistant": measure( + "action_assistant", is_action_assistant_enabled, settings + ), # Only shows the Review center's AI entry points; every assist and suggestion # route re-checks the toggle and the caller's reviewer role. The guidance text # itself is never sent. - "enable_admin_review_ai_assistant": is_admin_review_assistant_enabled(settings), + "enable_admin_review_ai_assistant": measure( + "admin_review_assistant", is_admin_review_assistant_enabled, settings + ), # Only hides the chip and entry points; the server re-checks every read. - "enable_chat_workflow_results": is_chat_workflow_results_enabled_for_user( - settings, user_roles=current_user_roles + "enable_chat_workflow_results": measure( + "chat_workflow_results", + is_chat_workflow_results_enabled_for_user, + settings, + user_roles=current_user_roles, ), "enable_workflow_alert_sounds": ( settings.get("enable_workflow_alert_sounds", True) is not False ), - "enable_source_review": is_source_review_enabled_for_user( + "enable_source_review": measure( + "source_review", + is_source_review_enabled_for_user, settings, user_id, user_email=current_user_info.get("email"), user_roles=current_user_roles, ), - "enable_url_access": is_url_access_enabled_for_user( - settings, user_roles=current_user_roles + "enable_url_access": measure( + "url_access", + is_url_access_enabled_for_user, + settings, + user_roles=current_user_roles, ), - "enable_conversation_contents_drawer": is_conversation_contents_drawer_enabled( - public_settings, user_settings_dict + "enable_conversation_contents_drawer": measure( + "conversation_contents_drawer", + is_conversation_contents_drawer_enabled, + public_settings, + user_settings_dict, ), } @@ -1014,6 +1051,7 @@ def v2_bootstrap(): ] except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load user groups: {exc}") + timer.mark("groups") public_workspaces = [] try: @@ -1025,6 +1063,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load public workspaces: {exc}") + timer.mark("public_workspaces") agents = [] try: @@ -1041,6 +1080,7 @@ def v2_bootstrap(): ] except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load agent catalog: {exc}") + timer.mark("agent_catalog") models = [] try: @@ -1052,6 +1092,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load model catalog: {exc}") + timer.mark("model_catalog") prompts = [] try: @@ -1063,6 +1104,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load prompt catalog: {exc}") + timer.mark("prompt_catalog") initial_model_selection = None try: @@ -1075,33 +1117,16 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to resolve initial model: {exc}") - - # The stored active scope is only a UI preference, so it is reported back - # rather than used to authorize anything here: the chat endpoints re-derive and - # authorize scope on every request via _get_authorized_chat_scope_context. - # - # It is still validated against the caller's own authorized lists, because a - # group or workspace can be revoked after the preference was saved and echoing - # a stale id would show the user a scope badge they no longer have access to. - # bac-check: ignore - preference read, filtered against the caller's authorized - # group and workspace lists below; not used for an authorization decision. - stored_group_id = user_settings_dict.get("activeGroupOid", "") or None - # bac-check: ignore - preference read, filtered against the caller's visible - # public workspace list below; not used for an authorization decision. - stored_workspace_id = user_settings_dict.get("activePublicWorkspaceOid", "") or None + timer.mark("initial_model") authorized_group_ids = {group["id"] for group in groups} - active_group_id = stored_group_id if stored_group_id in authorized_group_ids else None - active_group_name = None - if active_group_id: - group_doc = find_group_by_id(active_group_id) - if group_doc: - active_group_name = group_doc.get("name", "") - visible_workspace_ids = {workspace["id"] for workspace in public_workspaces} - active_public_workspace_id = ( - stored_workspace_id if stored_workspace_id in visible_workspace_ids else None + active_scope = _resolve_active_scope( + user_settings_dict, + authorized_group_ids, + lambda workspace_id: workspace_id in visible_workspace_ids, ) + timer.mark("active_scope") # Which workspace sections this user may see. Computed server-side because the # answer combines settings, app-role checks and governance policy, and only @@ -1118,6 +1143,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to resolve workspace sections: {exc}") + timer.mark("workspace_sections") # Safety warnings an administrator sent that still need the user's # acknowledgment. Only the count rides here, so the interface asks for the @@ -1132,6 +1158,24 @@ def v2_bootstrap(): extra={"user_id": user_id, "error_type": type(exc).__name__}, level=logging.WARNING, ) + timer.mark("safety_warnings") + + branding = _build_branding(settings, public_settings) + timer.mark("branding") + navigation = _build_navigation(settings, current_user_roles) + timer.mark("navigation") + features = _build_feature_flags(public_settings, per_user_overrides) + timer.mark("features") + capabilities = _build_capabilities(settings) + timer.mark("capabilities") + control_center = get_control_center_capabilities(session_user, settings) + timer.mark("control_center") + orchestration = _build_orchestration(settings, public_settings) + timer.mark("orchestration") + notices = _build_notices(public_settings, user_settings_dict) + timer.mark("notices") + workspace_uploads = _build_workspace_uploads(public_settings) + timer.mark("workspace_uploads") payload = { "version": VERSION, @@ -1146,12 +1190,12 @@ def v2_bootstrap(): "is_admin": "Admin" in current_user_roles, "roles": list(current_user_roles), }, - "branding": _build_branding(settings, public_settings), - "navigation": _build_navigation(settings, current_user_roles), - "features": _build_feature_flags(public_settings, per_user_overrides), - "capabilities": _build_capabilities(settings), - "control_center": get_control_center_capabilities(session_user, settings), - "orchestration": _build_orchestration(settings, public_settings), + "branding": branding, + "navigation": navigation, + "features": features, + "capabilities": capabilities, + "control_center": control_center, + "orchestration": orchestration, "catalogs": { "models": models, "agents": agents, @@ -1159,21 +1203,21 @@ def v2_bootstrap(): "initial_model_selection": initial_model_selection, }, "scope": { - "active_group_id": active_group_id, - "active_group_name": active_group_name, - "active_public_workspace_id": active_public_workspace_id, + **active_scope, "groups": groups, "public_workspaces": public_workspaces, }, "admin_nav": ADMIN_NAV if "Admin" in current_user_roles else [], - "notices": _build_notices(public_settings, user_settings_dict), + "notices": notices, "workspace": workspace, - "workspace_uploads": _build_workspace_uploads(public_settings), + "workspace_uploads": workspace_uploads, "safety_warnings": {"pending": pending_safety_warnings}, "settings": public_settings, } - return jsonify(payload), 200 + response = jsonify(payload) + timer.mark("serialize") + return response, 200 except Exception as exc: log_event( f"[V2_BOOTSTRAP] Failed to build bootstrap payload: {exc}", @@ -1181,6 +1225,135 @@ def v2_bootstrap(): exceptionTraceback=True, ) return jsonify({"error": "Failed to load application bootstrap"}), 500 + finally: + timer.mark("response_tail") + timer.emit() + + @bp.route("/api/v2/scope", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def v2_scope(): + """Return only the caller's active group and public workspace. + + Switching group workspaces changes nothing else in the bootstrap payload, so the + SPA confirms a switch with this read instead of rebuilding every catalog. The + values are validated exactly as the bootstrap validates them, by the same helper. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated"}), 401, {"Cache-Control": "no-store"} + + try: + user_settings = get_user_settings(user_id) + user_settings_dict = ( + user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} + ) + authorized_group_ids = { + group["id"] for group in (get_user_groups(user_id) or []) if group.get("id") + } + + def is_visible_workspace(workspace_id): + visible_ids = visible_public_workspace_ids_from_user_settings(user_settings) + if workspace_id not in visible_ids: + return False + return find_public_workspace_by_id(workspace_id) is not None + + active_scope = _resolve_active_scope( + user_settings_dict, authorized_group_ids, is_visible_workspace + ) + response = jsonify({"user": {"id": user_id}, "scope": active_scope}) + return response, 200, {"Cache-Control": "no-store"} + except Exception as exc: + log_event( + "[V2_SCOPE] Failed to resolve active scope.", + extra={"error_type": type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"error": "Failed to load active scope"}), 503, {"Cache-Control": "no-store"} + + +class BootstrapPhaseTimer: + """Accumulate elapsed milliseconds per named section of one bootstrap request.""" + + def __init__(self, clock=time.perf_counter): + self._clock = clock + self._started = clock() + self._last = self._started + self.phases_ms = {} + + def mark(self, phase): + """Charge the time since the previous mark to ``phase``.""" + now = self._clock() + self.phases_ms[phase] = round( + self.phases_ms.get(phase, 0.0) + (now - self._last) * 1000.0, 1 + ) + self._last = now + + def measure(self, phase, func, *args, **kwargs): + """Call ``func`` and charge its duration to ``phase``, even when it raises.""" + self.mark("_between") + try: + return func(*args, **kwargs) + finally: + self.mark(phase) + + def total_ms(self): + return round((self._clock() - self._started) * 1000.0, 1) + + def emit(self): + """Log the timings as one trace, correlated to the request by operation id. + + ``log_event`` keeps only numeric values from ``extra`` (nested dicts collapse to a + count and strings to a length), so each phase is a flat ``phase__ms`` key. + """ + extra = {"total_ms": self.total_ms()} + for phase, elapsed_ms in self.phases_ms.items(): + if phase != "_between": + extra[f"phase_{phase}_ms"] = elapsed_ms + log_event("[V2_BOOTSTRAP] Phase timings", extra=extra) + + +def _resolve_active_scope(user_settings_dict, authorized_group_ids, is_visible_workspace): + """Report the caller's stored active group and public workspace, if still authorized. + + The stored active scope is only a UI preference, so it is reported back rather than + used to authorize anything: the chat endpoints re-derive and authorize scope on every + request via _get_authorized_chat_scope_context. + + It is still validated against the caller's own authorized lists, because a group or + workspace can be revoked after the preference was saved and echoing a stale id would + show the user a scope badge they no longer have access to. Both the bootstrap and the + scope endpoint use this helper, so the two cannot disagree on a user's active scope. + """ + user_settings_dict = user_settings_dict if isinstance(user_settings_dict, dict) else {} + # bac-check: ignore - preference read, filtered against the caller's authorized + # group list below; not used for an authorization decision. + stored_group_id = user_settings_dict.get("activeGroupOid", "") or None + # bac-check: ignore - preference read, filtered against the caller's visible + # public workspace list below; not used for an authorization decision. + stored_workspace_id = user_settings_dict.get("activePublicWorkspaceOid", "") or None + + active_group_id = stored_group_id if stored_group_id in authorized_group_ids else None + active_group_name = None + if active_group_id: + group_doc = find_group_by_id(active_group_id) + if group_doc: + active_group_name = group_doc.get("name", "") + else: + active_group_id = None + + active_public_workspace_id = ( + stored_workspace_id + if stored_workspace_id and is_visible_workspace(stored_workspace_id) + else None + ) + return { + "active_group_id": active_group_id, + "active_group_name": active_group_name, + "active_public_workspace_id": active_public_workspace_id, + } def _load_global_model_endpoints(settings=None): diff --git a/application/v2_ui/src/lib/apiClient.ts b/application/v2_ui/src/lib/apiClient.ts index f16582a7a..23336054e 100644 --- a/application/v2_ui/src/lib/apiClient.ts +++ b/application/v2_ui/src/lib/apiClient.ts @@ -68,6 +68,11 @@ interface RequestOptions { body?: unknown; signal?: AbortSignal; headers?: Record; + /** + * Fetch cache mode. Preference confirmations use `no-store` so an earlier cached + * response cannot stand in for a fresh server read. + */ + cache?: RequestCache; } /** A bare machine code, one lowercase token such as `document_propagation_incomplete`. */ @@ -187,7 +192,7 @@ export interface ApiResponse { } export async function requestWithStatus(path: string, options: RequestOptions = {}): Promise> { - const { method = 'GET', body, signal, headers = {} } = options; + const { method = 'GET', body, signal, headers = {}, cache } = options; const init: RequestInit = { method, @@ -199,6 +204,10 @@ export async function requestWithStatus(path: string, options: RequestOptions }, }; + if (cache) { + init.cache = cache; + } + if (body !== undefined) { init.headers = { ...init.headers, 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); diff --git a/application/v2_ui/src/lib/endpoints.ts b/application/v2_ui/src/lib/endpoints.ts index 63a0b18e6..a1652873f 100644 --- a/application/v2_ui/src/lib/endpoints.ts +++ b/application/v2_ui/src/lib/endpoints.ts @@ -5,7 +5,7 @@ // route_backend_chats.py, route_backend_documents.py and functions_conversation_feed.py. // Keeping them in one module means a backend path change is a one-line edit here. -import { api, apiUrl, uploadFile, ApiError, API_BASE, CREDENTIALS_MODE } from './apiClient'; +import { api, apiUrl, request, uploadFile, ApiError, API_BASE, CREDENTIALS_MODE } from './apiClient'; import { buildDocumentListParams } from './documentExplorer'; import { artifactDownloadPath, artifactFileName } from './generatedArtifacts'; import { ANALYSIS_PAGE_SIZE, analysisResultContext, validateAnalysisPage } from './savedAnalysis'; @@ -14,6 +14,7 @@ import type { ExportVisualAsset } from './exportVisuals'; import type { GeneratedArtifact, GeneratedRunStatus } from './generatedArtifacts'; import type { MaskAction, MaskedRange, MaskSelection } from './masking'; import type { + ActiveScopePayload, AiNoticeFrequency, BootstrapPayload, ChatMessage, @@ -74,8 +75,16 @@ export async function fetchAnalysisEvidence( /* Bootstrap */ /* -------------------------------------------------------------------------- */ +/* + * Keep these account-specific reads out of the browser's HTTP cache, including any + * same-URL request coalescing while another bootstrap read is in flight. + */ export const fetchBootstrap = (signal?: AbortSignal) => - api.get('/api/v2/bootstrap', signal); + request('/api/v2/bootstrap', { method: 'GET', signal, cache: 'no-store' }); + +/** Only the caller's active group and public workspace, validated as bootstrap does. */ +export const fetchActiveScope = (signal?: AbortSignal) => + request('/api/v2/scope', { method: 'GET', signal, cache: 'no-store' }); /** * Record that the caller has dismissed the AI notice. diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 89624e5e1..ccf9b176a 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1150,6 +1150,19 @@ export interface ControlCenterCapabilities { can_run_maintenance: boolean; } +/** The caller's saved active scope, as bootstrap reports it under `scope`. */ +export interface ActiveScope { + active_group_id: string | null; + active_group_name: string | null; + active_public_workspace_id: string | null; +} + +/** GET /api/v2/scope: the active scope alone, for confirming a workspace switch. */ +export interface ActiveScopePayload { + user: { id: string }; + scope: ActiveScope; +} + export interface BootstrapPayload { version: string; user: { @@ -1219,10 +1232,7 @@ export interface BootstrapPayload { * `useBootstrapStore`. */ orchestration?: OrchestrationBootstrap; - scope: { - active_group_id: string | null; - active_group_name: string | null; - active_public_workspace_id: string | null; + scope: ActiveScope & { groups: WorkspaceRef[]; public_workspaces: WorkspaceRef[]; }; diff --git a/application/v2_ui/src/stores/bootstrapStore.ts b/application/v2_ui/src/stores/bootstrapStore.ts index 6910ef296..b8a8f9d50 100644 --- a/application/v2_ui/src/stores/bootstrapStore.ts +++ b/application/v2_ui/src/stores/bootstrapStore.ts @@ -4,7 +4,7 @@ // from here rather than refetching. import { create } from 'zustand'; -import { fetchBootstrap } from '../lib/endpoints'; +import { fetchActiveScope, fetchBootstrap } from '../lib/endpoints'; import { ApiError, isAccessRestricted, isTermsOfUseRequired } from '../lib/apiClient'; import type { BootstrapPayload, PromptOption } from '../lib/types'; @@ -17,6 +17,37 @@ import type { BootstrapPayload, PromptOption } from '../lib/types'; */ let refreshSequence = 0; +/** + * Bumped whenever `refreshScope` starts or commits. + * + * A workspace switch confirms the new active group with a scope-only read. A full bootstrap + * read that left before that one -- a tab refocus, say -- may have been built before the + * switch was saved, so when it lands it must not put the previous group back. Its scope + * fields are dropped in favour of the ones already in the store; everything else applies. + */ +let scopeSequence = 0; + +/** Install a full payload, keeping the newer active scope if one arrived after it left. */ +function withCurrentScope( + data: BootstrapPayload, + startedAtScope: number, + current: BootstrapPayload | null, +): BootstrapPayload { + if (startedAtScope === scopeSequence || !current?.scope || !data?.scope + || current.user?.id !== data.user?.id) { + return data; + } + return { + ...data, + scope: { + ...data.scope, + active_group_id: current.scope.active_group_id, + active_group_name: current.scope.active_group_name, + active_public_workspace_id: current.scope.active_public_workspace_id, + }, + }; +} + /** * When a bootstrap read last started, and how many are in flight. * @@ -59,6 +90,14 @@ interface BootstrapState { refreshWhenStale: (minIntervalMs: number) => Promise; /** An identity-bound selection must not install a response from a different sign-in. */ refreshRequired: (expectedViewerId?: string) => Promise; + /** + * Re-read only the active group and public workspace, and patch them in place. + * + * Switching group workspaces changes nothing else in the payload, and the full read + * takes seconds to build. Holds the same identity guard as `refreshRequired`, and + * throws if a newer scope read started while this one was running. + */ + refreshScope: (expectedViewerId: string) => Promise; /** * Put a prompt into the catalog straight away, before a refresh has been round-tripped. * @@ -80,9 +119,10 @@ export const useBootstrapStore = create((set, get) => ({ load: async () => { set({ loading: true, error: null, authExpired: false }); const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; try { const data = await fetchBootstrap(); - set({ data, loading: false }); + set({ data: withCurrentScope(data, startedAtScope, get().data), loading: false }); } catch (error) { // The terms gate or the access gate refused the call and apiClient is already // navigating to the page that explains it. Staying on the boot screen avoids @@ -111,10 +151,11 @@ export const useBootstrapStore = create((set, get) => ({ refresh: async () => { const sequence = ++refreshSequence; const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; try { const data = await fetchBootstrap(); if (sequence === refreshSequence) { - set({ data }); + set({ data: withCurrentScope(data, startedAtScope, get().data) }); } } catch { // Advisory on purpose, and the reason this cannot be `load()`. App.tsx @@ -138,6 +179,7 @@ export const useBootstrapStore = create((set, get) => ({ refreshRequired: async (expectedViewerId) => { const sequence = ++refreshSequence; const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; let data: BootstrapPayload; try { data = await fetchBootstrap(); @@ -151,6 +193,51 @@ export const useBootstrapStore = create((set, get) => ({ || data?.user?.id !== expectedViewerId)) { throw new Error('Your sign-in changed during refresh. Reload the workspace.'); } + data = withCurrentScope(data, startedAtScope, get().data); + set({ data }); + return data; + }, + + refreshScope: async (expectedViewerId) => { + const previous = get().data; + if (get().authExpired || previous?.user?.id !== expectedViewerId) { + throw new Error('Your sign-in changed during refresh. Reload the workspace.'); + } + const sequence = ++scopeSequence; + const result = await fetchActiveScope(); + if (sequence !== scopeSequence) { + throw new Error('The active workspace changed during refresh. Try again.'); + } + const current = get().data; + if (!current || get().authExpired || current.user?.id !== expectedViewerId + || result?.user?.id !== expectedViewerId) { + throw new Error('Your sign-in changed during refresh. Reload the workspace.'); + } + const scope = result?.scope; + if (!scope || typeof scope !== 'object' + || ![scope.active_group_id, scope.active_public_workspace_id].every( + (id) => id === null || (typeof id === 'string' && id.trim().length > 0), + ) + || !(scope.active_group_name === null || typeof scope.active_group_name === 'string')) { + throw new Error('The active workspace could not be read.'); + } + if ((['active_group_id', 'active_public_workspace_id'] as const).some( + (key) => current.scope?.[key] !== previous.scope?.[key] + && current.scope?.[key] !== scope[key], + )) { + throw new Error('The active workspace changed during refresh. Try again.'); + } + const data: BootstrapPayload = { + ...current, + scope: { + ...current.scope, + active_group_id: scope.active_group_id, + active_group_name: scope.active_group_name, + active_public_workspace_id: scope.active_public_workspace_id, + }, + }; + // A full read may have started while this scope read was pending. + ++scopeSequence; set({ data }); return data; }, diff --git a/application/v2_ui/src/stores/groupWorkspaceStore.ts b/application/v2_ui/src/stores/groupWorkspaceStore.ts index c6ecb7d17..471032685 100644 --- a/application/v2_ui/src/stores/groupWorkspaceStore.ts +++ b/application/v2_ui/src/stores/groupWorkspaceStore.ts @@ -160,7 +160,9 @@ export const useGroupWorkspaceStore = create((set, get) => await GROUP_WORKSPACES.setActive(groupId); writeAcknowledged = true; assertCurrent(token, viewerId); - const bootstrap = await useBootstrapStore.getState().refreshRequired(viewerId); + // Only the active scope changes on a switch, so it is confirmed with the scope + // read rather than the full bootstrap, which takes seconds to rebuild. + const bootstrap = await useBootstrapStore.getState().refreshScope(viewerId); assertCurrent(token, viewerId); if (bootstrap.scope?.active_group_id !== groupId) { throw new Error('The active group changed during selection.'); @@ -199,7 +201,7 @@ export const useGroupWorkspaceStore = create((set, get) => set({ context: null, loading: true, refreshing: false, needsRevalidation: false, needsReconciliation: true, error: null }); try { // Recovery is read-only: never replay an ambiguously acknowledged mutation. - const bootstrap = await useBootstrapStore.getState().refreshRequired(viewerId); + const bootstrap = await useBootstrapStore.getState().refreshScope(viewerId); assertCurrent(token, viewerId); const groupId = bootstrap.scope?.active_group_id; const context = groupId diff --git a/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md new file mode 100644 index 000000000..25d061e10 --- /dev/null +++ b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md @@ -0,0 +1,127 @@ +# V2 Group Switch Latency Fix + +Fixed in version: **0.261.305** +Response contract hardened in version: **0.261.306** + +Related backend follow-up: [#1725](https://github.com/microsoft/simplechat/issues/1725). +The application version is recorded in `application/single_app/config.py`. + +## Issue and root cause + +Switching group workspaces waited for the complete V2 bootstrap response between +persisting the selection and reading the selected workspace's fresh context. +Production telemetry showed bootstrap taking approximately 5.1 seconds at the +median, compared with roughly 0.1 seconds for saving the selection and 0.15 seconds +for a workspace context read. A switch could therefore wait several seconds for +catalogs and other startup data that the selection itself did not change. + +Some observed switches also coincided with an earlier bootstrap read. Their next +bootstrap request reached the server just after the earlier request finished. +Browser same-URL request coalescing is a possible explanation, not a confirmed +root cause: the telemetry lacks browser queueing information and user/session +identifiers. These timelines cannot prove all neighboring requests belonged to +the same browser. + +## Technical changes + +### Lightweight, server-authoritative confirmation + +`GET /api/v2/scope` returns the authenticated viewer id and three active-scope +fields: `active_group_id`, `active_group_name`, and +`active_public_workspace_id`. It requires a signed-in, unrestricted User or +Admin session and is registered on the existing protected V2 Blueprint. + +The endpoint reads the caller's saved preferences, current groups, and any +selected public workspace. It shares `_resolve_active_scope` with bootstrap, +filters unavailable selections, and returns `Cache-Control: no-store`. +Storage failures return an explicit 503 rather than an empty successful +selection. It sends no application settings or catalog content to the browser. +Success, missing-identity, and storage-failure paths consistently return +`(response, status, headers)`, with unchanged status codes and no-store behavior. + +The group-switch sequence is now: + +1. Confirm that leaving the current page is allowed. +2. Read the target workspace context to validate access. +3. Persist the active group through the existing `PATCH /api/groups/setActive`. +4. Confirm the persisted selection through `GET /api/v2/scope`. +5. Read the target context again before exposing editable workspace resources. + +The preflight and final context reads remain because they validate current access. +A missing write acknowledgement still requires read-only reconciliation and +never causes the client to replay a possibly committed PATCH. + +### Client state and response ordering + +`bootstrapStore.refreshScope` validates the viewer and response shape and changes +only the three active-scope fields, preserving cached catalogs and workspace +lists. The group store uses this action for activation and reconciliation. +Responses from another sign-in or an older scope request cannot install state. +If another refresh has already installed a competing selection while the scope +read was pending, the scope read reports a conflict instead of hiding that change. + +A full bootstrap that overlaps a scope refresh still updates branding, features, +and catalogs, but cannot restore an older active selection when it arrives late. +Bootstrap and scope fetches explicitly use `cache: 'no-store'`; other API requests +retain their existing cache behavior. + +### Bootstrap phase timing + +Each authenticated bootstrap execution emits one `[V2_BOOTSTRAP] Phase timings` +event, including failed executions. `time.perf_counter` measures settings reads +and sanitization, user settings, individual feature predicates, groups, public +workspaces, catalogs, scope resolution, workspace eligibility, safety warnings, +shell projections, and JSON serialization. + +Timings are flat numeric properties because `log_event` reduces nested objects +to counts. Logging adds the `sc_` prefix, producing properties such as +`sc_total_ms` and `sc_phase_model_catalog_ms`. No user identity, settings contents, +catalog entries, or credentials are included in this timing event. + +Example Application Insights query: + +```kusto +traces +| where timestamp > ago(1d) +| where tostring(customDimensions.sc_message) == "[V2_BOOTSTRAP] Phase timings" +| extend total_ms = todouble(customDimensions.sc_total_ms), + models_ms = todouble(customDimensions.sc_phase_model_catalog_ms), + review_ms = todouble(customDimensions.sc_phase_source_review_ms) +| summarize p50_total_ms = percentile(total_ms, 50), + p90_total_ms = percentile(total_ms, 90), + p50_models_ms = percentile(models_ms, 50), + p50_review_ms = percentile(review_ms, 50) +``` + +## Files and impact + +Backend changes are in `route_backend_v2.py` and `config.py`. Frontend changes +are in `apiClient.ts`, `endpoints.ts`, `types.ts`, `bootstrapStore.ts`, and +`groupWorkspaceStore.ts`. The shared legacy active-group PATCH is unchanged. +The production V2 bundle is generated using `npm run build` in +`application/v2_ui`; generated assets remain outside source control. + +First-load bootstrap still builds the complete startup payload. This fix removes +that work from group switching; it does not claim to fix initial-load latency. +The instrumentation informs the broader backend optimization tracked in #1725. + +## Validation + +Functional coverage includes `test_v2_scope_and_bootstrap_timings.py`, +`test_v2_bootstrap_refresh_logic.mjs`, and +`test_v2_group_workspace_context_logic.mjs`. Cases cover response shape, authorized +scope filtering, explicit storage failure, anonymous access, flat numeric timings, +overlapping bootstrap/scope reads, sign-in changes, cancellation, competing +selections, revoked access, and read-only recovery. + +`ui_tests/test_v2_group_workspace_shell.py` exercises the real built SPA with +isolated HTTP fixtures. Desktop and mobile switch checks assert that a scope +confirmation replaces the extra bootstrap read, and recovery checks ensure an +ambiguous switch does not replay the PATCH. The existing route policy tests cover +the new endpoint's authenticated Blueprint and user-session requirements. + +The removed bootstrap wait suggests a substantial switch improvement, but the +approximately one-second estimate is not a measured production result. After +deployment, compare repeated stopwatch and DevTools measurements, scope endpoint +latency, and bootstrap phase timings. Live Azure latency and first-load LCP are +not validated by isolated functional or browser tests. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 62012a6f8..6c608129d 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,24 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.306)** + +#### Bug Fixes + +* **Consistent V2 Scope Response Contract** + * Scope confirmation now uses the same response tuple format for successful reads and error paths, resolving the CodeQL mixed-tuple-returns finding without changing status codes, response bodies, or no-store behavior. + * (Ref: `route_backend_v2.py` `v2_scope`, `test_v2_scope_and_bootstrap_timings.py`) + +### **(v0.261.305)** + +#### Bug Fixes + +* **V2 Group Switching No Longer Rebuilds All Startup Data** + * A group switch now confirms the saved selection through a lightweight scope read instead of waiting for the complete startup payload. Current-access checks, sign-in checks, and read-only recovery after an uncertain save remain in place. + * An overlapping startup refresh cannot restore the previous selection. Startup and scope reads bypass the browser's HTTP cache. + * Startup requests now report per-phase timing so the remaining first-load bottleneck can be measured; first-load optimization is a separate follow-up. + * (Ref: `route_backend_v2.py`, `bootstrapStore.ts`, `groupWorkspaceStore.ts`, [V2 Group Switch Latency Fix](fixes/V2_GROUP_SWITCH_LATENCY_FIX.md), [#1725](https://github.com/microsoft/simplechat/issues/1725)) + ### **(v0.261.304)** #### Bug Fixes diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 67868118c..24a0b2d2c 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -287,6 +287,7 @@ Last inventoried: 2026-08-10 - `[XSD_GENERATION]` - `[XSD_INGESTION]` - `[V2_BOOTSTRAP]` +- `[V2_SCOPE]` - `[VIDEO]` - `[VIDEO_CHUNK]` - `[VIDEO_INDEXER]` diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index d126dba5a..92ff4d304 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for route blueprint policy inventory. -Version: 0.261.299 +Version: 0.261.305 Implemented in: 0.242.069 Plan editor policy coverage: 0.261.102 Selected-group context policy coverage: 0.261.126 @@ -355,6 +355,7 @@ # The warned user's own safety warnings: a user session, deliberately not gated on the # content checks report, so a warning already sent stays acknowledgeable. ("route_backend_safety.py", "get_pending_safety_warnings"): ("login_required", "user_required"), + ("route_backend_v2.py", "v2_scope"): ("login_required", "user_required"), ("route_backend_safety.py", "acknowledge_pending_safety_warning"): ("login_required", "user_required"), # The Review center: each section's reviewer role and its feature gate, like the # single-record review routes they sit beside. diff --git a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py index 32204299e..ca6647958 100644 --- a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py +++ b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py @@ -2,7 +2,7 @@ # test_route_unauthenticated_policy_contract.py """ Functional test for route unauthenticated access policy contract. -Version: 0.261.299 +Version: 0.261.305 Implemented in: 0.242.069 Workflow result context coverage: 0.261.214 Workflow run status coverage: 0.261.227 @@ -288,6 +288,15 @@ def test_workflow_result_context_requires_a_signed_in_user_session() -> None: assert {"login_required", "user_required"} <= set(matches[0].decorator_names) +def test_v2_scope_requires_a_signed_in_user_session() -> None: + """The lightweight scope confirmation is never public or bearer-only.""" + path = "/api/v2/scope" + matches = [route for route in iter_route_functions() if route.path == path] + assert [route.function_name for route in matches] == ["v2_scope"] + assert expected_policy(path) == "session_user_401_or_redirect" + assert {"swagger_route", "login_required", "user_required"} <= set(matches[0].decorator_names) + + def test_workflow_run_status_requires_a_signed_in_user_session() -> None: """The run card's status rows are never public or bearer-only. diff --git a/functional_tests/test_v2_admin_settings_live_shell_refresh.py b/functional_tests/test_v2_admin_settings_live_shell_refresh.py index c359f86df..dee3b59b6 100644 --- a/functional_tests/test_v2_admin_settings_live_shell_refresh.py +++ b/functional_tests/test_v2_admin_settings_live_shell_refresh.py @@ -1,8 +1,9 @@ +# test_v2_admin_settings_live_shell_refresh.py #!/usr/bin/env python3 """ Functional test for the V2 interface refreshing its shell after an admin save. -Version: 0.261.046 +Version: 0.261.305 Implemented in: 0.261.046 An administrator enabled the classification banner in the V2 admin settings, saved, and @@ -78,7 +79,10 @@ def test_the_store_can_reread_bootstrap(): "classification banner exists at all -- it is emitted only when enabled and " "non-empty -- and applies its colour defaults, so the browser cannot re-derive it" ) - assert "set({ data })" in body, "The refresh must apply the payload it fetched" + assert "set({ data: withCurrentScope(data," in body, ( + "The refresh must apply its fetched branding and catalogs while preserving " + "a newer active workspace selection" + ) print("Bootstrap refresh action test passed!") return True diff --git a/functional_tests/test_v2_bootstrap_branding_and_navigation.py b/functional_tests/test_v2_bootstrap_branding_and_navigation.py index 87c310030..3af478223 100644 --- a/functional_tests/test_v2_bootstrap_branding_and_navigation.py +++ b/functional_tests/test_v2_bootstrap_branding_and_navigation.py @@ -2,7 +2,7 @@ # test_v2_bootstrap_branding_and_navigation.py """ Functional test for the V2 bootstrap branding and navigation blocks. -Version: 0.261.296 +Version: 0.261.305 Implemented in: 0.261.047 Send Feedback navigation entry added in: 0.261.296 @@ -460,7 +460,10 @@ def test_bootstrap_payload_carries_the_navigation_block(): source = BACKEND_V2.read_text(encoding="utf-8") - assert '"navigation": _build_navigation(settings, current_user_roles)' in source, ( + assert 'navigation = _build_navigation(settings, current_user_roles)' in source, ( + "The timed navigation builder must still use settings and the caller's roles" + ) + assert '"navigation": navigation' in source, ( "The bootstrap payload must carry a navigation block built from the caller's " "roles, or Custom Pages and External Links cannot appear in the V2 rail" ) diff --git a/functional_tests/test_v2_bootstrap_refresh_logic.mjs b/functional_tests/test_v2_bootstrap_refresh_logic.mjs index 47035f178..ed8fd60f1 100644 --- a/functional_tests/test_v2_bootstrap_refresh_logic.mjs +++ b/functional_tests/test_v2_bootstrap_refresh_logic.mjs @@ -1,9 +1,10 @@ // test_v2_bootstrap_refresh_logic.mjs // // Runtime test for the V2 bootstrap store's refresh action. -// Version: 0.261.096 +// Version: 0.261.305 // Implemented in: 0.261.046 // Required authoring selection refresh added in: 0.261.096 +// Scope-only refresh for group switching added in: 0.261.305 (#1725) // // The companion test, test_v2_admin_settings_live_shell_refresh.py, asserts that the pieces // are wired together. Those are source assertions: they prove the call exists, not that it @@ -301,6 +302,108 @@ async function testSupersededRequiredRefreshCannotAuthorizeFromAnOlderResult() { assert.equal(useBootstrapStore.getState().data, latest); } +async function testScopeRefreshPatchesOnlyTheActiveScope() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', active_group_name: 'One', active_public_workspace_id: null, groups: [{ id: 'g1' }, { id: 'g2' }], public_workspaces: [] }; + loaded.catalogs = { agents: [{ id: 'kept' }] }; + let seen; + fetchImpl = async (path, init) => { + seen = { path: String(path), cache: init?.cache }; + return jsonResponse({ user: { id: 'u1' }, scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null } }); + }; + const result = await useBootstrapStore.getState().refreshScope('u1'); + assert.equal(seen.path, '/api/v2/scope'); + assert.equal(seen.cache, 'no-store', 'A group switch must not queue behind a cached bootstrap read'); + assert.equal(result.scope.active_group_id, 'g2'); + assert.equal(result.scope.active_group_name, 'Two'); + assert.equal(result.scope.groups, loaded.scope.groups); + assert.equal(result.catalogs, loaded.catalogs); + assert.equal(result.branding, loaded.branding); + assert.equal(useBootstrapStore.getState().data, result); +} + +async function testScopeRefreshRejectsAnotherViewer() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + fetchImpl = async () => jsonResponse({ user: { id: 'someone-else' }, scope: { active_group_id: 'g2' } }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /sign-in changed/); + await assert.rejects(useBootstrapStore.getState().refreshScope('not-u1'), /sign-in changed/); + fetchImpl = async () => jsonResponse({ user: { id: 'u1' } }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /could not be read/); + assert.equal(useBootstrapStore.getState().data, loaded); +} + +async function testSupersededScopeRefreshCannotApply() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let release; + fetchImpl = () => new Promise((resolve) => { release = resolve; }); + const older = useBootstrapStore.getState().refreshScope('u1'); + const releaseOlder = release; + fetchImpl = async () => jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g3', active_group_name: 'Three', active_public_workspace_id: null }, + }); + await useBootstrapStore.getState().refreshScope('u1'); + releaseOlder(jsonResponse({ user: { id: 'u1' }, scope: { active_group_id: 'g2' } })); + await assert.rejects(older, /active workspace changed/); + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'g3'); +} + +async function testMalformedScopeIsNotAnEmptySelection() { + const loaded = (await loadWith(BANNER_ON)).data; + for (const scope of [ + {}, { active_group_id: 12, active_group_name: null, active_public_workspace_id: null }, + { active_group_id: '', active_group_name: null, active_public_workspace_id: null }, + { active_group_id: null, active_group_name: {}, active_public_workspace_id: null }, + ]) { + fetchImpl = async () => jsonResponse({ user: { id: 'u1' }, scope }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /could not be read/); + assert.equal(useBootstrapStore.getState().data, loaded); + } +} + +async function testBootstrapStartedDuringScopeRefreshCannotRestoreOldSelection() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let releaseScope; + let releaseBootstrap; + fetchImpl = (path) => new Promise((resolve) => { + if (String(path) === '/api/v2/scope') releaseScope = resolve; + else releaseBootstrap = resolve; + }); + const scopeRead = useBootstrapStore.getState().refreshScope('u1'); + const fullRead = useBootstrapStore.getState().refresh(); + releaseScope(jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null }, + })); + await scopeRead; + const stale = payloadWithBanner(BANNER_OFF); + stale.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + releaseBootstrap(jsonResponse(stale)); + await fullRead; + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'g2'); + assert.equal(useBootstrapStore.getState().data.branding.classification_banner, BANNER_OFF); +} + +async function testScopeRefreshCannotHideACompetingSelectionAlreadyInstalled() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let releaseScope; + fetchImpl = () => new Promise((resolve) => { releaseScope = resolve; }); + const scopeRead = useBootstrapStore.getState().refreshScope('u1'); + const competing = payloadWithBanner(BANNER_ON); + competing.scope = { active_group_id: 'g3', active_public_workspace_id: null }; + useBootstrapStore.setState({ data: competing }); + releaseScope(jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null }, + })); + await assert.rejects(scopeRead, /active workspace changed/); + assert.equal(useBootstrapStore.getState().data, competing); +} + const tests = [ testRefreshAppliesTheNewPayload, testRefreshNeverBlanksTheInterface, @@ -310,6 +413,12 @@ const tests = [ testRequiredRefreshReturnsFreshDataWithoutBlanking, testRequiredRefreshRejectsInsteadOfReturningCachedAgents, testSupersededRequiredRefreshCannotAuthorizeFromAnOlderResult, + testScopeRefreshPatchesOnlyTheActiveScope, + testScopeRefreshRejectsAnotherViewer, + testSupersededScopeRefreshCannotApply, + testMalformedScopeIsNotAnEmptySelection, + testBootstrapStartedDuringScopeRefreshCannotRestoreOldSelection, + testScopeRefreshCannotHideACompetingSelectionAlreadyInstalled, ]; let passed = 0; diff --git a/functional_tests/test_v2_group_workspace_context_logic.mjs b/functional_tests/test_v2_group_workspace_context_logic.mjs index f447eee1b..d2a1405e6 100644 --- a/functional_tests/test_v2_group_workspace_context_logic.mjs +++ b/functional_tests/test_v2_group_workspace_context_logic.mjs @@ -1,10 +1,11 @@ // test_v2_group_workspace_context_logic.mjs -// Version: 0.261.296 +// Version: 0.261.305 // Implemented in: 0.261.126 // Shared shell navigation and revalidation: 0.261.127 // Members section validation (M7B): 0.261.155 // Workspace navigation URL validation: 0.261.281 // User Settings Open path for a group row: 0.261.296 +// Switch confirmed by the scope-only read, not the full bootstrap (#1725): 0.261.305 // Executes the real context API and stores with controlled HTTP ordering. import assert from 'node:assert/strict'; @@ -68,6 +69,17 @@ function bootstrap(viewer = serverViewer, active = activeGroup) { }; } +function activeScope(viewer = serverViewer, active = activeGroup) { + return { + user: { id: viewer }, + scope: { + active_group_id: active, + active_group_name: active ? `Name ${active}` : null, + active_public_workspace_id: null, + }, + }; +} + function json(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } @@ -81,6 +93,7 @@ function defaultResponse(call) { return json({ message: 'Active group updated' }); } if (call.path === '/api/v2/bootstrap') return json(bootstrap()); + if (call.path === '/api/v2/scope') return json(activeScope()); throw new Error(`Unexpected request: ${call.method} ${call.path}`); } @@ -101,7 +114,10 @@ async function run(name, check) { } globalThis.fetch = async (path, options = {}) => { - const call = { path: String(path), method: options.method ?? 'GET', body: options.body ? JSON.parse(options.body) : null, signal: options.signal }; + const call = { + path: String(path), method: options.method ?? 'GET', + body: options.body ? JSON.parse(options.body) : null, signal: options.signal, cache: options.cache, + }; calls.push(call); return handler(call); }; @@ -229,7 +245,7 @@ try { await assert.rejects(loading); assert.equal(useGroupWorkspaceStore.getState().context, null); }); - await run('activation confirms drafts, authorization, persistence, catalogs and fresh context in order', async () => { + await run('activation confirms drafts, authorization, persistence, scope and fresh context in order', async () => { await useGroupWorkspaceStore.getState().load('group-a'); calls = []; let confirmed = false; @@ -242,11 +258,53 @@ try { assert.equal(result.context.scope.id, 'group-b'); assert.deepEqual(calls.map(({ method, path }) => `${method} ${path}`), [ 'GET /api/v2/workspaces/group/group-b', 'PATCH /api/groups/setActive', - 'GET /api/v2/bootstrap', 'GET /api/v2/workspaces/group/group-b', + 'GET /api/v2/scope', 'GET /api/v2/workspaces/group/group-b', ]); assert.deepEqual(calls[1].body, { groupId: 'group-b' }); assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'group-b'); }); + await run('activation patches only the active scope, read outside the HTTP cache', async () => { + const before = useBootstrapStore.getState().data; + before.features = { enable_example: true }; + before.scope.groups = [{ id: 'group-a', name: 'A' }, { id: 'group-b', name: 'B' }]; + const result = await useGroupWorkspaceStore.getState().activate('group-b', () => true); + assert.equal(result.status, 'activated'); + assert.equal(calls.some((call) => call.path === '/api/v2/bootstrap'), false); + const scopeRead = calls.find((call) => call.path === '/api/v2/scope'); + assert.equal(scopeRead.cache, 'no-store'); + const after = useBootstrapStore.getState().data; + assert.equal(after.scope.active_group_id, 'group-b'); + assert.equal(after.scope.active_group_name, 'Name group-b'); + assert.equal(after.features, before.features); + assert.equal(after.scope.groups, before.scope.groups); + }); + await run('a full bootstrap read issued before a switch cannot restore the previous group', async () => { + const stale = deferred(); + const entered = deferred(); + handler = (call) => { + if (call.path === '/api/v2/bootstrap') { + entered.resolve(); + return stale.promise; + } + return defaultResponse(call); + }; + // A tab refocus starts a full read built while group-a is still the saved group. + const background = useBootstrapStore.getState().refresh(); + await entered.promise; + assert.equal(calls[0].cache, 'no-store'); + const result = await useGroupWorkspaceStore.getState().activate('group-b', () => true); + assert.equal(result.status, 'activated'); + const landed = bootstrap('viewer', 'group-a'); + landed.features = { enable_late: true }; + stale.resolve(json(landed)); + await background; + const data = useBootstrapStore.getState().data; + assert.equal(data.scope.active_group_id, 'group-b'); + assert.deepEqual(data.features, { enable_late: true }); + handler = defaultResponse; + await useBootstrapStore.getState().refresh(); + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'group-b'); + }); await run('cancelled draft guard performs no request and preserves prior context', async () => { const previous = await useGroupWorkspaceStore.getState().load('group-a'); calls = []; @@ -277,7 +335,7 @@ try { assert.equal(useGroupWorkspaceStore.getState().context, previous); assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); assert.equal(activeGroup, 'group-a'); - assert.equal(calls.some((call) => call.path === '/api/v2/bootstrap'), false); + assert.equal(calls.some((call) => /^\/api\/v2\/(bootstrap|scope)$/.test(call.path)), false); }); } await run('lost write acknowledgement requires read-only reconciliation, never a replay', async () => { @@ -300,7 +358,7 @@ try { assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); }); await run('failed mandatory refresh cannot reuse cached bootstrap authorization', async () => { - handler = (call) => call.path === '/api/v2/bootstrap' + handler = (call) => call.path === '/api/v2/scope' ? json({ error: 'unavailable' }, 503) : defaultResponse(call); const switching = useGroupWorkspaceStore.getState().activate('group-b', () => true); await assert.rejects(switching, /could not be confirmed/); @@ -324,7 +382,7 @@ try { }); await run('a competing tab selection is reported rather than overwritten or hidden', async () => { handler = (call) => { - if (call.path === '/api/v2/bootstrap') activeGroup = 'group-c'; + if (call.path === '/api/v2/scope') activeGroup = 'group-c'; return defaultResponse(call); }; const switching = useGroupWorkspaceStore.getState().activate('group-b', () => true); @@ -375,7 +433,7 @@ try { const refresh = deferred(); const entered = deferred(); handler = (call) => { - if (call.path === '/api/v2/bootstrap') { + if (call.path === '/api/v2/scope') { entered.resolve(); return refresh.promise; } @@ -385,7 +443,7 @@ try { const rejected = assert.rejects(switching, WorkspaceRequestSuperseded); await entered.promise; useBootstrapStore.setState({ data: bootstrap('other', 'group-c') }); - refresh.resolve(json(bootstrap('viewer', 'group-b'))); + refresh.resolve(json(activeScope('viewer', 'group-b'))); await rejected; assert.equal(useBootstrapStore.getState().data.user.id, 'other'); assert.equal(useGroupWorkspaceStore.getState().context, null); @@ -415,7 +473,7 @@ try { const result = await useGroupWorkspaceStore.getState().reconcile(); assert.equal(result, null); assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); - assert.deepEqual(calls.map((call) => call.path), ['/api/v2/bootstrap']); + assert.deepEqual(calls.map((call) => call.path), ['/api/v2/scope']); }); await run('native delegation remains navigable without claiming full action authoring is enabled', async () => { const value = context('group-a'); diff --git a/functional_tests/test_v2_scope_and_bootstrap_timings.py b/functional_tests/test_v2_scope_and_bootstrap_timings.py new file mode 100644 index 000000000..df87c7004 --- /dev/null +++ b/functional_tests/test_v2_scope_and_bootstrap_timings.py @@ -0,0 +1,218 @@ +# test_v2_scope_and_bootstrap_timings.py +""" +Functional contracts for the lightweight V2 active-scope read and phase timings. + +Version: 0.261.306 +Implemented in: 0.261.305 + +The real route bodies, scope helper, timer, and authentication decorators execute +against isolated storage seams, without importing Azure client initialization. +This validates request behavior, not live Azure latency or cold-import safety. +""" + +import ast +import logging +import time +from functools import wraps +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from flask import Blueprint, Flask, jsonify, request, session + +from test_support.agent_delegation import APP_ROOT, execute_functions +from test_support.versioning import assert_app_version_at_least + + +@pytest.fixture +def environment(): + groups = [{"id": "group-a", "name": "Group A"}] + preferences = { + "activeGroupOid": "group-a", "activePublicWorkspaceOid": "public-a", + "publicDirectorySettings": {"public-a": True, "hidden": False}, + "private_key": "fixture-private-settings", + } + logs = Mock() + app = Flask("scope_and_timings") + app.config.update(TESTING=True, SECRET_KEY="fixture-only-signing") + bp = Blueprint("backend_v2", __name__) + namespace = { + "__name__": __name__, "bp": bp, "jsonify": jsonify, "request": request, + "session": session, "wraps": wraps, "logging": logging, "time": time, + "VERSION": "0.261.306", "log_event": logs, "debug_print": Mock(), + "logger": logging.getLogger(__name__), + "get_auth_security": lambda: [{"sessionAuth": []}], + "swagger_route": lambda **kwargs: lambda function: function, + "check_user_access_status": Mock(return_value=(True, None)), + "get_settings": Mock(return_value={"private_key": "fixture-private-settings"}), + "sanitize_settings_for_user": Mock(return_value={"enable_group_workspaces": True}), + "get_user_settings": Mock(side_effect=lambda user_id: {"settings": preferences}), + "get_current_user_info": lambda: {"userId": session.get("user", {}).get("oid")}, + "get_user_groups": Mock(side_effect=lambda user_id: groups), + "find_group_by_id": Mock(side_effect=lambda group_id: next( + (group for group in groups if group["id"] == group_id), None, + )), + "find_public_workspace_by_id": Mock(side_effect=lambda workspace_id: + {"id": workspace_id, "name": "Public A"} + if workspace_id == "public-a" else None), + "get_all_public_workspaces": Mock(return_value=[{"id": "public-a"}]), + "get_user_visible_public_workspace_ids_from_settings": Mock(return_value=["public-a"]), + "build_accessible_agent_catalog": Mock(return_value=[]), + "_is_chat_agent_allowed_by_governance": Mock(return_value=True), + "_build_chat_model_catalog": Mock(return_value=[]), + "_build_chat_prompt_catalog": Mock(return_value=[]), + "_build_initial_chat_model_selection": Mock(return_value=None), + "build_workspace_section_availability": Mock(return_value={"enabled": False, "sections": {}}), + "count_pending_safety_warnings": Mock(return_value=0), + "ADMIN_NAV": [], + } + for name in ( + "is_chat_file_upload_enabled_for_user", "is_user_workflows_enabled_for_user", + "is_workflow_assistant_enabled_for_user", "is_agent_assistant_enabled", + "is_action_assistant_enabled", "is_admin_review_assistant_enabled", + "is_chat_workflow_results_enabled_for_user", "is_source_review_enabled_for_user", + "is_url_access_enabled_for_user", "is_conversation_contents_drawer_enabled", + ): + namespace[name] = Mock(return_value=True) + for name in ( + "_build_branding", "_build_navigation", "_build_feature_flags", "_build_capabilities", + "get_control_center_capabilities", "_build_orchestration", "_build_notices", + "_build_workspace_uploads", + ): + namespace[name] = Mock(return_value={}) + execute_functions("functions_authentication.py", { + "get_current_user_id", "login_required", "user_required", + }, namespace) + execute_functions("functions_public_workspaces.py", { + "visible_public_workspace_ids_from_user_settings", + }, namespace) + tree = ast.parse((APP_ROOT / "route_backend_v2.py").read_text(encoding="utf-8")) + wanted = {"BootstrapPhaseTimer", "_resolve_active_scope", "v2_scope", "v2_bootstrap"} + nodes = [node for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in wanted] + assert {node.name for node in nodes} == wanted + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(APP_ROOT / "route_backend_v2.py"), "exec"), namespace) + app.register_blueprint(bp) + client = app.test_client() + with client.session_transaction() as state: + state["user"] = {"oid": "viewer", "roles": ["User"]} + return SimpleNamespace( + client=client, namespace=namespace, preferences=preferences, groups=groups, logs=logs, + ) + + +def test_scope_is_private_uncached_and_does_not_build_catalogs(environment): + assert_app_version_at_least("0.261.305") + response = environment.client.get("/api/v2/scope") + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.get_json() == { + "user": {"id": "viewer"}, + "scope": { + "active_group_id": "group-a", "active_group_name": "Group A", + "active_public_workspace_id": "public-a", + }, + } + assert "fixture-private-settings" not in response.get_data(as_text=True) + environment.namespace["get_user_settings"].assert_called_once_with("viewer") + for name in ("get_settings", "_build_chat_model_catalog", "_build_chat_prompt_catalog", + "build_accessible_agent_catalog", "sanitize_settings_for_user"): + environment.namespace[name].assert_not_called() + + +@pytest.mark.parametrize("group_id,workspace_id", [ + ("revoked-group", "hidden"), ("group-a", "missing-public"), (None, None), +]) +def test_stale_preferences_are_filtered_and_match_bootstrap(environment, group_id, workspace_id): + environment.preferences.update(activeGroupOid=group_id, activePublicWorkspaceOid=workspace_id) + scope_response = environment.client.get("/api/v2/scope") + bootstrap_response = environment.client.get("/api/v2/bootstrap") + assert scope_response.status_code == bootstrap_response.status_code == 200 + active = scope_response.get_json()["scope"] + full = bootstrap_response.get_json()["scope"] + assert active == {key: full[key] for key in active} + assert active["active_group_id"] == ("group-a" if group_id == "group-a" else None) + assert active["active_public_workspace_id"] is None + + +def test_deleted_active_group_is_not_echoed(environment): + environment.namespace["find_group_by_id"].return_value = None + environment.namespace["find_group_by_id"].side_effect = None + response = environment.client.get("/api/v2/scope") + assert response.status_code == 200 + assert response.get_json()["scope"]["active_group_id"] is None + + +def test_scope_failures_are_explicit_and_do_not_expose_provider_errors(environment): + environment.namespace["get_user_groups"].side_effect = RuntimeError("fixture-private-provider-error") + response = environment.client.get("/api/v2/scope") + assert response.status_code == 503 + assert response.headers["Cache-Control"] == "no-store" + assert response.get_json() == {"error": "Failed to load active scope"} + assert environment.logs.call_args.kwargs["extra"] == {"error_type": "RuntimeError"} + + +def test_scope_requires_a_signed_in_user(environment): + with environment.client.session_transaction() as state: + state.clear() + response = environment.client.get("/api/v2/scope") + assert response.status_code == 401 + environment.namespace["get_user_settings"].assert_not_called() + + +@pytest.mark.parametrize("outcome,expected_status", [ + ("success", 200), ("missing_identity", 401), ("storage_failure", 503), +]) +def test_scope_body_returns_consistent_response_tuples(environment, outcome, expected_status): + """All scope body return paths preserve response, status, and no-store headers.""" + route = unwrap(environment.namespace["v2_scope"]) + with environment.client.application.test_request_context("/api/v2/scope"): + if outcome != "missing_identity": + session["user"] = {"oid": "viewer", "roles": ["User"]} + if outcome == "storage_failure": + environment.namespace["get_user_groups"].side_effect = RuntimeError("fixture failure") + result = route() + assert isinstance(result, tuple) + assert len(result) == 3 + response, status, headers = result + assert response.is_json + assert status == expected_status + assert headers == {"Cache-Control": "no-store"} + + +@pytest.mark.parametrize("fails", [False, True]) +def test_bootstrap_emits_one_flat_numeric_timing_event_even_on_failure(environment, fails): + if fails: + environment.namespace["get_settings"].side_effect = RuntimeError("fixture failure") + response = environment.client.get("/api/v2/bootstrap") + assert response.status_code == (500 if fails else 200) + timings = [call for call in environment.logs.call_args_list + if call.args[0] == "[V2_BOOTSTRAP] Phase timings"] + assert len(timings) == 1 + properties = timings[0].kwargs["extra"] + assert properties["total_ms"] >= 0 + assert all(isinstance(value, (int, float)) and value >= 0 for value in properties.values()) + if not fails: + assert {"phase_settings_ms", "phase_source_review_ms", "phase_model_catalog_ms", + "phase_serialize_ms", "phase_active_scope_ms"} <= properties.keys() + environment.namespace["_build_navigation"].assert_called_once_with( + environment.namespace["get_settings"].return_value, ["User"], + ) + assert response.get_json()["settings"] == {"enable_group_workspaces": True} + + +def test_timer_measures_elapsed_work_and_failed_calls(environment): + ticks = iter([0.0, 0.2, 0.3, 0.7, 0.8]) + timer = environment.namespace["BootstrapPhaseTimer"](clock=lambda: next(ticks)) + timer.mark("settings") + failing = Mock(side_effect=ValueError("fixture failure")) + with pytest.raises(ValueError): + timer.measure("failed_catalog", failing) + timer.emit() + properties = environment.logs.call_args.kwargs["extra"] + assert properties == {"total_ms": 800.0, "phase_settings_ms": 200.0, "phase_failed_catalog_ms": 400.0} + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/fixtures/group_workspace.py b/ui_tests/fixtures/group_workspace.py index 99c34b2fc..b56bab5cc 100644 --- a/ui_tests/fixtures/group_workspace.py +++ b/ui_tests/fixtures/group_workspace.py @@ -1,7 +1,7 @@ # group_workspace.py """ Closed HTTP fixtures for the real V2 group workspace shell. -Version: 0.261.178 +Version: 0.261.305 Implemented in: 0.261.127 Members section in the group context (M7B): 0.261.155 File source credential identifiers modelled as `_prepare_auth_payload` stores them: 0.261.156 @@ -1458,6 +1458,17 @@ def _route(self, route): def _dispatch(self, route, entry): path, method = entry.path, entry.method + if path == "/api/v2/scope" and method == "GET": + group = self.groups.get(self.active_group) + self._json(route, { + "user": {"id": self.viewer_id}, + "scope": { + "active_group_id": self.active_group if group else None, + "active_group_name": group["workspace"]["name"] if group else None, + "active_public_workspace_id": None, + }, + }) + return leak = personal_scope_leak(path, entry.query) if leak: # A group workspace page must never read a personal-scope resource. The M4 group action diff --git a/ui_tests/test_v2_group_workspace_shell.py b/ui_tests/test_v2_group_workspace_shell.py index 49d35a1a5..4b2af4011 100644 --- a/ui_tests/test_v2_group_workspace_shell.py +++ b/ui_tests/test_v2_group_workspace_shell.py @@ -1,7 +1,7 @@ # test_v2_group_workspace_shell.py """ Real-SPA group selection, navigation, scope, and draft safety. -Version: 0.261.271 +Version: 0.261.305 Implemented in: 0.261.127 Group workflow member run/cancel, status gates, delete, and search coverage: 0.261.178 The workflow editor takes focus and hands it back to Edit on close: 0.261.188 @@ -105,7 +105,7 @@ def test_partial_switch_has_explicit_read_only_recovery_without_replaying_patch( ui = group_ui ui.active_group = "group-a" ui.open("/groups/group-a") - ui.reject_next("GET", "/api/v2/bootstrap") + ui.reject_next("GET", "/api/v2/scope") ui.page.get_by_role("combobox", name="Group workspace").select_option("group-b") expect(ui.page.get_by_role("button", name="Refresh workspace selection", exact=True)).to_be_visible() expect(ui.page.get_by_role("combobox", name="Group workspace")).to_be_disabled() @@ -117,6 +117,22 @@ def test_partial_switch_has_explicit_read_only_recovery_without_replaying_patch( assert len([entry for entry in ui.writes if entry.path == "/api/groups/setActive"]) == 1 +@pytest.mark.parametrize("width,height", [(1440, 900), (390, 844)]) +def test_group_switch_uses_scope_confirmation_without_another_bootstrap(group_ui, width, height): + ui = group_ui + ui.active_group = "group-a" + ui.open("/groups/group-a", width=width, height=height) + expect(ui.page.get_by_text("About this group", exact=True)).to_be_visible() + before = len([entry for entry in ui.requests if entry.path == "/api/v2/bootstrap"]) + ui.page.get_by_role("combobox", name="Group workspace").select_option("group-b") + expect(ui.page).to_have_url(f"{ORIGIN}/v2/groups/group-b") + expect(ui.page.get_by_text("Role: Member", exact=True)).to_be_visible() + expect(ui.page.get_by_role("combobox", name="Group workspace")).to_have_value("group-b") + assert len([entry for entry in ui.requests if entry.path == "/api/v2/bootstrap"]) == before + assert len([entry for entry in ui.requests if entry.path == "/api/v2/scope"]) == 1 + assert len([entry for entry in ui.writes if entry.path == "/api/groups/setActive"]) == 1 + ui.assert_no_overflow() + def test_group_delegation_editing_and_navigation_keep_the_correct_scope(group_ui): ui = group_ui ui.open("/groups/group-a/actions")