refactor(agent): isolate node filesystem session dependencies
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
FileSystem,
|
||||
JsonlSessionCreateOptions,
|
||||
JsonlSessionListOptions,
|
||||
JsonlSessionMetadata,
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
} from "../types.js";
|
||||
import { getOrThrow } from "../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
|
||||
|
||||
type JsonlSessionRepoFileSystem = Pick<
|
||||
FileSystem,
|
||||
| "cwd"
|
||||
| "absolutePath"
|
||||
| "joinPath"
|
||||
| "readTextFile"
|
||||
| "writeFile"
|
||||
| "appendFile"
|
||||
| "listDir"
|
||||
| "exists"
|
||||
| "createDir"
|
||||
| "remove"
|
||||
>;
|
||||
|
||||
function encodeCwd(cwd: string): string {
|
||||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
private readonly fs: JsonlSessionRepoFileSystem;
|
||||
private readonly sessionsRootInput: string;
|
||||
private sessionsRoot: string | undefined;
|
||||
|
||||
constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) {
|
||||
this.fs = options.fs;
|
||||
this.sessionsRootInput = options.sessionsRoot;
|
||||
}
|
||||
|
||||
private async getSessionsRoot(): Promise<string> {
|
||||
if (!this.sessionsRoot) {
|
||||
this.sessionsRoot = getOrThrow(await this.fs.absolutePath(this.sessionsRootInput));
|
||||
}
|
||||
return this.sessionsRoot;
|
||||
}
|
||||
|
||||
private async getSessionDir(cwd: string): Promise<string> {
|
||||
return getOrThrow(await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]));
|
||||
}
|
||||
|
||||
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
|
||||
return getOrThrow(
|
||||
await this.fs.joinPath([
|
||||
await this.getSessionDir(cwd),
|
||||
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
|
||||
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath,
|
||||
});
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (!getOrThrow(await this.fs.exists(metadata.path))) {
|
||||
throw new Error(`Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
|
||||
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
|
||||
const sessions: JsonlSessionMetadata[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!getOrThrow(await this.fs.exists(dir))) continue;
|
||||
const files = getOrThrow(await this.fs.listDir(dir)).filter(
|
||||
(file) => file.kind !== "directory" && file.name.endsWith(".jsonl"),
|
||||
);
|
||||
for (const file of files) {
|
||||
try {
|
||||
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
|
||||
} catch {
|
||||
// Ignore invalid session files when listing a directory.
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
getOrThrow(await this.fs.remove(metadata.path, { force: true }));
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: JsonlSessionMetadata,
|
||||
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
|
||||
const storage = await JsonlSessionStorage.create(
|
||||
this.fs,
|
||||
await this.createSessionFilePath(options.cwd, id, createdAt),
|
||||
{
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
|
||||
},
|
||||
);
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
const sessionsRoot = await this.getSessionsRoot();
|
||||
if (!getOrThrow(await this.fs.exists(sessionsRoot))) return [];
|
||||
const entries = getOrThrow(await this.fs.listDir(sessionsRoot));
|
||||
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
|
||||
}
|
||||
}
|
||||
+42
-37
@@ -1,9 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
import type { FileSystem, JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import { getOrThrow } from "../types.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "writeFile" | "appendFile">;
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
@@ -34,10 +33,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return randomUUID();
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
|
||||
@@ -50,32 +49,32 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadJsonlSessionMetadata(filePath: string): Promise<JsonlSessionMetadata> {
|
||||
const stream = createReadStream(filePath, { encoding: "utf8" });
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) break;
|
||||
try {
|
||||
const header = JSON.parse(line) as SessionHeader;
|
||||
return headerToSessionMetadata(header, resolve(filePath));
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
export async function loadJsonlSessionMetadata(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionMetadata> {
|
||||
const content = getOrThrow(await fs.readTextFile(filePath));
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) break;
|
||||
try {
|
||||
const header = JSON.parse(line) as SessionHeader;
|
||||
return headerToSessionMetadata(header, filePath);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
} finally {
|
||||
lines.close();
|
||||
stream.destroy();
|
||||
}
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(filePath: string): Promise<{
|
||||
async function loadJsonlStorage(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<{
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const content = getOrThrow(await fs.readTextFile(filePath));
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
@@ -103,6 +102,7 @@ async function loadJsonlStorage(filePath: string): Promise<{
|
||||
}
|
||||
|
||||
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
|
||||
private readonly fs: JsonlSessionStorageFileSystem;
|
||||
private readonly filePath: string;
|
||||
private readonly metadata: JsonlSessionMetadata;
|
||||
private entries: SessionTreeEntry[];
|
||||
@@ -110,8 +110,15 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
private labelsById: Map<string, string>;
|
||||
private currentLeafId: string | null;
|
||||
|
||||
private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) {
|
||||
this.filePath = resolve(filePath);
|
||||
private constructor(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
) {
|
||||
this.fs = fs;
|
||||
this.filePath = filePath;
|
||||
this.metadata = headerToSessionMetadata(header, this.filePath);
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
@@ -119,13 +126,13 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
static async open(filePath: string): Promise<JsonlSessionStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const loaded = await loadJsonlStorage(resolvedPath);
|
||||
return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId);
|
||||
static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> {
|
||||
const loaded = await loadJsonlStorage(fs, filePath);
|
||||
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
@@ -133,7 +140,6 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
parentSessionPath?: string;
|
||||
},
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
@@ -142,9 +148,8 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
await mkdir(dirname(resolvedPath), { recursive: true });
|
||||
await writeFile(resolvedPath, `${JSON.stringify(header)}\n`);
|
||||
return new JsonlSessionStorage(resolvedPath, header, [], null);
|
||||
getOrThrow(await fs.writeFile(filePath, `${JSON.stringify(header)}\n`));
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<JsonlSessionMetadata> {
|
||||
@@ -167,7 +172,7 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
|
||||
getOrThrow(await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`));
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { Session, SessionMetadata, SessionRepo } from "../../types.js";
|
||||
import { InMemorySessionStorage } from "../storage/memory.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
|
||||
import type { Session, SessionMetadata, SessionRepo } from "../types.js";
|
||||
import { InMemorySessionStorage } from "./memory-storage.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
|
||||
|
||||
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
|
||||
private sessions = new Map<string, Session<SessionMetadata>>();
|
||||
+4
-5
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
import { uuidv7 } from "../uuid.js";
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
if (entry.type !== "label") return;
|
||||
@@ -22,10 +21,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return randomUUID();
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
export class InMemorySessionStorage implements SessionStorage {
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
import { Session } from "../session.js";
|
||||
import { uuidv7 } from "../uuid.js";
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import { Session } from "./session.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
export function createSessionId(): string {
|
||||
return uuidv7();
|
||||
@@ -1,109 +0,0 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import type {
|
||||
JsonlSessionCreateOptions,
|
||||
JsonlSessionListOptions,
|
||||
JsonlSessionMetadata,
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
} from "../../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCwd(cwd: string): string {
|
||||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
private sessionsRoot: string;
|
||||
|
||||
constructor(options: { sessionsRoot: string }) {
|
||||
this.sessionsRoot = resolve(options.sessionsRoot);
|
||||
}
|
||||
|
||||
private getSessionDir(cwd: string): string {
|
||||
return join(this.sessionsRoot, encodeCwd(cwd));
|
||||
}
|
||||
|
||||
private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string {
|
||||
return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
|
||||
await mkdir(this.sessionsRoot, { recursive: true });
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const filePath = this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(filePath, {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath,
|
||||
});
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (!(await exists(metadata.path))) {
|
||||
throw new Error(`Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(metadata.path);
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
|
||||
const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs();
|
||||
const sessions: JsonlSessionMetadata[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!(await exists(dir))) continue;
|
||||
const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
sessions.push(await loadJsonlSessionMetadata(filePath));
|
||||
} catch {
|
||||
// Ignore invalid session files when listing a directory.
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
await rm(metadata.path, { force: true });
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: JsonlSessionMetadata,
|
||||
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
|
||||
});
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
if (!(await exists(this.sessionsRoot))) return [];
|
||||
const entries = await readdir(this.sessionsRoot, { withFileTypes: true });
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
let lastTimestamp = -Infinity;
|
||||
let sequence = 0;
|
||||
|
||||
function fillRandomBytes(bytes: Uint8Array): void {
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto?.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
export function uuidv7(): string {
|
||||
const random = randomBytes(16);
|
||||
const random = new Uint8Array(16);
|
||||
fillRandomBytes(random);
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (timestamp > lastTimestamp) {
|
||||
|
||||
Reference in New Issue
Block a user