diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 3f8464073..99514bd54 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -57,12 +57,19 @@ import type { RecipePullOptions } from '@shared/src/models/IRecipe'; import type { ContainerProviderConnection } from '@podman-desktop/api'; import type { NavigationRegistry } from './registries/NavigationRegistry'; import type { FilterRecipesResult, RecipeFilters } from '@shared/src/models/FilterRecipesResult'; +import type { ErrorState } from '@shared/src/models/IError'; +import type { ServiceMetadata } from '@shared/src/models/ServiceMetadata'; +import { Messages } from '@shared/Messages'; +import type { RpcBrowser } from '@shared/src/messages/MessageProxy'; interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem { port: number; } export class StudioApiImpl implements StudioAPI { + #errors: ErrorState[] = []; + #rpcBrowser: RpcBrowser | undefined; + constructor( private applicationManager: ApplicationManager, private catalogManager: CatalogManager, @@ -78,7 +85,55 @@ export class StudioApiImpl implements StudioAPI { private recipeManager: RecipeManager, private podmanConnection: PodmanConnection, private navigationRegistry: NavigationRegistry, - ) {} + rpcBrowser?: RpcBrowser, + ) { + this.#rpcBrowser = rpcBrowser; + } + + async getErrors(): Promise { + return this.#errors; + } + + async acknowledgeError(errorId: string): Promise { + const error = this.#errors.find(e => e.id === errorId); + if (error) { + error.acknowledged = true; + // Notify subscribers of the updated error state + this.notify(Messages.MSG_NEW_ERROR_STATE, this.#errors); + } + } + + async resolveServiceUri(uri: string): Promise { + // Basic validation of MCP URI format + if (!uri.startsWith('mcp://')) { + throw new Error('Invalid MCP URI format. Must start with mcp://'); + } + + // For now return empty metadata - this can be expanded based on requirements + return {}; + } + + // Helper method to create and track errors + async createError(error: Omit): Promise { + const newError: ErrorState = { + ...error, + id: crypto.randomUUID(), + timestamp: Date.now(), + }; + this.#errors.push(newError); + // Notify subscribers of the new error state + this.notify(Messages.MSG_NEW_ERROR_STATE, this.#errors); + } + + private notify(channel: string, message: unknown): void { + if (this.#rpcBrowser) { + // Use the RpcBrowser's subscribe mechanism to notify subscribers + const subscribers = this.#rpcBrowser.subscribers.get(channel); + if (subscribers) { + subscribers.forEach(listener => listener(message)); + } + } + } async readRoute(): Promise { return this.navigationRegistry.readRoute(); diff --git a/packages/backend/src/utils/Publisher.ts b/packages/backend/src/utils/Publisher.ts index e2abd6c5e..8c20b7687 100644 --- a/packages/backend/src/utils/Publisher.ts +++ b/packages/backend/src/utils/Publisher.ts @@ -22,17 +22,19 @@ export class Publisher { constructor( private webview: Webview, private channel: Messages, - private getter: () => T, + private stateGetter: () => T, ) {} - notify(): void { - this.webview - .postMessage({ + protected async notify(): Promise { + try { + const data = this.stateGetter(); + await this.webview.postMessage({ id: this.channel, - body: this.getter(), - }) - .catch((err: unknown) => { - console.error(`Something went wrong while emitting ${this.channel}: ${String(err)}`); + body: data, }); + } catch (error) { + console.error(`Error publishing to ${this.channel}:`, error); + throw error; // Re-throw to allow error handling by caller + } } } diff --git a/packages/backend/src/utils/StateManager.ts b/packages/backend/src/utils/StateManager.ts new file mode 100644 index 000000000..71f700e29 --- /dev/null +++ b/packages/backend/src/utils/StateManager.ts @@ -0,0 +1,79 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { Publisher } from './Publisher'; +import type { Webview } from '@podman-desktop/api'; +import { + ApplicationStateError, + ApplicationStateErrorType, + type ApplicationStateErrorDetails, +} from '@shared/src/models/IError'; +import type { Messages } from '@shared/Messages'; + +/** + * Base class for managing state with persistence and error handling + */ +export abstract class StateManager extends Publisher { + constructor(webview: Webview, channel: Messages, stateGetter: () => T) { + super(webview, channel, stateGetter); + } + + /** + * Persists the current state and notifies subscribers + * @throws {ApplicationStateError} if persistence fails + */ + protected async persistState(): Promise { + try { + await this.notify(); + } catch (err: unknown) { + const details: ApplicationStateErrorDetails = { + operation: 'persist', + timestamp: Date.now(), + }; + throw new ApplicationStateError(ApplicationStateErrorType.PERSISTENCE_ERROR, 'Failed to persist state', { + originalError: err, + details, + }); + } + } + + /** + * Loads persisted state + * @throws {ApplicationStateError} if loading fails + */ + protected async loadPersistedState(): Promise { + try { + await this.refresh(); + } catch (err: unknown) { + const details: ApplicationStateErrorDetails = { + operation: 'load', + timestamp: Date.now(), + }; + throw new ApplicationStateError(ApplicationStateErrorType.LOAD_ERROR, 'Failed to load persisted state', { + originalError: err, + details, + }); + } + } + + /** + * Refreshes the current state + * Should be implemented by derived classes + */ + protected abstract refresh(): Promise; +} diff --git a/packages/backend/src/utils/__tests__/Publisher.spec.ts b/packages/backend/src/utils/__tests__/Publisher.spec.ts new file mode 100644 index 000000000..e61ef87df --- /dev/null +++ b/packages/backend/src/utils/__tests__/Publisher.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from 'vitest'; +import { Publisher } from '../Publisher'; +import type { Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +interface TestState { + testData: string; +} + +describe('Publisher', () => { + let mockWebview: Webview; + let publisher: Publisher; + let mockStateGetter: Mock<() => TestState>; + + beforeEach(() => { + mockWebview = { + postMessage: vi.fn().mockImplementation(() => Promise.resolve()), + } as unknown as Webview; + + mockStateGetter = vi.fn().mockReturnValue({ testData: 'test' }); + publisher = new Publisher(mockWebview, Messages.MSG_ASK_ERROR_STATE, mockStateGetter); + }); + + it('should successfully notify with state data', async () => { + await publisher['notify'](); + + expect(mockStateGetter).toHaveBeenCalled(); + expect(mockWebview.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_ASK_ERROR_STATE, + body: { testData: 'test' }, + }); + }); + + it('should handle postMessage rejection by re-throwing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('Communication failed'); + (mockWebview.postMessage as Mock).mockRejectedValueOnce(error); + + await expect(publisher['notify']()).rejects.toThrow(error); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Error publishing to'), error); + + consoleSpy.mockRestore(); + }); + + it('should handle stateGetter errors by re-throwing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('State getter failed'); + mockStateGetter.mockImplementation(() => { + throw error; + }); + + await expect(publisher['notify']()).rejects.toThrow(error); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Error publishing to'), error); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/shared/Messages.ts b/packages/shared/Messages.ts index 76416d72c..ba7733605 100644 --- a/packages/shared/Messages.ts +++ b/packages/shared/Messages.ts @@ -18,6 +18,7 @@ export enum Messages { MSG_NEW_CATALOG_STATE = 'new-catalog-state', + MSG_ASK_CATALOG_STATE = 'ask-catalog-state', MSG_TASKS_UPDATE = 'tasks-update', MSG_NEW_MODELS_STATE = 'new-models-state', MSG_APPLICATIONS_STATE_UPDATE = 'applications-state-update', @@ -32,4 +33,9 @@ export enum Messages { MSG_PODMAN_CONNECTION_UPDATE = 'podman-connecting-update', MSG_INSTRUCTLAB_SESSIONS_UPDATE = 'instructlab-sessions-update', MSG_NAVIGATION_ROUTE_UPDATE = 'navigation-route-update', + MSG_NEW_ERROR_STATE = 'new-error-state', + MSG_ASK_ERROR_STATE = 'ask-error-state', + MSG_NEW_PULLED_APPLICATION_STATE = 'new-pulled-application-state', + MSG_ASK_PULLED_APPLICATIONS_STATE = 'ask-pulled-applications-state', + MSG_ACK_ERROR = 'ack-error', } diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 7569be2a6..bd84f58e0 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -37,6 +37,8 @@ import type { import type { ExtensionConfiguration } from './models/IExtensionConfiguration'; import type { RecipePullOptions } from './models/IRecipe'; import type { FilterRecipesResult, RecipeFilters } from './models/FilterRecipesResult'; +import type { ErrorState } from './models/IError'; +import type { ServiceMetadata } from './models/ServiceMetadata'; export abstract class StudioAPI { static readonly CHANNEL: string = 'StudioAPI'; @@ -246,4 +248,20 @@ export abstract class StudioAPI { * route it should use. This method has a side effect of removing the pending route after calling. */ abstract readRoute(): Promise; + + /** + * Get the current error state + */ + abstract getErrors(): Promise; + + /** + * Acknowledge an error + * @param errorId the id of the error to acknowledge + */ + abstract acknowledgeError(errorId: string): Promise; + + /** + * Resolve an MCP service URI (e.g. mcp://api.myservice.com) and return metadata. + */ + abstract resolveServiceUri(uri: string): Promise; } diff --git a/packages/shared/src/models/IApplicationStateError.ts b/packages/shared/src/models/IApplicationStateError.ts new file mode 100644 index 000000000..56339c903 --- /dev/null +++ b/packages/shared/src/models/IApplicationStateError.ts @@ -0,0 +1,55 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export enum ApplicationStateErrorType { + PERSISTENCE_ERROR = 'PERSISTENCE_ERROR', + LOAD_ERROR = 'LOAD_ERROR', + POD_ERROR = 'POD_ERROR', + RESOURCE_ERROR = 'RESOURCE_ERROR', + GPU_ERROR = 'GPU_ERROR', + CLEANUP_ERROR = 'CLEANUP_ERROR', + MODEL_ERROR = 'MODEL_ERROR', + INFERENCE_ERROR = 'INFERENCE_ERROR', + UNKNOWN = 'UNKNOWN', +} + +export class ApplicationStateError extends Error { + constructor( + public type: ApplicationStateErrorType, + message: string, + public details?: unknown, + ) { + super(message); + this.name = 'ApplicationStateError'; + } +} + +export interface ApplicationStateErrorDetails { + recipeId?: string; + modelId?: string; + podId?: string; + operation?: string; + timestamp?: number; + resourceType?: 'memory' | 'cpu' | 'gpu' | 'disk' | 'model'; + resourceDetails?: { + required?: number; + available?: number; + unit?: string; + }; + cleanupStage?: 'extension' | 'playground' | 'recipe' | 'container' | 'image'; +} diff --git a/packages/shared/src/models/IError.ts b/packages/shared/src/models/IError.ts new file mode 100644 index 000000000..f5303e684 --- /dev/null +++ b/packages/shared/src/models/IError.ts @@ -0,0 +1,64 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ErrorState { + id: string; + message: string; + timestamp: number; + acknowledged: boolean; + source?: string; + details?: unknown; +} + +export interface ApplicationStateErrorDetails { + recipeId?: string; + modelId?: string; + podId?: string; + operation?: string; + timestamp?: number; + resourceType?: 'memory' | 'cpu' | 'gpu' | 'disk' | 'model'; + resourceDetails?: { + required?: number; + available?: number; + unit?: string; + }; + cleanupStage?: 'extension' | 'playground' | 'recipe' | 'container' | 'image'; +} + +export enum ApplicationStateErrorType { + PERSISTENCE_ERROR = 'PERSISTENCE_ERROR', + LOAD_ERROR = 'LOAD_ERROR', + POD_ERROR = 'POD_ERROR', + RESOURCE_ERROR = 'RESOURCE_ERROR', + GPU_ERROR = 'GPU_ERROR', + CLEANUP_ERROR = 'CLEANUP_ERROR', + MODEL_ERROR = 'MODEL_ERROR', + INFERENCE_ERROR = 'INFERENCE_ERROR', + UNKNOWN = 'UNKNOWN', +} + +export class ApplicationStateError extends Error { + constructor( + public type: ApplicationStateErrorType, + message: string, + public details?: unknown, + ) { + super(message); + this.name = 'ApplicationStateError'; + } +} diff --git a/packages/shared/src/models/ServiceMetadata.ts b/packages/shared/src/models/ServiceMetadata.ts new file mode 100644 index 000000000..b00202ff4 --- /dev/null +++ b/packages/shared/src/models/ServiceMetadata.ts @@ -0,0 +1,7 @@ +export interface ServiceMetadata { + authentication?: string[]; + description?: string; + supportedFeatures?: string[]; + apiDocumentation?: string; + paymentInformation?: string; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 689b7a371..ead5b7749 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -15,13 +15,10 @@ */ "allowJs": true, "checkJs": true, + "moduleResolution": "Node", + "paths": { + "@podman-desktop/api": ["../node_modules/@podman-desktop/api"] + } }, - "include": [ - "src/**/*.d.ts", - "src/**/*.ts", - "src/**/*.js", - "src/**/*.svelte", - "types/*.d.ts", - "../../types/**/*.d.ts", - ] + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "types/*.d.ts", "../../types/**/*.d.ts"] } diff --git a/types/podman-desktop-api.d.ts b/types/podman-desktop-api.d.ts index fd69ff26b..c4ae330d1 100644 --- a/types/podman-desktop-api.d.ts +++ b/types/podman-desktop-api.d.ts @@ -2,6 +2,82 @@ // podman-desktop-api.d.ts /* eslint-disable @typescript-eslint/no-explicit-any */ +declare module '@podman-desktop/api' { + export interface ContainerProviderConnection { + name: string; + } + + export interface QuickPickItem { + label: string; + description?: string; + } + + export interface ContainerCreateOptions { + readinessProbe?: { + exec?: { + command: string[]; + }; + }; + } + + export interface TelemetryLogger { + logUsage(event: string, data?: Record): void; + logError(event: string, data?: Record): void; + } + + export interface Webview { + postMessage(message: { id: string; body: any }): Promise; + } + + export const window: { + showWarningMessage(message: string, ...choices: string[]): Promise; + showErrorMessage(message: string): Promise; + showQuickPick(items: T[], options: { placeHolder?: string }): Promise; + }; + + export const env: { + openExternal(uri: Uri): Promise; + clipboard: { writeText(text: string): Promise }; + createTelemetryLogger(): TelemetryLogger; + }; + + export class Uri { + static parse(input: string): Uri; + with(options: { scheme?: string; authority?: string }): Uri; + static file(path: string): Uri; + } + + export const navigation: { + navigateToContainer(containerId: string): Promise; + navigateToPod(kind: string, name: string, engineId: string): Promise; + navigateToResources?(): Promise; + navigateToEditProviderContainerConnection?(connection: ContainerProviderConnection): Promise; + }; + + export const containerEngine: { + listPods(): Promise<{ Id: string; Name: string; engineId: string; kind: string }[]>; + }; + + export interface ExtensionContext { + storagePath: string; + extensionUri: Uri; + subscriptions: { dispose(): void }[]; + } + + export interface WebviewPanel { + webview: Webview; + onDidChangeViewState(callback: (e: WebviewPanelOnDidChangeViewStateEvent) => void): void; + visible: boolean; + } + + export interface WebviewPanelOnDidChangeViewStateEvent { + webviewPanel: WebviewPanel; + } + + export const version: string; +} + +// Also declare the global PodmanDesktopApi interface for legacy support declare global { export interface PodmanDesktopApi { getState: () => any;