From d4f06c242aa1c741e9ebd3d0f0e560952e9d6e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sun, 19 Jul 2026 11:33:52 -0300 Subject: [PATCH] refactor(core): centralize session lifecycle composition --- packages/cli/src/commands/session.ts | 257 +++++---------- packages/core/src/index.ts | 22 ++ packages/core/src/session-lifecycle.ts | 318 +++++++++++++++++++ packages/core/test/session-lifecycle.test.ts | 282 ++++++++++++++++ packages/mcp-server/src/tools/session.ts | 298 ++++++----------- 5 files changed, 791 insertions(+), 386 deletions(-) create mode 100644 packages/core/src/session-lifecycle.ts create mode 100644 packages/core/test/session-lifecycle.test.ts diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 4afc741..a46c536 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -9,9 +9,18 @@ import { getBrowserSessionStatus, } from "@pickforge/picklab-browser"; import { + createLocalSessions, + destroyLocalSessions, getSession, listSessions, loadConfig, + localSessionStatusEntry, + type LocalSessionCreateRuntime, + type LocalSessionDestroyRuntime, + type LocalSessionRecipe, + type LocalSessionStatusEntry, + type LocalSessionStatusRuntime, + type LocalSessionSummary, type SessionRecord, } from "@pickforge/picklab-core"; import { @@ -28,7 +37,7 @@ import { import { watchDesktopSession } from "./watch.js"; export interface SessionCreateOptions extends BaseCliOptions { - type: string; + type: LocalSessionRecipe; width?: string; height?: string; vnc?: boolean; @@ -37,80 +46,65 @@ export interface SessionCreateOptions extends BaseCliOptions { viewer?: boolean; } -interface SessionSummary extends Record { - id: string; - type: "desktop" | "android" | "browser"; -} - -async function createDesktopLeg( - opts: SessionCreateOptions, -): Promise { - const handle = await createDesktopSession({ - projectDir: resolveProjectDir(opts), - width: - opts.width === undefined ? undefined : parseIntArg(opts.width, "--width"), - height: - opts.height === undefined - ? undefined - : parseIntArg(opts.height, "--height"), - vnc: opts.vnc, - vncControl: opts.vncControl, - }); - const summary: SessionSummary = { - id: handle.id, - type: "desktop", - display: handle.display, - logDir: handle.logDir, - }; - if (handle.vncPort !== undefined) { - summary.vncPort = handle.vncPort; - summary.vncViewOnly = handle.vncViewOnly; - } - return summary; -} -async function createBrowserLeg( - opts: SessionCreateOptions, -): Promise { - const handle = await createBrowserSession({ - projectDir: resolveProjectDir(opts), - width: - opts.width === undefined ? undefined : parseIntArg(opts.width, "--width"), - height: - opts.height === undefined - ? undefined - : parseIntArg(opts.height, "--height"), - }); - return { - id: handle.id, - type: "browser", - display: handle.display, - cdpPort: handle.cdpPort, - profileDir: handle.profileDir, - binaryPath: handle.binaryPath, - logDir: handle.logDir, - }; -} - -async function createAndroidLeg( - opts: SessionCreateOptions, -): Promise { +function createRuntime(opts: SessionCreateOptions): LocalSessionCreateRuntime { const projectDir = resolveProjectDir(opts); - const config = await loadConfig(projectDir); - const avdName = opts.avdName ?? config.android?.avdName; - const handle = await createAndroidSession( - avdName === undefined ? { projectDir } : { projectDir, avdName }, - ); return { - id: handle.id, - type: "android", - avdName: handle.avdName, - serial: handle.serial, - consolePort: handle.consolePort, - logDir: handle.logDir, + desktop: { + create: () => + createDesktopSession({ + projectDir, + width: + opts.width === undefined + ? undefined + : parseIntArg(opts.width, "--width"), + height: + opts.height === undefined + ? undefined + : parseIntArg(opts.height, "--height"), + vnc: opts.vnc, + vncControl: opts.vncControl, + }), + destroy: (id) => destroyDesktopSession(id), + }, + browser: { + create: () => + createBrowserSession({ + projectDir, + width: + opts.width === undefined + ? undefined + : parseIntArg(opts.width, "--width"), + height: + opts.height === undefined + ? undefined + : parseIntArg(opts.height, "--height"), + }), + }, + android: { + create: async () => { + const config = await loadConfig(projectDir); + const avdName = opts.avdName ?? config.android?.avdName; + return createAndroidSession( + avdName === undefined ? { projectDir } : { projectDir, avdName }, + ); + }, + }, }; } -function describeCreated(summary: SessionSummary): string { +const statusRuntime: LocalSessionStatusRuntime = { + desktop: { status: (id) => getDesktopSessionStatus(id) }, + browser: { status: (id) => getBrowserSessionStatus(id) }, + android: { status: (id) => getAndroidSessionStatus(id) }, +}; + +const destroyRuntime: LocalSessionDestroyRuntime = { + desktop: { destroy: (id) => destroyDesktopSession(id) }, + browser: { destroy: (id) => destroyBrowserSession(id) }, + android: { destroy: (id) => destroyAndroidSession(id) }, +}; + +function describeCreated(summary: LocalSessionSummary): string { if (summary.type === "desktop") { const vnc = summary.vncPort === undefined ? "" : `, vnc port ${summary.vncPort}`; @@ -159,24 +153,7 @@ export async function runSessionCreate( (viewerMode === "auto" && (!createsWritableDesktop || opts.vncControl !== true))); - const sessions: SessionSummary[] = []; - if (opts.type === "desktop" || opts.type === "desktop+android") { - sessions.push(await createDesktopLeg(opts)); - } - if (opts.type === "browser") { - sessions.push(await createBrowserLeg(opts)); - } - if (opts.type === "android" || opts.type === "desktop+android") { - try { - sessions.push(await createAndroidLeg(opts)); - } catch (error) { - const desktop = sessions.find((session) => session.type === "desktop"); - if (desktop !== undefined) { - await destroyDesktopSession(desktop.id).catch(() => {}); - } - throw error; - } - } + const sessions = await createLocalSessions(opts.type, createRuntime(opts)); const lines = sessions.map(describeCreated); const data: Record = { sessions }; @@ -212,75 +189,7 @@ export async function runSessionCreate( }); } -async function sessionStatusEntry( - record: SessionRecord, -): Promise> { - const entry: Record = { - id: record.id, - type: record.type, - status: record.status, - createdAt: record.createdAt, - projectDir: record.projectDir, - }; - if (record.type === "browser") { - const browserStatus = await getBrowserSessionStatus(record.id); - const desktopStatus = await getDesktopSessionStatus(record.id); - if (record.status === "running" && !browserStatus.alive) { - entry.status = "dead"; - } - entry.desktop = { - ...record.desktop, - xvfbAlive: browserStatus.xvfbAlive, - vncAlive: desktopStatus.vncAlive, - displayAlive: browserStatus.displayAlive, - }; - entry.browser = { - ...record.browser, - browserAlive: browserStatus.browserAlive, - }; - entry.viewer = { - endpoint: - record.desktop?.vncPort === undefined - ? null - : `vnc://127.0.0.1:${record.desktop.vncPort}`, - ready: desktopStatus.vncAlive, - readOnly: record.desktop?.vncViewOnly === true, - }; - } else if (record.desktop !== undefined) { - const status = await getDesktopSessionStatus(record.id); - if (record.status === "running" && !status.xvfbAlive) { - entry.status = "dead"; - } - entry.desktop = { - ...record.desktop, - xvfbAlive: status.xvfbAlive, - vncAlive: status.vncAlive, - displayAlive: status.displayAlive, - }; - entry.viewer = { - endpoint: - record.desktop.vncPort === undefined - ? null - : `vnc://127.0.0.1:${record.desktop.vncPort}`, - ready: status.vncAlive, - readOnly: record.desktop.vncViewOnly === true, - }; - } - if (record.android !== undefined) { - const status = await getAndroidSessionStatus(record.id); - if (record.status === "running" && !status.emulatorAlive) { - entry.status = "dead"; - } - entry.android = { - ...record.android, - emulatorAlive: status.emulatorAlive, - deviceState: status.deviceState, - }; - } - return entry; -} - -function statusLine(entry: Record): string { +function statusLine(entry: LocalSessionStatusEntry): string { const parts = [`${entry.id} ${entry.type} ${entry.status}`]; const desktop = entry.desktop as Record | undefined; if (desktop !== undefined) { @@ -325,9 +234,9 @@ export async function runSessionStatus( } else { records = await listSessions(); } - const sessions: Array> = []; + const sessions: LocalSessionStatusEntry[] = []; for (const record of records) { - sessions.push(await sessionStatusEntry(record)); + sessions.push(await localSessionStatusEntry(record, statusRuntime)); } return { data: { sessions }, @@ -340,20 +249,6 @@ export interface SessionDestroyOptions extends BaseCliOptions { all?: boolean; } -async function destroyRecord(record: SessionRecord): Promise { - if (record.type === "desktop") { - await destroyDesktopSession(record.id); - } else if (record.type === "browser") { - await destroyBrowserSession(record.id); - } else if (record.type === "android") { - await destroyAndroidSession(record.id); - } else { - throw new Error( - `Cannot destroy session ${record.id} of type "${record.type}"`, - ); - } -} - export async function runSessionDestroy( id: string | undefined, opts: SessionDestroyOptions, @@ -375,18 +270,10 @@ export async function runSessionDestroy( } else { records.push(...(await listSessions())); } - const destroyed: string[] = []; - const errors: string[] = []; - for (const record of records) { - try { - await destroyRecord(record); - destroyed.push(record.id); - } catch (error) { - errors.push( - `${record.id}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } + const { destroyed, errors } = await destroyLocalSessions( + records, + destroyRuntime, + ); return { data: { destroyed }, lines: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dee19ef..cb8a877 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -131,6 +131,28 @@ export { type SessionCapability, } from "./target.js"; +export { + createLocalSessions, + destroyLocalSession, + destroyLocalSessions, + localSessionStatusEntry, + type AndroidLegHandle, + type AndroidLiveStatus, + type BrowserLegHandle, + type BrowserLiveStatus, + type DesktopLegHandle, + type DesktopLiveStatus, + type DestroyLocalSessionsOptions, + type DestroyLocalSessionsResult, + type LocalSessionCreateRuntime, + type LocalSessionDestroyRuntime, + type LocalSessionLifecycle, + type LocalSessionRecipe, + type LocalSessionStatusEntry, + type LocalSessionStatusRuntime, + type LocalSessionSummary, +} from "./session-lifecycle.js"; + export { REAPER_CLEANUP_PENDING_META_KEY, createSession, diff --git a/packages/core/src/session-lifecycle.ts b/packages/core/src/session-lifecycle.ts new file mode 100644 index 0000000..3a2a354 --- /dev/null +++ b/packages/core/src/session-lifecycle.ts @@ -0,0 +1,318 @@ +import type { SessionRecord, SessionType } from "./session.js"; + +export type LocalSessionRecipe = SessionType; + +export interface LocalSessionLifecycle { + signal?: AbortSignal; +} + +export interface DesktopLegHandle { + id: string; + display: string; + logDir: string; + vncPort?: number; + vncViewOnly?: boolean; +} + +export interface AndroidLegHandle { + id: string; + avdName: string; + serial: string; + consolePort: number; + logDir: string; +} + +export interface BrowserLegHandle { + id: string; + display: string; + cdpPort: number; + profileDir: string; + binaryPath: string; + logDir: string; +} + +export type LocalSessionSummary = + | ({ type: "desktop" } & DesktopLegHandle) + | ({ type: "android" } & AndroidLegHandle) + | ({ type: "browser" } & BrowserLegHandle); + +export interface DesktopLiveStatus { + xvfbAlive: boolean; + vncAlive: boolean; + displayAlive: boolean; +} + +export interface AndroidLiveStatus { + emulatorAlive: boolean; + deviceState: string | null; +} + +export interface BrowserLiveStatus { + alive: boolean; + xvfbAlive: boolean; + displayAlive: boolean; + browserAlive: boolean; +} + +export interface LocalSessionCreateRuntime { + desktop: { + create: () => Promise; + destroy: (id: string) => Promise; + }; + android: { + create: () => Promise; + }; + browser: { + create: () => Promise; + }; +} + +export interface LocalSessionStatusRuntime { + desktop: { + status: (id: string) => Promise; + }; + android: { + status: (id: string) => Promise; + }; + browser: { + status: (id: string) => Promise; + }; +} + +export interface LocalSessionDestroyRuntime { + desktop: { + destroy: (id: string) => Promise; + }; + android: { + destroy: (id: string) => Promise; + }; + browser: { + destroy: (id: string) => Promise; + }; +} + +export interface LocalSessionStatusEntry extends Record { + id: string; + type: SessionType; + status: string; + createdAt: string; + projectDir: string; + desktop?: Record; + android?: Record; + browser?: Record; + viewer?: Record; +} + +export interface DestroyLocalSessionsResult { + destroyed: string[]; + errors: string[]; +} + +export interface DestroyLocalSessionsOptions { + aroundDestroy?: ( + record: SessionRecord, + destroy: () => Promise, + ) => Promise; +} + +function assertRecipe(recipe: LocalSessionRecipe): void { + if ( + recipe !== "desktop" && + recipe !== "android" && + recipe !== "desktop+android" && + recipe !== "browser" + ) { + throw new Error(`Unsupported local session recipe: ${String(recipe)}`); + } +} + +function assertNotAborted( + signal: AbortSignal | undefined, + recipe?: LocalSessionRecipe, +): void { + if (signal?.aborted === true) { + throw new Error( + recipe === "browser" + ? "Browser session creation aborted by the client" + : "Session creation aborted by the client", + ); + } +} + +function desktopSummary(handle: DesktopLegHandle): LocalSessionSummary { + const summary: LocalSessionSummary = { + id: handle.id, + type: "desktop", + display: handle.display, + logDir: handle.logDir, + }; + if (handle.vncPort !== undefined) { + summary.vncPort = handle.vncPort; + summary.vncViewOnly = handle.vncViewOnly; + } + return summary; +} + +function androidSummary(handle: AndroidLegHandle): LocalSessionSummary { + return { + id: handle.id, + type: "android", + avdName: handle.avdName, + serial: handle.serial, + consolePort: handle.consolePort, + logDir: handle.logDir, + }; +} + +function browserSummary(handle: BrowserLegHandle): LocalSessionSummary { + return { + id: handle.id, + type: "browser", + display: handle.display, + cdpPort: handle.cdpPort, + profileDir: handle.profileDir, + binaryPath: handle.binaryPath, + logDir: handle.logDir, + }; +} + +export async function createLocalSessions( + recipe: LocalSessionRecipe, + runtime: LocalSessionCreateRuntime, + lifecycle: LocalSessionLifecycle = {}, +): Promise { + assertRecipe(recipe); + assertNotAborted(lifecycle.signal, recipe); + + const sessions: LocalSessionSummary[] = []; + if (recipe === "desktop" || recipe === "desktop+android") { + sessions.push(desktopSummary(await runtime.desktop.create())); + } + if (recipe === "browser") { + sessions.push(browserSummary(await runtime.browser.create())); + } + if (recipe === "android" || recipe === "desktop+android") { + try { + assertNotAborted(lifecycle.signal); + sessions.push(androidSummary(await runtime.android.create())); + } catch (error) { + const desktop = sessions.find((session) => session.type === "desktop"); + if (desktop !== undefined) { + await runtime.desktop.destroy(desktop.id).catch(() => {}); + } + throw error; + } + } + return sessions; +} + +export async function localSessionStatusEntry( + record: SessionRecord, + runtime: LocalSessionStatusRuntime, +): Promise { + const entry: LocalSessionStatusEntry = { + id: record.id, + type: record.type, + status: record.status, + createdAt: record.createdAt, + projectDir: record.projectDir, + }; + if (record.type === "browser") { + const browserStatus = await runtime.browser.status(record.id); + const desktopStatus = await runtime.desktop.status(record.id); + if (record.status === "running" && !browserStatus.alive) { + entry.status = "dead"; + } + entry.desktop = { + ...record.desktop, + xvfbAlive: browserStatus.xvfbAlive, + vncAlive: desktopStatus.vncAlive, + displayAlive: browserStatus.displayAlive, + }; + entry.browser = { + ...record.browser, + browserAlive: browserStatus.browserAlive, + }; + entry.viewer = { + endpoint: + record.desktop?.vncPort === undefined + ? null + : `vnc://127.0.0.1:${record.desktop.vncPort}`, + ready: desktopStatus.vncAlive, + readOnly: record.desktop?.vncViewOnly === true, + }; + } else if (record.desktop !== undefined) { + const status = await runtime.desktop.status(record.id); + if (record.status === "running" && !status.xvfbAlive) { + entry.status = "dead"; + } + entry.desktop = { + ...record.desktop, + xvfbAlive: status.xvfbAlive, + vncAlive: status.vncAlive, + displayAlive: status.displayAlive, + }; + entry.viewer = { + endpoint: + record.desktop.vncPort === undefined + ? null + : `vnc://127.0.0.1:${record.desktop.vncPort}`, + ready: status.vncAlive, + readOnly: record.desktop.vncViewOnly === true, + }; + } + if (record.android !== undefined) { + const status = await runtime.android.status(record.id); + if (record.status === "running" && !status.emulatorAlive) { + entry.status = "dead"; + } + entry.android = { + ...record.android, + emulatorAlive: status.emulatorAlive, + deviceState: status.deviceState, + }; + } + return entry; +} + +export async function destroyLocalSession( + record: SessionRecord, + runtime: LocalSessionDestroyRuntime, +): Promise { + if (record.type === "desktop") { + await runtime.desktop.destroy(record.id); + } else if (record.type === "browser") { + await runtime.browser.destroy(record.id); + } else if (record.type === "android") { + await runtime.android.destroy(record.id); + } else { + throw new Error( + `Cannot destroy session ${record.id} of type "${record.type}"`, + ); + } +} + +export async function destroyLocalSessions( + records: readonly SessionRecord[], + runtime: LocalSessionDestroyRuntime, + options: DestroyLocalSessionsOptions = {}, +): Promise { + const destroyed: string[] = []; + const errors: string[] = []; + for (const record of records) { + try { + const destroy = () => destroyLocalSession(record, runtime); + if (options.aroundDestroy === undefined) { + await destroy(); + } else { + await options.aroundDestroy(record, destroy); + } + destroyed.push(record.id); + } catch (error) { + errors.push( + `${record.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { destroyed, errors }; +} diff --git a/packages/core/test/session-lifecycle.test.ts b/packages/core/test/session-lifecycle.test.ts new file mode 100644 index 0000000..3a54bf8 --- /dev/null +++ b/packages/core/test/session-lifecycle.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createLocalSessions, + destroyLocalSessions, + localSessionStatusEntry, + type LocalSessionCreateRuntime, + type LocalSessionDestroyRuntime, + type LocalSessionRecipe, + type LocalSessionStatusRuntime, + type SessionRecord, +} from "../src/index.js"; + +const desktopHandle = { + id: "desk-123456", + display: ":90", + logDir: "/logs/desktop", + vncPort: 5900, + vncViewOnly: true, +}; +const androidHandle = { + id: "andr-123456", + avdName: "picklab", + serial: "emulator-5554", + consolePort: 5554, + logDir: "/logs/android", +}; +const browserHandle = { + id: "brow-123456", + display: ":200", + cdpPort: 9222, + profileDir: "/logs/browser/profile", + binaryPath: "/usr/bin/chromium", + logDir: "/logs/browser", +}; + +function createRuntime( + overrides: Partial = {}, +): LocalSessionCreateRuntime { + return { + desktop: { + create: vi.fn(async () => desktopHandle), + destroy: vi.fn(async () => undefined), + ...overrides.desktop, + }, + android: { + create: vi.fn(async () => androidHandle), + ...overrides.android, + }, + browser: { + create: vi.fn(async () => browserHandle), + ...overrides.browser, + }, + }; +} + +function record(patch: Partial = {}): SessionRecord { + return { + id: "desk-123456", + type: "desktop", + createdAt: "2026-01-01T00:00:00.000Z", + status: "running", + projectDir: "/project", + desktop: { display: ":90", vncPort: 5900, vncViewOnly: true }, + ...patch, + }; +} + +describe("local session lifecycle", () => { + it("validates recipes and expands desktop+android in order", async () => { + const calls: string[] = []; + const runtime = createRuntime({ + desktop: { + create: vi.fn(async () => { + calls.push("desktop"); + return desktopHandle; + }), + destroy: vi.fn(async () => undefined), + }, + android: { + create: vi.fn(async () => { + calls.push("android"); + return androidHandle; + }), + }, + }); + + await expect( + createLocalSessions("cloud" as LocalSessionRecipe, runtime), + ).rejects.toThrow("Unsupported local session recipe: cloud"); + const sessions = await createLocalSessions("desktop+android", runtime); + + expect(calls).toEqual(["desktop", "android"]); + expect(sessions).toEqual([ + { type: "desktop", ...desktopHandle }, + { type: "android", ...androidHandle }, + ]); + }); + + it("rolls back the desktop leg when android creation fails", async () => { + const failure = new Error("android failed"); + const destroy = vi.fn(async () => undefined); + const runtime = createRuntime({ + desktop: { + create: vi.fn(async () => desktopHandle), + destroy, + }, + android: { create: vi.fn(async () => Promise.reject(failure)) }, + }); + + await expect( + createLocalSessions("desktop+android", runtime), + ).rejects.toBe(failure); + expect(destroy).toHaveBeenCalledWith(desktopHandle.id); + }); + + it("honors cancellation before and between recipe legs", async () => { + const before = new AbortController(); + before.abort(); + const untouched = createRuntime(); + await expect( + createLocalSessions("desktop", untouched, { signal: before.signal }), + ).rejects.toThrow("Session creation aborted by the client"); + expect(untouched.desktop.create).not.toHaveBeenCalled(); + await expect( + createLocalSessions("browser", untouched, { signal: before.signal }), + ).rejects.toThrow("Browser session creation aborted by the client"); + expect(untouched.browser.create).not.toHaveBeenCalled(); + + const between = new AbortController(); + const destroy = vi.fn(async () => undefined); + const runtime = createRuntime({ + desktop: { + create: vi.fn(async () => { + between.abort(); + return desktopHandle; + }), + destroy, + }, + }); + await expect( + createLocalSessions("desktop+android", runtime, { + signal: between.signal, + }), + ).rejects.toThrow("Session creation aborted by the client"); + expect(runtime.android.create).not.toHaveBeenCalled(); + expect(destroy).toHaveBeenCalledWith(desktopHandle.id); + }); + + it("keeps browser display ownership in the browser leg and status", async () => { + const create = createRuntime(); + const sessions = await createLocalSessions("browser", create); + expect(sessions).toEqual([{ type: "browser", ...browserHandle }]); + expect(create.desktop.create).not.toHaveBeenCalled(); + + const status: LocalSessionStatusRuntime = { + browser: { + status: vi.fn(async () => ({ + alive: true, + xvfbAlive: true, + displayAlive: true, + browserAlive: true, + })), + }, + desktop: { + status: vi.fn(async () => ({ + xvfbAlive: false, + vncAlive: true, + displayAlive: false, + })), + }, + android: { + status: vi.fn(async () => ({ + emulatorAlive: false, + deviceState: null, + })), + }, + }; + const entry = await localSessionStatusEntry( + record({ + id: browserHandle.id, + type: "browser", + browser: { + browserPid: 10, + browserStartTimeTicks: 20, + binaryPath: browserHandle.binaryPath, + profileMode: "ephemeral", + profileDir: browserHandle.profileDir, + cdpPort: browserHandle.cdpPort, + }, + desktop: { display: browserHandle.display, vncPort: 5901 }, + }), + status, + ); + + expect(entry.desktop).toMatchObject({ + display: browserHandle.display, + xvfbAlive: true, + displayAlive: true, + vncAlive: true, + }); + expect(entry.browser).toMatchObject({ browserAlive: true }); + expect(entry.viewer).toEqual({ + endpoint: "vnc://127.0.0.1:5901", + ready: true, + readOnly: false, + }); + }); + + it("aggregates dead status across desktop and android liveness", async () => { + const status: LocalSessionStatusRuntime = { + desktop: { + status: vi.fn(async () => ({ + xvfbAlive: true, + vncAlive: false, + displayAlive: true, + })), + }, + browser: { + status: vi.fn(async () => ({ + alive: false, + xvfbAlive: false, + displayAlive: false, + browserAlive: false, + })), + }, + android: { + status: vi.fn(async () => ({ + emulatorAlive: false, + deviceState: "offline", + })), + }, + }; + const entry = await localSessionStatusEntry( + record({ + id: "andr-123456", + type: "android", + desktop: undefined, + android: { avdName: "picklab", serial: "emulator-5554" }, + }), + status, + ); + + expect(entry.status).toBe("dead"); + expect(entry.android).toMatchObject({ + emulatorAlive: false, + deviceState: "offline", + }); + }); + + it("dispatches typed destroy and continues after individual failures", async () => { + const desktopDestroy = vi.fn(async () => { + throw new Error("desktop stuck"); + }); + const browserDestroy = vi.fn(async () => undefined); + const androidDestroy = vi.fn(async () => undefined); + const runtime: LocalSessionDestroyRuntime = { + desktop: { destroy: desktopDestroy }, + browser: { destroy: browserDestroy }, + android: { destroy: androidDestroy }, + }; + const result = await destroyLocalSessions( + [ + record(), + record({ id: "brow-123456", type: "browser" }), + record({ + id: "andr-123456", + type: "android", + desktop: undefined, + android: { avdName: "picklab" }, + }), + ], + runtime, + ); + + expect(result).toEqual({ + destroyed: ["brow-123456", "andr-123456"], + errors: ["desk-123456: desktop stuck"], + }); + expect(browserDestroy).toHaveBeenCalledWith("brow-123456"); + expect(androidDestroy).toHaveBeenCalledWith("andr-123456"); + }); +}); diff --git a/packages/mcp-server/src/tools/session.ts b/packages/mcp-server/src/tools/session.ts index c9ebd5b..f2da8ca 100644 --- a/packages/mcp-server/src/tools/session.ts +++ b/packages/mcp-server/src/tools/session.ts @@ -16,9 +16,18 @@ import { getBrowserSessionStatus, } from "@pickforge/picklab-browser"; import { + createLocalSessions, + destroyLocalSessions, getSession, listSessions, loadConfig, + localSessionStatusEntry, + type LocalSessionCreateRuntime, + type LocalSessionDestroyRuntime, + type LocalSessionLifecycle, + type LocalSessionStatusEntry, + type LocalSessionStatusRuntime, + type LocalSessionSummary, type SessionRecord, } from "@pickforge/picklab-core"; import { @@ -29,14 +38,8 @@ import { import { runTool, type ServerContext } from "../context.js"; import { withMcpEvidence } from "../evidence.js"; -interface SessionSummary extends Record { - id: string; - type: "desktop" | "android" | "browser"; -} - -export interface SessionLifecycle { +export interface SessionLifecycle extends LocalSessionLifecycle { onProgress?: (message: string) => void; - signal?: AbortSignal; } export function progressReporter( @@ -58,82 +61,76 @@ export function progressReporter( }; } -async function createDesktopLeg( +function createRuntime( ctx: ServerContext, args: { width?: number; height?: number; vnc?: boolean; vncControl?: boolean; + avdName?: string; }, -): Promise { - const handle = await createDesktopSession({ - projectDir: ctx.projectDir, - registryEnv: ctx.env, - env: ctx.env, - width: args.width, - height: args.height, - vnc: args.vnc, - vncControl: args.vncControl, - }); - const summary: SessionSummary = { - id: handle.id, - type: "desktop", - display: handle.display, - logDir: handle.logDir, + lifecycle: SessionLifecycle, +): LocalSessionCreateRuntime { + return { + desktop: { + create: () => + createDesktopSession({ + projectDir: ctx.projectDir, + registryEnv: ctx.env, + env: ctx.env, + width: args.width, + height: args.height, + vnc: args.vnc, + vncControl: args.vncControl, + }), + destroy: (id) => destroyDesktopSession(id, ctx.env), + }, + browser: { + create: () => + createBrowserSession({ + projectDir: ctx.projectDir, + registryEnv: ctx.env, + env: ctx.env, + width: args.width, + height: args.height, + signal: lifecycle.signal, + }), + }, + android: { + create: async () => { + const config = await loadConfig(ctx.projectDir, ctx.env); + const avdName = args.avdName ?? config.android?.avdName; + return createAndroidSession({ + projectDir: ctx.projectDir, + registryEnv: ctx.env, + env: ctx.env, + onProgress: lifecycle.onProgress, + signal: lifecycle.signal, + ...(avdName === undefined ? {} : { avdName }), + }); + }, + }, }; - if (handle.vncPort !== undefined) { - summary.vncPort = handle.vncPort; - summary.vncViewOnly = handle.vncViewOnly; - } - return summary; } -async function createBrowserLeg( - ctx: ServerContext, - args: { width?: number; height?: number }, - lifecycle: SessionLifecycle, -): Promise { - const handle = await createBrowserSession({ - projectDir: ctx.projectDir, - registryEnv: ctx.env, - env: ctx.env, - width: args.width, - height: args.height, - signal: lifecycle.signal, - }); + +function statusRuntime(ctx: ServerContext): LocalSessionStatusRuntime { return { - id: handle.id, - type: "browser", - display: handle.display, - cdpPort: handle.cdpPort, - profileDir: handle.profileDir, - binaryPath: handle.binaryPath, - logDir: handle.logDir, + desktop: { status: (id) => getDesktopSessionStatus(id, ctx.env) }, + browser: { status: (id) => getBrowserSessionStatus(id, ctx.env) }, + android: { + status: (id) => getAndroidSessionStatus(id, ctx.env, { env: ctx.env }), + }, }; } -async function createAndroidLeg( - ctx: ServerContext, - args: { avdName?: string }, - lifecycle: SessionLifecycle, -): Promise { - const config = await loadConfig(ctx.projectDir, ctx.env); - const avdName = args.avdName ?? config.android?.avdName; - const handle = await createAndroidSession({ - projectDir: ctx.projectDir, - registryEnv: ctx.env, - env: ctx.env, - onProgress: lifecycle.onProgress, - signal: lifecycle.signal, - ...(avdName === undefined ? {} : { avdName }), - }); +function destroyRuntime(ctx: ServerContext): LocalSessionDestroyRuntime { return { - id: handle.id, - type: "android", - avdName: handle.avdName, - serial: handle.serial, - consolePort: handle.consolePort, - logDir: handle.logDir, + desktop: { destroy: (id) => destroyDesktopSession(id, ctx.env) }, + browser: { destroy: (id) => destroyBrowserSession(id, ctx.env) }, + android: { + destroy: (id) => destroyAndroidSession(id, ctx.env, { env: ctx.env }), + }, }; } @@ -148,35 +145,17 @@ export async function createSessions( avdName?: string; }, lifecycle: SessionLifecycle = {}, -): Promise { - const type = args.type ?? "desktop"; - const sessions: SessionSummary[] = []; - if (type === "desktop" || type === "desktop+android") { - sessions.push(await createDesktopLeg(ctx, args)); - } - if (type === "browser") { - sessions.push(await createBrowserLeg(ctx, args, lifecycle)); - } - if (type === "android" || type === "desktop+android") { - try { - if (lifecycle.signal?.aborted === true) { - throw new Error("Session creation aborted by the client"); - } - sessions.push(await createAndroidLeg(ctx, args, lifecycle)); - } catch (error) { - const desktop = sessions.find((session) => session.type === "desktop"); - if (desktop !== undefined) { - await destroyDesktopSession(desktop.id, ctx.env).catch(() => {}); - } - throw error; - } - } - return sessions; +): Promise { + return createLocalSessions( + args.type ?? "desktop", + createRuntime(ctx, args, lifecycle), + lifecycle, + ); } export async function recordCreatedSessionsEvidence( ctx: ServerContext, - sessions: readonly SessionSummary[], + sessions: readonly LocalSessionSummary[], tool: "session_create" | "android_start", ): Promise { for (const session of sessions) { @@ -195,93 +174,14 @@ export async function recordCreatedSessionsEvidence( export async function sessionStatusEntry( ctx: ServerContext, record: SessionRecord, -): Promise> { - const entry: Record = { - id: record.id, - type: record.type, - status: record.status, - createdAt: record.createdAt, - projectDir: record.projectDir, - }; - if (record.type === "browser") { - const browserStatus = await getBrowserSessionStatus(record.id, ctx.env); - const desktopStatus = await getDesktopSessionStatus(record.id, ctx.env); - if (record.status === "running" && !browserStatus.alive) { - entry.status = "dead"; - } - entry.desktop = { - ...record.desktop, - xvfbAlive: browserStatus.xvfbAlive, - vncAlive: desktopStatus.vncAlive, - displayAlive: browserStatus.displayAlive, - }; - entry.browser = { - ...record.browser, - browserAlive: browserStatus.browserAlive, - }; - entry.viewer = { - endpoint: - record.desktop?.vncPort === undefined - ? null - : `vnc://127.0.0.1:${record.desktop.vncPort}`, - ready: desktopStatus.vncAlive, - readOnly: record.desktop?.vncViewOnly === true, - hostGuiLaunchSupported: false, - }; - } else if (record.desktop !== undefined) { - const status = await getDesktopSessionStatus(record.id, ctx.env); - if (record.status === "running" && !status.xvfbAlive) { - entry.status = "dead"; - } - entry.desktop = { - ...record.desktop, - xvfbAlive: status.xvfbAlive, - vncAlive: status.vncAlive, - displayAlive: status.displayAlive, - }; - entry.viewer = { - endpoint: - record.desktop.vncPort === undefined - ? null - : `vnc://127.0.0.1:${record.desktop.vncPort}`, - ready: status.vncAlive, - readOnly: record.desktop.vncViewOnly === true, - hostGuiLaunchSupported: false, - }; - } - if (record.android !== undefined) { - const status = await getAndroidSessionStatus(record.id, ctx.env, { - env: ctx.env, - }); - if (record.status === "running" && !status.emulatorAlive) { - entry.status = "dead"; - } - entry.android = { - ...record.android, - emulatorAlive: status.emulatorAlive, - deviceState: status.deviceState, - }; +): Promise { + const entry = await localSessionStatusEntry(record, statusRuntime(ctx)); + if (entry.viewer !== undefined) { + entry.viewer.hostGuiLaunchSupported = false; } return entry; } -async function destroyRecord( - ctx: ServerContext, - record: SessionRecord, -): Promise { - if (record.type === "desktop") { - await destroyDesktopSession(record.id, ctx.env); - } else if (record.type === "browser") { - await destroyBrowserSession(record.id, ctx.env); - } else if (record.type === "android") { - await destroyAndroidSession(record.id, ctx.env, { env: ctx.env }); - } else { - throw new Error( - `Cannot destroy session ${record.id} of type "${record.type}"`, - ); - } -} - export function registerSessionTools( server: McpServer, ctx: ServerContext, @@ -394,30 +294,26 @@ export function registerSessionTools( } else { records.push(...(await listSessions(ctx.env))); } - const destroyed: string[] = []; - const errors: string[] = []; - for (const record of records) { - try { - await withMcpEvidence( - ctx, - { - sessionId: record.id, - tool: "session_destroy", - target: { name: record.type }, - refreshReportAfterRecord: true, - }, - async () => { - await destroyRecord(ctx, record); - return { data: {} }; - }, - ); - destroyed.push(record.id); - } catch (error) { - errors.push( - `${record.id}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } + const { destroyed, errors } = await destroyLocalSessions( + records, + destroyRuntime(ctx), + { + aroundDestroy: (record, destroy) => + withMcpEvidence( + ctx, + { + sessionId: record.id, + tool: "session_destroy", + target: { name: record.type }, + refreshReportAfterRecord: true, + }, + async () => { + await destroy(); + return { data: {} }; + }, + ).then(() => undefined), + }, + ); return { data: { destroyed }, errors }; }), );