blob: 2dba5e581f190c3a9d6ab941eeee6c18bdead40c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
decodeProjectCatalogProject,
decodeProjectCatalogProjectDetails,
type ConnectionCatalogCursor,
type ConnectionCatalogPageItem,
type ConnectionCatalogQueryResult,
type ModelCatalogEntry,
type ModelOverride,
type ModelOverrides,
type SessionCatalogItem,
type SessionCatalogRevision,
type SkillCatalogWorkspaceContext,
type SkillCatalogInvocableItem,
type SkillCatalogInvocableTarget,
type SkillCatalogPageItem,
type SkillCatalogRevision,
type SkillCatalogView,
type WorkspaceProjection,
type OperationOutput,
type ProjectCatalogPageItem,
type ProjectCatalogProject,
type ProjectCatalogProjectDetails,
type ProjectCatalogQueryResult,
type ProjectCatalogView,
} from '../protocol/index.js';
import type { RuntimeHostConnection } from './connection.js';
const MAX_STABLE_READ_ATTEMPTS = 8;
const STABLE_READ_RETRY_BASE_DELAY_MS = 8;
const STABLE_READ_RETRY_MAX_DELAY_MS = 64;
type RuntimeHostCatalogConnection = Pick<RuntimeHostConnection, 'request'>;
export interface RuntimeHostSkillCatalogSnapshot {
readonly revision: SkillCatalogRevision;
readonly view: SkillCatalogView;
readonly items: readonly SkillCatalogPageItem[];
readonly resolvedWorkspace: WorkspaceProjection;
}
export type RuntimeHostConnectionCatalogEntry = Omit<
Extract<ConnectionCatalogPageItem, { kind: 'connection' }>,
'kind' | 'connectionIndex' | 'enabledModelIdCount' | 'modelCount' | 'catalogEntryCount'
> & {
readonly enabledModelIds: readonly string[];
readonly models: readonly Extract<ConnectionCatalogPageItem, { kind: 'model' }>['model'][];
/** The connection's models as the Host resolved them, in catalog order. */
readonly catalogEntries: readonly ModelCatalogEntry[];
readonly modelOverrides?: ModelOverrides;
};
export interface RuntimeHostConnectionCatalogSnapshot {
readonly revision: Extract<ConnectionCatalogQueryResult, { kind: 'page' }>['revision'];
readonly defaultTarget: Extract<ConnectionCatalogQueryResult, { kind: 'page' }>['defaultTarget'];
readonly connections: readonly RuntimeHostConnectionCatalogEntry[];
}
export class RuntimeHostCatalogReadError extends Error {
constructor(
readonly catalog: 'connection' | 'project' | 'session' | 'skill' | 'runtime_resource',
readonly reason: 'unstable' | 'invalid_projection' | 'repeated_cursor',
) {
super(`Runtime Host ${catalog} catalog read failed: ${reason}`);
this.name = 'RuntimeHostCatalogReadError';
}
}
export interface RuntimeHostSessionCatalogPageCursor {
readonly revision: SessionCatalogRevision;
readonly cursor: string;
}
export interface RuntimeHostSessionCatalogPage {
readonly revision: SessionCatalogRevision;
readonly sessions: readonly SessionCatalogItem[];
readonly nextCursor: RuntimeHostSessionCatalogPageCursor | null;
}
export class RuntimeHostSessionCatalogRevisionChangedError extends Error {
constructor(
readonly expectedRevision: SessionCatalogRevision,
readonly actualRevision: SessionCatalogRevision,
) {
super('Runtime Host Session catalog revision changed');
this.name = 'RuntimeHostSessionCatalogRevisionChangedError';
}
}
export async function readRuntimeHostConnectionCatalog(
connection: RuntimeHostCatalogConnection,
): Promise<RuntimeHostConnectionCatalogSnapshot> {
const { first, pages } = await collectStablePages(
'connection',
async () => {
const result = await connection.request('connection.catalog.query', { kind: 'start' });
return result.kind === 'page' ? result : null;
},
async (revision, cursor) => {
const result = await connection.request('connection.catalog.query', {
kind: 'continue',
revision,
cursor,
});
return result.kind === 'page' ? result : null;
},
);
return assembleConnectionCatalog(
first,
pages.flatMap((page) => page.items),
);
}
export async function readRuntimeHostSkillCatalog(
connection: RuntimeHostCatalogConnection,
context: SkillCatalogWorkspaceContext,
view: SkillCatalogView,
): Promise<RuntimeHostSkillCatalogSnapshot> {
let resolvedWorkspace: WorkspaceProjection | undefined;
const { first, pages } = await collectStablePages(
'skill',
async () => {
const result = await connection.request('skill.catalog.query', {
kind: 'start',
context,
view,
});
if (result.kind !== 'page' || result.view !== view) return null;
resolvedWorkspace = result.resolvedWorkspace;
return result;
},
async (revision, cursor) => {
const result = await connection.request('skill.catalog.query', {
kind: 'continue',
context,
view,
revision,
cursor,
});
return result.kind === 'page' &&
result.view === view &&
workspaceProjectionsEqual(result.resolvedWorkspace, resolvedWorkspace)
? result
: null;
},
);
return {
revision: first.revision,
view,
items: pages.flatMap((page) => page.items),
resolvedWorkspace: first.resolvedWorkspace,
};
}
function workspaceProjectionsEqual(
left: WorkspaceProjection,
right: WorkspaceProjection | undefined,
): boolean {
if (!right) return false;
if (left.hostCwd !== right.hostCwd || left.target.kind !== right.target.kind) return false;
return left.target.kind === 'project'
? right.target.kind === 'project' && left.target.projectId === right.target.projectId
: right.target.kind === 'host_path' && left.target.path === right.target.path;
}
export async function readRuntimeHostInvocableSkills(
connection: RuntimeHostCatalogConnection,
target: SkillCatalogInvocableTarget,
): Promise<readonly SkillCatalogInvocableItem[]> {
const { pages } = await collectStablePages(
'skill',
async () => {
const result = await connection.request('skill.catalog.invocable.query', {
kind: 'start',
target,
});
return result.kind === 'page' ? result : null;
},
async (revision, cursor) => {
const result = await connection.request('skill.catalog.invocable.query', {
kind: 'continue',
target,
revision,
cursor,
});
return result.kind === 'page' ? result : null;
},
);
return pages.flatMap((page) => page.items);
}
export async function readRuntimeHostSessions(
connection: RuntimeHostCatalogConnection,
): Promise<SessionCatalogItem[]> {
const readPageOrRestart = async (
cursor?: RuntimeHostSessionCatalogPageCursor,
): Promise<RuntimeHostSessionCatalogPage | null> => {
try {
return await readRuntimeHostSessionCatalogPage(connection, cursor);
} catch (error) {
if (error instanceof RuntimeHostSessionCatalogRevisionChangedError) return null;
throw error;
}
};
const { pages } = await collectStablePages(
'session',
() => readPageOrRestart(),
(_revision, cursor) => readPageOrRestart(cursor),
);
return pages.flatMap((page) => page.sessions);
}
export async function readRuntimeHostSessionCatalogPage(
connection: RuntimeHostCatalogConnection,
cursor?: RuntimeHostSessionCatalogPageCursor,
): Promise<RuntimeHostSessionCatalogPage> {
const result = await connection.request(
'session.catalog.query',
cursor
? { kind: 'list_continue', revision: cursor.revision, cursor: cursor.cursor }
: { kind: 'list_start' },
);
if (result.kind === 'revision_changed') {
throw new RuntimeHostSessionCatalogRevisionChangedError(
result.expectedRevision,
result.actualRevision,
);
}
if (result.kind !== 'page' || (cursor && result.revision !== cursor.revision)) {
throw new RuntimeHostCatalogReadError('session', 'invalid_projection');
}
if (cursor && result.nextCursor === cursor.cursor) {
throw new RuntimeHostCatalogReadError('session', 'repeated_cursor');
}
return {
revision: result.revision,
sessions: result.sessions,
nextCursor:
result.nextCursor === null ? null : { revision: result.revision, cursor: result.nextCursor },
};
}
export async function readRuntimeHostProjects(
connection: RuntimeHostCatalogConnection,
): Promise<ProjectCatalogProject[]> {
return readRuntimeHostProjectCatalog(connection, 'summary');
}
export async function readRuntimeHostProjectDetails(
connection: RuntimeHostCatalogConnection,
): Promise<ProjectCatalogProjectDetails[]> {
return readRuntimeHostProjectCatalog(connection, 'locations');
}
function readRuntimeHostProjectCatalog(
connection: RuntimeHostCatalogConnection,
view: 'summary',
): Promise<ProjectCatalogProject[]>;
function readRuntimeHostProjectCatalog(
connection: RuntimeHostCatalogConnection,
view: 'locations',
): Promise<ProjectCatalogProjectDetails[]>;
async function readRuntimeHostProjectCatalog(
connection: RuntimeHostCatalogConnection,
view: ProjectCatalogView,
): Promise<ProjectCatalogProject[] | ProjectCatalogProjectDetails[]> {
const { first, pages } = await collectStablePages(
'project',
async () => {
const result = await connection.request('project.catalog.query', {
kind: 'list_start',
view,
});
return result.kind === 'page' && result.view === view ? result : null;
},
async (revision, cursor) => {
const result = await connection.request('project.catalog.query', {
kind: 'list_continue',
view,
revision,
cursor,
});
return result.kind === 'page' && result.view === view ? result : null;
},
);
return assembleProjectCatalog(
first,
pages.flatMap((page) => page.items),
);
}
export async function readRuntimeHostResources(
connection: RuntimeHostCatalogConnection,
sessionId: string,
): Promise<
Extract<OperationOutput<'runtime.resource.query'>, { kind: 'page' }>['resources'][number][]
> {
const { pages } = await collectStablePages(
'runtime_resource',
async () => {
const result = await connection.request('runtime.resource.query', {
kind: 'list_start',
sessionId,
});
return result.kind === 'page' && result.sessionId === sessionId ? result : null;
},
async (revision, cursor) => {
const result = await connection.request('runtime.resource.query', {
kind: 'list_continue',
sessionId,
revision,
cursor,
});
return result.kind === 'page' && result.sessionId === sessionId ? result : null;
},
);
return pages.flatMap((page) => page.resources);
}
interface StableCatalogPage {
readonly revision: string | number;
readonly nextCursor:
| string
| ConnectionCatalogCursor
| RuntimeHostSessionCatalogPageCursor
| null;
}
async function collectStablePages<Page extends StableCatalogPage>(
catalog: RuntimeHostCatalogReadError['catalog'],
readFirst: () => Promise<Page | null>,
readNext: (
revision: Page['revision'],
cursor: NonNullable<Page['nextCursor']>,
) => Promise<Page | null>,
): Promise<{ first: Page; pages: Page[] }> {
for (let attempt = 0; attempt < MAX_STABLE_READ_ATTEMPTS; attempt += 1) {
const first = await readFirst();
if (!first) continue;
const pages = [first];
const cursors = new Set<string>();
let page = first;
let retry = false;
while (page.nextCursor !== null) {
const cursor = uniqueCursor(catalog, cursors, page.nextCursor);
const next = await readNext(first.revision, cursor);
if (!next || next.revision !== first.revision) {
retry = true;
break;
}
pages.push(next);
page = next;
}
if (!retry) return { first, pages };
if (attempt + 1 < MAX_STABLE_READ_ATTEMPTS) {
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(STABLE_READ_RETRY_BASE_DELAY_MS * 2 ** attempt, STABLE_READ_RETRY_MAX_DELAY_MS),
),
);
}
}
throw new RuntimeHostCatalogReadError(catalog, 'unstable');
}
function uniqueCursor<T>(
catalog: RuntimeHostCatalogReadError['catalog'],
cursors: Set<string>,
cursor: T,
): T {
const key = typeof cursor === 'string' ? cursor : JSON.stringify(cursor);
if (cursors.has(key)) throw new RuntimeHostCatalogReadError(catalog, 'repeated_cursor');
cursors.add(key);
return cursor;
}
function assembleProjectCatalog(
first: Extract<ProjectCatalogQueryResult, { kind: 'page' }>,
items: readonly ProjectCatalogPageItem[],
): ProjectCatalogProject[] {
const projects = new Map<
number,
{
header: Extract<ProjectCatalogPageItem, { kind: 'project' }>;
aliases: Map<number, string>;
locations: Map<number, Extract<ProjectCatalogPageItem, { kind: 'location' }>['location']>;
}
>();
for (const item of items) {
if (item.kind !== 'project') continue;
if (projects.has(item.projectIndex)) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
projects.set(item.projectIndex, { header: item, aliases: new Map(), locations: new Map() });
}
for (const item of items) {
if (item.kind === 'project') continue;
const project = projects.get(item.projectIndex);
if (!project) throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
const values = item.kind === 'alias' ? project.aliases : project.locations;
const expectedCount =
item.kind === 'alias' ? project.header.aliasCount : project.header.locationCount;
if (item.itemIndex >= expectedCount || values.has(item.itemIndex)) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
if (item.kind === 'alias') project.aliases.set(item.itemIndex, item.alias);
else project.locations.set(item.itemIndex, item.location);
}
if (projects.size !== first.projectCount) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
return [...projects.entries()]
.sort(([left], [right]) => left - right)
.map(([, { header, aliases, locations }]) => {
if (
aliases.size !== header.aliasCount ||
header.available !== (header.preferredLocationIndex !== null) ||
(header.preferredLocationIndex !== null &&
header.preferredLocationIndex >= header.locationCount)
) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
const project = decodeProjectCatalogProject({
id: header.id,
aliases: orderedValues(aliases),
name: header.name,
locationCount: header.locationCount,
archivedAt: header.archivedAt,
available: header.available,
});
if (first.view === 'summary') {
if (locations.size !== 0) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
return project;
}
if (locations.size !== header.locationCount) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
const orderedLocations = orderedValues(locations);
const preferredPath =
header.preferredLocationIndex === null
? null
: orderedLocations[header.preferredLocationIndex]?.path;
if (preferredPath === undefined) {
throw new RuntimeHostCatalogReadError('project', 'invalid_projection');
}
return decodeProjectCatalogProjectDetails({
...project,
locations: orderedLocations,
preferredPath,
});
});
}
function assembleConnectionCatalog(
first: Extract<ConnectionCatalogQueryResult, { kind: 'page' }>,
items: readonly ConnectionCatalogPageItem[],
): RuntimeHostConnectionCatalogSnapshot {
const entries = new Map<
number,
{
header: Extract<ConnectionCatalogPageItem, { kind: 'connection' }>;
enabledModelIds: Map<number, string>;
models: Map<number, RuntimeHostConnectionCatalogEntry['models'][number]>;
catalogEntries: Map<number, ModelCatalogEntry>;
modelOverrides: Map<string, ModelOverride>;
}
>();
for (const item of items) {
if (item.kind !== 'connection') continue;
if (entries.has(item.connectionIndex)) {
throw new RuntimeHostCatalogReadError('connection', 'invalid_projection');
}
entries.set(item.connectionIndex, {
header: item,
enabledModelIds: new Map(),
models: new Map(),
catalogEntries: new Map(),
modelOverrides: new Map(),
});
}
for (const item of items) {
if (item.kind === 'connection') continue;
const entry = entries.get(item.connectionIndex);
if (!entry) throw new RuntimeHostCatalogReadError('connection', 'invalid_projection');
const values =
item.kind === 'enabled_model_id'
? entry.enabledModelIds
: item.kind === 'model'
? entry.models
: entry.catalogEntries;
const expectedCount =
item.kind === 'enabled_model_id'
? entry.header.enabledModelIdCount
: item.kind === 'model'
? entry.header.modelCount
: entry.header.catalogEntryCount;
if (item.itemIndex >= expectedCount || values.has(item.itemIndex)) {
throw new RuntimeHostCatalogReadError('connection', 'invalid_projection');
}
if (item.kind === 'enabled_model_id') {
entry.enabledModelIds.set(item.itemIndex, item.modelId);
} else if (item.kind === 'model') {
entry.models.set(item.itemIndex, item.model);
} else {
entry.catalogEntries.set(item.itemIndex, item.entry);
if (item.modelOverride !== undefined)
entry.modelOverrides.set(item.entry.id, item.modelOverride);
}
}
if (entries.size !== first.connectionCount) {
throw new RuntimeHostCatalogReadError('connection', 'invalid_projection');
}
const connections = [...entries.entries()]
.sort(([left], [right]) => left - right)
.map(([, entry]): RuntimeHostConnectionCatalogEntry => {
if (
entry.enabledModelIds.size !== entry.header.enabledModelIdCount ||
entry.models.size !== entry.header.modelCount ||
entry.catalogEntries.size !== entry.header.catalogEntryCount
) {
throw new RuntimeHostCatalogReadError('connection', 'invalid_projection');
}
const {
kind: _kind,
connectionIndex: _index,
enabledModelIdCount: _enabledCount,
modelCount: _modelCount,
catalogEntryCount: _catalogEntryCount,
...header
} = entry.header;
return {
...header,
enabledModelIds: orderedValues(entry.enabledModelIds),
models: orderedValues(entry.models),
catalogEntries: orderedValues(entry.catalogEntries),
...(entry.modelOverrides.size === 0
? {}
: { modelOverrides: Object.fromEntries(entry.modelOverrides) }),
};
});
return { revision: first.revision, defaultTarget: first.defaultTarget, connections };
}
function orderedValues<T>(values: ReadonlyMap<number, T>): T[] {
return [...values.entries()].sort(([left], [right]) => left - right).map(([, value]) => value);
}