Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/debug-zip-timestamped-filename.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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`;
Comment on lines +258 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make default debug-zip names unique within one second

When two default exports for the same session start within the same UTC second, this timestamp string is identical, so both engines still resolve to the same kimi-debug-<shortId>-<timestamp>.zip; the v2 writer later renames over an existing destination and the legacy writer opens with the default truncating flags, so the second export can still replace the first. This is easy to hit with kimi export <session> twice on a small session, and the new regression test avoids the case by sleeping past the second boundary instead of proving same-second safety.

Useful? React with 👍 / 👎.

}

function sessionZipEntryPath(entry: SessionZipEntry): string {
return typeof entry === 'string' ? entry : entry.path;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
readFile,
readdir,
rename,
rm,
stat,
symlink,
unlink,
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core/src/session/export/session-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<Buffer | undefined> {
try {
return await readFile(path);
Expand Down
41 changes: 35 additions & 6 deletions packages/node-sdk/test/export-session.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading