/** * InMemorySessionStore — reference in-memory implementation of * {@link SessionStore}. * * Every accessor takes explicit {@link TenantId} (Convention #17). Any * accessor called with a tenantId that does not match the resource's owning * tenant throws {@link TenantIsolationError} — there is no fallback * (Convention #5 deny-by-default, session-hierarchy.md §12.2). */ import { realpathSync } from 'node:fs' import { resolve } from 'node:path' import { ProjectRootPathTakenError, StaleProjectError, StaleSessionError, TenantIsolationError, } from '../../session/errors.js' import { SessionAlreadySummarizedError } from '../../session/summary/errors.js' import type { SessionId, TenantId } from '../../types/ids/index.js' import type { Project, ProjectStatus } from '../../types/project/entity.js' import type { Session } from '../../types/session/entity.js' import type { ProjectId, SubSessionId, TopicId } from '../../types/session/ids.js' import type { CreateProjectParams, CreateSessionParams, CreateSubSessionParams, ProjectConfigInput, SessionStore, SessionView, } from '../../types/session/store.js' import type { SubSession } from '../../types/session/sub-session.js' import type { SessionSummaryRef } from '../../types/summary/ref.js' import { asProjectId, asTenantId, generateProjectId, generateSessionId, generateSubSessionId, } from '../../utils/id.js' import { canonicalizePath } from './canonical-path.js' import { getAncestry, getChildren, orderChildren } from './linkage.js' import type { LinkageView } from './linkage.js' interface ProjectRecord { tenantId: TenantId project: Project } interface SessionRecord { tenantId: TenantId session: Session } interface SubSessionRecord { tenantId: TenantId subSession: SubSession } interface SummaryRecord { tenantId: TenantId summary: SessionSummaryRef } /** * Non-terminal statuses from which {@link InMemorySessionStore.recordSummary} * flips the owning session to `'idle'` as part of the atomic materialize + * transition contract (session-hierarchy.md §8.1). Other statuses — already * terminal or awaiting HITL — are left untouched. */ const SUMMARY_TERMINAL_FLIP_STATUSES: ReadonlySet = new Set([ 'active', 'locked', 'awaiting_merge', ]) export class InMemorySessionStore implements SessionStore { private readonly projects = new Map() private readonly sessions = new Map() private readonly subSessions = new Map() private readonly summaries = new Map() /** Hydrate existing Project snapshots without minting replacement identities. */ constructor(projects: readonly Project[] = []) { const roots = new Map() for (const input of projects) { const project = structuredClone(input) asProjectId(project.id) asTenantId(project.tenantId) if (this.projects.has(project.id)) throw new Error(`Duplicate project ${project.id}`) if (project.rootPath !== undefined) { let rootPath: string try { rootPath = realpathSync(project.rootPath) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error rootPath = resolve(project.rootPath) } const key = `${project.tenantId}\0${rootPath}` const existingProjectId = roots.get(key) if (existingProjectId) throw new ProjectRootPathTakenError({ rootPath, existingProjectId }) roots.set(key, project.id) this.projects.set(project.id, { tenantId: project.tenantId, project: { ...project, rootPath }, }) } else { this.projects.set(project.id, { tenantId: project.tenantId, project }) } } } // Project CRUD ------------------------------------------------------------ async createProject(params: CreateProjectParams, tenantId: TenantId): Promise { if (params.tenantId !== tenantId) { throw new TenantIsolationError({ requested: tenantId, resource: `project(name=${params.name})`, }) } // Canonicalized BEFORE the uniqueness check, not after storage. A path // stored as typed makes `/tmp/p`, `/tmp/p/` and a symlink to it three // records for one directory, and the check would pass every time. const rootPath = params.rootPath === undefined ? undefined : await canonicalizePath(params.rootPath) if (rootPath !== undefined) { const existing = await this.findProjectByRootPath(rootPath, tenantId) if (existing) { throw new ProjectRootPathTakenError({ rootPath, existingProjectId: existing.id }) } } const now = new Date() const project: Project = { id: generateProjectId(), tenantId, name: params.name, config: { maxDelegationDepth: params.config?.maxDelegationDepth ?? 4, maxDelegationWidth: params.config?.maxDelegationWidth ?? 8, maxInterventionDepth: 10, }, status: 'open', ownerVersion: 0, ...(rootPath !== undefined ? { rootPath } : {}), createdAt: now, updatedAt: now, } this.projects.set(project.id, { tenantId, project }) return project } /** * Scanned rather than indexed, and that is the right call HERE and only * here: this store's projects are already a `Map` in memory, so an index * would be a second copy of the same data with a consistency problem * attached. The disk store, where a scan means opening every * `project.json`, keeps a real index. */ async findProjectByRootPath(rootPath: string, tenantId: TenantId): Promise { const canonical = await canonicalizePath(rootPath) for (const record of this.projects.values()) { // Tenant FIRST, not as a post-filter. Two tenants may legitimately // bind projects to the same path on a shared machine, and matching // on path alone would hand one tenant the other's project id. if (record.tenantId !== tenantId) continue if (record.project.rootPath === canonical) return record.project } return null } async getProject(projectId: ProjectId, tenantId: TenantId): Promise { const record = this.projects.get(projectId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `project(${projectId})`) return record.project } async updateProject( projectId: ProjectId, config: ProjectConfigInput, tenantId: TenantId, ): Promise { const record = this.projects.get(projectId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `project(${projectId})`) // Per field: an omitted limit is left alone rather than reset, because a // caller raising the width is saying nothing about the depth. const project: Project = { ...record.project, config: { ...record.project.config, ...(config.maxDelegationDepth !== undefined ? { maxDelegationDepth: config.maxDelegationDepth } : {}), ...(config.maxDelegationWidth !== undefined ? { maxDelegationWidth: config.maxDelegationWidth } : {}), }, updatedAt: new Date(), } this.projects.set(projectId, { tenantId, project }) return project } async setProjectStatus( projectId: ProjectId, status: ProjectStatus, tenantId: TenantId, expectedOwnerVersion: number, ): Promise { const record = this.projects.get(projectId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `project(${projectId})`) // Against the STORED version, not the caller's copy of it — comparing a // value against itself is the shape the session CAS was written wrong in // the first time. if (record.project.ownerVersion !== expectedOwnerVersion) { throw new StaleProjectError({ projectId, expectedOwnerVersion, actualOwnerVersion: record.project.ownerVersion, }) } const project: Project = { ...record.project, status, ownerVersion: record.project.ownerVersion + 1, updatedAt: new Date(), } this.projects.set(projectId, { tenantId, project }) return project } async listProjects(tenantId: TenantId): Promise { const matches: Project[] = [] for (const record of this.projects.values()) { if (record.tenantId !== tenantId) continue matches.push(record.project) } // Tie-broken by id, because two projects created in the same // millisecond otherwise fall back to insertion or directory order and // "oldest first" stops being a total order. A caller paginating a // listing that reorders under it sees items move between pages. matches.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id), ) return matches } // Session CRUD ------------------------------------------------------------ async createSession(params: CreateSessionParams, tenantId: TenantId): Promise { const projectRecord = this.projects.get(params.projectId) if (!projectRecord) { throw new Error(`Project ${params.projectId} not found`) } this.assertTenant(projectRecord.tenantId, tenantId, `project(${params.projectId})`) const now = new Date() if (params.id !== undefined && (await this.getSession(params.id, tenantId))) { throw new Error(`Session ${params.id} already exists`) } const session: Session = { id: params.id ?? generateSessionId(), topicId: params.topicId, projectId: params.projectId, tenantId, status: 'idle', currentActor: params.currentActor, previousActors: [], workspaceId: null, ownerVersion: 0, createdAt: now, updatedAt: now, } this.sessions.set(session.id, { tenantId, session }) return session } async getSession(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.sessions.get(sessionId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `session(${sessionId})`) return record.session } async listSessionsByTopic(topicId: TopicId, tenantId: TenantId): Promise { const matches: Session[] = [] for (const record of this.sessions.values()) { if (record.tenantId !== tenantId) continue if (record.session.topicId !== topicId) continue matches.push(record.session) } matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) return matches } async listSessionsByProject( projectId: ProjectId, tenantId: TenantId, ): Promise { const matches: Session[] = [] for (const record of this.sessions.values()) { if (record.tenantId !== tenantId) continue if (record.session.projectId !== projectId) continue matches.push(record.session) } matches.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id), ) return matches } async updateSession( session: Session, tenantId: TenantId, expectedOwnerVersion?: number, ): Promise { const record = this.sessions.get(session.id) if (!record) { throw new Error(`Session ${session.id} not found`) } this.assertTenant(record.tenantId, tenantId, `session(${session.id})`) if (session.tenantId !== tenantId) { throw new TenantIsolationError({ requested: tenantId, resource: `session(${session.id}) payload`, }) } // Compared against the STORED version, not against `session.ownerVersion` // — the payload is the caller's copy, and comparing it to itself is // precisely the check the handoff path was already making and getting // nothing from. if ( expectedOwnerVersion !== undefined && record.session.ownerVersion !== expectedOwnerVersion ) { throw new StaleSessionError({ sessionId: session.id, expectedVersion: expectedOwnerVersion, actualVersion: record.session.ownerVersion, }) } this.sessions.set(session.id, { tenantId, session: { ...session, updatedAt: new Date() } }) } async deleteSession(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.sessions.get(sessionId) if (!record) return // Idempotent: missing = no-op. this.assertTenant(record.tenantId, tenantId, `session(${sessionId})`) // Policy: reject if sub-sessions still attach to this session (either as // parent or child). Callers must delete children first — Convention #5 // deny-by-default; no implicit cascade. for (const subRecord of this.subSessions.values()) { const { subSession } = subRecord if (subSession.parentSessionId === sessionId || subSession.childSessionId === sessionId) { throw new Error( `Session ${sessionId} has attached sub-sessions; delete them before deleting the session`, ) } } this.sessions.delete(sessionId) this.summaries.delete(sessionId) } // SubSession CRUD --------------------------------------------------------- async createSubSession(params: CreateSubSessionParams, tenantId: TenantId): Promise { const parentRecord = this.sessions.get(params.parentSessionId) if (!parentRecord) { throw new Error(`Parent session ${params.parentSessionId} not found`) } this.assertTenant(parentRecord.tenantId, tenantId, `session(${params.parentSessionId})`) const childRecord = this.sessions.get(params.childSessionId) if (!childRecord) { throw new Error(`Child session ${params.childSessionId} not found`) } this.assertTenant(childRecord.tenantId, tenantId, `session(${params.childSessionId})`) const now = new Date() const subSession: SubSession = { id: generateSubSessionId(), parentSessionId: params.parentSessionId, childSessionId: params.childSessionId, kind: params.kind, status: 'pending', spawnedBy: params.spawnedBy, spawnedAt: now, failureMode: params.failureMode ?? 'delegate', completionMode: params.completionMode ?? 'summary_ref', workspaceId: null, updatedAt: now, } this.subSessions.set(subSession.id, { tenantId, subSession }) return subSession } async getSubSession(subSessionId: SubSessionId, tenantId: TenantId): Promise { const record = this.subSessions.get(subSessionId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `sub-session(${subSessionId})`) return record.subSession } async updateSubSession(subSession: SubSession, tenantId: TenantId): Promise { const record = this.subSessions.get(subSession.id) if (!record) { throw new Error(`SubSession ${subSession.id} not found`) } this.assertTenant(record.tenantId, tenantId, `sub-session(${subSession.id})`) this.subSessions.set(subSession.id, { tenantId, subSession: { ...subSession, updatedAt: new Date() }, }) } async deleteSubSession(subSessionId: SubSessionId, tenantId: TenantId): Promise { const record = this.subSessions.get(subSessionId) if (!record) return // Idempotent: missing = no-op. this.assertTenant(record.tenantId, tenantId, `sub-session(${subSessionId})`) this.subSessions.delete(subSessionId) } // Linkage ----------------------------------------------------------------- async getChildren(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.sessions.get(sessionId) if (!record) return [] this.assertTenant(record.tenantId, tenantId, `session(${sessionId})`) return orderChildren(getChildren(this.linkageView(tenantId), sessionId)) } async getAncestry(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.sessions.get(sessionId) if (!record) return [] this.assertTenant(record.tenantId, tenantId, `session(${sessionId})`) return getAncestry(this.linkageView(tenantId), sessionId) } async drill(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.sessions.get(sessionId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `session(${sessionId})`) const view = this.linkageView(tenantId) return { session: record.session, children: orderChildren(getChildren(view, sessionId)), ancestry: getAncestry(view, sessionId), } } // Summary (§4.7 / §8.1) --------------------------------------------------- async recordSummary( summary: SessionSummaryRef & { materializedBy: 'kernel' }, tenantId: TenantId, ): Promise { if (summary.tenantId !== tenantId) { throw new TenantIsolationError({ requested: tenantId, resource: `summary(${summary.id}) payload`, }) } const sessionRecord = this.sessions.get(summary.sessionRef) if (!sessionRecord) { throw new Error(`Session ${summary.sessionRef} not found`) } this.assertTenant(sessionRecord.tenantId, tenantId, `session(${summary.sessionRef})`) // Atomic within the call: summary persist + session status flip commit // together. An existing summary with the same id is the recovery path — // idempotently replay the status flip without duplicating the record. const existing = this.summaries.get(summary.sessionRef) if (existing && existing.summary.id !== summary.id) { throw new SessionAlreadySummarizedError({ sessionId: summary.sessionRef, existingSummaryId: existing.summary.id, }) } if (!existing) { this.summaries.set(summary.sessionRef, { tenantId, summary }) } if (SUMMARY_TERMINAL_FLIP_STATUSES.has(sessionRecord.session.status)) { this.sessions.set(summary.sessionRef, { tenantId, session: { ...sessionRecord.session, status: 'idle', updatedAt: new Date(), }, }) } } async getSummary(sessionId: SessionId, tenantId: TenantId): Promise { const record = this.summaries.get(sessionId) if (!record) return null this.assertTenant(record.tenantId, tenantId, `summary(${record.summary.id})`) return record.summary } // Helpers ----------------------------------------------------------------- private assertTenant(actual: TenantId, requested: TenantId, resource: string): void { if (actual !== requested) { throw new TenantIsolationError({ requested, resource }) } } private linkageView(tenantId: TenantId): LinkageView { return { findChildSubSessions: (parentSessionId) => { const matches: SubSession[] = [] for (const record of this.subSessions.values()) { if (record.tenantId !== tenantId) continue if (record.subSession.parentSessionId === parentSessionId) { matches.push(record.subSession) } } return matches }, findParentSubSession: (childSessionId) => { for (const record of this.subSessions.values()) { if (record.tenantId !== tenantId) continue if (record.subSession.childSessionId === childSessionId) { return record.subSession } } return null }, } } }