import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; type JsonRecord = Record; const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => error instanceof Error && "code" in error; export const ensureDirectory = async (path: string) => { await mkdir(path, { recursive: true }); }; export const atomicWriteFile = async (path: string, content: string) => { await ensureDirectory(dirname(path)); const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`; await writeFile(tempPath, content, "utf8"); await rename(tempPath, path); }; type HistoricalWriteOptions = { afterWrite?: () => Promise | void; historyDir: string; rootDir: string; }; export const atomicWriteFileWithHistory = async ( path: string, content: string, options: HistoricalWriteOptions, ) => { const previous = await readTextFile(path); if (previous === content) { return { backupPath: null as string | null, changed: false }; } let backupPath: string | null = null; if (previous !== null) { // 仅在覆盖已有文件时保留历史版本,避免为首次创建产生空备份。 backupPath = buildHistoryBackupPath(path, options); await atomicWriteFile(backupPath, previous); } try { await atomicWriteFile(path, content); // 给调用方预留一个写后钩子;若后续步骤失败,这里仍会回滚到旧内容。 await options.afterWrite?.(); } catch (error) { try { if (previous === null) { await removeFileIfExists(path); } else { await atomicWriteFile(path, previous); } } catch (rollbackError) { throw new AggregateError( [error, rollbackError], `write failed and rollback failed for ${path}`, ); } throw error; } return { backupPath, changed: true }; }; export const atomicWriteJson = async (path: string, value: JsonRecord | unknown[]) => { await atomicWriteFile(path, JSON.stringify(value, null, 2)); }; export const readJsonFile = async (path: string): Promise => { try { const content = await readFile(path, "utf8"); return JSON.parse(content) as T; } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return null; } throw error; } }; export const readTextFile = async (path: string): Promise => { try { return await readFile(path, "utf8"); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return null; } throw error; } }; export const listJsonFiles = async (path: string) => { try { const names = await readdir(path); return names.filter((name) => name.endsWith(".json")).map((name) => join(path, name)); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return []; } throw error; } }; export const listFiles = async (path: string) => { try { const names = await readdir(path); return names.map((name) => join(path, name)); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return []; } throw error; } }; export const removeFileIfExists = async (path: string) => { try { await rm(path, { force: true }); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return; } throw error; } }; export const getFileStat = async (path: string) => { try { return await stat(path); } catch (error) { if (isErrnoException(error) && error.code === "ENOENT") { return null; } throw error; } }; export const toScopedKey = (prefix: string, value?: string) => { const normalized = value?.trim() || `${prefix}-default`; return `${prefix}-${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`; }; export const toActorKey = (userId?: string) => toScopedKey("actor", userId); export const toProjectKey = (projectId?: string) => toScopedKey("project", projectId); export const toStableId = (...parts: string[]) => createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24); export const toConversationScopeKey = ( actorKey: string, projectKey: string, sessionId: string, ) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`; export const slugify = (value: string) => value .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64) || "entry"; const buildHistoryBackupPath = (path: string, options: HistoricalWriteOptions) => { const relativePath = relative(options.rootDir, path); const scopedPath = relativePath && !relativePath.startsWith("..") ? relativePath : basename(path); // 备份目录尽量复用原始相对路径,便于按业务目录回看历史。 const backupName = `${basename(path)}.${Date.now().toString(36)}.bak`; return join(options.historyDir, dirname(scopedPath), backupName); };