Files
oh-my-pi/packages/coding-agent/src/session/session-storage.ts
T
2026-02-22 11:01:11 +01:00

339 lines
8.9 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, toError } from "@oh-my-pi/pi-utils";
export interface SessionStorageStat {
size: number;
mtimeMs: number;
mtime: Date;
}
export interface SessionStorageWriter {
writeLine(line: string): Promise<void>;
flush(): Promise<void>;
fsync(): Promise<void>;
close(): Promise<void>;
getError(): Error | undefined;
}
export interface SessionStorage {
ensureDirSync(dir: string): void;
existsSync(path: string): boolean;
writeTextSync(path: string, content: string): void;
statSync(path: string): SessionStorageStat;
listFilesSync(dir: string, pattern: string): string[];
exists(path: string): Promise<boolean>;
readText(path: string): Promise<string>;
readTextPrefix(path: string, maxBytes: number): Promise<string>;
writeText(path: string, content: string): Promise<void>;
rename(path: string, nextPath: string): Promise<void>;
unlink(path: string): Promise<void>;
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
}
// FinalizationRegistry to clean up leaked file descriptors
const writerRegistry = new FinalizationRegistry<number>(fd => {
try {
fs.closeSync(fd);
} catch {
// Ignore - fd may already be closed or invalid
}
});
class FileSessionStorageWriter implements SessionStorageWriter {
#fd: number;
#closed = false;
#error: Error | undefined;
#onError: ((err: Error) => void) | undefined;
constructor(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
this.#onError = options?.onError;
const flags = options?.flags ?? "a";
// Ensure parent directory exists
const dir = path.dirname(fpath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Open file once, keep fd for lifetime
this.#fd = fs.openSync(fpath, flags === "w" ? "w" : "a");
// Register for cleanup if abandoned without close()
writerRegistry.register(this, this.#fd, this);
}
#recordError(err: unknown): Error {
const error = toError(err);
if (!this.#error) this.#error = error;
this.#onError?.(error);
return error;
}
async writeLine(line: string): Promise<void> {
if (this.#closed) throw new Error("Writer closed");
if (this.#error) throw this.#error;
try {
const buf = Buffer.from(line, "utf-8");
let offset = 0;
while (offset < buf.length) {
const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
if (written === 0) {
throw new Error("Short write");
}
offset += written;
}
} catch (err) {
throw this.#recordError(err);
}
}
async flush(): Promise<void> {
if (this.#error) throw this.#error;
// OS buffers are flushed on fsync, nothing to do here
}
async fsync(): Promise<void> {
if (this.#closed) throw new Error("Writer closed");
if (this.#error) throw this.#error;
try {
fs.fsyncSync(this.#fd);
} catch (err) {
throw this.#recordError(err);
}
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
// Unregister from finalization - we're closing properly
writerRegistry.unregister(this);
try {
fs.closeSync(this.#fd);
} catch {
// Ignore close errors
}
}
getError(): Error | undefined {
return this.#error;
}
}
export class FileSessionStorage implements SessionStorage {
ensureDirSync(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
existsSync(path: string): boolean {
return fs.existsSync(path);
}
writeTextSync(fpath: string, content: string): void {
this.ensureDirSync(path.dirname(fpath));
fs.writeFileSync(fpath, content);
}
statSync(path: string): SessionStorageStat {
const stats = fs.statSync(path);
return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
}
listFilesSync(dir: string, pattern: string): string[] {
try {
return Array.from(new Bun.Glob(pattern).scanSync(dir)).map(name => path.join(dir, name));
} catch {
return [];
}
}
async exists(path: string): Promise<boolean> {
try {
await fs.promises.access(path);
return true;
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
}
readText(path: string): Promise<string> {
return Bun.file(path).text();
}
async readTextPrefix(path: string, maxBytes: number): Promise<string> {
return Bun.file(path).slice(0, maxBytes).text();
}
async writeText(path: string, content: string): Promise<void> {
await Bun.write(path, content, { createPath: true });
}
async rename(path: string, nextPath: string): Promise<void> {
try {
await fs.promises.rename(path, nextPath);
} catch (err) {
throw toError(err);
}
}
unlink(path: string): Promise<void> {
return fs.promises.unlink(path);
}
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
return new FileSessionStorageWriter(path, options);
}
}
function matchesPattern(name: string, pattern: string): boolean {
if (pattern === "*") return true;
if (pattern.startsWith("*.")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
}
class MemorySessionStorageWriter implements SessionStorageWriter {
#storage: MemorySessionStorage;
#path: string;
#closed = false;
#error: Error | undefined;
#onError: ((err: Error) => void) | undefined;
#ready: Promise<void>;
constructor(
storage: MemorySessionStorage,
path: string,
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
) {
this.#storage = storage;
this.#path = path;
this.#onError = options?.onError;
this.#ready = this.#initialize(options?.flags ?? "a");
}
async #initialize(flags: "a" | "w"): Promise<void> {
if (flags === "w") {
await this.#storage.writeText(this.#path, "");
}
}
#recordError(err: unknown): Error {
const error = toError(err);
if (!this.#error) this.#error = error;
this.#onError?.(error);
return error;
}
async writeLine(line: string): Promise<void> {
if (this.#closed) throw new Error("Writer closed");
await this.#ready;
if (this.#error) throw this.#error;
try {
const existing = this.#storage.existsSync(this.#path) ? await this.#storage.readText(this.#path) : "";
await this.#storage.writeText(this.#path, `${existing}${line}`);
} catch (err) {
throw this.#recordError(err);
}
}
async flush(): Promise<void> {
await this.#ready;
if (this.#error) throw this.#error;
}
async fsync(): Promise<void> {
// No-op for in-memory storage
await this.#ready;
if (this.#error) throw this.#error;
}
async close(): Promise<void> {
if (this.#closed) return;
await this.#ready;
this.#closed = true;
}
getError(): Error | undefined {
return this.#error;
}
}
export class MemorySessionStorage implements SessionStorage {
#files = new Map<string, { content: string; mtimeMs: number }>();
ensureDirSync(_dir: string): void {
// No-op for in-memory storage.
}
existsSync(path: string): boolean {
return this.#files.has(path);
}
writeTextSync(path: string, content: string): void {
this.#files.set(path, { content, mtimeMs: Date.now() });
}
statSync(path: string): SessionStorageStat {
const entry = this.#files.get(path);
if (!entry) throw new Error(`File not found: ${path}`);
return {
size: entry.content.length,
mtimeMs: entry.mtimeMs,
mtime: new Date(entry.mtimeMs),
};
}
listFilesSync(dir: string, pattern: string): string[] {
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
const files: string[] = [];
for (const path of this.#files.keys()) {
if (!path.startsWith(prefix)) continue;
const name = path.slice(prefix.length);
if (name.includes("/") || name.includes("\\")) continue;
if (!matchesPattern(name, pattern)) continue;
files.push(path);
}
return files;
}
exists(path: string): Promise<boolean> {
return Promise.resolve(this.existsSync(path));
}
readText(path: string): Promise<string> {
const entry = this.#files.get(path);
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
return Promise.resolve(entry.content);
}
readTextPrefix(path: string, maxBytes: number): Promise<string> {
const entry = this.#files.get(path);
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
return Promise.resolve(entry.content.slice(0, maxBytes));
}
writeText(path: string, content: string): Promise<void> {
this.writeTextSync(path, content);
return Promise.resolve();
}
rename(path: string, nextPath: string): Promise<void> {
const entry = this.#files.get(path);
if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
this.#files.set(nextPath, entry);
this.#files.delete(path);
return Promise.resolve();
}
unlink(path: string): Promise<void> {
this.#files.delete(path);
return Promise.resolve();
}
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
return new MemorySessionStorageWriter(this, path, options);
}
}