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
9 changes: 9 additions & 0 deletions api/openapi-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/lib/api/workspaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) =>
Expand All @@ -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),
Expand Down
21 changes: 12 additions & 9 deletions frontend/src/lib/dialogs/RequestTypeModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/restapi/v2/contract-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions internal/restapi/v2/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions internal/restapi/v2/scoped_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions internal/services/catalog_read_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
Loading