This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5262-bde8e88971c33b3e3948f96ef8d86e68b7713962 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 0467c76529981b3951bceb33eedf6bcc776f16a3 Author: Ryan Zhang <[email protected]> AuthorDate: Mon Jul 20 16:11:48 2026 -0700 feat(python-notebook-migration): add notebook migration orchestration service (#5262) ### What changes were proposed in this PR? Introduces `NotebookMigrationService`, the frontend orchestration service that sits between the migration-tool UI and the lower layers: the LLM client (`migration-tool-llm-client`) and the backend notebook-migration microservice (`migration-tool-backend-notebook-migration-service`). **`notebook-migration.service.ts`** - `getAvailableModels()` — `GET /api/models` against the existing LiteLLM proxy, returns the model dropdown options. - `sendToAIGenerateWorkflow(notebook, modelType)` — drives the full `NotebookMigrationLLM` lifecycle (initialize → verify connection → convert → close in `finally`) and returns `{ workflowContent, mappingContent }`. - `sendNotebookToJupyter(notebookData)` — `POST /api/notebook-migration/set-notebook`; surfaces a `NotificationService` toast on success and failure; returns `1` / `0`. - `getJupyterURL()`, `getJupyterIframeURL()` — calls the matching microservice endpoints to retrieve URLs to embed. - `storeNotebookAndMapping(wid, vid, mappingContent, notebookContent)` — `POST /api/notebook-migration/store-notebook-and-mapping`; returns the `HttpClient` observable directly so callers can compose with `switchMap`. - Mapping cache — small in-memory dictionary `{ [key: string]: MappingContent }` keyed by `mapping_wid_<workflowId>`, with `hasMapping`, `getMapping`, `setMapping`, `deleteMapping`. **`notebook-migration.service.spec.ts`** - `getAvailableModels`: maps the LiteLLM `data[].id` array correctly; falls back to an empty array on HTTP error. - `sendNotebookToJupyter`: success → returns `1`; error → returns `0` and toasts. - `getJupyterURL` / `getJupyterIframeURL`: success → returns the URL; non-OK response or thrown error → returns `null`. - Mapping cache: `setMapping` then `getMapping` round-trips; `deleteMapping` removes the entry. - `storeNotebookAndMapping`: makes the expected `POST` to the persistence endpoint. ### Any related issues, documentation, discussions? Closes #5261 Parent issue #4301 ### How was this PR tested? The new `notebook-migration.service.spec.ts` adds `HttpClientTestingModule`-driven test cases ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.7) --- .../notebook-migration/migration-llm.spec.ts | 37 +-- .../service/notebook-migration/migration-llm.ts | 25 +- .../notebook-migration.service.spec.ts | 327 +++++++++++++++++++++ .../notebook-migration.service.ts | 227 ++++++++++++++ 4 files changed, 584 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts b/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts index 58c17cdfc3..b68a6d2d96 100644 --- a/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts +++ b/frontend/src/app/workspace/service/notebook-migration/migration-llm.spec.ts @@ -20,21 +20,15 @@ import { NotebookMigrationLLM, Notebook } from "./migration-llm"; import { GuiConfigService } from "../../../common/service/gui-config.service"; import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service"; -import { generateText } from "ai"; -import type { Mock } from "vitest"; - -// The LLM transport and OpenAI client are mocked so the tests exercise only the -// deterministic transformation (parsing, operator/edge construction, cell<->operator mapping). -vi.mock("ai", () => ({ generateText: vi.fn() })); -vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: vi.fn(() => ({ chat: vi.fn(() => ({})) })), -})); - -const mockGenerateText = generateText as unknown as Mock; describe("NotebookMigrationLLM", () => { let opIdCounter = 0; let stubUtil: WorkflowUtilService; + // Stub the model transport at the class seam (callModel) rather than mocking the + // "ai" module. Module mocks are unreliable in the Angular unit-test builder when + // "ai" is also loaded by a sibling spec, which silently hangs these tests on real + // network calls. + let callModelSpy: ReturnType<typeof vi.spyOn>; // Build a fresh, initialized session with stubbed dependencies. The stubbed // getNewOperatorPredicate hands out deterministic ids (PythonUDFV2-0, -1, ...). @@ -75,12 +69,17 @@ describe("NotebookMigrationLLM", () => { // Queue the two responses convertNotebookToWorkflow consumes, in order. function mockResponses(workflowResponse: string, mappingResponse: string) { - mockGenerateText.mockResolvedValueOnce({ text: workflowResponse }).mockResolvedValueOnce({ text: mappingResponse }); + callModelSpy.mockResolvedValueOnce({ text: workflowResponse }).mockResolvedValueOnce({ text: mappingResponse }); } beforeEach(() => { opIdCounter = 0; - mockGenerateText.mockReset(); + // Default: resolve empty so an unarmed call never reaches the real transport. + callModelSpy = vi.spyOn(NotebookMigrationLLM.prototype as any, "callModel").mockResolvedValue({ text: "" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); describe("convertNotebookToWorkflow", () => { @@ -218,7 +217,7 @@ describe("NotebookMigrationLLM", () => { await expect(makeLLM().convertNotebookToWorkflow(notebook)).rejects.toThrow(/metadata\.uuid/); // It fails before prompting, so the LLM is never called. - expect(mockGenerateText).not.toHaveBeenCalled(); + expect(callModelSpy).not.toHaveBeenCalled(); }); it("joins array-form cell source (nbformat lines) without inserting commas", async () => { @@ -238,8 +237,9 @@ describe("NotebookMigrationLLM", () => { await makeLLM().convertNotebookToWorkflow(notebook); - const allPromptContent = mockGenerateText.mock.calls - .flatMap(call => call[0].messages.map((m: any) => m.content)) + // callModel's first argument is the messages array. + const allPromptContent = callModelSpy.mock.calls + .flatMap((call: any[]) => (call[0] as any[]).map((m: any) => m.content)) .join("\n"); expect(allPromptContent).toContain("import pandas as pd\nx = 1\n"); expect(allPromptContent).not.toContain("import pandas as pd\n,"); @@ -262,8 +262,9 @@ describe("NotebookMigrationLLM", () => { ); await llm.convertNotebookToWorkflow({ cells: [codeCell("BBB", "b = 2")] }); - // The 3rd generateText call is the workflow prompt of the second conversion. - const secondConversionMessages = mockGenerateText.mock.calls[2][0].messages.map((m: any) => m.content).join("\n"); + // The 3rd callModel call is the workflow prompt of the second conversion; + // its first argument is the messages array. + const secondConversionMessages = (callModelSpy.mock.calls[2][0] as any[]).map((m: any) => m.content).join("\n"); expect(secondConversionMessages).toContain("# START BBB"); expect(secondConversionMessages).not.toContain("AAA"); diff --git a/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts b/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts index 2922c3ee0e..6a1ba8b489 100644 --- a/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts +++ b/frontend/src/app/workspace/service/notebook-migration/migration-llm.ts @@ -175,16 +175,7 @@ export class NotebookMigrationLLM { } try { - await generateText({ - model: this.model, - messages: [ - { - role: "user", - content: "ping", - }, - ], - maxOutputTokens: 10, - }); + await this.callModel([{ role: "user", content: "ping" }], 10); return true; } catch (err) { @@ -193,6 +184,15 @@ export class NotebookMigrationLLM { } } + // Seam over the `ai` transport. Specs stub this by spying the method, instead of + // mocking the "ai" module — module mocks are unreliable in the Angular unit-test + // builder when "ai" is also loaded by a sibling spec (e.g. via + // NotebookMigrationService), which silently breaks the mock and hangs these + // tests on a real network call. + protected callModel(messages: ModelMessage[], maxOutputTokens?: number): Promise<{ text: string }> { + return generateText({ model: this.model, messages, maxOutputTokens }); + } + /** * Send a prompt and receive a response. * All prior documentation and conversation is preserved. @@ -207,10 +207,7 @@ export class NotebookMigrationLLM { content: prompt, }); - const result = await generateText({ - model: this.model, - messages: this.messages, - }); + const result = await this.callModel(this.messages); this.messages.push({ role: "assistant", diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts new file mode 100644 index 0000000000..6de43b4146 --- /dev/null +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.spec.ts @@ -0,0 +1,327 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { NotebookMigrationService } from "./notebook-migration.service"; +import { HttpClient } from "@angular/common/http"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { GuiConfigService } from "src/app/common/service/gui-config.service"; +import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service"; +import { firstValueFrom, throwError } from "rxjs"; + +describe("NotebookMigrationService", () => { + let service: NotebookMigrationService; + let httpMock: HttpTestingController; + let mockNotificationService: { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> }; + // Mutable so individual describe blocks can flip the flag mid-spec by + // reassigning `mockGuiConfigService.env.pythonNotebookMigrationEnabled`. + // The service stores a reference to this object, so mutations are observed + // on the next read of `this.enabled`. + let mockGuiConfigService: { env: { pythonNotebookMigrationEnabled: boolean } }; + + beforeEach(() => { + mockNotificationService = { + success: vi.fn(), + error: vi.fn(), + }; + mockGuiConfigService = { env: { pythonNotebookMigrationEnabled: true } }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + NotebookMigrationService, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: GuiConfigService, useValue: mockGuiConfigService }, + // Stub so the real WorkflowUtilService (and its OperatorMetadataService, + // which fires GET /api/resources/operator-metadata on construction) is + // never built. The service only passes it to NotebookMigrationLLM, which + // no test exercises. + { provide: WorkflowUtilService, useValue: {} }, + ], + }); + + service = TestBed.inject(NotebookMigrationService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + vi.restoreAllMocks(); + }); + + // getAvailableModels + it("should fetch and map available models", async () => { + const mockResponse = { + data: [ + { id: "gpt-4", object: "", created: 0, owned_by: "" }, + { id: "gpt-3.5", object: "", created: 0, owned_by: "" }, + ], + object: "", + }; + + const promise = firstValueFrom(service.getAvailableModels()); + + const req = httpMock.expectOne(req => req.url.includes("/models")); + expect(req.request.method).toBe("GET"); + req.flush(mockResponse); + + const models = await promise; + expect(models.length).toBe(2); + expect(models[0].name).toBe("gpt-4"); + }); + + it("should return empty array on getAvailableModels error", async () => { + const promise = firstValueFrom(service.getAvailableModels()); + + const req = httpMock.expectOne(req => req.url.includes("/models")); + req.error(new ErrorEvent("Network error")); + + expect(await promise).toEqual([]); + }); + + // sendNotebookToJupyter + it("should send notebook successfully and return 1", async () => { + const mockNotebook: any = { cells: [] }; + + const promise = service.sendNotebookToJupyter(mockNotebook); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/set-notebook")); + + expect(req.request.method).toBe("POST"); + + req.flush({ success: true }); + + const result = await promise; + + expect(result).toBe(1); + expect(mockNotificationService.success).toHaveBeenCalled(); + }); + + it("should handle error when sending notebook and return 0", async () => { + const mockNotebook: any = { cells: [] }; + + const promise = service.sendNotebookToJupyter(mockNotebook); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/set-notebook")); + + req.error(new ErrorEvent("Server error")); + + const result = await promise; + + expect(result).toBe(0); + expect(mockNotificationService.error).toHaveBeenCalled(); + }); + + it("includes the Error message in the failure toast when an Error is thrown", async () => { + // HttpTestingController's req.error yields an HttpErrorResponse (not an Error + // instance), so spy on http.post directly to exercise the `error instanceof + // Error` branch. No request reaches the testing backend, so verify() stays happy. + vi.spyOn(TestBed.inject(HttpClient), "post").mockReturnValue(throwError(() => new Error("network down"))); + + const result = await service.sendNotebookToJupyter({ cells: [] } as any); + + expect(result).toBe(0); + expect(mockNotificationService.error).toHaveBeenCalledWith(expect.stringContaining("network down")); + }); + + // jupyter URL methods (HttpClient so the JwtModule interceptor attaches the auth token) + it("should return Jupyter URL when the request succeeds", async () => { + const promise = service.getJupyterURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-url")); + expect(req.request.method).toBe("GET"); + req.flush({ success: true, url: "http://jupyter" }); + + expect(await promise).toBe("http://jupyter"); + }); + + it("should return null when the Jupyter URL request fails", async () => { + const promise = service.getJupyterURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-url")); + req.flush({ success: false }, { status: 500, statusText: "Server Error" }); + + expect(await promise).toBeNull(); + }); + + it("should return null when the Jupyter URL response is 200 but unsuccessful", async () => { + const promise = service.getJupyterURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-url")); + req.flush({ success: false }); + + expect(await promise).toBeNull(); + }); + + it("should return iframe URL when the request succeeds", async () => { + const promise = service.getJupyterIframeURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-iframe-url")); + expect(req.request.method).toBe("GET"); + req.flush({ success: true, url: "http://iframe" }); + + expect(await promise).toBe("http://iframe"); + }); + + it("should return null when the iframe URL request fails", async () => { + const promise = service.getJupyterIframeURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-iframe-url")); + req.flush({ success: false }, { status: 500, statusText: "Server Error" }); + + expect(await promise).toBeNull(); + }); + + it("should return null when the iframe URL response is 200 but unsuccessful", async () => { + const promise = service.getJupyterIframeURL(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/get-jupyter-iframe-url")); + req.flush({ success: false }); + + expect(await promise).toBeNull(); + }); + + // mapping logic + it("should set and get mapping", () => { + const mockMapping: any = { + cell_to_operator: { a: 1 }, + operator_to_cell: { b: 2 }, + }; + + service.setMapping("test", mockMapping); + + expect(service.hasMapping("test")).toBe(true); + expect(service.getMapping("test")).toEqual(mockMapping); + }); + + it("should delete mapping", () => { + service.setMapping("test", { cell_to_operator: {}, operator_to_cell: {} }); + + service.deleteMapping("test"); + + expect(service.hasMapping("test")).toBe(false); + }); + + // storeNotebookAndMapping + it("should call storeNotebookAndMapping API", () => { + service.storeNotebookAndMapping(1, 1, {}, {}).subscribe(); + + const req = httpMock.expectOne(req => req.url.includes("/notebook-migration/store-notebook-and-mapping")); + + expect(req.request.method).toBe("POST"); + req.flush({ success: true, message: "stored" }); + }); + + // sendToAIGenerateWorkflow (enabled) — drives the NotebookMigrationLLM lifecycle. + // The service builds the client through its createMigrationLLM() seam, so stub + // that with a plain fake. This keeps the real NotebookMigrationLLM (and its "ai" + // transport) out of this spec's module graph, avoiding collisions with the "ai" + // mock in migration-llm.spec.ts. + describe("sendToAIGenerateWorkflow (enabled)", () => { + let fakeLLM: { + initialize: ReturnType<typeof vi.fn>; + verifyConnection: ReturnType<typeof vi.fn>; + convertNotebookToWorkflow: ReturnType<typeof vi.fn>; + close: ReturnType<typeof vi.fn>; + }; + + beforeEach(() => { + fakeLLM = { + initialize: vi.fn(), + verifyConnection: vi.fn().mockResolvedValue(true), + convertNotebookToWorkflow: vi.fn(), + close: vi.fn(), + }; + vi.spyOn(service as any, "createMigrationLLM").mockReturnValue(fakeLLM); + }); + + it("returns the parsed workflow and mapping, and closes the client", async () => { + fakeLLM.convertNotebookToWorkflow.mockResolvedValue( + JSON.stringify({ workflowJSON: { ops: 1 }, workflowNotebookMapping: { m: 2 } }) + ); + + const result = await service.sendToAIGenerateWorkflow({ cells: [] } as any, "gpt-4"); + + expect(result).toEqual({ workflowContent: { ops: 1 }, mappingContent: { m: 2 } }); + expect(fakeLLM.initialize).toHaveBeenCalledWith("gpt-4"); + expect(fakeLLM.close).toHaveBeenCalled(); + }); + + it("rejects when the connection cannot be verified, and still closes the client", async () => { + fakeLLM.verifyConnection.mockResolvedValue(false); + + await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any, "gpt-4")).rejects.toThrow(/authenticate/i); + // verifyConnection runs inside the outer try, so the finally still closes the client. + expect(fakeLLM.close).toHaveBeenCalled(); + }); + + it("rethrows conversion errors and still closes the client", async () => { + fakeLLM.convertNotebookToWorkflow.mockRejectedValue(new Error("conversion boom")); + + await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any, "gpt-4")).rejects.toThrow(/conversion boom/); + expect(fakeLLM.close).toHaveBeenCalled(); + }); + }); + + // Feature flag gate (defence in depth). With the flag off, every public + // method must short-circuit — no HTTP traffic, no fetch, no LLM lifecycle, + // no notifications. + describe("when the feature flag is disabled", () => { + beforeEach(() => { + mockGuiConfigService.env.pythonNotebookMigrationEnabled = false; + }); + + it("getAvailableModels emits an empty array and makes no HTTP call", async () => { + const models = await firstValueFrom(service.getAvailableModels()); + expect(models).toEqual([]); + httpMock.expectNone(req => req.url.includes("/models")); + }); + + it("sendToAIGenerateWorkflow rejects with a disabled-feature error", async () => { + await expect(service.sendToAIGenerateWorkflow({ cells: [] } as any, "gpt-4")).rejects.toThrow(/disabled/i); + }); + + it("sendNotebookToJupyter returns 0 with no HTTP call or notification", async () => { + const result = await service.sendNotebookToJupyter({ cells: [] } as any); + expect(result).toBe(0); + expect(mockNotificationService.success).not.toHaveBeenCalled(); + expect(mockNotificationService.error).not.toHaveBeenCalled(); + httpMock.expectNone(req => req.url.includes("/notebook-migration/set-notebook")); + }); + + it("getJupyterURL returns null without making an HTTP call", async () => { + const result = await service.getJupyterURL(); + expect(result).toBeNull(); + httpMock.expectNone(req => req.url.includes("/notebook-migration/get-jupyter-url")); + }); + + it("getJupyterIframeURL returns null without making an HTTP call", async () => { + const result = await service.getJupyterIframeURL(); + expect(result).toBeNull(); + httpMock.expectNone(req => req.url.includes("/notebook-migration/get-jupyter-iframe-url")); + }); + + it("storeNotebookAndMapping emits a disabled result without making an HTTP call", async () => { + const result = await firstValueFrom(service.storeNotebookAndMapping(1, 1, {}, {})); + expect(result.success).toBe(false); + httpMock.expectNone(req => req.url.includes("/notebook-migration/store-notebook-and-mapping")); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts new file mode 100644 index 0000000000..7bfd3ef541 --- /dev/null +++ b/frontend/src/app/workspace/service/notebook-migration/notebook-migration.service.ts @@ -0,0 +1,227 @@ +/** + * 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 { Injectable } from "@angular/core"; +import { AppSettings } from "../../../common/app-setting"; +import { Notebook, NotebookMigrationLLM } from "./migration-llm"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { NotificationService } from "src/app/common/service/notification/notification.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { WorkflowUtilService } from "../workflow-graph/util/workflow-util.service"; +import { catchError, firstValueFrom, map, Observable, of } from "rxjs"; + +interface LiteLLMModel { + id: string; + object: string; + created: number; + owned_by: string; +} + +interface LiteLLMModelsResponse { + data: LiteLLMModel[]; + object: string; +} + +interface MappingContent { + cell_to_operator: Record<string, string[]>; + operator_to_cell: Record<string, string[]>; +} + +interface StoreNotebookResponse { + success: boolean; + message: string; +} + +@Injectable({ + providedIn: "root", +}) +export class NotebookMigrationService { + private mapping: { [key: string]: MappingContent } = {}; + + constructor( + private http: HttpClient, + private notificationService: NotificationService, + private config: GuiConfigService, + private workflowUtilService: WorkflowUtilService + ) {} + + private get enabled(): boolean { + return this.config.env.pythonNotebookMigrationEnabled; + } + + public getAvailableModels(): Observable<{ name: string }[]> { + if (!this.enabled) return of([]); + return this.http.get<LiteLLMModelsResponse>(`${AppSettings.getApiEndpoint()}/models`).pipe( + map(response => + response.data.map(model => ({ + name: model.id, + })) + ), + catchError((err: unknown) => { + console.error("Failed to fetch models", err); + return of([]); + }) + ); + } + + public async sendToAIGenerateWorkflow(notebookContent: Notebook, modelType: string) { + if (!this.enabled) throw new Error("Notebook migration feature is disabled"); + const migrationLLM = this.createMigrationLLM(); + // initialize() defaults to the user's Texera JWT via AuthService.getAccessToken(). + // The outer try/finally guarantees close() runs for the whole lifecycle, + // including a verifyConnection failure. + try { + migrationLLM.initialize(modelType); + + const isValid = await migrationLLM.verifyConnection(); + if (!isValid) { + throw new Error("Unable to authenticate with or reach the LLM backend"); + } + + try { + const result = await migrationLLM.convertNotebookToWorkflow(notebookContent); + const parsedResult = JSON.parse(result); + const workflowContent = parsedResult.workflowJSON; + const mappingContent = parsedResult.workflowNotebookMapping; + return { workflowContent, mappingContent }; + } catch (error) { + console.error("Error converting notebook:", error); + throw error; + } + } finally { + migrationLLM.close(); + } + } + + // Factory seam for the LLM client. Extracted so specs can override it to supply + // a fake, keeping the real NotebookMigrationLLM (and its `ai` transport) out of + // the test module graph. A new instance is created per conversion. + protected createMigrationLLM(): NotebookMigrationLLM { + return new NotebookMigrationLLM(this.config, this.workflowUtilService); + } + + public async sendNotebookToJupyter(notebookData: Notebook) { + if (!this.enabled) return 0; + const jupyterAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/set-notebook`; + + const requestBody = { + // Fixed filename is intentional for the v1 per-user-pod design: each user runs + // their own notebook-migration-service and Jupyter, so a single notebook.ipynb + // never collides. A shared multi-user (global) service would need per-user or + // per-workflow keying here and for the backend's process-global jupyterIframeURL. + notebookName: "notebook.ipynb", + notebookData: notebookData, + }; + + const headers = new HttpHeaders({ + "Content-Type": "application/json", + }); + + try { + await firstValueFrom(this.http.post(jupyterAPIUrl, requestBody, { headers })); + this.notificationService.success("Notebook successfully sent to Jupyter"); + return 1; + } catch (error) { + console.error("Error sending notebook to pod: ", error); + const message = error instanceof Error ? error.message : String(error); + this.notificationService.error("Error sending notebook to Jupyter: " + message); + return 0; + } + } + + public async getJupyterURL(): Promise<string | null> { + if (!this.enabled) return null; + try { + const data = await firstValueFrom( + this.http.get<{ success: boolean; url?: string }>( + `${AppSettings.getApiEndpoint()}/notebook-migration/get-jupyter-url` + ) + ); + + if (!data.success || !data.url) { + console.error("Jupyter server unavailable"); + return null; + } + + return data.url; + } catch (err) { + console.error("Error fetching Jupyter URL:", err); + return null; + } + } + + public async getJupyterIframeURL(): Promise<string | null> { + if (!this.enabled) return null; + try { + const data = await firstValueFrom( + this.http.get<{ success: boolean; url?: string }>( + `${AppSettings.getApiEndpoint()}/notebook-migration/get-jupyter-iframe-url` + ) + ); + + if (!data.success || !data.url) { + console.error("Jupyter server unavailable"); + return null; + } + + return data.url; + } catch (err) { + console.error("Error fetching Jupyter iframe URL:", err); + return null; + } + } + + public storeNotebookAndMapping( + wid: number | undefined, + vid: number = 1, + mappingContent: any, + notebookContent: any + ): Observable<StoreNotebookResponse> { + if (!this.enabled) { + return of({ success: false, message: "Notebook migration feature is disabled" }); + } + const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/store-notebook-and-mapping`; + const headers = new HttpHeaders({ "Content-Type": "application/json" }); + + const payload = { + wid, + vid, + mapping: mappingContent, + notebook: notebookContent, + }; + + return this.http.post<StoreNotebookResponse>(dbAPIUrl, payload, { headers }); + } + + public hasMapping(id: string): boolean { + return id in this.mapping; + } + + public getMapping(id: string): MappingContent | undefined { + return this.mapping[id]; + } + + public setMapping(id: string, value: MappingContent): void { + this.mapping[id] = value; + } + + public deleteMapping(id: string): void { + delete this.mapping[id]; + } +}
