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
This commit is contained in:
liruifengv 2026-07-27 12:07:58 +08:00 committed by GitHub
parent d40d0d305d
commit 48bf3d4c28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 124 additions and 4 deletions

View file

@ -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;

View file

@ -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(() => {});
}
}
}

View file

@ -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');

View file

@ -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
// (`<home>/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<typeof exportSessionRequestSchema>;

View file

@ -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,
},
{

View file

@ -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<SessionWire>('/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<SessionWire>('/api/v1/sessions', { metadata: { cwd } });

View file

@ -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,

View file

@ -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
// (`<home>/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<typeof exportSessionRequestSchema>;