Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
275 changes: 224 additions & 51 deletions application/single_app/route_backend_v2.py

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion application/v2_ui/src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ interface RequestOptions {
body?: unknown;
signal?: AbortSignal;
headers?: Record<string, string>;
/**
* 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`. */
Expand Down Expand Up @@ -187,7 +192,7 @@ export interface ApiResponse<T> {
}

export async function requestWithStatus<T>(path: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
const { method = 'GET', body, signal, headers = {} } = options;
const { method = 'GET', body, signal, headers = {}, cache } = options;

const init: RequestInit = {
method,
Expand All @@ -199,6 +204,10 @@ export async function requestWithStatus<T>(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);
Expand Down
13 changes: 11 additions & 2 deletions application/v2_ui/src/lib/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<BootstrapPayload>('/api/v2/bootstrap', signal);
request<BootstrapPayload>('/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<ActiveScopePayload>('/api/v2/scope', { method: 'GET', signal, cache: 'no-store' });

/**
* Record that the caller has dismissed the AI notice.
Expand Down
18 changes: 14 additions & 4 deletions application/v2_ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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[];
};
Expand Down
93 changes: 90 additions & 3 deletions application/v2_ui/src/stores/bootstrapStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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.
*
Expand Down Expand Up @@ -59,6 +90,14 @@ interface BootstrapState {
refreshWhenStale: (minIntervalMs: number) => Promise<void>;
/** An identity-bound selection must not install a response from a different sign-in. */
refreshRequired: (expectedViewerId?: string) => Promise<BootstrapPayload>;
/**
* 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<BootstrapPayload>;
/**
* Put a prompt into the catalog straight away, before a refresh has been round-tripped.
*
Expand All @@ -80,9 +119,10 @@ export const useBootstrapStore = create<BootstrapState>((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
Expand Down Expand Up @@ -111,10 +151,11 @@ export const useBootstrapStore = create<BootstrapState>((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
Expand All @@ -138,6 +179,7 @@ export const useBootstrapStore = create<BootstrapState>((set, get) => ({
refreshRequired: async (expectedViewerId) => {
const sequence = ++refreshSequence;
const settle = startBootstrapRead();
const startedAtScope = scopeSequence;
let data: BootstrapPayload;
try {
data = await fetchBootstrap();
Expand All @@ -151,6 +193,51 @@ export const useBootstrapStore = create<BootstrapState>((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;
},
Expand Down
6 changes: 4 additions & 2 deletions application/v2_ui/src/stores/groupWorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ export const useGroupWorkspaceStore = create<GroupWorkspaceState>((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.');
Expand Down Expand Up @@ -199,7 +201,7 @@ export const useGroupWorkspaceStore = create<GroupWorkspaceState>((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
Expand Down
Loading
Loading