mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:50:43 +00:00
refactor: async export file io
This commit is contained in:
committed by
GitHub
parent
f43a184103
commit
78010b65ed
@@ -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';");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user