diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index c28a9d44c..3146d3a30 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -90,7 +90,7 @@ order, verification, decisions, and remaining work. ### Slice 4: Session REST API -- Status: pending +- Status: complete - Goal: add session create/list/load/delete/rename endpoints backed by ACP. - Files: - `packages/desktop/src/server/http/router.ts` @@ -225,6 +225,17 @@ order, verification, decisions, and remaining work. - `npm run typecheck` passed across workspaces. - `npm run build` passed across the configured build order. Existing VS Code companion lint warnings were reported by its build script, with no errors. +- 2026-04-25 Slice 4: + - `npx prettier --check design/qwen-code-electron-desktop-implementation-plan.md scripts/build.js packages/desktop` passed. + - `npm run test --workspace=packages/desktop` passed: 2 files, 17 tests. + - `npm run lint --workspace=packages/desktop` passed. + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. + - `npm exec --workspace=packages/desktop -- electron --version` passed: + `v41.3.0`. + - `npm run typecheck` passed across workspaces. + - `npm run build` passed across the configured build order. Existing VS Code + companion lint warnings were reported by its build script, with no errors. ## Self Review Notes @@ -253,10 +264,18 @@ order, verification, decisions, and remaining work. permission bridge slice supplies a UI-backed resolver. - Startup failures race initialize against child exit; later process exits clear connection state without leaving a rejected startup promise. +- 2026-04-25 Slice 4: + - Session REST routes share the same origin and bearer-token gate as health + and runtime routes. + - The route layer is ACP-backed through an injected client so tests cover the + Qwen ACP method contracts without requiring credentials or a live model. + - Electron main intentionally does not auto-start real ACP yet; CLI path + resolution and packaged `ELECTRON_RUN_AS_NODE=1` behavior remain for the + packaging/runtime integration slices. ## Remaining Work -- Commit Slice 3. -- Continue with Slice 4 session REST API. +- Commit Slice 4. +- Continue with Slice 5 WebSocket chat loop. - Continue through the ACP, session, WebSocket, permission, settings, and packaging slices until the architecture MVP is fully verified. diff --git a/packages/desktop/src/server/http/errors.ts b/packages/desktop/src/server/http/errors.ts new file mode 100644 index 000000000..00218c9bd --- /dev/null +++ b/packages/desktop/src/server/http/errors.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export class DesktopHttpError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function isDesktopHttpError(error: unknown): error is DesktopHttpError { + return error instanceof DesktopHttpError; +} diff --git a/packages/desktop/src/server/index.test.ts b/packages/desktop/src/server/index.test.ts index 44c4f0f5a..fa11017a8 100644 --- a/packages/desktop/src/server/index.test.ts +++ b/packages/desktop/src/server/index.test.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { startDesktopServer } from './index.js'; import type { DesktopServer } from './types.js'; +import type { AcpSessionClient } from './services/sessionService.js'; const servers: DesktopServer[] = []; @@ -111,6 +112,115 @@ describe('DesktopServer', () => { }); }); + it('returns a typed error when session routes have no ACP client', async () => { + const server = await createTestServer(); + + const response = await getJson(server, '/api/sessions', { + Authorization: 'Bearer test-token', + }); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + ok: false, + code: 'acp_unavailable', + }); + }); + + it('lists sessions through the ACP client', async () => { + const acpClient = createAcpClient(); + const server = await createTestServer(acpClient); + + const response = await getJson( + server, + '/api/sessions?cwd=%2Frepo&cursor=2&size=5', + { + Authorization: 'Bearer test-token', + }, + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ok: true, + sessions: [{ sessionId: 'session-1', title: 'Test session' }], + nextCursor: '3', + }); + expect(acpClient.listSessions).toHaveBeenCalledWith({ + cwd: '/repo', + cursor: 2, + size: 5, + }); + }); + + it('creates and loads sessions through the ACP client', async () => { + const acpClient = createAcpClient(); + const server = await createTestServer(acpClient); + + const created = await postJson(server, '/api/sessions', { cwd: '/repo' }); + const loaded = await postJson(server, '/api/sessions/session-1/load', { + cwd: '/repo', + }); + + expect(created.status).toBe(200); + expect(created.body).toMatchObject({ + ok: true, + session: { sessionId: 'session-1' }, + }); + expect(loaded.status).toBe(200); + expect(loaded.body).toMatchObject({ + ok: true, + session: { models: [] }, + }); + expect(acpClient.newSession).toHaveBeenCalledWith('/repo'); + expect(acpClient.loadSession).toHaveBeenCalledWith('session-1', '/repo'); + }); + + it('renames and deletes sessions through ACP extension methods', async () => { + const acpClient = createAcpClient(); + const server = await createTestServer(acpClient); + + const renamed = await patchJson(server, '/api/sessions/session-1', { + title: 'Renamed', + cwd: '/repo', + }); + const deleted = await deleteJson( + server, + '/api/sessions/session-1?cwd=%2Frepo', + ); + + expect(renamed.status).toBe(200); + expect(deleted.status).toBe(200); + expect(acpClient.extMethod).toHaveBeenCalledWith('renameSession', { + sessionId: 'session-1', + title: 'Renamed', + cwd: '/repo', + }); + expect(acpClient.extMethod).toHaveBeenCalledWith('deleteSession', { + sessionId: 'session-1', + cwd: '/repo', + }); + }); + + it('validates session JSON request bodies', async () => { + const acpClient = createAcpClient(); + const server = await createTestServer(acpClient); + + const response = await fetch(`${server.info.url}/api/sessions`, { + method: 'POST', + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }, + body: 'not-json', + }); + const body = (await response.json()) as unknown; + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + ok: false, + code: 'bad_json', + }); + }); + it('returns a typed error for unknown authenticated routes', async () => { const server = await createTestServer(); @@ -126,10 +236,13 @@ describe('DesktopServer', () => { }); }); -async function createTestServer(): Promise { +async function createTestServer( + acpClient?: AcpSessionClient, +): Promise { const server = await startDesktopServer({ token: 'test-token', now: () => new Date('2026-04-25T00:00:00.000Z'), + acpClient, }); servers.push(server); return server; @@ -146,3 +259,68 @@ async function getJson( body: (await response.json()) as unknown, }; } + +async function postJson( + server: DesktopServer, + path: string, + body: Record, +): Promise<{ status: number; body: unknown }> { + return writeJson(server, path, 'POST', body); +} + +async function patchJson( + server: DesktopServer, + path: string, + body: Record, +): Promise<{ status: number; body: unknown }> { + return writeJson(server, path, 'PATCH', body); +} + +async function deleteJson( + server: DesktopServer, + path: string, +): Promise<{ status: number; body: unknown }> { + const response = await fetch(`${server.info.url}${path}`, { + method: 'DELETE', + headers: { + Authorization: 'Bearer test-token', + }, + }); + return { + status: response.status, + body: (await response.json()) as unknown, + }; +} + +async function writeJson( + server: DesktopServer, + path: string, + method: 'PATCH' | 'POST', + body: Record, +): Promise<{ status: number; body: unknown }> { + const response = await fetch(`${server.info.url}${path}`, { + method, + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + return { + status: response.status, + body: (await response.json()) as unknown, + }; +} + +function createAcpClient(): AcpSessionClient { + return { + isConnected: true, + listSessions: vi.fn().mockResolvedValue({ + sessions: [{ sessionId: 'session-1', title: 'Test session' }], + nextCursor: '3', + }), + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), + loadSession: vi.fn().mockResolvedValue({ models: [] }), + extMethod: vi.fn().mockResolvedValue({ success: true }), + }; +} diff --git a/packages/desktop/src/server/index.ts b/packages/desktop/src/server/index.ts index e7f15aa70..dd6a8996e 100644 --- a/packages/desktop/src/server/index.ts +++ b/packages/desktop/src/server/index.ts @@ -17,11 +17,11 @@ import { isAllowedOrigin, isAuthorized, } from './http/auth.js'; +import { isDesktopHttpError, DesktopHttpError } from './http/errors.js'; import { getRuntimeInfo } from './services/runtimeService.js'; +import { DesktopSessionService } from './services/sessionService.js'; import type { - DesktopErrorResponse, - DesktopHealthResponse, - DesktopRuntimeResponse, + DesktopJsonResponse, DesktopServer, DesktopServerOptions, } from './types.js'; @@ -30,6 +30,7 @@ interface HandlerContext { token: string; startedAt: number; now: () => Date; + sessionService: DesktopSessionService; } export async function startDesktopServer( @@ -38,19 +39,33 @@ export async function startDesktopServer( const token = options.token ?? createServerToken(); const now = options.now ?? (() => new Date()); const startedAt = now().getTime(); + const sessionService = new DesktopSessionService(options.acpClient); const server = createServer((request, response) => { - void handleRequest(request, response, { token, startedAt, now }).catch( - (error: unknown) => { - sendJson(response, getSingleHeader(request.headers.origin), 500, { + void handleRequest(request, response, { + token, + startedAt, + now, + sessionService, + }).catch((error: unknown) => { + const origin = getSingleHeader(request.headers.origin); + if (isDesktopHttpError(error)) { + sendJson(response, origin, error.statusCode, { ok: false, - code: 'internal_error', - message: - error instanceof Error - ? error.message - : 'Desktop server request failed.', + code: error.code, + message: error.message, }); - }, - ); + return; + } + + sendJson(response, origin, 500, { + ok: false, + code: 'internal_error', + message: + error instanceof Error + ? error.message + : 'Desktop server request failed.', + }); + }); }); await new Promise((resolve, reject) => { @@ -138,6 +153,42 @@ async function handleRequest( return; } + if (requestUrl.pathname === '/api/sessions') { + await handleSessionsRoute(request, response, origin, requestUrl, context); + return; + } + + const loadSessionMatch = matchSessionRoute( + requestUrl.pathname, + /^\/api\/sessions\/([^/]+)\/load$/u, + ); + if (loadSessionMatch) { + await handleLoadSessionRoute( + request, + response, + origin, + context, + loadSessionMatch, + ); + return; + } + + const sessionMatch = matchSessionRoute( + requestUrl.pathname, + /^\/api\/sessions\/([^/]+)$/u, + ); + if (sessionMatch) { + await handleSessionRoute( + request, + response, + origin, + requestUrl, + context, + sessionMatch, + ); + return; + } + sendJson(response, origin, 404, { ok: false, code: 'not_found', @@ -145,6 +196,84 @@ async function handleRequest( }); } +async function handleSessionsRoute( + request: IncomingMessage, + response: ServerResponse, + origin: string | undefined, + requestUrl: URL, + context: HandlerContext, +): Promise { + if (request.method === 'GET') { + const result = await context.sessionService.listSessions({ + cwd: getOptionalSearchParam(requestUrl, 'cwd'), + cursor: getOptionalNumberSearchParam(requestUrl, 'cursor'), + size: getOptionalNumberSearchParam(requestUrl, 'size'), + }); + sendJson(response, origin, 200, { ok: true, ...result }); + return; + } + + if (request.method === 'POST') { + const body = await readObjectBody(request); + const cwd = getRequiredString(body, 'cwd'); + const session = await context.sessionService.createSession(cwd); + sendJson(response, origin, 200, { ok: true, session }); + return; + } + + sendMethodNotAllowed(response, origin); +} + +async function handleLoadSessionRoute( + request: IncomingMessage, + response: ServerResponse, + origin: string | undefined, + context: HandlerContext, + sessionId: string, +): Promise { + if (request.method !== 'POST') { + sendMethodNotAllowed(response, origin); + return; + } + + const body = await readObjectBody(request); + const cwd = getRequiredString(body, 'cwd'); + const session = await context.sessionService.loadSession(sessionId, cwd); + sendJson(response, origin, 200, { ok: true, session }); +} + +async function handleSessionRoute( + request: IncomingMessage, + response: ServerResponse, + origin: string | undefined, + requestUrl: URL, + context: HandlerContext, + sessionId: string, +): Promise { + if (request.method === 'PATCH') { + const body = await readObjectBody(request); + const title = getRequiredString(body, 'title'); + const result = await context.sessionService.renameSession( + sessionId, + title, + getOptionalString(body, 'cwd'), + ); + sendJson(response, origin, 200, { ok: true, result }); + return; + } + + if (request.method === 'DELETE') { + const result = await context.sessionService.deleteSession( + sessionId, + getOptionalSearchParam(requestUrl, 'cwd'), + ); + sendJson(response, origin, 200, { ok: true, result }); + return; + } + + sendMethodNotAllowed(response, origin); +} + function parseRequestUrl(request: IncomingMessage): URL | undefined { try { return new URL(request.url ?? '/', 'http://127.0.0.1'); @@ -157,10 +286,7 @@ function sendJson( response: ServerResponse, origin: string | undefined, statusCode: number, - payload: - | DesktopHealthResponse - | DesktopRuntimeResponse - | DesktopErrorResponse, + payload: DesktopJsonResponse, ) { if (response.headersSent) { response.end(); @@ -174,6 +300,121 @@ function sendJson( response.end(`${JSON.stringify(payload)}\n`); } +function sendMethodNotAllowed( + response: ServerResponse, + origin: string | undefined, +): void { + sendJson(response, origin, 405, { + ok: false, + code: 'method_not_allowed', + message: 'Method not allowed.', + }); +} + +function matchSessionRoute(pathname: string, pattern: RegExp): string | null { + const match = pattern.exec(pathname); + if (!match?.[1]) { + return null; + } + + return decodeURIComponent(match[1]); +} + +async function readObjectBody( + request: IncomingMessage, +): Promise> { + const raw = await readRequestBody(request); + if (!raw) { + throw new DesktopHttpError(400, 'bad_request', 'Request body is required.'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new DesktopHttpError(400, 'bad_json', 'Request body must be JSON.'); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new DesktopHttpError( + 400, + 'bad_request', + 'Request body must be an object.', + ); + } + + return parsed as Record; +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > 1024 * 1024) { + throw new DesktopHttpError( + 413, + 'payload_too_large', + 'Request body is too large.', + ); + } + chunks.push(buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +function getRequiredString(body: Record, key: string): string { + const value = body[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new DesktopHttpError( + 400, + 'bad_request', + `${key} must be a non-empty string.`, + ); + } + + return value; +} + +function getOptionalString( + body: Record, + key: string, +): string | undefined { + const value = body[key]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new DesktopHttpError(400, 'bad_request', `${key} must be a string.`); + } + + return value; +} + +function getOptionalSearchParam(url: URL, key: string): string | undefined { + return url.searchParams.get(key) ?? undefined; +} + +function getOptionalNumberSearchParam( + url: URL, + key: string, +): number | undefined { + const value = url.searchParams.get(key); + if (value === null) { + return undefined; + } + + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + throw new DesktopHttpError(400, 'bad_request', `${key} must be a number.`); + } + + return numberValue; +} + function isAddressInfo( address: string | AddressInfo | null, ): address is AddressInfo { diff --git a/packages/desktop/src/server/services/sessionService.ts b/packages/desktop/src/server/services/sessionService.ts new file mode 100644 index 000000000..5ab32415f --- /dev/null +++ b/packages/desktop/src/server/services/sessionService.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ListSessionsResponse, + LoadSessionResponse, + NewSessionResponse, +} from '@agentclientprotocol/sdk'; +import { DesktopHttpError } from '../http/errors.js'; + +export interface AcpSessionClient { + readonly isConnected?: boolean; + connect?(): Promise; + listSessions(options?: { + cwd?: string; + cursor?: number; + size?: number; + }): Promise; + newSession(cwd: string): Promise; + loadSession(sessionId: string, cwd: string): Promise; + extMethod>( + method: string, + params: Record, + ): Promise; +} + +export interface SessionListOptions { + cwd?: string; + cursor?: number; + size?: number; +} + +export class DesktopSessionService { + constructor(private readonly acpClient?: AcpSessionClient) {} + + async listSessions( + options: SessionListOptions, + ): Promise { + return this.getClient().then((client) => client.listSessions(options)); + } + + async createSession(cwd: string): Promise { + return this.getClient().then((client) => client.newSession(cwd)); + } + + async loadSession( + sessionId: string, + cwd: string, + ): Promise { + return this.getClient().then((client) => + client.loadSession(sessionId, cwd), + ); + } + + async renameSession( + sessionId: string, + title: string, + cwd?: string, + ): Promise> { + return this.getClient().then((client) => + client.extMethod('renameSession', { sessionId, title, cwd }), + ); + } + + async deleteSession( + sessionId: string, + cwd?: string, + ): Promise> { + return this.getClient().then((client) => + client.extMethod('deleteSession', { sessionId, cwd }), + ); + } + + private async getClient(): Promise { + if (!this.acpClient) { + throw new DesktopHttpError( + 503, + 'acp_unavailable', + 'ACP client is not configured.', + ); + } + + if (this.acpClient.connect && this.acpClient.isConnected === false) { + await this.acpClient.connect(); + } + + return this.acpClient; + } +} diff --git a/packages/desktop/src/server/types.ts b/packages/desktop/src/server/types.ts index 45dfa2304..bdaba5f7f 100644 --- a/packages/desktop/src/server/types.ts +++ b/packages/desktop/src/server/types.ts @@ -5,6 +5,7 @@ */ import type { DesktopServerInfo } from '../shared/desktopApi.js'; +import type { AcpSessionClient } from './services/sessionService.js'; export type { DesktopServerInfo }; @@ -16,6 +17,7 @@ export interface DesktopServer { export interface DesktopServerOptions { token?: string; now?: () => Date; + acpClient?: AcpSessionClient; } export interface DesktopHealthResponse { @@ -53,3 +55,9 @@ export interface DesktopErrorResponse { code: string; message: string; } + +export type DesktopJsonResponse = + | DesktopHealthResponse + | DesktopRuntimeResponse + | DesktopErrorResponse + | (Record & { ok: true });