| import { |
| requireCount, |
| requireEncodedByteLimit, |
| requireEntityId, |
| requireExactRecord, |
| 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; |
| 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', |
| ] 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; |
| } |
| |
| 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; |
| } |
| |
| export interface ExternalSessionImportResult { |
| readonly session: SessionCatalogItem; |
| } |
| |
| 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'], |
| ); |
| 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) } : {}), |
| }; |
| } |
| |
| 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 = requireExactRecord(value, 'external Session import result', ['session']); |
| const decoded = { 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'); |
| } |
| |
| 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; |
| } |