mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 13:45:28 +00:00
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:
parent
d40d0d305d
commit
48bf3d4c28
8 changed files with 124 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue