mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-15 11:45:52 +00:00
fix(web): make session export atomic
This commit is contained in:
parent
3631a0f555
commit
14530835e8
7 changed files with 552 additions and 68 deletions
|
|
@ -291,7 +291,6 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
apiMock.exportSession.mockReset();
|
||||
clearTrace();
|
||||
anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||
|
|
@ -307,8 +306,6 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
|
||||
afterEach(() => {
|
||||
clearTrace();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -326,7 +323,6 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
const workspace = useWorkspaceState(createState(), createDeps());
|
||||
|
||||
await workspace.exportSession();
|
||||
vi.runAllTimers();
|
||||
|
||||
const webLog = apiMock.exportSession.mock.calls[0]?.[1] as string;
|
||||
expect(webLog).toContain('prompt:start');
|
||||
|
|
@ -337,7 +333,10 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
expect(append).toHaveBeenCalledWith(anchor);
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(anchor.remove).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
await vi.waitFor(() => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps one request targeted at the session selected when export started', async () => {
|
||||
|
|
@ -355,6 +354,9 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
const second = workspace.exportSession();
|
||||
resolveExport({ blob: new Blob(['zip']), fileName: 'sess_1.zip' });
|
||||
await Promise.all([first, second]);
|
||||
await vi.waitFor(() => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
});
|
||||
|
||||
expect(apiMock.exportSession).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.exportSession).toHaveBeenCalledWith('sess_1', expect.any(String));
|
||||
|
|
@ -369,10 +371,12 @@ describe('useWorkspaceState — exportSession', () => {
|
|||
const workspace = useWorkspaceState(createState(), deps);
|
||||
|
||||
await workspace.exportSession();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(anchor.remove).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
await vi.waitFor(() => {
|
||||
expect(revokeObjectURL).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
});
|
||||
expect(deps.pushOperationFailure).toHaveBeenCalledWith(
|
||||
'exportSession',
|
||||
expect.any(Error),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const SessionExportErrors = {
|
|||
codes: {
|
||||
SESSION_EXPORT_NOT_FOUND: 'session.export_not_found',
|
||||
SESSION_EXPORT_MISSING_VERSION: 'session.export_missing_version',
|
||||
SESSION_EXPORT_OUTPUT_CONFLICT: 'session.export_output_conflict',
|
||||
SESSION_EXPORT_TOO_LARGE: 'session.export_too_large',
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
|
|
|||
|
|
@ -8,38 +8,50 @@
|
|||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { resolve } from 'pathe';
|
||||
|
||||
export interface ZipSource {
|
||||
readonly stream: Readable;
|
||||
readonly size: number;
|
||||
readonly mtime: Date;
|
||||
readonly mode: number;
|
||||
readonly identity: ZipSourceIdentity;
|
||||
readonly sourcePath?: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ZipSourceIdentity {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
}
|
||||
|
||||
export async function openZipSource(source: string, signal?: AbortSignal): Promise<ZipSource> {
|
||||
const handle = await open(source, 'r');
|
||||
let stream: Readable | undefined;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const file = await handle.stat();
|
||||
const file = await handle.stat({ bigint: true });
|
||||
if (!file.isFile()) throw new Error(`not a file: ${source}`);
|
||||
const size = Number(file.size);
|
||||
if (!Number.isSafeInteger(size)) throw new Error(`file is too large to export: ${source}`);
|
||||
signal?.throwIfAborted();
|
||||
stream =
|
||||
file.size === 0
|
||||
size === 0
|
||||
? Readable.from([])
|
||||
: handle.createReadStream({
|
||||
autoClose: false,
|
||||
start: 0,
|
||||
end: file.size - 1,
|
||||
end: size - 1,
|
||||
signal,
|
||||
});
|
||||
let closing: Promise<void> | undefined;
|
||||
return {
|
||||
stream,
|
||||
size: file.size,
|
||||
size,
|
||||
mtime: file.mtime,
|
||||
mode: file.mode,
|
||||
mode: Number(file.mode),
|
||||
identity: { device: file.dev, inode: file.ino },
|
||||
sourcePath: resolve(source),
|
||||
close: () => {
|
||||
closing ??= closeZipSource(stream!, handle);
|
||||
return closing;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* packages diagnostic files through the local zip writer. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { resolve } from 'pathe';
|
||||
import { join, resolve } from 'pathe';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { scanSessionWire } from './wire-scan';
|
||||
import {
|
||||
type ExtraZipEntry,
|
||||
type SessionZipEntry,
|
||||
collectFilesRecursive,
|
||||
writeExportZip,
|
||||
} from './zip';
|
||||
|
|
@ -164,49 +165,58 @@ export async function exportSessionDirectory(input: {
|
|||
}): Promise<ExportSessionResult> {
|
||||
input.signal?.throwIfAborted();
|
||||
const sessionDir = input.summary.sessionDir;
|
||||
const sessionFiles = await collectFilesRecursive(sessionDir);
|
||||
if (sessionFiles.length === 0) {
|
||||
throw new Error2(
|
||||
ErrorCodes.SESSION_EXPORT_NOT_FOUND,
|
||||
`Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`,
|
||||
{ details: { sessionId: input.summary.id, sessionDir } },
|
||||
);
|
||||
}
|
||||
|
||||
const sessionScan = await scanSessionWire(sessionDir, input.signal);
|
||||
const hasSessionLog = sessionFiles.some((f) =>
|
||||
f.endsWith(`/${SESSION_LOG_REL}`) || f.endsWith(`\\${SESSION_LOG_REL.replaceAll('/', '\\')}`),
|
||||
);
|
||||
|
||||
const bundledWebLog = input.webLog !== undefined;
|
||||
const baseManifest = buildExportManifest({
|
||||
summary: input.summary,
|
||||
now: new Date(),
|
||||
version: input.request.version,
|
||||
sessionScan,
|
||||
sessionLogPath: hasSessionLog ? SESSION_LOG_REL : undefined,
|
||||
webLogPath: bundledWebLog ? WEB_LOG_REL : undefined,
|
||||
installSource: input.request.installSource,
|
||||
shellEnv: input.request.shellEnv,
|
||||
});
|
||||
const outputPath =
|
||||
input.request.outputPath !== undefined
|
||||
? resolve(input.request.outputPath)
|
||||
: resolve(`${input.summary.id}.zip`);
|
||||
|
||||
const extras: ExtraZipEntry[] = [];
|
||||
if (input.webLog !== undefined) {
|
||||
extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL });
|
||||
}
|
||||
const sessionLogPath = join(sessionDir, SESSION_LOG_REL);
|
||||
let sessionLogSource: ZipSource | undefined;
|
||||
let sessionLogSourceTransferred = false;
|
||||
let globalSource: ZipSource | undefined;
|
||||
let globalSourceTransferred = false;
|
||||
|
||||
try {
|
||||
sessionLogSource = await openOptionalZipSource(sessionLogPath, input.signal);
|
||||
if (input.request.includeGlobalLog === true && input.globalLogPath !== undefined) {
|
||||
globalSource = await openOptionalZipSource(input.globalLogPath, input.signal);
|
||||
if (globalSource !== undefined) {
|
||||
extras.push({ source: globalSource, target: GLOBAL_LOG_REL });
|
||||
}
|
||||
}
|
||||
const sessionFiles = await collectFilesRecursive(sessionDir);
|
||||
if (sessionFiles.length === 0 && sessionLogSource === undefined) {
|
||||
throw new Error2(
|
||||
ErrorCodes.SESSION_EXPORT_NOT_FOUND,
|
||||
`Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`,
|
||||
{ details: { sessionId: input.summary.id, sessionDir } },
|
||||
);
|
||||
}
|
||||
|
||||
const sessionScan = await scanSessionWire(sessionDir, input.signal);
|
||||
const stableSessionLog = sessionLogSource;
|
||||
const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter(
|
||||
(file) => file !== sessionLogPath,
|
||||
);
|
||||
if (stableSessionLog !== undefined) {
|
||||
selectedSessionFiles.push({ path: sessionLogPath, source: stableSessionLog });
|
||||
selectedSessionFiles.sort((left, right) =>
|
||||
sessionZipEntryPath(left).localeCompare(sessionZipEntryPath(right)),
|
||||
);
|
||||
}
|
||||
const bundledWebLog = input.webLog !== undefined;
|
||||
const baseManifest = buildExportManifest({
|
||||
summary: input.summary,
|
||||
now: new Date(),
|
||||
version: input.request.version,
|
||||
sessionScan,
|
||||
sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL,
|
||||
webLogPath: bundledWebLog ? WEB_LOG_REL : undefined,
|
||||
installSource: input.request.installSource,
|
||||
shellEnv: input.request.shellEnv,
|
||||
});
|
||||
const outputPath =
|
||||
input.request.outputPath !== undefined
|
||||
? resolve(input.request.outputPath)
|
||||
: resolve(`${input.summary.id}.zip`);
|
||||
const extras: ExtraZipEntry[] = [];
|
||||
if (input.webLog !== undefined) {
|
||||
extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL });
|
||||
}
|
||||
if (globalSource !== undefined) {
|
||||
extras.push({ source: globalSource, target: GLOBAL_LOG_REL });
|
||||
}
|
||||
const manifest =
|
||||
globalSource === undefined
|
||||
|
|
@ -217,11 +227,12 @@ export async function exportSessionDirectory(input: {
|
|||
outputPath,
|
||||
manifest,
|
||||
sessionDir,
|
||||
sessionFiles,
|
||||
sessionFiles: selectedSessionFiles,
|
||||
extraEntries: extras,
|
||||
signal: input.signal,
|
||||
maxArchiveBytes: input.maxArchiveBytes,
|
||||
});
|
||||
sessionLogSourceTransferred = sessionLogSource !== undefined;
|
||||
globalSourceTransferred = globalSource !== undefined;
|
||||
const entries = await writing;
|
||||
|
||||
|
|
@ -232,24 +243,41 @@ export async function exportSessionDirectory(input: {
|
|||
manifest,
|
||||
};
|
||||
} finally {
|
||||
if (sessionLogSource !== undefined && !sessionLogSourceTransferred) {
|
||||
await sessionLogSource.close().catch(() => {});
|
||||
}
|
||||
if (globalSource !== undefined && !globalSourceTransferred) {
|
||||
await globalSource.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionZipEntryPath(entry: SessionZipEntry): string {
|
||||
return typeof entry === 'string' ? entry : entry.path;
|
||||
}
|
||||
|
||||
async function openOptionalZipSource(
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ZipSource | undefined> {
|
||||
try {
|
||||
return await openZipSource(path, signal);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
return undefined;
|
||||
if (isMissingPath(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
ISessionExportService,
|
||||
|
|
|
|||
|
|
@ -7,16 +7,20 @@
|
|||
*/
|
||||
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { dirname, join, relative } from 'pathe';
|
||||
import { dirname, join, relative, resolve } from 'pathe';
|
||||
import { ZipFile, type ReadStreamOptions } from 'yazl';
|
||||
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
|
||||
import { openZipSource, type ZipSource } from './file-source';
|
||||
import {
|
||||
openZipSource,
|
||||
type ZipSource,
|
||||
type ZipSourceIdentity,
|
||||
} from './file-source';
|
||||
import type { ExportSessionManifest } from './sessionExport';
|
||||
|
||||
export async function collectFilesRecursive(root: string): Promise<string[]> {
|
||||
|
|
@ -36,18 +40,23 @@ export type ExtraZipEntry =
|
|||
| { readonly source: ZipSource; readonly target: string }
|
||||
| { readonly data: Buffer; readonly target: string };
|
||||
|
||||
export type SessionZipEntry = string | { readonly path: string; readonly source: ZipSource };
|
||||
|
||||
export async function writeExportZip(args: {
|
||||
readonly outputPath: string;
|
||||
readonly manifest: ExportSessionManifest;
|
||||
readonly sessionDir: string;
|
||||
readonly sessionFiles: readonly string[];
|
||||
readonly sessionFiles: readonly SessionZipEntry[];
|
||||
readonly extraEntries?: readonly ExtraZipEntry[];
|
||||
readonly signal?: AbortSignal;
|
||||
readonly maxArchiveBytes?: number;
|
||||
}): Promise<readonly string[]> {
|
||||
const unusedSources = new Set(
|
||||
(args.extraEntries ?? []).flatMap((entry) => ('source' in entry ? [entry.source] : [])),
|
||||
);
|
||||
const unusedSources = new Set<ZipSource>([
|
||||
...args.sessionFiles.flatMap((entry) => (typeof entry === 'string' ? [] : [entry.source])),
|
||||
...(args.extraEntries ?? []).flatMap((entry) =>
|
||||
'source' in entry ? [entry.source] : [],
|
||||
),
|
||||
]);
|
||||
const pendingOpens = new Set<Promise<void>>();
|
||||
let activeSource: ZipSource | undefined;
|
||||
let releaseActive: (() => void) | undefined;
|
||||
|
|
@ -57,6 +66,7 @@ export async function writeExportZip(args: {
|
|||
let stopped: Error | undefined;
|
||||
let failure: { readonly error: unknown } | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
let tempDir: string | undefined;
|
||||
|
||||
const getStopError = (): Error | undefined => stopped;
|
||||
const stop = (error: Error): void => {
|
||||
|
|
@ -73,8 +83,18 @@ export async function writeExportZip(args: {
|
|||
};
|
||||
|
||||
try {
|
||||
const conflictingSource = await findConflictingSource(args);
|
||||
if (conflictingSource !== undefined) {
|
||||
throw new Error2(
|
||||
ErrorCodes.SESSION_EXPORT_OUTPUT_CONFLICT,
|
||||
`Session export output conflicts with selected source "${conflictingSource}".`,
|
||||
{ details: { outputPath: args.outputPath, source: conflictingSource } },
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(args.outputPath), { recursive: true });
|
||||
args.signal?.throwIfAborted();
|
||||
tempDir = await mkdtemp(join(dirname(args.outputPath), '.kimi-session-export-'));
|
||||
const tempOutputPath = join(tempDir, 'archive.zip');
|
||||
|
||||
const zip = new ZipFile() as LazyZipFile;
|
||||
output = zip.outputStream as unknown as Readable;
|
||||
|
|
@ -89,7 +109,7 @@ export async function writeExportZip(args: {
|
|||
};
|
||||
args.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const destination = createWriteStream(args.outputPath);
|
||||
const destination = createWriteStream(tempOutputPath, { flags: 'wx' });
|
||||
writing =
|
||||
args.maxArchiveBytes === undefined
|
||||
? pipeline(output, destination, { signal: args.signal })
|
||||
|
|
@ -152,9 +172,18 @@ export async function writeExportZip(args: {
|
|||
|
||||
zip.addBuffer(Buffer.from(JSON.stringify(args.manifest, null, 2), 'utf8'), 'manifest.json');
|
||||
|
||||
for (const source of args.sessionFiles) {
|
||||
const target = relative(args.sessionDir, source).split(/[\\/]/).join('/');
|
||||
addLazySource(target, {}, () => openZipSource(source, args.signal));
|
||||
for (const entry of args.sessionFiles) {
|
||||
const sourcePath = sessionEntryPath(entry);
|
||||
const target = relative(args.sessionDir, sourcePath).split(/[\\/]/).join('/');
|
||||
if (typeof entry === 'string') {
|
||||
addLazySource(target, {}, () => openZipSource(entry, args.signal));
|
||||
} else {
|
||||
addLazySource(
|
||||
target,
|
||||
{ size: entry.source.size, mtime: entry.source.mtime, mode: entry.source.mode },
|
||||
async () => entry.source,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of args.extraEntries ?? []) {
|
||||
|
|
@ -171,6 +200,14 @@ export async function writeExportZip(args: {
|
|||
|
||||
zip.end();
|
||||
await writing;
|
||||
await Promise.allSettled(pendingOpens);
|
||||
await closing;
|
||||
if (onAbort !== undefined) {
|
||||
args.signal?.removeEventListener('abort', onAbort);
|
||||
onAbort = undefined;
|
||||
}
|
||||
args.signal?.throwIfAborted();
|
||||
await rename(tempOutputPath, args.outputPath);
|
||||
} catch (error) {
|
||||
failure = { error };
|
||||
stop(asError(error));
|
||||
|
|
@ -186,14 +223,21 @@ export async function writeExportZip(args: {
|
|||
} catch (error) {
|
||||
failure ??= { error };
|
||||
}
|
||||
if (tempDir !== undefined) {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
failure ??= { error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failure !== undefined) throw failure.error;
|
||||
if (stopped !== undefined) throw stopped;
|
||||
return [
|
||||
'manifest.json',
|
||||
...args.sessionFiles.map((source) =>
|
||||
relative(args.sessionDir, source).split(/[\\/]/).join('/'),
|
||||
...args.sessionFiles.map((entry) =>
|
||||
relative(args.sessionDir, sessionEntryPath(entry)).split(/[\\/]/).join('/'),
|
||||
),
|
||||
...(args.extraEntries ?? []).map((entry) => entry.target),
|
||||
];
|
||||
|
|
@ -239,6 +283,72 @@ function createArchiveLimit(maxArchiveBytes: number): Transform {
|
|||
});
|
||||
}
|
||||
|
||||
async function findConflictingSource(args: {
|
||||
readonly outputPath: string;
|
||||
readonly sessionFiles: readonly SessionZipEntry[];
|
||||
readonly extraEntries?: readonly ExtraZipEntry[];
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<string | undefined> {
|
||||
args.signal?.throwIfAborted();
|
||||
const outputPath = resolve(args.outputPath);
|
||||
for (const entry of args.sessionFiles) {
|
||||
const sourcePath = sessionEntryPath(entry);
|
||||
if (resolve(sourcePath) === outputPath) return sourcePath;
|
||||
}
|
||||
for (const entry of args.extraEntries ?? []) {
|
||||
if (
|
||||
'source' in entry &&
|
||||
entry.source.sourcePath !== undefined &&
|
||||
resolve(entry.source.sourcePath) === outputPath
|
||||
) {
|
||||
return entry.target;
|
||||
}
|
||||
}
|
||||
|
||||
const output = await statExisting(outputPath);
|
||||
if (output === undefined) return undefined;
|
||||
|
||||
for (const entry of args.sessionFiles) {
|
||||
args.signal?.throwIfAborted();
|
||||
const input =
|
||||
typeof entry === 'string' ? await statExisting(entry) : entry.source.identity;
|
||||
if (input !== undefined && sameFile(output, input)) return sessionEntryPath(entry);
|
||||
}
|
||||
for (const entry of args.extraEntries ?? []) {
|
||||
args.signal?.throwIfAborted();
|
||||
if ('source' in entry && sameFile(output, entry.source.identity)) return entry.target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function statExisting(
|
||||
path: string,
|
||||
): Promise<ZipSourceIdentity | undefined> {
|
||||
try {
|
||||
const file = await stat(path, { bigint: true });
|
||||
return { device: file.dev, inode: file.ino };
|
||||
} catch (error) {
|
||||
if (!isMissingPath(error)) throw error;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sameFile(
|
||||
left: ZipSourceIdentity,
|
||||
right: ZipSourceIdentity,
|
||||
): boolean {
|
||||
return (
|
||||
left.inode !== 0n &&
|
||||
right.inode !== 0n &&
|
||||
left.device === right.device &&
|
||||
left.inode === right.inode
|
||||
);
|
||||
}
|
||||
|
||||
function sessionEntryPath(entry: SessionZipEntry): string {
|
||||
return typeof entry === 'string' ? entry : entry.path;
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
import { appendFile, mkdir, mkdtemp, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
appendFile,
|
||||
type FileHandle,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { join } from 'pathe';
|
||||
import { open as openZip } from 'yauzl';
|
||||
|
||||
|
|
@ -44,6 +58,22 @@ import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/se
|
|||
import { stubBootstrap } from '../bootstrap/stubs';
|
||||
import { stubLog } from '../../_base/log/stubs';
|
||||
|
||||
const fsOpenHook = vi.hoisted(() => ({
|
||||
afterOpen: undefined as ((path: string, handle: FileHandle) => Promise<void>) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args);
|
||||
await fsOpenHook.afterOpen?.(String(args[0]), handle);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const noopDisposable: IDisposable = { dispose: () => {} };
|
||||
const noopEvent = () => noopDisposable;
|
||||
|
||||
|
|
@ -115,6 +145,146 @@ describe('sessionExport', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const logPath = join(sessionDir, 'logs', 'kimi-code.log');
|
||||
const rotatedPath = `${logPath}.1`;
|
||||
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
||||
const outputPath = join(tmp, 'rotating-log.zip');
|
||||
const log = Buffer.from('session log before rotation\n', 'utf8');
|
||||
await mkdir(join(sessionDir, 'logs'), { recursive: true });
|
||||
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
|
||||
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
|
||||
await writeFile(logPath, log);
|
||||
await writeFile(wirePath, `${JSON.stringify({ type: 'metadata', time: 1_700_000_000 })}\n`);
|
||||
let rotated = false;
|
||||
fsOpenHook.afterOpen = async (path) => {
|
||||
if (!rotated && path === wirePath) {
|
||||
await rename(logPath, rotatedPath);
|
||||
rotated = true;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await exportSessionDirectory({
|
||||
request: {
|
||||
sessionId: 'ses_rotating_log',
|
||||
outputPath,
|
||||
version: '1.0.0-test',
|
||||
},
|
||||
summary: {
|
||||
id: 'ses_rotating_log',
|
||||
sessionDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rotated).toBe(true);
|
||||
expect(result.manifest.sessionLogPath).toBe('logs/kimi-code.log');
|
||||
expect(result.entries).toContain('logs/kimi-code.log');
|
||||
await expect(readZipEntry(outputPath, 'logs/kimi-code.log')).resolves.toEqual(log);
|
||||
} finally {
|
||||
fsOpenHook.afterOpen = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the global 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_global');
|
||||
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
||||
const globalLogPath = join(tmp, 'logs', 'kimi-code.log');
|
||||
const rotatedPath = `${globalLogPath}.1`;
|
||||
const outputPath = join(tmp, 'rotating-global.zip');
|
||||
const log = Buffer.from('global log before rotation\n', 'utf8');
|
||||
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
|
||||
await mkdir(join(tmp, 'logs'), { recursive: true });
|
||||
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
|
||||
await writeFile(wirePath, `${JSON.stringify({ type: 'metadata', time: 1_700_000_000 })}\n`);
|
||||
await writeFile(globalLogPath, log);
|
||||
let rotated = false;
|
||||
fsOpenHook.afterOpen = async (path) => {
|
||||
if (!rotated && path === wirePath) {
|
||||
await rename(globalLogPath, rotatedPath);
|
||||
rotated = true;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await exportSessionDirectory({
|
||||
request: {
|
||||
sessionId: 'ses_rotating_global',
|
||||
outputPath,
|
||||
includeGlobalLog: true,
|
||||
version: '1.0.0-test',
|
||||
},
|
||||
summary: {
|
||||
id: 'ses_rotating_global',
|
||||
sessionDir,
|
||||
},
|
||||
globalLogPath,
|
||||
});
|
||||
|
||||
expect(rotated).toBe(true);
|
||||
expect(result.manifest.globalLogPath).toBe('logs/global/kimi-code.log');
|
||||
expect(result.entries).toContain('logs/global/kimi-code.log');
|
||||
await expect(readZipEntry(outputPath, 'logs/global/kimi-code.log')).resolves.toEqual(log);
|
||||
} finally {
|
||||
fsOpenHook.afterOpen = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('closes pre-opened logs when manifest creation fails before writer ownership', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_invalid_manifest');
|
||||
const sessionLogPath = join(sessionDir, 'logs', 'kimi-code.log');
|
||||
const globalLogPath = join(tmp, 'logs', 'kimi-code.log');
|
||||
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
||||
const outputPath = join(tmp, 'invalid-manifest.zip');
|
||||
await mkdir(join(sessionDir, 'logs'), { recursive: true });
|
||||
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
|
||||
await mkdir(join(tmp, 'logs'), { recursive: true });
|
||||
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
|
||||
await writeFile(sessionLogPath, 'session log\n', 'utf8');
|
||||
await writeFile(globalLogPath, 'global log\n', 'utf8');
|
||||
await writeFile(
|
||||
wirePath,
|
||||
`${JSON.stringify({ type: 'metadata', time: 9_000_000_000_000_001 })}\n`,
|
||||
);
|
||||
const logHandles: FileHandle[] = [];
|
||||
fsOpenHook.afterOpen = async (path, handle) => {
|
||||
if (path === sessionLogPath || path === globalLogPath) logHandles.push(handle);
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(
|
||||
exportSessionDirectory({
|
||||
request: {
|
||||
sessionId: 'ses_invalid_manifest',
|
||||
outputPath,
|
||||
includeGlobalLog: true,
|
||||
version: '1.0.0-test',
|
||||
},
|
||||
summary: {
|
||||
id: 'ses_invalid_manifest',
|
||||
sessionDir,
|
||||
},
|
||||
globalLogPath,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(RangeError);
|
||||
} finally {
|
||||
fsOpenHook.afterOpen = undefined;
|
||||
}
|
||||
|
||||
expect(logHandles).toHaveLength(2);
|
||||
for (const handle of logHandles) {
|
||||
await expect(handle.stat()).rejects.toMatchObject({ code: 'EBADF' });
|
||||
}
|
||||
await expect(stat(outputPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect((await readdir(tmp)).filter((entry) => entry.startsWith('.kimi-session-export-'))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the optional global log when the configured file is missing', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_unreadable_global');
|
||||
|
|
@ -165,6 +335,110 @@ describe('sessionExport', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects an output path that is also a selected session file without modifying it', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const outputPath = join(tmp, 'state.json');
|
||||
const original = Buffer.from('{"state":"preserved"}\n', 'utf8');
|
||||
await writeFile(outputPath, original);
|
||||
|
||||
await expect(
|
||||
exportSessionDirectory({
|
||||
request: {
|
||||
sessionId: 'ses_output_conflict',
|
||||
outputPath,
|
||||
version: '1.0.0-test',
|
||||
},
|
||||
summary: {
|
||||
id: 'ses_output_conflict',
|
||||
sessionDir: tmp,
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'Error2',
|
||||
code: 'session.export_output_conflict',
|
||||
details: { outputPath, source: outputPath },
|
||||
});
|
||||
await expect(readFile(outputPath)).resolves.toEqual(original);
|
||||
});
|
||||
|
||||
it('rejects a hard-linked output path without modifying the selected session file', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const sourcePath = join(tmp, 'state.json');
|
||||
const outputPath = join(tmp, 'export.zip');
|
||||
const original = Buffer.from('{"state":"preserved"}\n', 'utf8');
|
||||
await writeFile(sourcePath, original);
|
||||
await link(sourcePath, outputPath);
|
||||
|
||||
await expect(
|
||||
writeExportZip({
|
||||
outputPath,
|
||||
manifest: testManifest('ses_hard_link_conflict'),
|
||||
sessionDir: tmp,
|
||||
sessionFiles: [sourcePath],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'session.export_output_conflict' });
|
||||
await expect(readFile(sourcePath)).resolves.toEqual(original);
|
||||
});
|
||||
|
||||
it('closes a pre-opened source when it conflicts with the output path', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const outputPath = join(tmp, 'global.log');
|
||||
const original = Buffer.from('global log\n', 'utf8');
|
||||
await writeFile(outputPath, original);
|
||||
const opened = await openZipSource(outputPath);
|
||||
let closeCalls = 0;
|
||||
const source: ZipSource = {
|
||||
...opened,
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
await opened.close();
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
writeExportZip({
|
||||
outputPath,
|
||||
manifest: testManifest('ses_extra_conflict'),
|
||||
sessionDir: tmp,
|
||||
sessionFiles: [],
|
||||
extraEntries: [{ source, target: 'logs/global/kimi-code.log' }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'session.export_output_conflict' });
|
||||
expect(closeCalls).toBe(1);
|
||||
await expect(readFile(outputPath)).resolves.toEqual(original);
|
||||
});
|
||||
|
||||
it('archives a bound session log after its path is rotated', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const logPath = join(tmp, 'logs', 'kimi-code.log');
|
||||
const rotatedPath = `${logPath}.1`;
|
||||
const outputPath = join(tmp, 'rotated-log.zip');
|
||||
const original = Buffer.from('before rotation\n', 'utf8');
|
||||
await mkdir(join(tmp, 'logs'), { recursive: true });
|
||||
await writeFile(logPath, original);
|
||||
const opened = await openZipSource(logPath);
|
||||
let closeCalls = 0;
|
||||
const source: ZipSource = {
|
||||
...opened,
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
await opened.close();
|
||||
},
|
||||
};
|
||||
await rename(logPath, rotatedPath);
|
||||
|
||||
await expect(
|
||||
writeExportZip({
|
||||
outputPath,
|
||||
manifest: testManifest('ses_rotated_log'),
|
||||
sessionDir: tmp,
|
||||
sessionFiles: [{ path: logPath, source }],
|
||||
}),
|
||||
).resolves.toContain('logs/kimi-code.log');
|
||||
await expect(readZipEntry(outputPath, 'logs/kimi-code.log')).resolves.toEqual(original);
|
||||
expect(closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('includes a bounded Web log in the exported archive', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_web_log');
|
||||
|
|
@ -210,6 +484,7 @@ describe('sessionExport', () => {
|
|||
sessionFiles: [removedPath],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await expect(readdir(tmp)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('archives the opened file size when the source is appended during compression', async () => {
|
||||
|
|
@ -260,6 +535,7 @@ describe('sessionExport', () => {
|
|||
size: 7,
|
||||
mtime: new Date(0),
|
||||
mode: 0o600,
|
||||
identity: { device: -1n, inode: -1n },
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
|
|
@ -283,6 +559,57 @@ describe('sessionExport', () => {
|
|||
expect(stream.destroyed).toBe(true);
|
||||
expect(closeCalls).toBe(1);
|
||||
allowRead.resolve();
|
||||
await expect(readdir(tmp)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not follow an output symlink swapped during compression', async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
|
||||
const statePath = join(tmp, 'state.json');
|
||||
const safeTarget = join(tmp, 'safe-output');
|
||||
const outputPath = join(tmp, 'export.zip');
|
||||
const state = Buffer.from('{"state":"preserved"}\n', 'utf8');
|
||||
await writeFile(statePath, state);
|
||||
await writeFile(safeTarget, 'safe\n', 'utf8');
|
||||
await symlink(safeTarget, outputPath);
|
||||
const readStarted = deferred();
|
||||
const allowRead = deferred();
|
||||
const payload = Buffer.from('payload', 'utf8');
|
||||
const stream = Readable.from(
|
||||
(async function* (): AsyncGenerator<Buffer> {
|
||||
readStarted.resolve();
|
||||
await allowRead.promise;
|
||||
yield payload;
|
||||
})(),
|
||||
);
|
||||
const source: ZipSource = {
|
||||
stream,
|
||||
size: payload.length,
|
||||
mtime: new Date(0),
|
||||
mode: 0o600,
|
||||
identity: { device: -1n, inode: -1n },
|
||||
close: async () => {
|
||||
stream.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
const writing = writeExportZip({
|
||||
outputPath,
|
||||
manifest: testManifest('ses_symlink_swap'),
|
||||
sessionDir: tmp,
|
||||
sessionFiles: [],
|
||||
extraEntries: [{ source, target: 'controlled.bin' }],
|
||||
});
|
||||
await readStarted.promise;
|
||||
await unlink(outputPath);
|
||||
await symlink(statePath, outputPath);
|
||||
allowRead.resolve();
|
||||
await writing;
|
||||
|
||||
await expect(readFile(statePath)).resolves.toEqual(state);
|
||||
await expect(readFile(safeTarget, 'utf8')).resolves.toBe('safe\n');
|
||||
expect((await lstat(outputPath)).isSymbolicLink()).toBe(false);
|
||||
await expect(readZipEntry(outputPath, 'controlled.bin')).resolves.toEqual(payload);
|
||||
expect((await readdir(tmp)).toSorted()).toEqual(['export.zip', 'safe-output', 'state.json']);
|
||||
});
|
||||
|
||||
it('rejects with a coded error when compressed output exceeds the configured limit', async () => {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export type KimiErrorCode =
|
|||
| 'session.undo_unavailable'
|
||||
| 'session.export_not_found'
|
||||
| 'session.export_missing_version'
|
||||
| 'session.export_output_conflict'
|
||||
| 'session.export_too_large'
|
||||
| 'session.closed'
|
||||
| 'session.permission_mode_invalid'
|
||||
|
|
@ -1121,6 +1122,7 @@ export const kimiErrorCodeSchema = z.enum([
|
|||
'session.undo_unavailable',
|
||||
'session.export_not_found',
|
||||
'session.export_missing_version',
|
||||
'session.export_output_conflict',
|
||||
'session.export_too_large',
|
||||
'session.closed',
|
||||
'session.permission_mode_invalid',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue