diff --git a/api/openapi-v2.json b/api/openapi-v2.json index f830aefec..520dbec9a 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -121157,6 +121157,15 @@ "schema": { "type": "string" } + }, + { + "description": "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.", + "in": "query", + "name": "ids", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { diff --git a/frontend/src/lib/api/workspaces.js b/frontend/src/lib/api/workspaces.js index 924ef0646..c0df6db06 100644 --- a/frontend/src/lib/api/workspaces.js +++ b/frontend/src/lib/api/workspaces.js @@ -5,6 +5,9 @@ import { normalizeStatuses } from './workflows.js'; const workspaceCRUD = createCrudClient('/workspaces', { v2: true, allV2: true }); +// Matches the server's cap on a direct workspace id resolve. +const WORKSPACE_ID_BATCH = 200; + export const workspaces = { ...workspaceCRUD, getAll: (filters = {}, requestOptions = {}) => @@ -14,6 +17,29 @@ export const workspaces = { // the cached first page reachable without fetch-all loops (WI-1442). getPage: (filters = {}, requestOptions = {}) => fetchAPIV2(`/workspaces${buildQueryString(filters)}`, requestOptions), + // Directly resolve a known id set in one read per chunk instead of one read + // per id. The server omits ids the caller cannot view, so callers compare + // the requested and returned sets to report unresolvable ids. + getByIds: async (ids, requestOptions = {}) => { + const unique = [ + ...new Set((ids || []).map(Number).filter((id) => Number.isInteger(id) && id > 0)), + ]; + if (unique.length === 0) return []; + const chunks = []; + for (let i = 0; i < unique.length; i += WORKSPACE_ID_BATCH) { + chunks.push(unique.slice(i, i + WORKSPACE_ID_BATCH)); + } + const pages = await Promise.allSettled( + chunks.map((chunk) => + fetchAPIV2(`/workspaces?ids=${chunk.join(',')}&page_size=${chunk.length}`, requestOptions) + ) + ); + const resolved = []; + for (const page of pages) { + if (page.status === 'fulfilled') resolved.push(...(page.value?.data || [])); + } + return resolved; + }, get: (id, requestOptions = {}) => fetchV2Data(`/workspaces/${id}`, requestOptions), getBootstrap: (id, requestOptions = {}) => fetchAPI(`/workspaces/${id}/bootstrap`, requestOptions), diff --git a/frontend/src/lib/dialogs/RequestTypeModal.svelte b/frontend/src/lib/dialogs/RequestTypeModal.svelte index 9c5b535ed..2cc977d9e 100644 --- a/frontend/src/lib/dialogs/RequestTypeModal.svelte +++ b/frontend/src/lib/dialogs/RequestTypeModal.svelte @@ -2,6 +2,7 @@ import { workspaces } from '../api/workspaces.js'; import { requestTypes } from '../api/channels.js'; import { workspacesStore } from '../stores/workspaces.svelte.js'; + import { FOREGROUND_READ } from '../api/readScheduler.js'; import Button from '../components/Button.svelte'; import Input from '../components/Input.svelte'; import BasePicker from '../pickers/BasePicker.svelte'; @@ -53,9 +54,9 @@ // Load workspaces filtered to channel's configured IDs. The directory // store caches only the first server page, so a restricted channel - // resolves each configured id it names explicitly — the allowed set is the - // channel's own small list, not whatever the cache happens to hold - // (WI-1585). Unrestricted channels keep the cached page and gain + // resolves the configured ids it names explicitly in one batched read — the + // allowed set is the channel's own list, not whatever the cache happens to + // hold (WI-1585). Unrestricted channels keep the cached page and gain // server-backed search over the rest of the directory. let workspaceSearchResults = $state(null); @@ -65,12 +66,14 @@ const byId = new Map(cached.map(ws => [ws.id, ws])); const configured = channelWorkspaceIds || []; const missing = configured.filter(id => !byId.has(id)); - const fetched = await Promise.allSettled(missing.map(id => workspaces.get(id))); - for (const result of fetched) { - if (result.status === 'fulfilled' && result.value?.id) { - byId.set(result.value.id, result.value); - } else if (result.status === 'rejected') { - console.error('Failed to resolve configured workspace:', result.reason); + if (missing.length > 0) { + const resolved = await workspaces.getByIds(missing, FOREGROUND_READ); + for (const workspace of resolved) { + if (workspace?.id != null) byId.set(workspace.id, workspace); + } + const unresolved = missing.filter(id => !byId.has(id)); + if (unresolved.length > 0) { + console.error('Failed to resolve configured workspaces:', unresolved.join(', ')); } } availableWorkspaces = configured.length > 0 diff --git a/internal/restapi/v2/contract-metadata.json b/internal/restapi/v2/contract-metadata.json index 6d5bdf8e3..20a723627 100644 --- a/internal/restapi/v2/contract-metadata.json +++ b/internal/restapi/v2/contract-metadata.json @@ -11308,6 +11308,15 @@ "Schema": { "type": "string" } + }, + { + "Name": "ids", + "In": "query", + "Description": "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.", + "Required": false, + "Schema": { + "type": "string" + } } ], "responses": { diff --git a/internal/restapi/v2/router.go b/internal/restapi/v2/router.go index 4ef64576b..d0e890b80 100644 --- a/internal/restapi/v2/router.go +++ b/internal/restapi/v2/router.go @@ -1155,6 +1155,8 @@ func applyParameterCorrections(route *Route) { map[string]any{"type": "string", "pattern": "^[A-Za-z0-9]{2,10}-[1-9][0-9]*$", "example": "WI-1238"}, }}, }) + case "GET /workspaces": + upsertParameter(route, stringQuery("ids", "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.")) case "GET /query-language/values": source := enumQuery("source", "Completion value catalog to search.", "workspaces", "statuses", "status_categories", "priorities", "users", "milestones", "iterations", "projects", "item_types", "labels") diff --git a/internal/restapi/v2/scoped_catalog.go b/internal/restapi/v2/scoped_catalog.go index 6b77f4de8..267929a54 100644 --- a/internal/restapi/v2/scoped_catalog.go +++ b/internal/restapi/v2/scoped_catalog.go @@ -141,11 +141,23 @@ func listWorkspaces(catalog catalogReader) pageOperation[workspaceDTO] { if err != nil { return nil, Pagination{}, 0, err } + // An explicit id set resolves exactly those rows in one read. The + // page size mirrors the requested set so the page is self-describing + // even when the caller omits page_size. + var ids []int + if strings.TrimSpace(r.URL.Query().Get("ids")) != "" { + ids, err = queryItemIDs(r, "ids", maxWorkspaceIDBatch) + if err != nil { + return nil, Pagination{}, 0, err + } + page.PageSize = len(ids) + } // Server-side directory search over name, key, and description — // the same fields the client-side filter matches, so results are // consistent whether they come from cache or the server. pageParams := catalogPage(page) pageParams.Search = clampSearch(r.URL.Query().Get("search")) + pageParams.IDs = ids user, err := principal(r) if err != nil { return nil, Pagination{}, 0, err @@ -172,6 +184,10 @@ func clampSearch(raw string) string { return search } +// maxWorkspaceIDBatch bounds a direct workspace id resolve so one request +// cannot name an unbounded set. +const maxWorkspaceIDBatch = 200 + func listWorkspaceTemplates(catalog catalogReader) readOperation[[]workspaceTemplateDTO] { return func(r *http.Request) ([]workspaceTemplateDTO, error) { user, err := principal(r) diff --git a/internal/services/catalog_read_service.go b/internal/services/catalog_read_service.go index 78e47354c..b152228c5 100644 --- a/internal/services/catalog_read_service.go +++ b/internal/services/catalog_read_service.go @@ -62,6 +62,9 @@ type CatalogPageParams struct { Sort string Desc bool Search string + // IDs, when set, restricts the page to exactly these resources. Callers + // use it to resolve a known id set in one read instead of one read per id. + IDs []int } func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) ([]models.Workspace, int, error) { @@ -87,6 +90,14 @@ func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) visibleIDs = append(visibleIDs, candidate.ID) } } + if len(page.IDs) > 0 { + // The caller named the exact set, so return all of it in one page: + // ids outside the visible set are dropped and never leak existence. + visibleIDs = intersectIDs(visibleIDs, page.IDs) + page.Search = "" + page.Limit = len(visibleIDs) + page.Offset = 0 + } workspaces, total, err := s.workspaceRepo.FindByIDsPage(repository.WorkspaceIDPageParams{ IDs: visibleIDs, Search: page.Search, @@ -108,6 +119,22 @@ func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) return workspaces, total, nil } +// intersectIDs returns the available ids that appear in requested, in the +// available order. Requested ids absent from available are omitted. +func intersectIDs(available, requested []int) []int { + wanted := make(map[int]struct{}, len(requested)) + for _, id := range requested { + wanted[id] = struct{}{} + } + result := make([]int, 0, len(requested)) + for _, id := range available { + if _, ok := wanted[id]; ok { + result = append(result, id) + } + } + return result +} + func (s *CatalogReadService) ListWorkspaceTemplates(ctx context.Context, userID int) ([]models.WorkspaceTemplateSummary, error) { items, err := s.workspaces.ListTemplateSummaries(ctx) if err != nil {