blob: c002a0a8ddb5b2d824594e9df93af6a98164cbba [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 {
EXTERNAL_SESSION_LIMIT_KINDS,
type ExternalSessionLimit,
} from '@maka/core/external-session';
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineHostPathOperation, defineOperation } from './operation-spec.js';
import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js';
import { decodeWorkspaceTarget, type WorkspaceTarget } from './workspace.js';
export const EXTERNAL_SESSION_PAGE_MAX_ITEMS = 16;
/** Byte bound for a catalog search term. The 200-char contract in
* `@maka/core/external-session` at 4 bytes per character, so a term the core
* matcher would accept can always reach it. */
export const EXTERNAL_SESSION_QUERY_TEXT_MAX_BYTES = 800;
export const EXTERNAL_SESSION_RESULT_MAX_BYTES = 72 * 1024;
export const EXTERNAL_SESSION_CWD_MAX_BYTES = 4 * 1024;
export const EXTERNAL_SESSION_NAME_MAX_BYTES = 320;
export const EXTERNAL_SESSION_SOURCE_SESSION_ID_MAX_BYTES = 512;
export const EXTERNAL_SESSION_SOURCE_MAX_ITEMS = 16;
export const EXTERNAL_SESSION_IMPORTED_SESSION_IDS_MAX_ITEMS = 8;
const EXTERNAL_SESSION_CURSOR_MAX_BYTES = 32;
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'invalid_request',
'persistence_failed',
'internal_failure',
] as const;
const IMPORT_ERRORS = [
...QUERY_ERRORS,
'not_found',
'operation_conflict',
'commit_outcome_unknown',
// Distinct, stable reasons the import page renders as specific banners, so the
// shell classifies by code rather than by the redacted error message.
'model_unavailable',
'source_unreadable',
] as const;
export type ExternalSessionSourceQueryInput = Record<never, never>;
export interface ExternalSessionSourceQueryResult {
readonly adapterIds: readonly string[];
}
export interface ExternalSessionCatalogQueryInput {
readonly adapterId: string;
readonly includeArchived?: boolean;
readonly workspace?: WorkspaceTarget;
readonly cursor?: string;
/** Free text matched against a session's title and cwd, applied before paging. */
readonly text?: string;
}
export interface ExternalSessionCatalogQueryResult {
readonly sessions: readonly ExternalSessionCatalogItem[];
readonly nextCursor: string | null;
}
export interface ExternalSessionCatalogItem {
readonly id: string;
readonly name: string;
readonly hostCwd: string;
readonly importState: {
readonly importedCount: number;
readonly importedSessionIds: readonly string[];
readonly isImporting: boolean;
};
readonly createdAt?: number;
readonly updatedAt?: number;
readonly archived?: boolean;
}
export interface ExternalSessionImportInput {
readonly adapterId: string;
readonly sourceSessionId: string;
}
/** A completed import command may refuse the source before any Session is written. */
export type ExternalSessionImportResult<Session extends SessionCatalogItem = SessionCatalogItem> =
| { readonly kind: 'imported'; readonly session: Session }
| { readonly kind: 'source_limit_exceeded'; readonly limit: ExternalSessionLimit };
export const EXTERNAL_SESSION_OPERATION_SPECS = {
'external-session.source.query': defineOperation<
ExternalSessionSourceQueryInput,
ExternalSessionSourceQueryResult,
(typeof QUERY_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeExternalSessionSourceQueryInput,
decodeOutput: decodeExternalSessionSourceQueryResult,
}),
'external-session.catalog.query': defineHostPathOperation<
ExternalSessionCatalogQueryInput,
ExternalSessionCatalogQueryResult,
(typeof QUERY_ERRORS)[number]
>(
{
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeExternalSessionCatalogQueryInput,
decodeOutput: decodeExternalSessionCatalogQueryResult,
},
(input) => input.workspace?.kind === 'host_path',
),
'external-session.import': defineOperation<
ExternalSessionImportInput,
ExternalSessionImportResult,
(typeof IMPORT_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: IMPORT_ERRORS,
decodeInput: decodeExternalSessionImportInput,
decodeOutput: decodeExternalSessionImportResult,
}),
} as const;
export function decodeExternalSessionSourceQueryInput(
value: unknown,
): ExternalSessionSourceQueryInput {
requireExactRecord(value, 'external Session source query input', []);
return {};
}
export function decodeExternalSessionSourceQueryResult(
value: unknown,
): ExternalSessionSourceQueryResult {
const result = requireExactRecord(value, 'external Session source query result', ['adapterIds']);
if (
!Array.isArray(result.adapterIds) ||
result.adapterIds.length > EXTERNAL_SESSION_SOURCE_MAX_ITEMS
) {
throw invalidProtocolFrame('Invalid external Session source list');
}
const decoded = { adapterIds: result.adapterIds.map((id) => adapterId(id)) };
requireEncodedByteLimit(
decoded,
'external Session source query result',
EXTERNAL_SESSION_RESULT_MAX_BYTES,
);
return decoded;
}
export function decodeExternalSessionCatalogQueryInput(
value: unknown,
): ExternalSessionCatalogQueryInput {
const input = requireShapedRecord(
value,
'external Session catalog query input',
['adapterId'],
['includeArchived', 'workspace', 'cursor', 'text'],
);
return {
adapterId: adapterId(input.adapterId),
...(Object.hasOwn(input, 'includeArchived')
? { includeArchived: boolean(input.includeArchived, 'includeArchived') }
: {}),
...(Object.hasOwn(input, 'workspace')
? { workspace: decodeWorkspaceTarget(input.workspace) }
: {}),
...(Object.hasOwn(input, 'cursor') ? { cursor: cursor(input.cursor) } : {}),
...(Object.hasOwn(input, 'text') ? { text: catalogQueryText(input.text) } : {}),
};
}
export function decodeExternalSessionCatalogQueryResult(
value: unknown,
): ExternalSessionCatalogQueryResult {
const result = requireExactRecord(value, 'external Session catalog query result', [
'sessions',
'nextCursor',
]);
if (!Array.isArray(result.sessions) || result.sessions.length > EXTERNAL_SESSION_PAGE_MAX_ITEMS) {
throw invalidProtocolFrame('Invalid external Session catalog page');
}
const decoded = {
sessions: result.sessions.map(decodeExternalSessionSummary),
nextCursor: result.nextCursor === null ? null : cursor(result.nextCursor),
};
requireEncodedByteLimit(
decoded,
'external Session catalog query result',
EXTERNAL_SESSION_RESULT_MAX_BYTES,
);
return decoded;
}
export function decodeExternalSessionImportInput(value: unknown): ExternalSessionImportInput {
const input = requireExactRecord(value, 'external Session import input', [
'adapterId',
'sourceSessionId',
]);
const sourceSessionId = requireUtf8String(
input.sourceSessionId,
'external source Session id',
EXTERNAL_SESSION_SOURCE_SESSION_ID_MAX_BYTES,
);
if (/[\u0000-\u001f\u007f]/.test(sourceSessionId)) {
throw invalidProtocolFrame('Invalid external source Session id');
}
return { adapterId: adapterId(input.adapterId), sourceSessionId };
}
export function decodeExternalSessionImportResult(value: unknown): ExternalSessionImportResult {
const result = requireRecord(value, 'external Session import result');
if (result.kind === 'source_limit_exceeded') {
requireExactRecord(result, 'external Session import limit result', ['kind', 'limit']);
const limit = requireExactRecord(result.limit, 'external Session import limit', [
'kind',
'max',
]);
if (!EXTERNAL_SESSION_LIMIT_KINDS.some((kind) => kind === limit.kind)) {
throw invalidProtocolFrame('Invalid external Session import limit kind');
}
const max = requireCount(limit.max, 'external Session import limit maximum');
if (max === 0) throw invalidProtocolFrame('Invalid external Session import limit maximum');
return {
kind: 'source_limit_exceeded',
limit: { kind: limit.kind as ExternalSessionLimit['kind'], max },
};
}
if (result.kind !== 'imported')
throw invalidProtocolFrame('Invalid external Session import result kind');
requireExactRecord(result, 'external Session import result', ['kind', 'session']);
const decoded = { kind: 'imported' as const, session: decodeSessionCatalogItem(result.session) };
requireEncodedByteLimit(
decoded,
'external Session import result',
EXTERNAL_SESSION_RESULT_MAX_BYTES,
);
return decoded;
}
function decodeExternalSessionSummary(value: unknown): ExternalSessionCatalogItem {
const summary = requireShapedRecord(
value,
'external Session summary',
['id', 'name', 'hostCwd', 'importState'],
['createdAt', 'updatedAt', 'archived'],
);
const id = requireUtf8String(
summary.id,
'external source Session id',
EXTERNAL_SESSION_SOURCE_SESSION_ID_MAX_BYTES,
);
if (/[\u0000-\u001f\u007f]/.test(id))
throw invalidProtocolFrame('Invalid external source Session id');
return {
id,
name: requireUtf8String(summary.name, 'external Session name', EXTERNAL_SESSION_NAME_MAX_BYTES),
hostCwd: boundedPossiblyEmptyText(
summary.hostCwd,
'external Session Host cwd',
EXTERNAL_SESSION_CWD_MAX_BYTES,
),
importState: decodeExternalSessionImportState(summary.importState),
...(Object.hasOwn(summary, 'createdAt')
? { createdAt: requireCount(summary.createdAt, 'external Session createdAt') }
: {}),
...(Object.hasOwn(summary, 'updatedAt')
? { updatedAt: requireCount(summary.updatedAt, 'external Session updatedAt') }
: {}),
...(Object.hasOwn(summary, 'archived')
? { archived: boolean(summary.archived, 'archived') }
: {}),
};
}
function decodeExternalSessionImportState(
value: unknown,
): ExternalSessionCatalogItem['importState'] {
const state = requireExactRecord(value, 'external Session import state', [
'importedCount',
'importedSessionIds',
'isImporting',
]);
if (
!Array.isArray(state.importedSessionIds) ||
state.importedSessionIds.length > EXTERNAL_SESSION_IMPORTED_SESSION_IDS_MAX_ITEMS
) {
throw invalidProtocolFrame('Invalid external Session imported Session ids');
}
const importedSessionIds = Array.from(state.importedSessionIds, (id) =>
requireEntityId(id, 'imported Session id'),
);
const importedCount = requireCount(state.importedCount, 'external Session imported count');
if (importedCount < importedSessionIds.length) {
throw invalidProtocolFrame('Invalid external Session imported count');
}
return {
importedCount,
importedSessionIds,
isImporting: boolean(state.isImporting, 'external Session import state'),
};
}
function adapterId(value: unknown): string {
return requireEntityId(value, 'external Session adapter id');
}
/**
* A bounded search term. Bounded rather than free-form because it reaches the
* adapters, and an unbounded string from a client would be matched against
* every summary on every source.
*/
function catalogQueryText(value: unknown): string {
return requireUtf8String(
value,
'external Session catalog query text',
EXTERNAL_SESSION_QUERY_TEXT_MAX_BYTES,
);
}
function cursor(value: unknown): string {
const decoded = requireUtf8String(
value,
'external Session cursor',
EXTERNAL_SESSION_CURSOR_MAX_BYTES,
);
if (!/^\d+$/.test(decoded) || !Number.isSafeInteger(Number(decoded))) {
throw invalidProtocolFrame('Invalid external Session cursor');
}
return decoded;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${label}`);
return value;
}
function boundedPossiblyEmptyText(value: unknown, label: string, maxBytes: number): string {
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > maxBytes) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}