import { join } from "node:path"; import { SessionTopicStore } from "./topic-store.js"; import { config } from "../config.js"; import { type SessionWorkspaceStore } from "./workspace-store.js"; import { atomicWriteJson, ensureDirectory, listJsonFiles, readJsonFile, slugify, } from "../utils/fileStore.js"; export type SessionStatus = "active" | "archived"; export type SessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration"; export type SessionRecord = { sessionId: string; actorKey: string; ownerUserId?: string; projectId?: string; projectKey: string; parentSessionId?: string; createdAt: string; updatedAt: string; status: SessionStatus; deletedAt?: string; purpose?: SessionPurpose; taskId?: string; runId?: string; requestId?: string; modelId?: string; title?: string; topicId?: string | null; triggerId?: string; triggerSource?: string; triggerSubjectType?: string; triggerSubjectId?: string; triggerWorkflow?: string; triggerOccurredAt?: string; }; export type SessionTriggerMetadata = { triggerId: string; source: string; subjectType: string; subjectId: string; workflow: string; occurredAt: string; }; type SessionMetadataContext = { actorKey: string; userId?: string; projectId?: string; projectKey: string; }; type EnsureSessionMetadataInput = SessionMetadataContext & { sessionId: string; parentSessionId?: string; trigger?: SessionTriggerMetadata; purpose?: SessionPurpose; taskId?: string; runId?: string; requestId?: string; modelId?: string; topicId?: string; }; export class SessionMetadataStore { readonly topics: SessionTopicStore; private readonly ensureQueues = new Map>(); private index?: Map>; private indexLoading?: Promise; private indexVersion = 0; constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) { this.topics = new SessionTopicStore(join(baseDir, "topics")); workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt)); } async initialize() { await ensureDirectory(this.baseDir); } async ensure(input: EnsureSessionMetadataInput) { return this.serialize(input.sessionId, () => this.ensureRecord(input)); } private async serialize(sessionId: string, operation: () => Promise): Promise { const key = this.filePath(sessionId); const previous = this.ensureQueues.get(key) ?? Promise.resolve(); const result = previous.catch(() => undefined).then(operation); const pending = result.then(() => undefined, () => undefined); this.ensureQueues.set(key, pending); try { return await result; } finally { if (this.ensureQueues.get(key) === pending) this.ensureQueues.delete(key); } } private async ensureRecord(input: EnsureSessionMetadataInput) { const sessionId = normalizeSessionId(input.sessionId); if (!sessionId) { throw new Error("sessionId is required"); } const existing = await readJsonFile( this.filePath(sessionId), ); if (existing) { if ( existing.deletedAt || existing.sessionId !== sessionId || !matchesContext(existing, input) || (input.trigger && existing.triggerId !== input.trigger.triggerId) || (input.purpose && readPurpose(existing) !== input.purpose) || (input.taskId && existing.taskId !== input.taskId.trim()) || (input.runId && existing.runId !== input.runId.trim()) || (input.requestId && existing.requestId !== input.requestId.trim()) || (input.modelId && existing.modelId !== input.modelId.trim()) ) { throw new SessionContextMismatchError(); } if (this.workspaces) await this.workspaces.ensure({ actorKey: existing.actorKey, projectKey: existing.projectKey, sessionId }); return { created: false, record: existing }; } const now = new Date().toISOString(); if (this.workspaces) await this.workspaces.ensure({ actorKey: input.actorKey, projectKey: input.projectKey, sessionId }); 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", purpose: input.purpose ?? (input.trigger ? "task_execution" : "ordinary_chat"), taskId: input.taskId?.trim() || input.trigger?.subjectId, runId: input.runId?.trim(), requestId: input.requestId?.trim(), modelId: input.modelId?.trim(), topicId: input.topicId, ...(input.trigger ? { triggerId: input.trigger.triggerId, triggerSource: input.trigger.source, triggerSubjectType: input.trigger.subjectType, triggerSubjectId: input.trigger.subjectId, triggerWorkflow: input.trigger.workflow, triggerOccurredAt: input.trigger.occurredAt, } : {}), }; await atomicWriteJson( this.filePath(record.sessionId), record, 0o600, ); this.updateIndex(record); return { created: true, record }; } async get(context: SessionMetadataContext, sessionId: string, options?: { includeDeleted?: boolean }) { const normalizedSessionId = normalizeSessionId(sessionId); if (!normalizedSessionId) { return null; } const record = await readJsonFile( this.filePath(normalizedSessionId), ); return record && (!record.deletedAt || options?.includeDeleted) && record.sessionId === normalizedSessionId && matchesContext(record, context) ? record : null; } // Internal delivery only, after Server has authorized and resolved this run. // Does not authorize ordinary chat access or expose a global session lookup. async getTaskExecutionBinding(projectId: string, taskId: string, runId: string, sessionId: string) { const id = normalizeSessionId(sessionId); if (!id) return null; const record = await readJsonFile(this.filePath(id)); return record && !record.deletedAt && record.sessionId === id && record.projectId === projectId && record.taskId === taskId && record.runId === runId && readPurpose(record) === "task_execution" ? record : null; } async touch( record: SessionRecord, updates: Partial> = {}, options: { preserveUpdatedAt?: boolean; expectedTitle?: string } = {}, ) { return this.serialize(record.sessionId, async () => { const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId); if (!current) throw new SessionContextMismatchError(); if (options.expectedTitle !== undefined && (current.title ?? "") !== options.expectedTitle) return current; const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), updatedAt: options.preserveUpdatedAt ? current.updatedAt : new Date().toISOString() }; await atomicWriteJson(this.filePath(record.sessionId), next, 0o600); this.updateIndex(next); return next; }); } async list( context: SessionMetadataContext, filter?: { purpose?: SessionPurpose; taskId?: string }, ) { await this.loadIndex(); const records = [...(this.index?.get(this.scopeKey(context))?.values() ?? [])].map((record) => ({ ...record })); return records .filter((record): record is SessionRecord => Boolean(record)) .filter( (record) => !record.deletedAt && matchesContext(record, context), ) .filter((record) => !filter?.purpose || readPurpose(record) === filter.purpose) .filter((record) => !filter?.taskId || record.taskId === filter.taskId.trim()) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async findByTask( context: SessionMetadataContext, taskId: string, purpose: SessionPurpose, ) { const records = await this.list(context, { purpose, taskId }); return records[0] ?? null; } async findByTrigger(context: SessionMetadataContext, triggerId: string) { const normalizedTriggerId = triggerId.trim(); if (!normalizedTriggerId) { return null; } const records = await this.list(context); return records.find((record) => record.triggerId === normalizedTriggerId) ?? null; } async findByRequest(context: SessionMetadataContext, requestId: string) { const normalized = requestId.trim(); if (!normalized) return null; const records = await this.list(context); return records.find((record) => record.requestId === normalized) ?? null; } async remove(record: SessionRecord) { return this.serialize(record.sessionId, async () => { const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true }); if (!current) throw new SessionContextMismatchError(); // Retain only identity and deletion state, so legacy imports and late writes cannot revive it. const deleted: SessionRecord = { sessionId: current.sessionId, actorKey: current.actorKey, projectKey: current.projectKey, ownerUserId: current.ownerUserId, projectId: current.projectId, createdAt: current.createdAt, updatedAt: new Date().toISOString(), status: "archived", deletedAt: current.deletedAt ?? new Date().toISOString(), }; await atomicWriteJson(this.filePath(record.sessionId), deleted, 0o600); this.updateIndex(deleted); await this.workspaces?.markDeleted(deleted); }); } async purge(record: SessionRecord) { const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true }); if (!current?.deletedAt) throw new SessionContextMismatchError(); await this.workspaces?.purge(current); } private scopeKey(context: { actorKey: string; projectKey: string }) { return JSON.stringify([context.actorKey, context.projectKey]); } private updateIndex(record: SessionRecord) { this.indexVersion++; if (!this.index) return; const key = this.scopeKey(record); const records = this.index.get(key) ?? new Map(); records.set(record.sessionId, { ...record }); this.index.set(key, records); } private async loadIndex(): Promise { if (this.index) return; if (this.indexLoading) { await this.indexLoading; return this.loadIndex(); } const version = this.indexVersion; const loading = (async () => { const files = await listJsonFiles(this.baseDir); const records = await Promise.all(files.map((file) => readJsonFile(file))); const index = new Map>(); for (const record of records) { if (!record) continue; const key = this.scopeKey(record); const scoped = index.get(key) ?? new Map(); scoped.set(record.sessionId, record); index.set(key, scoped); } if (version === this.indexVersion) this.index = index; })(); this.indexLoading = loading; try { await loading; } finally { if (this.indexLoading === loading) this.indexLoading = undefined; } if (!this.index) await this.loadIndex(); } 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; }; export const readPurpose = (record: SessionRecord): SessionPurpose => record.purpose ?? (record.triggerId ? "task_execution" : "ordinary_chat"); export class SessionContextMismatchError extends Error { constructor() { super("session not found"); } } const matchesContext = (record: SessionRecord, context: SessionMetadataContext) => record.actorKey === context.actorKey && record.projectKey === context.projectKey && (context.userId === undefined || record.ownerUserId === context.userId) && (context.projectId === undefined || record.projectId === context.projectId); const normalizeSessionUpdates = ( updates: Partial>, ) => { const normalized: Partial> = {}; if (updates.status === "active" || updates.status === "archived") { normalized.status = updates.status; } if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId; if (typeof updates.title === "string") { const trimmed = updates.title.trim(); if (trimmed) { normalized.title = trimmed.slice(0, 120); } } return normalized; };