refactor: async export file io

This commit is contained in:
Peter Steinberger
2026-05-02 05:03:02 +01:00
committed by GitHub
parent f43a184103
commit 78010b65ed
10 changed files with 227 additions and 146 deletions

View File

@@ -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<string, string>(),
};
});
@@ -38,9 +40,6 @@ vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("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<typeof import("node:fs/promises")>("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';");

View File

@@ -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<string> {
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<string> {
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<boolean> {
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;

View File

@@ -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<void>) => {
await actualAccess(file);
},
),
};
});
@@ -45,9 +47,18 @@ vi.mock("../../trajectory/export.js", () => ({
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
const mockedFs = { ...actual };
return {
...mockedFs,
default: mockedFs,
};
});
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("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<string, unknown>
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<void>) => {
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<void>) => {
if (file.toString() === "/tmp/target-store/session.jsonl") {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
}
await actualAccess(file);
},
);
const reply = await buildExportTrajectoryReply(makeParams());

View File

@@ -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<boolean> {
try {
await fsp.access(pathName);
return true;
} catch {
return false;
}
}
export async function buildExportTrajectoryCommandReply(
params: HandleCommandsParams,
deps: Partial<ExportTrajectoryCommandDeps> = {},
@@ -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<typeof exportTrajectoryForCommand>;
let summary: TrajectoryCommandExportSummary;
try {
summary = exportTrajectoryForCommand({
summary = await exportTrajectoryForCommand({
outputDir,
sessionFile,
sessionId: entry.sessionId,

View File

@@ -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<boolean> {
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<typeof exportTrajectoryForCommand>;
let summary: TrajectoryCommandExportSummary;
try {
summary = exportTrajectoryForCommand({
summary = await exportTrajectoryForCommand({
outputPath: resolvedOpts.output,
sessionFile,
sessionId: entry.sessionId,

View File

@@ -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 }),

View File

@@ -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<void> {
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<void> {
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<DiagnosticSupportBundleContent[]> {
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;
}

View File

@@ -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<string> {
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<string> {
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<boolean> {
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<string> {
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<TrajectoryCommandExportSummary> {
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,

View File

@@ -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",

View File

@@ -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<T>(
async function parseJsonlFile<T>(
filePath: string,
params: {
maxBytes: number;
maxEvents: number;
validate?: (value: unknown) => value is T;
},
): T[] {
if (!fs.existsSync(filePath)) {
): Promise<T[]> {
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<boolean> {
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<string | undefined> {
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<string | undefined> {
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<TrajectoryEvent>(runtimeFile, {
? await parseJsonlFile<TrajectoryEvent>(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,
};
}