From 78010b65edd585ee678ad677821d461458c0d482 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:03:02 +0100 Subject: [PATCH] refactor: async export file io --- .../reply/commands-export-session.test.ts | 25 ++++--- .../reply/commands-export-session.ts | 38 ++++++---- .../reply/commands-export-trajectory.test.ts | 37 +++++++--- .../reply/commands-export-trajectory.ts | 20 +++-- src/commands/export-trajectory.ts | 18 ++++- src/logging/diagnostic-support-bundle.test.ts | 4 +- src/logging/diagnostic-support-bundle.ts | 29 ++++---- src/trajectory/command-export.ts | 67 +++++++++-------- src/trajectory/export.test.ts | 62 ++++++++-------- src/trajectory/export.ts | 73 ++++++++++++------- 10 files changed, 227 insertions(+), 146 deletions(-) diff --git a/src/auto-reply/reply/commands-export-session.test.ts b/src/auto-reply/reply/commands-export-session.test.ts index 2474a7c898f..09f132b114f 100644 --- a/src/auto-reply/reply/commands-export-session.test.ts +++ b/src/auto-reply/reply/commands-export-session.test.ts @@ -13,9 +13,11 @@ const hoisted = await vi.hoisted(async () => { injectedFiles: [], sandboxRuntime: { sandboxed: false, mode: "off" }, })), - writeFileSyncMock: vi.fn(), - mkdirSyncMock: vi.fn(), - existsSyncMock: vi.fn(() => true), + writeFileMock: vi.fn( + async (_filePath: string, _data: string, _encoding?: BufferEncoding) => undefined, + ), + mkdirMock: vi.fn(async (_filePath: string, _options?: { recursive?: boolean }) => undefined), + accessMock: vi.fn(async (_filePath: string) => undefined), exportHtmlTemplateContents: new Map(), }; }); @@ -38,9 +40,6 @@ vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); const mockedFs = { ...actual, - existsSync: hoisted.existsSyncMock, - mkdirSync: hoisted.mkdirSyncMock, - writeFileSync: hoisted.writeFileSyncMock, readFileSync: vi.fn((filePath: string) => { for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) { if (filePath.endsWith(suffix)) { @@ -63,10 +62,18 @@ vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); const mockedFsPromises = { ...actual, + access: hoisted.accessMock, + mkdir: hoisted.mkdirMock, + writeFile: hoisted.writeFileMock, readFile: vi.fn(async (filePath: string, encoding?: BufferEncoding) => { if (filePath === "/tmp/target-store/session.jsonl") { return ""; } + for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) { + if (filePath.endsWith(suffix)) { + return contents; + } + } return actual.readFile(filePath, encoding); }), }; @@ -133,7 +140,7 @@ describe("buildExportSessionReply", () => { injectedFiles: [], sandboxRuntime: { sandboxed: false, mode: "off" }, }); - hoisted.existsSyncMock.mockReturnValue(true); + hoisted.accessMock.mockResolvedValue(undefined); hoisted.exportHtmlTemplateContents.clear(); }); @@ -202,7 +209,7 @@ describe("buildExportSessionReply", () => { await buildExportSessionReply(makeParams()); - const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1]; + const html = hoisted.writeFileMock.mock.calls[0]?.[1]; expect(typeof html).toBe("string"); expect(html).not.toContain("{{CSS}}"); expect(html).not.toContain("{{JS}}"); @@ -246,7 +253,7 @@ describe("buildExportSessionReply", () => { await buildExportSessionReply(makeParams()); - const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1]; + const html = hoisted.writeFileMock.mock.calls[0]?.[1]; expect(html).toContain("$&$1"); expect(html).toContain("const marker = '$&$1';"); expect(html).toContain("const markedMarker = '$&$1';"); diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index 578911b249b..6f0e830e2e6 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,8 +27,8 @@ interface SessionData { tools?: Array<{ name: string; description?: string; parameters?: unknown }>; } -function loadTemplate(fileName: string): string { - return fs.readFileSync(path.join(EXPORT_HTML_DIR, fileName), "utf-8"); +async function loadTemplate(fileName: string): Promise { + return await fsp.readFile(path.join(EXPORT_HTML_DIR, fileName), "utf-8"); } function replaceHtmlPlaceholder(template: string, name: string, value: string): string { @@ -51,12 +50,14 @@ function replaceHtmlPlaceholder(template: string, name: string, value: string): return next; } -function generateHtml(sessionData: SessionData): string { - const template = loadTemplate("template.html"); - const templateCss = loadTemplate("template.css"); - const templateJs = loadTemplate("template.js"); - const markedJs = loadTemplate(path.join("vendor", "marked.min.js")); - const hljsJs = loadTemplate(path.join("vendor", "highlight.min.js")); +async function generateHtml(sessionData: SessionData): Promise { + const [template, templateCss, templateJs, markedJs, hljsJs] = await Promise.all([ + loadTemplate("template.html"), + loadTemplate("template.css"), + loadTemplate("template.js"), + loadTemplate(path.join("vendor", "marked.min.js")), + loadTemplate(path.join("vendor", "highlight.min.js")), + ]); // Use pi-mono dark theme colors (matching their theme/dark.json) const themeVars = ` @@ -121,6 +122,15 @@ function generateHtml(sessionData: SessionData): string { ].reduce((html, [name, value]) => replaceHtmlPlaceholder(html, name, value), template); } +async function fileExists(pathName: string): Promise { + try { + await fsp.access(pathName); + return true; + } catch { + return false; + } +} + async function readSessionDataFromTranscript(sessionFile: string): Promise<{ header: SessionHeader | null; entries: PiSessionEntry[]; @@ -151,7 +161,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro } const { entry, sessionFile } = sessionTarget; - if (!fs.existsSync(sessionFile)) { + if (!(await fileExists(sessionFile))) { return { text: `❌ Session file not found: ${sessionFile}` }; } @@ -178,7 +188,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro }; // 5. Generate HTML - const html = generateHtml(sessionData); + const html = await generateHtml(sessionData); // 6. Determine output path const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); @@ -193,12 +203,10 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro // Ensure directory exists const outputDir = path.dirname(outputPath); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } + await fsp.mkdir(outputDir, { recursive: true }); // 7. Write file - fs.writeFileSync(outputPath, html, "utf-8"); + await fsp.writeFile(outputPath, html, "utf-8"); const relativePath = path.relative(params.workspaceDir, outputPath); const displayPath = relativePath.startsWith("..") ? outputPath : relativePath; diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test.ts index 71a1a1ba2eb..16376aef9f7 100644 --- a/src/auto-reply/reply/commands-export-trajectory.test.ts +++ b/src/auto-reply/reply/commands-export-trajectory.test.ts @@ -22,8 +22,10 @@ const hoisted = await vi.hoisted(async () => { resolveDefaultTrajectoryExportDirMock: vi.fn( () => "/tmp/workspace/.openclaw/trajectory-exports/openclaw-trajectory-session", ), - existsSyncMock: vi.fn((file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => - actualExistsSync(file), + accessMock: vi.fn( + async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise) => { + await actualAccess(file); + }, ), }; }); @@ -45,9 +47,18 @@ vi.mock("../../trajectory/export.js", () => ({ vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); + const mockedFs = { ...actual }; + return { + ...mockedFs, + default: mockedFs, + }; +}); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); const mockedFs = { ...actual, - existsSync: (file: fs.PathLike) => hoisted.existsSyncMock(file, actual.existsSync), + access: (file: fs.PathLike) => hoisted.accessMock(file, actual.access), }; return { ...mockedFs, @@ -154,9 +165,13 @@ function readEncodedRequestFromCommand(command: string): Record describe("buildExportTrajectoryReply", () => { beforeEach(() => { vi.clearAllMocks(); - hoisted.existsSyncMock.mockImplementation( - (file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => - file.toString() === "/tmp/target-store/session.jsonl" || actualExistsSync(file), + hoisted.accessMock.mockImplementation( + async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise) => { + if (file.toString() === "/tmp/target-store/session.jsonl") { + return; + } + await actualAccess(file); + }, ); }); @@ -223,9 +238,13 @@ describe("buildExportTrajectoryReply", () => { it("does not echo absolute session paths when the transcript is missing", async () => { const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); - hoisted.existsSyncMock.mockImplementation( - (file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => - file.toString() === "/tmp/target-store/session.jsonl" ? false : actualExistsSync(file), + hoisted.accessMock.mockImplementation( + async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise) => { + if (file.toString() === "/tmp/target-store/session.jsonl") { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + } + await actualAccess(file); + }, ); const reply = await buildExportTrajectoryReply(makeParams()); diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts index daa684f4842..8ac1cd58e31 100644 --- a/src/auto-reply/reply/commands-export-trajectory.ts +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fsp from "node:fs/promises"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { createExecTool } from "../../agents/bash-tools.js"; import type { ExecToolDetails } from "../../agents/bash-tools.js"; @@ -8,6 +8,7 @@ import { exportTrajectoryForCommand, formatTrajectoryCommandExportSummary, resolveTrajectoryCommandOutputDir, + type TrajectoryCommandExportSummary, } from "../../trajectory/command-export.js"; import type { ReplyPayload } from "../types.js"; import { @@ -55,6 +56,15 @@ const defaultExportTrajectoryCommandDeps: ExportTrajectoryCommandDeps = { deliverPrivateTrajectoryReply: deliverPrivateTrajectoryReply, }; +async function fileExists(pathName: string): Promise { + try { + await fsp.access(pathName); + return true; + } catch { + return false; + } +} + export async function buildExportTrajectoryCommandReply( params: HandleCommandsParams, deps: Partial = {}, @@ -136,13 +146,13 @@ export async function buildExportTrajectoryReply( } const { entry, sessionFile } = sessionTarget; - if (!fs.existsSync(sessionFile)) { + if (!(await fileExists(sessionFile))) { return { text: "❌ Session file not found." }; } let outputDir: string; try { - outputDir = resolveTrajectoryCommandOutputDir({ + outputDir = await resolveTrajectoryCommandOutputDir({ outputPath: args.outputPath, workspaceDir: params.workspaceDir, sessionId: entry.sessionId, @@ -153,9 +163,9 @@ export async function buildExportTrajectoryReply( }; } - let summary: ReturnType; + let summary: TrajectoryCommandExportSummary; try { - summary = exportTrajectoryForCommand({ + summary = await exportTrajectoryForCommand({ outputDir, sessionFile, sessionId: entry.sessionId, diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index ebe41bfa350..e56aa1fc485 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import { resolveDefaultSessionStorePath, @@ -13,6 +13,7 @@ import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { exportTrajectoryForCommand, formatTrajectoryCommandExportSummary, + type TrajectoryCommandExportSummary, } from "../trajectory/command-export.js"; type ExportTrajectoryCommandOptions = { @@ -71,6 +72,15 @@ function resolveExportTrajectoryOptions( }; } +async function fileExists(pathName: string): Promise { + try { + await fsp.access(pathName); + return true; + } catch { + return false; + } +} + export async function exportTrajectoryCommand( opts: ExportTrajectoryCommandOptions, runtime: RuntimeEnv, @@ -113,15 +123,15 @@ export async function exportTrajectoryCommand( runtime.exit(1); return; } - if (!fs.existsSync(sessionFile)) { + if (!(await fileExists(sessionFile))) { runtime.error("Session file not found."); runtime.exit(1); return; } - let summary: ReturnType; + let summary: TrajectoryCommandExportSummary; try { - summary = exportTrajectoryForCommand({ + summary = await exportTrajectoryForCommand({ outputPath: resolvedOpts.output, sessionFile, sessionId: entry.sessionId, diff --git a/src/logging/diagnostic-support-bundle.test.ts b/src/logging/diagnostic-support-bundle.test.ts index a51d1e238f9..5488b99f3e2 100644 --- a/src/logging/diagnostic-support-bundle.test.ts +++ b/src/logging/diagnostic-support-bundle.test.ts @@ -21,9 +21,9 @@ describe("diagnostic support bundle helpers", () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it("writes directory bundles with restrictive file permissions and byte inventory", () => { + it("writes directory bundles with restrictive file permissions and byte inventory", async () => { const outputDir = path.join(tempDir, "bundle"); - const contents = writeSupportBundleDirectory({ + const contents = await writeSupportBundleDirectory({ outputDir, files: [ jsonSupportBundleFile("manifest.json", { ok: true }), diff --git a/src/logging/diagnostic-support-bundle.ts b/src/logging/diagnostic-support-bundle.ts index f320efde35e..44fe13c80dc 100644 --- a/src/logging/diagnostic-support-bundle.ts +++ b/src/logging/diagnostic-support-bundle.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; export type DiagnosticSupportBundleFile = { @@ -72,9 +72,9 @@ function assertSafeBundleRelativePath(pathName: string): string { return normalized; } -function prepareSupportBundleDirectory(outputDir: string): void { - fs.mkdirSync(path.dirname(outputDir), { recursive: true, mode: 0o700 }); - fs.mkdirSync(outputDir, { mode: 0o700 }); +async function prepareSupportBundleDirectory(outputDir: string): Promise { + await fsp.mkdir(path.dirname(outputDir), { recursive: true, mode: 0o700 }); + await fsp.mkdir(outputDir, { mode: 0o700 }); } function resolveSupportBundleFilePath(outputDir: string, pathName: string): string { @@ -88,23 +88,26 @@ function resolveSupportBundleFilePath(outputDir: string, pathName: string): stri return resolvedFile; } -function writeSupportBundleFile(outputDir: string, file: DiagnosticSupportBundleFile): void { +async function writeSupportBundleFile( + outputDir: string, + file: DiagnosticSupportBundleFile, +): Promise { const filePath = resolveSupportBundleFilePath(outputDir, file.path); - fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(filePath, file.content, { + await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await fsp.writeFile(filePath, file.content, { encoding: "utf8", flag: "wx", mode: 0o600, }); } -export function writeSupportBundleDirectory(params: { +export async function writeSupportBundleDirectory(params: { outputDir: string; files: readonly DiagnosticSupportBundleFile[]; -}): DiagnosticSupportBundleContent[] { - prepareSupportBundleDirectory(params.outputDir); +}): Promise { + await prepareSupportBundleDirectory(params.outputDir); for (const file of params.files) { - writeSupportBundleFile(params.outputDir, file); + await writeSupportBundleFile(params.outputDir, file); } return supportBundleContents(params.files); } @@ -124,7 +127,7 @@ export async function writeSupportBundleZip(params: { compression: "DEFLATE", compressionOptions: { level: params.compressionLevel ?? 6 }, }); - fs.mkdirSync(path.dirname(params.outputPath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(params.outputPath, buffer, { mode: 0o600 }); + await fsp.mkdir(path.dirname(params.outputPath), { recursive: true, mode: 0o700 }); + await fsp.writeFile(params.outputPath, buffer, { mode: 0o600 }); return buffer.length; } diff --git a/src/trajectory/command-export.ts b/src/trajectory/command-export.ts index ee4e327fa51..34bfdaf46aa 100644 --- a/src/trajectory/command-export.ts +++ b/src/trajectory/command-export.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; @@ -17,53 +17,51 @@ function isPathInsideOrEqual(baseDir: string, candidate: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -function validateExistingExportDirectory(params: { +async function validateExistingExportDirectory(params: { dir: string; label: string; realWorkspace: string; -}): string { - const linkStat = fs.lstatSync(params.dir); +}): Promise { + const linkStat = await fsp.lstat(params.dir); if (linkStat.isSymbolicLink() || !linkStat.isDirectory()) { throw new Error(`${params.label} must be a real directory inside the workspace`); } - const realDir = fs.realpathSync(params.dir); + const realDir = await fsp.realpath(params.dir); if (!isPathInsideOrEqual(params.realWorkspace, realDir)) { throw new Error("Trajectory exports directory must stay inside the workspace"); } return realDir; } -function mkdirIfMissingThenValidate(params: { +async function mkdirIfMissingThenValidate(params: { dir: string; label: string; realWorkspace: string; -}): string { - if (!fs.existsSync(params.dir)) { - try { - fs.mkdirSync(params.dir, { mode: 0o700 }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") { - throw error; - } +}): Promise { + try { + await fsp.mkdir(params.dir, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; } } - return validateExistingExportDirectory(params); + return await validateExistingExportDirectory(params); } -function resolveTrajectoryExportBaseDir(workspaceDir: string): { +async function resolveTrajectoryExportBaseDir(workspaceDir: string): Promise<{ baseDir: string; realBase: string; -} { +}> { const workspacePath = path.resolve(workspaceDir); - const realWorkspace = fs.realpathSync(workspacePath); + const realWorkspace = await fsp.realpath(workspacePath); const stateDir = path.join(workspacePath, ".openclaw"); - mkdirIfMissingThenValidate({ + await mkdirIfMissingThenValidate({ dir: stateDir, label: "OpenClaw state directory", realWorkspace, }); const baseDir = path.join(stateDir, "trajectory-exports"); - const realBase = mkdirIfMissingThenValidate({ + const realBase = await mkdirIfMissingThenValidate({ dir: baseDir, label: "Trajectory exports directory", realWorkspace, @@ -71,12 +69,21 @@ function resolveTrajectoryExportBaseDir(workspaceDir: string): { return { baseDir: path.resolve(baseDir), realBase }; } -export function resolveTrajectoryCommandOutputDir(params: { +async function pathExists(pathName: string): Promise { + try { + await fsp.access(pathName); + return true; + } catch { + return false; + } +} + +export async function resolveTrajectoryCommandOutputDir(params: { outputPath?: string; workspaceDir: string; sessionId: string; -}): string { - const { baseDir, realBase } = resolveTrajectoryExportBaseDir(params.workspaceDir); +}): Promise { + const { baseDir, realBase } = await resolveTrajectoryExportBaseDir(params.workspaceDir); const raw = params.outputPath?.trim(); if (!raw) { const defaultDir = resolveDefaultTrajectoryExportDir({ @@ -95,36 +102,36 @@ export function resolveTrajectoryCommandOutputDir(params: { throw new Error("Output path must stay inside the workspace trajectory exports directory"); } let existingParent = outputDir; - while (!fs.existsSync(existingParent)) { + while (!(await pathExists(existingParent))) { const next = path.dirname(existingParent); if (next === existingParent) { break; } existingParent = next; } - const realExistingParent = fs.realpathSync(existingParent); + const realExistingParent = await fsp.realpath(existingParent); if (!isPathInsideOrEqual(realBase, realExistingParent)) { throw new Error("Output path must stay inside the real trajectory exports directory"); } return outputDir; } -export function exportTrajectoryForCommand(params: { +export async function exportTrajectoryForCommand(params: { outputDir?: string; outputPath?: string; sessionFile: string; sessionId: string; sessionKey: string; workspaceDir: string; -}): TrajectoryCommandExportSummary { +}): Promise { const outputDir = params.outputDir ?? - resolveTrajectoryCommandOutputDir({ + (await resolveTrajectoryCommandOutputDir({ outputPath: params.outputPath, workspaceDir: params.workspaceDir, sessionId: params.sessionId, - }); - const bundle = exportTrajectoryBundle({ + })); + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile: params.sessionFile, sessionId: params.sessionId, diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index f268e3f9eab..b3b23c6ec25 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -185,7 +185,7 @@ afterAll(() => { }); describe("exportTrajectoryBundle", () => { - it("sanitizes session ids in default export directory names", () => { + it("sanitizes session ids in default export directory names", async () => { const outputDir = resolveDefaultTrajectoryExportDir({ workspaceDir: "/tmp/workspace", sessionId: "../evil/session", @@ -202,30 +202,30 @@ describe("exportTrajectoryBundle", () => { ); }); - it("refuses to write into an existing output directory", () => { + it("refuses to write into an existing output directory", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.mkdirSync(outputDir); - expect(() => + await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }), - ).toThrow(); + ).rejects.toThrow(); }); - it("does not synthesize prompt files from export-time fallbacks", () => { + it("does not synthesize prompt files from export-time fallbacks", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -240,7 +240,7 @@ describe("exportTrajectoryBundle", () => { expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(false); }); - it("preserves numeric transcript timestamps", () => { + it("preserves numeric transcript timestamps", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); @@ -248,7 +248,7 @@ describe("exportTrajectoryBundle", () => { userEntryTimestamp: Date.parse("2026-04-01T05:46:40.000Z"), }); - exportTrajectoryBundle({ + await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -265,7 +265,7 @@ describe("exportTrajectoryBundle", () => { ); }); - it("rejects oversized runtime trajectory files", () => { + it("rejects oversized runtime trajectory files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); @@ -274,7 +274,7 @@ describe("exportTrajectoryBundle", () => { fs.closeSync(fs.openSync(runtimeFile, "w")); fs.truncateSync(runtimeFile, 50 * 1024 * 1024 + 1); - expect(() => + await expect( exportTrajectoryBundle({ outputDir, sessionFile, @@ -282,27 +282,27 @@ describe("exportTrajectoryBundle", () => { workspaceDir: tmpDir, runtimeFile, }), - ).toThrow(/too large/u); + ).rejects.toThrow(/too large/u); }); - it("rejects oversized session transcript files before export", () => { + it("rejects oversized session transcript files before export", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); fs.closeSync(fs.openSync(sessionFile, "w")); fs.truncateSync(sessionFile, 50 * 1024 * 1024 + 1); - expect(() => + await expect( exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", workspaceDir: tmpDir, }), - ).toThrow(/session file is too large/u); + ).rejects.toThrow(/session file is too large/u); }); - it("skips malformed-but-valid runtime json rows before sorting", () => { + it("skips malformed-but-valid runtime json rows before sorting", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); @@ -324,7 +324,7 @@ describe("exportTrajectoryBundle", () => { "utf8", ); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -335,7 +335,7 @@ describe("exportTrajectoryBundle", () => { expect(bundle.events.some((event) => event.type === "session.started")).toBe(true); }); - it("uses the recorded runtime pointer before current environment overrides", () => { + it("uses the recorded runtime pointer before current environment overrides", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const recordedRuntimeFile = path.join(tmpDir, "recorded", "session-1.jsonl"); @@ -387,7 +387,7 @@ describe("exportTrajectoryBundle", () => { const previous = process.env.OPENCLAW_TRAJECTORY_DIR; process.env.OPENCLAW_TRAJECTORY_DIR = envRuntimeDir; try { - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -406,7 +406,7 @@ describe("exportTrajectoryBundle", () => { } }); - it("ignores runtime pointers that do not look like this session's trajectory file", () => { + it("ignores runtime pointers that do not look like this session's trajectory file", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outsideFile = path.join(tmpDir, "outside.jsonl"); @@ -438,7 +438,7 @@ describe("exportTrajectoryBundle", () => { "utf8", ); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -449,7 +449,7 @@ describe("exportTrajectoryBundle", () => { expect(bundle.events.some((event) => event.type === "outside-runtime")).toBe(false); }); - it("does not fall back to runtime pointer targets that are not regular files", () => { + it("does not fall back to runtime pointer targets that are not regular files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const targetFile = path.join(tmpDir, "outside-target.jsonl"); @@ -484,7 +484,7 @@ describe("exportTrajectoryBundle", () => { ); fs.symlinkSync(targetFile, symlinkFile); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -495,13 +495,13 @@ describe("exportTrajectoryBundle", () => { expect(bundle.events.some((event) => event.type === "symlink-runtime")).toBe(false); }); - it("counts expanded transcript events when enforcing the total event limit", () => { + it("counts expanded transcript events when enforcing the total event limit", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const outputDir = path.join(tmpDir, "bundle"); writeToolCallOnlySessionFile(sessionFile); - expect(() => + await expect( exportTrajectoryBundle({ outputDir, sessionFile, @@ -509,10 +509,10 @@ describe("exportTrajectoryBundle", () => { workspaceDir: tmpDir, maxTotalEvents: 1, }), - ).toThrow(/too many events \(2; limit 1\)/u); + ).rejects.toThrow(/too many events \(2; limit 1\)/u); }); - it("skips runtime events for other sessions", () => { + it("skips runtime events for other sessions", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); @@ -534,7 +534,7 @@ describe("exportTrajectoryBundle", () => { "utf8", ); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -545,7 +545,7 @@ describe("exportTrajectoryBundle", () => { expect(bundle.events.some((event) => event.type === "other-runtime")).toBe(false); }); - it("redacts non-workspace paths in strings that also contain workspace paths", () => { + it("redacts non-workspace paths in strings that also contain workspace paths", async () => { const tmpDir = makeTempDir(); const homeDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); @@ -577,7 +577,7 @@ describe("exportTrajectoryBundle", () => { process.env.HOME = homeDir; try { - exportTrajectoryBundle({ + await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", @@ -599,7 +599,7 @@ describe("exportTrajectoryBundle", () => { expect(events).not.toContain(homeDir); }); - it("exports merged runtime and transcript events plus convenience files", () => { + it("exports merged runtime and transcript events plus convenience files", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); @@ -715,7 +715,7 @@ describe("exportTrajectoryBundle", () => { "utf8", ); - const bundle = exportTrajectoryBundle({ + const bundle = await exportTrajectoryBundle({ outputDir, sessionFile, sessionId: "session-1", diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index 63237b4729d..8f3b9d7a17d 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { FileEntry, SessionEntry, SessionHeader } from "@mariozechner/pi-coding-agent"; @@ -113,12 +113,12 @@ function migrateLegacySessionEntries(entries: FileEntry[]): void { } } -function readSessionBranch(filePath: string): { +async function readSessionBranch(filePath: string): Promise<{ header: SessionHeader | null; leafId: string | null; branchEntries: SessionEntry[]; -} { - const fileEntries = parseSessionEntries(fs.readFileSync(filePath, "utf8")); +}> { + const fileEntries = parseSessionEntries(await fsp.readFile(filePath, "utf8")); migrateLegacySessionEntries(fileEntries); const header = fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; @@ -140,24 +140,32 @@ function readSessionBranch(filePath: string): { return { header, leafId, branchEntries }; } -function parseJsonlFile( +async function parseJsonlFile( filePath: string, params: { maxBytes: number; maxEvents: number; validate?: (value: unknown) => value is T; }, -): T[] { - if (!fs.existsSync(filePath)) { +): Promise { + let stat; + try { + stat = await fsp.stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + if (!stat.isFile()) { return []; } - const stat = fs.statSync(filePath); if (stat.size > params.maxBytes) { throw new Error( `Trajectory runtime file is too large to export (${stat.size} bytes; limit ${params.maxBytes})`, ); } - const content = fs.readFileSync(filePath, "utf8"); + const content = await fsp.readFile(filePath, "utf8"); const rows = content .split(/\r?\n/u) .map((line) => line.trim()) @@ -209,26 +217,29 @@ function isRuntimeTrajectoryEventForSession( ); } -function isRegularNonSymlinkFile(filePath: string): boolean { +async function isRegularNonSymlinkFile(filePath: string): Promise { try { - const linkStat = fs.lstatSync(filePath); + const linkStat = await fsp.lstat(filePath); if (linkStat.isSymbolicLink() || !linkStat.isFile()) { return false; } - const stat = fs.statSync(filePath); + const stat = await fsp.stat(filePath); return stat.isFile() && stat.dev === linkStat.dev && stat.ino === linkStat.ino; } catch { return false; } } -function readRuntimePointerFile(sessionFile: string, sessionId: string): string | undefined { +async function readRuntimePointerFile( + sessionFile: string, + sessionId: string, +): Promise { const pointerPath = resolveTrajectoryPointerFilePath(sessionFile); - if (!isRegularNonSymlinkFile(pointerPath)) { + if (!(await isRegularNonSymlinkFile(pointerPath))) { return undefined; } try { - const parsed = JSON.parse(fs.readFileSync(pointerPath, "utf8")) as unknown; + const parsed = JSON.parse(await fsp.readFile(pointerPath, "utf8")) as unknown; if (!isRecord(parsed)) { return undefined; } @@ -253,16 +264,16 @@ function readRuntimePointerFile(sessionFile: string, sessionId: string): string } } -function resolveTrajectoryRuntimeFile(params: { +async function resolveTrajectoryRuntimeFile(params: { runtimeFile?: string; sessionFile: string; sessionId: string; -}): string | undefined { +}): Promise { if (params.runtimeFile) { return params.runtimeFile; } const candidates = [ - readRuntimePointerFile(params.sessionFile, params.sessionId), + await readRuntimePointerFile(params.sessionFile, params.sessionId), resolveTrajectoryFilePath({ env: {}, sessionFile: params.sessionFile, @@ -273,7 +284,12 @@ function resolveTrajectoryRuntimeFile(params: { sessionId: params.sessionId, }), ].filter((candidate): candidate is string => Boolean(candidate)); - return candidates.find((candidate) => isRegularNonSymlinkFile(candidate)); + for (const candidate of candidates) { + if (await isRegularNonSymlinkFile(candidate)) { + return candidate; + } + } + return undefined; } function normalizeTimestamp(value: unknown): string { @@ -814,31 +830,31 @@ export function resolveDefaultTrajectoryExportDir(params: { ); } -export function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): { +export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): Promise<{ manifest: TrajectoryBundleManifest; outputDir: string; events: TrajectoryEvent[]; header: SessionHeader | null; runtimeFile?: string; supplementalFiles: string[]; -} { +}> { const redaction = buildTrajectoryExportRedaction({ workspaceDir: params.workspaceDir, }); - const sessionStat = fs.statSync(params.sessionFile); + const sessionStat = await fsp.stat(params.sessionFile); if (sessionStat.size > MAX_TRAJECTORY_SESSION_FILE_BYTES) { throw new Error( `Trajectory session file is too large to export (${sessionStat.size} bytes; limit ${MAX_TRAJECTORY_SESSION_FILE_BYTES})`, ); } - const { header, leafId, branchEntries } = readSessionBranch(params.sessionFile); - const runtimeFile = resolveTrajectoryRuntimeFile({ + const { header, leafId, branchEntries } = await readSessionBranch(params.sessionFile); + const runtimeFile = await resolveTrajectoryRuntimeFile({ runtimeFile: params.runtimeFile, sessionFile: params.sessionFile, sessionId: params.sessionId, }); const runtimeEvents = runtimeFile - ? parseJsonlFile(runtimeFile, { + ? await parseJsonlFile(runtimeFile, { maxBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, maxEvents: MAX_TRAJECTORY_RUNTIME_EVENTS, validate: (value): value is TrajectoryEvent => @@ -876,7 +892,7 @@ export function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): { sourceFiles: { session: maybeRedactPathString(params.sessionFile, redaction), runtime: - runtimeFile && isRegularNonSymlinkFile(runtimeFile) + runtimeFile && (await isRegularNonSymlinkFile(runtimeFile)) ? maybeRedactPathString(runtimeFile, redaction) : undefined, }, @@ -955,7 +971,7 @@ export function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): { const contents: DiagnosticSupportBundleContent[] = [...supportBundleContents(files)]; manifest.contents = contents; - writeSupportBundleDirectory({ + await writeSupportBundleDirectory({ outputDir: params.outputDir, files: [jsonSupportBundleFile("manifest.json", manifest), ...files], }); @@ -965,7 +981,8 @@ export function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): { outputDir: params.outputDir, events, header, - runtimeFile: runtimeFile && isRegularNonSymlinkFile(runtimeFile) ? runtimeFile : undefined, + runtimeFile: + runtimeFile && (await isRegularNonSymlinkFile(runtimeFile)) ? runtimeFile : undefined, supplementalFiles, }; }