import { join } from "node:path"; import { config } from "../config.js"; import { atomicWriteJson, ensureDirectory, listJsonFiles, readJsonFile, removeFileIfExists, slugify, } from "../utils/fileStore.js"; export type SessionStatus = "active" | "archived"; export type SessionRecord = { sessionId: string; actorKey: string; ownerUserId?: string; projectId?: string; projectKey: string; parentSessionId?: string; createdAt: string; updatedAt: string; status: SessionStatus; title?: string; }; type SessionMetadataContext = { actorKey: string; userId?: string; projectId?: string; projectKey: string; }; type EnsureSessionMetadataInput = SessionMetadataContext & { sessionId: string; parentSessionId?: string; }; export class SessionMetadataStore { constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {} async initialize() { await ensureDirectory(this.baseDir); } async ensure(input: EnsureSessionMetadataInput) { const sessionId = normalizeSessionId(input.sessionId); if (!sessionId) { throw new Error("sessionId is required"); } const existing = await readJsonFile( this.filePath(sessionId), ); if (existing) { return { created: false, record: existing }; } const now = new Date().toISOString(); const record: SessionRecord = { sessionId, actorKey: input.actorKey, ownerUserId: input.userId?.trim(), projectId: input.projectId, projectKey: input.projectKey, parentSessionId: normalizeSessionId(input.parentSessionId), createdAt: now, updatedAt: now, status: "active", }; await atomicWriteJson( this.filePath(record.sessionId), record, ); return { created: true, record }; } async get(context: SessionMetadataContext, sessionId: string) { const normalizedSessionId = normalizeSessionId(sessionId); if (!normalizedSessionId) { return null; } return await readJsonFile( this.filePath(normalizedSessionId), ); } async touch( record: SessionRecord, updates: Partial> = {}, ) { const next: SessionRecord = { ...record, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString(), }; await atomicWriteJson( this.filePath(record.sessionId), next, ); return next; } async list(context: SessionMetadataContext) { const files = await listJsonFiles(this.baseDir); const records = await Promise.all( files.map((file) => readJsonFile(file)), ); return records .filter((record): record is SessionRecord => Boolean(record)) .filter( (record) => record.actorKey === context.actorKey && record.projectKey === context.projectKey, ) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async remove(record: SessionRecord) { await removeFileIfExists( this.filePath(record.sessionId), ); } private filePath(sessionId: string) { return join(this.baseDir, `${slugify(sessionId)}.json`); } } const normalizeSessionId = (value?: string) => { const normalized = value?.trim(); return normalized ? normalized.slice(0, 128) : undefined; }; const normalizeSessionUpdates = ( updates: Partial>, ) => { const normalized: Partial> = {}; if (updates.status === "active" || updates.status === "archived") { normalized.status = updates.status; } if (typeof updates.title === "string") { const trimmed = updates.title.trim(); if (trimmed) { normalized.title = trimmed.slice(0, 120); } } return normalized; };