diff --git a/.changeset/debug-zip-timestamped-filename.md b/.changeset/debug-zip-timestamped-filename.md new file mode 100644 index 0000000000..3ddb2f9559 --- /dev/null +++ b/.changeset/debug-zip-timestamped-filename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `/export-debug-zip` and `kimi export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 9aaf411d55..134dd8ea0e 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -197,9 +197,10 @@ export async function exportSessionDirectory(input: { ); } const bundledWebLog = input.webLog !== undefined; + const now = new Date(); const baseManifest = buildExportManifest({ summary: input.summary, - now: new Date(), + now, version: input.request.version, sessionScan, sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL, @@ -210,7 +211,7 @@ export async function exportSessionDirectory(input: { const outputPath = input.request.outputPath !== undefined ? resolve(input.request.outputPath) - : resolve(`${input.summary.id}.zip`); + : resolve(defaultExportZipName(input.summary.id, now)); const extras: ExtraZipEntry[] = []; if (input.webLog !== undefined) { extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL }); @@ -252,6 +253,12 @@ export async function exportSessionDirectory(input: { } } +function defaultExportZipName(sessionId: string, now: Date): string { + const shortId = sessionId.slice(0, 8); + const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); + return `kimi-debug-${shortId}-${timestamp}.zip`; +} + function sessionZipEntryPath(entry: SessionZipEntry): string { return typeof entry === 'string' ? entry : entry.path; } 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 24c39572f8..1b567758d6 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -8,6 +8,7 @@ import { readFile, readdir, rename, + rm, stat, symlink, unlink, @@ -17,7 +18,7 @@ import { tmpdir } from 'node:os'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { join } from 'pathe'; +import { basename, dirname, join, resolve } from 'pathe'; import { open as openZip } from 'yauzl'; import { Disposable, DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; @@ -143,6 +144,54 @@ describe('sessionExport', () => { }); }); + it('uses a timestamped default output path when outputPath is omitted', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_default_output'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + + const result = await exportSessionDirectory({ + request: { sessionId: 'ses_default_output', version: '1.0.0-test' }, + summary: { id: 'ses_default_output', sessionDir }, + }); + + try { + expect(dirname(result.zipPath)).toBe(resolve('.')); + expect(basename(result.zipPath)).toMatch(/^kimi-debug-ses_defa-\d{8}-\d{6}\.zip$/); + await expect(stat(result.zipPath)).resolves.toMatchObject({ size: expect.any(Number) }); + } finally { + await rm(result.zipPath, { force: true }); + } + }); + + it('does not overwrite a previous default-path export when run again', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_repeated_export'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + const summary = { id: 'ses_repeated_export', sessionDir }; + + const first = await exportSessionDirectory({ + request: { sessionId: 'ses_repeated_export', version: '1.0.0-test' }, + summary, + }); + // Cross the next second boundary so the second export gets a distinct timestamp. + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1100 - (Date.now() % 1000))); + const second = await exportSessionDirectory({ + request: { sessionId: 'ses_repeated_export', version: '1.0.0-test' }, + summary, + }); + + try { + expect(second.zipPath).not.toBe(first.zipPath); + await expect(stat(first.zipPath)).resolves.toMatchObject({ size: expect.any(Number) }); + await expect(stat(second.zipPath)).resolves.toMatchObject({ size: expect.any(Number) }); + } finally { + await rm(first.zipPath, { force: true }); + await rm(second.zipPath, { force: true }); + } + }); + it('keeps the session log bound when it rotates as wire scanning starts', async () => { const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_rotating_log'); diff --git a/packages/agent-core/src/session/export/session-export.ts b/packages/agent-core/src/session/export/session-export.ts index e4e6257224..056374cdfb 100644 --- a/packages/agent-core/src/session/export/session-export.ts +++ b/packages/agent-core/src/session/export/session-export.ts @@ -47,9 +47,10 @@ export async function exportSessionDirectory(input: { } } + const now = new Date(); const manifest = buildExportManifest({ summary: input.summary, - now: new Date(), + now, version: input.request.version, sessionScan, sessionLogPath: hasSessionLog ? SESSION_LOG_REL : undefined, @@ -61,7 +62,7 @@ export async function exportSessionDirectory(input: { const outputPath = input.request.outputPath !== undefined ? resolve(input.request.outputPath) - : resolve(`${input.summary.id}.zip`); + : resolve(defaultExportZipName(input.summary.id, now)); const entries = await writeExportZip({ outputPath, @@ -79,6 +80,12 @@ export async function exportSessionDirectory(input: { }; } +function defaultExportZipName(sessionId: string, now: Date): string { + const shortId = sessionId.slice(0, 8); + const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); + return `kimi-debug-${shortId}-${timestamp}.zip`; +} + async function readOptionalFile(path: string): Promise { try { return await readFile(path); diff --git a/packages/node-sdk/test/export-session.test.ts b/packages/node-sdk/test/export-session.test.ts index 04f04cff23..358e1fac4a 100644 --- a/packages/node-sdk/test/export-session.test.ts +++ b/packages/node-sdk/test/export-session.test.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { join, resolve, basename, dirname } from 'node:path'; import * as zlib from 'node:zlib'; import { afterEach, describe, expect, it } from 'vitest'; @@ -169,7 +169,7 @@ describe('exportSessionDirectory', () => { expect(manifest.workspaceDir).toBe(workDir); }); - it('uses the default output path when outputPath is omitted', async () => { + it('uses a timestamped default output path when outputPath is omitted', async () => { const tmp = await makeTempDir(); const sid = 'session_default_output'; const sessionDir = join(tmp, 'sessions', sid); @@ -181,10 +181,39 @@ describe('exportSessionDirectory', () => { summary: makeSummary({ id: sid, sessionDir, workDir: tmp }), }); - const expectedPath = resolve(`${sid}.zip`); - expect(result.zipPath).toBe(toPosix(expectedPath)); + expect(dirname(result.zipPath)).toBe(toPosix(resolve('.'))); + expect(basename(result.zipPath)).toMatch(/^kimi-debug-session_-\d{8}-\d{6}\.zip$/); expect(existsSync(result.zipPath)).toBe(true); - await rm(expectedPath, { force: true }); + await rm(result.zipPath, { force: true }); + }); + + it('does not overwrite a previous default-path export when run again', async () => { + const tmp = await makeTempDir(); + const sid = 'session_repeated_export'; + const sessionDir = join(tmp, 'sessions', sid); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}', 'utf-8'); + const summary = makeSummary({ id: sid, sessionDir, workDir: tmp }); + + const first = await exportSessionDirectory({ + request: { sessionId: sid, version: '1.0.0-test' }, + summary, + }); + // Cross the next second boundary so the second export gets a distinct timestamp. + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1100 - (Date.now() % 1000))); + const second = await exportSessionDirectory({ + request: { sessionId: sid, version: '1.0.0-test' }, + summary, + }); + + try { + expect(second.zipPath).not.toBe(first.zipPath); + expect(existsSync(first.zipPath)).toBe(true); + expect(existsSync(second.zipPath)).toBe(true); + } finally { + await rm(first.zipPath, { force: true }); + await rm(second.zipPath, { force: true }); + } }); it('omits global log manifest path when the global log cannot be bundled', async () => { @@ -245,7 +274,7 @@ describe('exportSessionDirectory', () => { expect(result.manifest.sessionFirstActivity).toBeUndefined(); expect(result.manifest.sessionLastActivity).toBeUndefined(); - await rm(resolve(`${sid}.zip`), { force: true }); + await rm(result.zipPath, { force: true }); }); it('rejects empty or missing session directories', async () => {