From 48bf3d4c28d5d595dee66b175fbc3f6a9f02b338 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 27 Jul 2026 12:07:58 +0800 Subject: [PATCH] feat(kap-server): bundle the desktop app log into session exports on request (#2223) * feat(kap-server): bundle the desktop app log into session exports on request * refactor(agent-core-v2): align the desktop log export with repo conventions --- .../src/app/sessionExport/sessionExport.ts | 2 + .../app/sessionExport/sessionExportService.ts | 27 ++++++++-- .../app/sessionExport/sessionExport.test.ts | 54 +++++++++++++++++++ .../kap-server/src/protocol/rest-session.ts | 4 ++ .../kap-server/src/routes/sessionExport.ts | 3 ++ packages/kap-server/test/sessions.test.ts | 30 +++++++++++ .../src/__tests__/rest-session.test.ts | 4 ++ packages/protocol/src/rest/session.ts | 4 ++ 8 files changed, 124 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts index 108c5c44a..a634941e3 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -21,6 +21,7 @@ export interface ExportSessionPayload { readonly sessionId: string; readonly outputPath?: string | undefined; readonly includeGlobalLog?: boolean | undefined; + readonly includeDesktopLog?: boolean; readonly version: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; @@ -39,6 +40,7 @@ export interface ExportSessionManifest { readonly workspaceDir?: string | undefined; readonly sessionLogPath?: string | undefined; readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string; readonly webLogPath?: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index a2fd536d4..17e59b797 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -39,6 +39,7 @@ import { openZipSource, type ZipSource } from './file-source'; const SESSION_LOG_REL = 'logs/kimi-code.log'; const GLOBAL_LOG_REL = 'logs/global/kimi-code.log'; const WEB_LOG_REL = 'logs/kimi-web.jsonl'; +const DESKTOP_LOG_REL = 'logs/kimi-desktop.log'; export class SessionExportService implements ISessionExportService { declare readonly _serviceBrand: undefined; @@ -85,6 +86,10 @@ export class SessionExportService implements ISessionExportService { request: input, summary: liveSummary, globalLogPath: resolveGlobalLogPath(this.bootstrap.homeDir), + desktopLogPath: + input.includeDesktopLog === true + ? join(this.bootstrap.homeDir, 'logs', 'kimi-code-desktop.log') + : undefined, webLog: options.webLog, signal: options.signal, maxArchiveBytes: options.maxArchiveBytes, @@ -158,6 +163,7 @@ export async function exportSessionDirectory(input: { readonly request: ExportSessionPayload; readonly summary: ExportSessionDirectorySummary; readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string | undefined; readonly webLog?: string; readonly signal?: AbortSignal; readonly maxArchiveBytes?: number; @@ -169,12 +175,17 @@ export async function exportSessionDirectory(input: { let sessionLogSourceTransferred = false; let globalSource: ZipSource | undefined; let globalSourceTransferred = false; + let desktopSource: ZipSource | undefined; + let desktopSourceTransferred = false; try { sessionLogSource = await openOptionalZipSource(sessionLogPath, input.signal); if (input.request.includeGlobalLog === true && input.globalLogPath !== undefined) { globalSource = await openOptionalZipSource(input.globalLogPath, input.signal); } + if (input.desktopLogPath !== undefined) { + desktopSource = await openOptionalZipSource(input.desktopLogPath, input.signal); + } const sessionFiles = await collectFilesRecursive(sessionDir); if (sessionFiles.length === 0 && sessionLogSource === undefined) { throw new Error2( @@ -218,10 +229,14 @@ export async function exportSessionDirectory(input: { if (globalSource !== undefined) { extras.push({ source: globalSource, target: GLOBAL_LOG_REL }); } - const manifest = - globalSource === undefined - ? baseManifest - : { ...baseManifest, globalLogPath: GLOBAL_LOG_REL }; + if (desktopSource !== undefined) { + extras.push({ source: desktopSource, target: DESKTOP_LOG_REL }); + } + const manifest = { + ...baseManifest, + globalLogPath: globalSource === undefined ? undefined : GLOBAL_LOG_REL, + desktopLogPath: desktopSource === undefined ? undefined : DESKTOP_LOG_REL, + }; const writing = writeExportZip({ outputPath, @@ -234,6 +249,7 @@ export async function exportSessionDirectory(input: { }); sessionLogSourceTransferred = sessionLogSource !== undefined; globalSourceTransferred = globalSource !== undefined; + desktopSourceTransferred = desktopSource !== undefined; const entries = await writing; return { @@ -249,6 +265,9 @@ export async function exportSessionDirectory(input: { if (globalSource !== undefined && !globalSourceTransferred) { await globalSource.close().catch(() => {}); } + if (desktopSource !== undefined && !desktopSourceTransferred) { + await desktopSource.close().catch(() => {}); + } } } diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 9c1230c6b..7cfdab0be 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -517,6 +517,60 @@ describe('sessionExport', () => { ); }); + it('includes the desktop app log when given', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_desktop_log'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + const desktopLogPath = join(tmp, 'logs', 'kimi-code-desktop.log'); + await mkdir(join(tmp, 'logs'), { recursive: true }); + const desktopLog = '2026-07-27T00:00:00.000Z INFO [renderer] hello\n'; + await writeFile(desktopLogPath, desktopLog, 'utf-8'); + const outputPath = join(tmp, 'desktop-log.zip'); + + const result = await exportSessionDirectory({ + request: { + sessionId: 'ses_desktop_log', + outputPath, + version: '1.0.0-test', + }, + summary: { + id: 'ses_desktop_log', + sessionDir, + }, + desktopLogPath, + }); + + expect(result.entries).toContain('logs/kimi-desktop.log'); + expect(result.manifest.desktopLogPath).toBe('logs/kimi-desktop.log'); + await expect(readZipEntry(outputPath, 'logs/kimi-desktop.log')).resolves.toEqual( + Buffer.from(desktopLog, 'utf8'), + ); + }); + + it('skips a missing desktop app log silently', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_desktop_log_missing'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + + const result = await exportSessionDirectory({ + request: { + sessionId: 'ses_desktop_log_missing', + outputPath: join(tmp, 'desktop-log-missing.zip'), + version: '1.0.0-test', + }, + summary: { + id: 'ses_desktop_log_missing', + sessionDir, + }, + desktopLogPath: join(tmp, 'logs', 'does-not-exist.log'), + }); + + expect(result.entries).not.toContain('logs/kimi-desktop.log'); + expect(result.manifest.desktopLogPath).toBeUndefined(); + }); + it('rejects when a collected file disappears before it can be archived', async () => { const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); const removedPath = join(tmp, 'removed-state.json'); diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts index 57528dfe1..4cf33af6a 100644 --- a/packages/kap-server/src/protocol/rest-session.ts +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -94,6 +94,10 @@ export const exportSessionRequestSchema = z message: `web_log must not exceed ${MAX_SESSION_EXPORT_WEB_LOG_BYTES} UTF-8 bytes`, }) .optional(), + // Desktop hosts set this to bundle the on-disk desktop app log + // (`/logs/kimi-code-desktop.log`) into the archive; the server reads + // the file itself, so no log content crosses the request. + desktop: z.boolean().optional(), }) .strict(); export type ExportSessionRequest = z.infer; diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts index 800766ffa..2a13dec73 100644 --- a/packages/kap-server/src/routes/sessionExport.ts +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -119,6 +119,9 @@ export function registerSessionExportRoute( sessionId: req.params.session_id, outputPath, includeGlobalLog: true, + // Desktop hosts ask for their own app log via `desktop: true`; + // the file is read server-side (missing files are skipped). + includeDesktopLog: req.body.desktop === true, version: options.serverVersion, }, { diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 1e50124d0..0073418f3 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -227,6 +227,36 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.details?.[0]?.path).toBe('web_log'); }); + it('bundles the on-disk desktop app log when the desktop flag is set', async () => { + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const id = created.body.data.id; + await mkdir(join(home as string, 'logs'), { recursive: true }); + await writeFile( + join(home as string, 'logs', 'kimi-code-desktop.log'), + '2026-07-27T00:00:00.000Z INFO [renderer] hello\n', + 'utf-8', + ); + + const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ desktop: true }), + } as never); + const archive = Buffer.from(await res.arrayBuffer()); + + expect(res.status).toBe(200); + const entries = readZipEntries(archive); + const manifest = JSON.parse(entries.get('manifest.json')?.toString('utf8') ?? 'null') as { + desktopLogPath?: string; + }; + expect(entries.get('logs/kimi-desktop.log')?.toString('utf8')).toBe( + '2026-07-27T00:00:00.000Z INFO [renderer] hello\n', + ); + expect(manifest.desktopLogPath).toBe('logs/kimi-desktop.log'); + }); + async function createStoppedGoalRig(status: 'paused' | 'blocked') { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts index 721b59650..849aa59d3 100644 --- a/packages/protocol/src/__tests__/rest-session.test.ts +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -32,6 +32,10 @@ describe('exportSessionRequestSchema', () => { }); }); + it('accepts the desktop log flag', () => { + expect(exportSessionRequestSchema.parse({ desktop: true })).toEqual({ desktop: true }); + }); + it('accepts a Web log at the 256 KiB UTF-8 boundary', () => { expect(exportSessionRequestSchema.safeParse({ web_log: 'a'.repeat(256 * 1024) }).success).toBe( true, diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index bf1d5aec4..c3e0c3b8a 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -74,6 +74,10 @@ export const exportSessionRequestSchema = z message: `web_log must not exceed ${MAX_SESSION_EXPORT_WEB_LOG_BYTES} UTF-8 bytes`, }) .optional(), + // Desktop hosts set this to bundle the on-disk desktop app log + // (`/logs/kimi-code-desktop.log`) into the archive; the server reads + // the file itself, so no log content crosses the request. + desktop: z.boolean().optional(), }) .strict(); export type ExportSessionRequest = z.infer;