| import { mkdir, open, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; |
| import { dirname, join } from 'node:path'; |
| import { createHash, randomUUID } from 'node:crypto'; |
| import { setTimeout as delay } from 'node:timers/promises'; |
| import { |
| decodeStoredMessageForRead, |
| decodeStoredMessageForRecovery, |
| } from './execution-record-codec.js'; |
| import { appendJsonl } from './jsonl-append.js'; |
| import { classifyJsonRecord } from './json-prefix.js'; |
| import { importLegacySessionMetadataTree } from './session-metadata-transfer.js'; |
| import { |
| createSqliteSessionMetadataStore, |
| type SessionConfigurationMetadataUpdate, |
| type SessionCatalogRevisionState, |
| type SessionMetadataRecord, |
| SessionMetadataVersionConflictError, |
| type SqliteSessionMetadataStore, |
| type StableSessionCreateProbe, |
| } from './sqlite-session-metadata-store.js'; |
| import { |
| isDiscardableConversationCopy, |
| isValidConversationCopyTransition, |
| } from './session-conversation-copy.js'; |
| import { |
| createSessionTranscriptMarker, |
| decodeSessionTranscriptMarker, |
| isSessionTranscriptMarker, |
| } from './session-transcript.js'; |
| import { chainWrite } from './write-queue.js'; |
| import { |
| acquireOperationalStateDatabase, |
| OPERATIONAL_STATE_DATABASE_NAME, |
| } from './operational-state-store.js'; |
| import { |
| DEFAULT_SESSION_NAME, |
| DurableStoreWriteError, |
| deriveTurnRecords, |
| isCollaborationMode, |
| isOrchestrationMode, |
| isPermissionMode, |
| isSessionBlockedReason, |
| isSessionConversationCopy, |
| isSubagentSessionParent, |
| isSubagentSessionRuntime, |
| isSubagentSessionSpawn, |
| isSubagentWorkspaceBinding, |
| isSessionStatus, |
| normalizeUserSessionName, |
| subagentSessionRuntimeSummary, |
| } from '@maka/core'; |
| import { syncDirectoryChain, syncFile } from './stable-storage.js'; |
| import type { |
| AgentGraphOperatorProvisionRequest, |
| AgentGraphOperatorProvisionResult, |
| CreateSandboxBoundaryRequest, |
| CreateSessionInput, |
| ExecutionBoundary, |
| SandboxBoundaryRequest, |
| SandboxBoundarySettlement, |
| SessionHeader, |
| SessionConversationCopy, |
| SessionListFilter, |
| SessionSummary, |
| StoredMessage, |
| SettleSandboxBoundaryRequest, |
| TurnRecord, |
| UserMessage, |
| } from '@maka/core'; |
| |
| const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; |
| /** @deprecated Session metadata is canonical in the operational runtime.sqlite database. */ |
| export const SQLITE_SESSION_METADATA_DATABASE_NAME = OPERATIONAL_STATE_DATABASE_NAME; |
| |
| export class SessionNotFoundError extends Error { |
| readonly name = 'SessionNotFoundError'; |
| readonly code = 'session_not_found'; |
| |
| constructor(readonly sessionId: string) { |
| super(`Session metadata not found: ${sessionId}`); |
| } |
| } |
| |
| export function isSessionNotFoundError(error: unknown): error is SessionNotFoundError { |
| return error instanceof SessionNotFoundError; |
| } |
| |
| export class SessionReadMarkerMessageNotFoundError extends Error { |
| readonly name = 'SessionReadMarkerMessageNotFoundError'; |
| readonly code = 'session_read_marker_message_not_found'; |
| |
| constructor( |
| readonly sessionId: string, |
| readonly messageId: string, |
| ) { |
| super(`Session read marker message does not exist: ${messageId}`); |
| } |
| } |
| |
| export interface SessionHeaderSnapshot { |
| readonly header: SessionHeader; |
| readonly revision: number; |
| readonly committedAt: number; |
| } |
| |
| export interface SessionCatalogRecord extends SessionHeaderSnapshot { |
| readonly summary: SessionSummary; |
| } |
| |
| export interface SessionCatalogPageCursor { |
| readonly activityAt: number; |
| readonly sessionId: string; |
| } |
| |
| export type SessionCatalogPageResult = |
| | { |
| readonly kind: 'page'; |
| readonly revision: `sha256:${string}`; |
| readonly records: readonly SessionCatalogRecord[]; |
| readonly hasMore: boolean; |
| } |
| | { |
| readonly kind: 'revision_changed'; |
| readonly expectedRevision: `sha256:${string}`; |
| readonly actualRevision: `sha256:${string}`; |
| }; |
| |
| export interface CreateStableSessionRequest { |
| readonly sessionId: string; |
| readonly requestFingerprint: string; |
| readonly input: StableSessionCreateInput; |
| } |
| |
| export type StableSessionCreateInput = CreateSessionInput & { |
| readonly conversationCopy?: SessionConversationCopy; |
| }; |
| |
| export type CreateStableSessionResult = |
| | { readonly kind: 'created'; readonly record: SessionHeaderSnapshot } |
| | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } |
| | { |
| readonly kind: 'conflict'; |
| readonly reason: 'identity_mismatch' | 'removed'; |
| }; |
| |
| export type ProbeStableSessionCreateResult = |
| | { readonly kind: 'absent' } |
| | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } |
| | { |
| readonly kind: 'conflict'; |
| readonly reason: 'identity_mismatch' | 'removed'; |
| }; |
| |
| export type UpdateSessionConfigurationRequest = SessionConfigurationMetadataUpdate; |
| |
| export interface SessionStore { |
| create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise<SessionHeader>; |
| list(filter?: SessionListFilter): Promise<SessionSummary[]>; |
| /** Enumerate durable metadata without reading transcript bodies. */ |
| listHeaders(): Promise<SessionHeader[]>; |
| listForRecovery(): Promise<SessionHeader[]>; |
| /** Read only the durable header without triggering connection-lock self-healing. */ |
| readHeaderSnapshot(sessionId: string): Promise<SessionHeader>; |
| /** Read durable messages without triggering connection-lock self-healing. */ |
| readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]>; |
| /** Read messages for startup recovery, rejecting durable JSONL corruption. */ |
| readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]>; |
| /** Derive durable turns without triggering connection-lock self-healing. */ |
| listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]>; |
| readHeader(sessionId: string): Promise<SessionHeader>; |
| readMessages(sessionId: string): Promise<StoredMessage[]>; |
| listTurns(sessionId: string): Promise<TurnRecord[]>; |
| appendMessage(sessionId: string, message: StoredMessage): Promise<void>; |
| appendMessages(sessionId: string, messages: StoredMessage[]): Promise<void>; |
| updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader>; |
| markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader>; |
| archive(sessionId: string): Promise<void>; |
| unarchive(sessionId: string): Promise<void>; |
| setFlagged(sessionId: string, isFlagged: boolean): Promise<void>; |
| rename(sessionId: string, name: string): Promise<void>; |
| setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise<SessionHeader | null>; |
| remove(sessionId: string): Promise<void>; |
| close?(): Promise<void>; |
| } |
| |
| export interface SessionAuthorityStore extends SessionStore { |
| createSubagent( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader; created: boolean }>; |
| createAgentGraphOperator( |
| input: CreateSessionInput, |
| request: AgentGraphOperatorProvisionRequest, |
| expectedRevision: number, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult>; |
| readExecutionBoundary(sessionId: string): Promise<ExecutionBoundary>; |
| createSandboxBoundaryRequest( |
| input: CreateSandboxBoundaryRequest, |
| ): Promise<SandboxBoundaryRequest>; |
| listPendingSandboxBoundaryRequests(sessionId: string): Promise<SandboxBoundaryRequest[]>; |
| /** Requests already closed against the user because the host restarted. */ |
| listSandboxBoundaryRestartClosures(sessionId: string): Promise<SandboxBoundaryRequest[]>; |
| settleSandboxBoundaryRequest( |
| input: SettleSandboxBoundaryRequest, |
| ): Promise<SandboxBoundarySettlement>; |
| setExecutionBoundaryKind( |
| sessionId: string, |
| kind: 'managed' | 'bypass', |
| projection?: { |
| permissionMode: SessionHeader['permissionMode']; |
| labels?: readonly string[]; |
| }, |
| ): Promise<ExecutionBoundary>; |
| probeStableSessionCreate( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<ProbeStableSessionCreateResult>; |
| createStableSession( |
| request: CreateStableSessionRequest, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<CreateStableSessionResult>; |
| discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise<boolean>; |
| listCatalogPage( |
| filter: SessionListFilter | undefined, |
| cursor: SessionCatalogPageCursor | undefined, |
| limit: number, |
| expectedRevision?: `sha256:${string}`, |
| ): Promise<SessionCatalogPageResult>; |
| readHeaderRecordSnapshot(sessionId: string): Promise<SessionHeaderSnapshot>; |
| readCatalogRecord(sessionId: string): Promise<SessionCatalogRecord>; |
| updateHeaderVersioned( |
| sessionId: string, |
| patch: Partial<SessionHeader>, |
| expectedRevision: number, |
| ): Promise<SessionHeaderSnapshot>; |
| updateSessionConfiguration( |
| sessionId: string, |
| input: UpdateSessionConfigurationRequest, |
| ): Promise<SessionHeaderSnapshot>; |
| markSessionReadThroughMessage( |
| sessionId: string, |
| messageId: string, |
| ): Promise<SessionHeaderSnapshot>; |
| } |
| |
| export function createSessionStore(workspaceRoot: string): SessionAuthorityStore { |
| return new SqliteSessionStore(workspaceRoot); |
| } |
| |
| /** Legacy JSONL-header store retained only for migration and compatibility tests. */ |
| export function createLegacyFileSessionStore(workspaceRoot: string): SessionStore { |
| return new FileSessionStore(workspaceRoot); |
| } |
| |
| class SqliteSessionStore implements SessionAuthorityStore { |
| private readonly files: FileSessionStore; |
| private readonly metadata: SqliteSessionMetadataStore; |
| private readonly ready: Promise<void>; |
| private closePromise: Promise<void> | null = null; |
| private activeCatalogProjectionWrites = 0; |
| private catalogProjectionWritesIdle: Promise<void> = Promise.resolve(); |
| private resolveCatalogProjectionWritesIdle: (() => void) | undefined; |
| private catalogProjectionRecovery: Promise<void> | null = null; |
| private catalogProjectionFailure: unknown; |
| |
| constructor(workspaceRoot: string) { |
| this.files = new FileSessionStore(workspaceRoot, true); |
| const databaseLease = acquireOperationalStateDatabase(workspaceRoot); |
| this.metadata = createSqliteSessionMetadataStore( |
| join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), |
| { databaseLease }, |
| ); |
| this.ready = importLegacySessionMetadataTree({ |
| workspaceRoot, |
| destination: this.metadata, |
| }).then(async (report) => { |
| if (report.headersImported > 0) { |
| await this.metadata.requireCatalogProjectionRecovery(); |
| } |
| await this.recoverCatalogProjections(); |
| }); |
| void this.ready.catch(() => {}); |
| } |
| |
| async create( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<SessionHeader> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| if (input.subagentSpawn) { |
| throw new Error('Subagent spawn metadata requires createSubagent()'); |
| } |
| const staged = await this.files.createTranscript(input); |
| try { |
| return (await this.metadata.create(staged, initialBoundary)).header; |
| } catch (error) { |
| await this.files.remove(staged.id).catch(() => {}); |
| throw error; |
| } |
| } |
| |
| async probeStableSessionCreate( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<ProbeStableSessionCreateResult> { |
| await this.ensureReady(); |
| return projectStableSessionCreateProbe( |
| await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint), |
| ); |
| } |
| |
| async createStableSession( |
| request: CreateStableSessionRequest, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<CreateStableSessionResult> { |
| await this.ensureReady(); |
| if ( |
| request.input.conversationCopy && |
| request.input.conversationCopy.requestFingerprint !== request.requestFingerprint |
| ) { |
| throw new Error('Conversation copy fingerprint does not match the stable create request'); |
| } |
| if (request.input.subagentSpawn) { |
| throw new Error('Subagent spawn metadata requires createSubagent()'); |
| } |
| const probe = await this.metadata.claimStableSessionCreate( |
| request.sessionId, |
| request.requestFingerprint, |
| ); |
| if (probe.kind === 'existing') { |
| return { kind: 'existing', record: projectHeaderSnapshot(probe.record) }; |
| } |
| if (probe.kind === 'conflict') return probe; |
| |
| const staged = await this.files.ensureStableTranscript(request.input, request.sessionId); |
| const result = await this.metadata.createStableSession( |
| staged, |
| request.requestFingerprint, |
| initialBoundary, |
| ); |
| return result.kind === 'created' || result.kind === 'existing' |
| ? { kind: result.kind, record: projectHeaderSnapshot(result.record) } |
| : result; |
| } |
| |
| async discardStableConversationCopy( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<boolean> { |
| await this.ensureReady(); |
| if (!(await this.metadata.hasStableSessionCreateClaim(sessionId, requestFingerprint))) { |
| throw new Error('Session is not owned by the matching stable create request'); |
| } |
| const probe = await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint); |
| if (probe.kind === 'conflict') { |
| throw new Error('Stable Session identity belongs to a different request'); |
| } |
| if (probe.kind === 'existing') { |
| const copy = probe.record.header.conversationCopy; |
| if ( |
| copy?.requestFingerprint !== requestFingerprint || |
| !isDiscardableConversationCopy(probe.record.header) |
| ) { |
| throw new Error('Only a matching incomplete conversation copy can be discarded'); |
| } |
| } |
| await this.files.remove(sessionId); |
| return this.metadata.discardStableSessionCreate(sessionId, requestFingerprint); |
| } |
| |
| async createSubagent( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader; created: boolean }> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| const staged = await this.files.createTranscript(input); |
| try { |
| const result = await this.metadata.createSubagent(staged, initialBoundary); |
| if (!result.created) await this.files.remove(staged.id); |
| return { header: result.record.header, created: result.created }; |
| } catch (error) { |
| await this.files.remove(staged.id).catch(() => {}); |
| throw error; |
| } |
| } |
| |
| async createAgentGraphOperator( |
| input: CreateSessionInput, |
| request: AgentGraphOperatorProvisionRequest, |
| expectedRevision: number, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| const staged = await this.files.createTranscript(input); |
| try { |
| const result = await this.metadata.createAgentGraphOperator( |
| staged, |
| request, |
| expectedRevision, |
| initialBoundary, |
| ); |
| if (!result.created) await this.files.remove(staged.id); |
| return { |
| header: result.record.header, |
| provision: result.provision, |
| created: result.created, |
| }; |
| } catch (error) { |
| await this.files.remove(staged.id).catch(() => {}); |
| throw error; |
| } |
| } |
| |
| async readExecutionBoundary(sessionId: string): Promise<ExecutionBoundary> { |
| await this.ensureReady(); |
| return this.metadata.readExecutionBoundary(sessionId); |
| } |
| |
| async createSandboxBoundaryRequest( |
| input: CreateSandboxBoundaryRequest, |
| ): Promise<SandboxBoundaryRequest> { |
| await this.ensureReady(); |
| return this.metadata.createSandboxBoundaryRequest(input); |
| } |
| |
| async listPendingSandboxBoundaryRequests(sessionId: string): Promise<SandboxBoundaryRequest[]> { |
| await this.ensureReady(); |
| return this.metadata.listPendingSandboxBoundaryRequests(sessionId); |
| } |
| |
| async listSandboxBoundaryRestartClosures(sessionId: string): Promise<SandboxBoundaryRequest[]> { |
| await this.ensureReady(); |
| return this.metadata.listSandboxBoundaryRestartClosures(sessionId); |
| } |
| |
| async settleSandboxBoundaryRequest( |
| input: SettleSandboxBoundaryRequest, |
| ): Promise<SandboxBoundarySettlement> { |
| await this.ensureReady(); |
| return this.metadata.settleSandboxBoundaryRequest(input); |
| } |
| |
| async setExecutionBoundaryKind( |
| sessionId: string, |
| kind: 'managed' | 'bypass', |
| projection?: { |
| permissionMode: SessionHeader['permissionMode']; |
| labels?: readonly string[]; |
| }, |
| ): Promise<ExecutionBoundary> { |
| await this.ensureReady(); |
| return this.metadata.setExecutionBoundaryKind(sessionId, kind, projection); |
| } |
| |
| async list(filter?: SessionListFilter): Promise<SessionSummary[]> { |
| await this.ensureReady(); |
| const records = (await this.metadata.list(filter)).filter( |
| (record) => record.header.conversationCopy?.state !== 'preparing', |
| ); |
| const withPreviews: Array<{ |
| record: SessionMetadataRecord; |
| previewMessages: StoredMessage[]; |
| }> = []; |
| for (const record of records) { |
| const previewMessages = await this.files |
| .readPreviewMessages(record.header.id) |
| .catch(() => []); |
| withPreviews.push({ record, previewMessages }); |
| } |
| withPreviews.sort((a, b) => { |
| const aLastMessageAt = maxTimestamp( |
| a.record.header.lastMessageAt, |
| latestVisibleMessageAt(a.previewMessages), |
| ); |
| const bLastMessageAt = maxTimestamp( |
| b.record.header.lastMessageAt, |
| latestVisibleMessageAt(b.previewMessages), |
| ); |
| const tsDelta = (bLastMessageAt ?? 0) - (aLastMessageAt ?? 0); |
| return tsDelta !== 0 ? tsDelta : a.record.header.id.localeCompare(b.record.header.id); |
| }); |
| |
| const summaries: SessionSummary[] = []; |
| for (let index = 0; index < withPreviews.length; index += 1) { |
| const { record, previewMessages } = withPreviews[index]!; |
| const { header } = record; |
| let messages = previewMessages.slice(-10); |
| if (index < 3) { |
| messages = ( |
| await this.files.readTranscriptMessagesSnapshot(header.id, header).catch(() => messages) |
| ).slice(-10); |
| } |
| summaries.push(toSummary(header, messages)); |
| } |
| return summaries; |
| } |
| |
| async listCatalogPage( |
| filter: SessionListFilter | undefined, |
| cursor: SessionCatalogPageCursor | undefined, |
| limit: number, |
| expectedRevision?: `sha256:${string}`, |
| ): Promise<SessionCatalogPageResult> { |
| await this.ensureCatalogProjectionReadable(); |
| const page = await this.metadata.listCatalogPage(filter ?? {}, cursor, limit); |
| const revision = projectCatalogRevision(page.revision); |
| if (expectedRevision !== undefined && expectedRevision !== revision) { |
| return { |
| kind: 'revision_changed', |
| expectedRevision, |
| actualRevision: revision, |
| }; |
| } |
| |
| return { |
| kind: 'page', |
| revision, |
| records: page.records.map((record) => ({ |
| ...projectHeaderSnapshot(record), |
| summary: toCatalogSummary(record.header, record.lastMessagePreview), |
| })), |
| hasMore: page.hasMore, |
| }; |
| } |
| |
| async listForRecovery(): Promise<SessionHeader[]> { |
| const headers = await this.listHeaders(); |
| for (const header of headers) { |
| await this.files.readTranscriptMessagesForRecovery(header.id, header); |
| } |
| return headers; |
| } |
| |
| async listHeaders(): Promise<SessionHeader[]> { |
| await this.ensureReady(); |
| return (await this.metadata.list()) |
| .map((record) => record.header) |
| .sort((a, b) => a.id.localeCompare(b.id)); |
| } |
| |
| async readHeaderSnapshot(sessionId: string): Promise<SessionHeader> { |
| return (await this.readHeaderRecordSnapshot(sessionId)).header; |
| } |
| |
| async readHeaderRecordSnapshot(sessionId: string): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| return projectHeaderSnapshot(await this.metadata.read(sessionId)); |
| } |
| |
| async readCatalogRecord(sessionId: string): Promise<SessionCatalogRecord> { |
| await this.ensureCatalogProjectionReadable(); |
| const record = await this.metadata.readCatalogRecord(sessionId); |
| return { |
| ...projectHeaderSnapshot(record), |
| summary: toCatalogSummary(record.header, record.lastMessagePreview), |
| }; |
| } |
| |
| async readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]> { |
| await this.ensureReady(); |
| const header = (await this.metadata.read(sessionId)).header; |
| return this.files.readTranscriptMessagesSnapshot(sessionId, header); |
| } |
| |
| async readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]> { |
| await this.ensureReady(); |
| const header = (await this.metadata.read(sessionId)).header; |
| return this.files.readTranscriptMessagesForRecovery(sessionId, header); |
| } |
| |
| async listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); |
| } |
| |
| async readHeader(sessionId: string): Promise<SessionHeader> { |
| const header = await this.readHeaderSnapshot(sessionId); |
| return this.lockConnectionAfterFirstUserMessage(header); |
| } |
| |
| async readMessages(sessionId: string): Promise<StoredMessage[]> { |
| const messages = await this.readMessagesSnapshot(sessionId); |
| const header = (await this.metadata.read(sessionId)).header; |
| await this.lockConnectionAfterFirstUserMessage(header, messages); |
| return messages; |
| } |
| |
| async listTurns(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessages(sessionId)); |
| } |
| |
| async appendMessage(sessionId: string, message: StoredMessage): Promise<void> { |
| await this.appendMessages(sessionId, [message]); |
| } |
| |
| async appendMessages(sessionId: string, messages: StoredMessage[]): Promise<void> { |
| if (messages.length === 0) return; |
| const release = await this.acquireCatalogProjectionWrite(); |
| try { |
| await this.metadata.beginCatalogProjectionWrite(); |
| await this.files.appendMessages(sessionId, messages); |
| await this.metadata.commitCatalogProjectionWrite( |
| sessionId, |
| catalogMessageProjection(messages), |
| ); |
| } catch (error) { |
| const recovery = this.scheduleCatalogProjectionRecovery(); |
| release(); |
| try { |
| await recovery; |
| } catch { |
| throw error; |
| } |
| throw error; |
| } finally { |
| release(); |
| } |
| } |
| |
| async updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader> { |
| await this.ensureReady(); |
| return (await this.metadata.update(sessionId, patch)).header; |
| } |
| |
| async updateHeaderVersioned( |
| sessionId: string, |
| patch: Partial<SessionHeader>, |
| expectedRevision: number, |
| ): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| return projectHeaderSnapshot( |
| await this.metadata.update(sessionId, patch, { |
| expectedVersion: expectedRevision, |
| skipNoop: true, |
| }), |
| ); |
| } |
| |
| async updateSessionConfiguration( |
| sessionId: string, |
| input: UpdateSessionConfigurationRequest, |
| ): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| return projectHeaderSnapshot(await this.metadata.updateSessionConfiguration(sessionId, input)); |
| } |
| |
| async markSessionReadThroughMessage( |
| sessionId: string, |
| messageId: string, |
| ): Promise<SessionHeaderSnapshot> { |
| for (let attempt = 0; attempt < 3; attempt += 1) { |
| const record = await this.readHeaderRecordSnapshot(sessionId); |
| const messages = await this.readMessagesSnapshot(sessionId); |
| const visibleMessages = messages.filter(isVisibleSessionMessage); |
| const targetIndex = visibleMessages.findIndex((message) => message.id === messageId); |
| if (targetIndex < 0) { |
| throw new SessionReadMarkerMessageNotFoundError(sessionId, messageId); |
| } |
| const currentIndex = |
| record.header.lastReadMessageId === undefined |
| ? -1 |
| : visibleMessages.findIndex((message) => message.id === record.header.lastReadMessageId); |
| if (targetIndex <= currentIndex) return record; |
| const hasUnread = targetIndex < visibleMessages.length - 1; |
| try { |
| return await this.updateHeaderVersioned( |
| sessionId, |
| { lastReadMessageId: messageId, hasUnread }, |
| record.revision, |
| ); |
| } catch (error) { |
| if (!(error instanceof SessionMetadataVersionConflictError) || attempt === 2) throw error; |
| } |
| } |
| throw new Error('Session read marker retry loop did not terminate'); |
| } |
| |
| async markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader> { |
| const header = await this.readHeaderSnapshot(sessionId); |
| const messages = await this.readMessagesSnapshot(sessionId); |
| const effectiveLastMessageAt = maxTimestamp( |
| header.lastMessageAt, |
| latestVisibleMessageAt(messages), |
| ); |
| if ( |
| !Number.isFinite(readThroughTs) || |
| !header.hasUnread || |
| (effectiveLastMessageAt !== undefined && effectiveLastMessageAt > readThroughTs) |
| ) { |
| return header; |
| } |
| return this.updateHeader(sessionId, { hasUnread: false }); |
| } |
| |
| async archive(sessionId: string): Promise<void> { |
| const now = Date.now(); |
| await this.updateHeader(sessionId, { |
| isArchived: true, |
| archivedAt: now, |
| status: 'archived', |
| statusUpdatedAt: now, |
| }); |
| } |
| |
| async unarchive(sessionId: string): Promise<void> { |
| await this.updateHeader(sessionId, { |
| isArchived: false, |
| archivedAt: undefined, |
| status: 'active', |
| blockedReason: undefined, |
| statusUpdatedAt: Date.now(), |
| }); |
| } |
| |
| async setFlagged(sessionId: string, isFlagged: boolean): Promise<void> { |
| await this.updateHeader(sessionId, { isFlagged }); |
| } |
| |
| async rename(sessionId: string, name: string): Promise<void> { |
| const normalized = normalizeUserSessionName(name); |
| if (!normalized.ok) throw new Error(normalized.error); |
| await this.updateHeader(sessionId, { |
| name: normalized.value, |
| titleIsManual: true, |
| }); |
| } |
| |
| async setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise<SessionHeader | null> { |
| const normalized = normalizeUserSessionName(title); |
| if (!normalized.ok) return null; |
| const current = await this.readHeaderSnapshot(sessionId); |
| if ( |
| current.titleIsManual || |
| current.name !== DEFAULT_SESSION_NAME || |
| normalized.value === current.name |
| ) { |
| return null; |
| } |
| return this.updateHeader(sessionId, { name: normalized.value }); |
| } |
| |
| async remove(sessionId: string): Promise<void> { |
| await this.ensureReady(); |
| await this.metadata.remove(sessionId); |
| await this.files.remove(sessionId); |
| } |
| |
| close(): Promise<void> { |
| this.closePromise ??= this.closeAfterReady(); |
| return this.closePromise; |
| } |
| |
| private async closeAfterReady(): Promise<void> { |
| await this.ready.catch(() => {}); |
| await this.catalogProjectionRecovery?.catch(() => {}); |
| this.metadata.close(); |
| } |
| |
| private async lockConnectionAfterFirstUserMessage( |
| header: SessionHeader, |
| knownMessages?: StoredMessage[], |
| ): Promise<SessionHeader> { |
| if (header.connectionLocked) return header; |
| const messages = |
| knownMessages ?? (await this.files.readTranscriptMessagesSnapshot(header.id, header)); |
| if (!messages.some((message) => message.type === 'user')) return header; |
| return this.updateHeader(header.id, { connectionLocked: true }); |
| } |
| |
| private async recoverCatalogProjections(): Promise<void> { |
| if (!(await this.metadata.hasPendingCatalogProjectionWrites())) return; |
| const projections = new Map<string, ReturnType<typeof catalogMessageProjection>>(); |
| for (const record of await this.metadata.list()) { |
| try { |
| const messages = await this.files.readTranscriptMessagesForRecovery( |
| record.header.id, |
| record.header, |
| ); |
| projections.set(record.header.id, catalogMessageProjection(messages)); |
| } catch (error) { |
| if (!isDiscardableConversationCopy(record.header)) throw error; |
| } |
| } |
| await this.metadata.recoverCatalogProjections(projections); |
| } |
| |
| private async ensureReady(): Promise<void> { |
| await this.ready; |
| if (this.catalogProjectionRecovery) await this.catalogProjectionRecovery; |
| if (this.catalogProjectionFailure) throw this.catalogProjectionFailure; |
| } |
| |
| private async ensureCatalogProjectionReadable(): Promise<void> { |
| await this.ensureReady(); |
| if ( |
| this.activeCatalogProjectionWrites === 0 && |
| (await this.metadata.hasPendingCatalogProjectionWrites()) |
| ) { |
| await this.scheduleCatalogProjectionRecovery(); |
| } |
| } |
| |
| private async acquireCatalogProjectionWrite(): Promise<() => void> { |
| while (true) { |
| await this.ensureReady(); |
| if (!this.catalogProjectionRecovery) break; |
| } |
| if (this.activeCatalogProjectionWrites === 0) { |
| this.catalogProjectionWritesIdle = new Promise<void>((resolve) => { |
| this.resolveCatalogProjectionWritesIdle = resolve; |
| }); |
| } |
| this.activeCatalogProjectionWrites += 1; |
| let released = false; |
| return () => { |
| if (released) return; |
| released = true; |
| this.activeCatalogProjectionWrites -= 1; |
| if (this.activeCatalogProjectionWrites === 0) { |
| this.resolveCatalogProjectionWritesIdle?.(); |
| this.resolveCatalogProjectionWritesIdle = undefined; |
| } |
| }; |
| } |
| |
| private scheduleCatalogProjectionRecovery(): Promise<void> { |
| if (this.catalogProjectionRecovery) return this.catalogProjectionRecovery; |
| const recovery = (async () => { |
| await this.catalogProjectionWritesIdle; |
| if (await this.metadata.hasPendingCatalogProjectionWrites()) { |
| await this.recoverCatalogProjections(); |
| } |
| })(); |
| this.catalogProjectionRecovery = recovery; |
| void recovery.then( |
| () => { |
| if (this.catalogProjectionRecovery === recovery) { |
| this.catalogProjectionRecovery = null; |
| } |
| }, |
| (error: unknown) => { |
| this.catalogProjectionFailure = error; |
| if (this.catalogProjectionRecovery === recovery) { |
| this.catalogProjectionRecovery = null; |
| } |
| }, |
| ); |
| return recovery; |
| } |
| } |
| |
| class FileSessionStore implements SessionStore { |
| private static readonly HEADER_BUDGET = 8192; |
| private static readonly MAX_HEADER_BYTES = 1024 * 1024; |
| private static readonly TAIL_PREVIEW_BUDGET = 64 * 1024; |
| private readonly sessionsRoot: string; |
| private readonly writeQueues = new Map<string, Promise<void>>(); |
| |
| constructor( |
| private readonly workspaceRoot: string, |
| private readonly durableTranscripts = false, |
| ) { |
| this.sessionsRoot = join(workspaceRoot, 'sessions'); |
| } |
| |
| async create(input: CreateSessionInput): Promise<SessionHeader> { |
| assertNoConversationCopyMetadata(input); |
| if (input.subagentSpawn) { |
| throw new Error('Child-session idempotency requires the SQLite metadata control plane'); |
| } |
| return this.createWithInitialRecord(input, 'legacy-header'); |
| } |
| |
| async createTranscript(input: CreateSessionInput, sessionId?: string): Promise<SessionHeader> { |
| assertNoConversationCopyMetadata(input); |
| return this.createWithInitialRecord(input, 'transcript-marker', sessionId); |
| } |
| |
| async ensureStableTranscript( |
| input: StableSessionCreateInput, |
| sessionId: string, |
| ): Promise<SessionHeader> { |
| return this.createWithInitialRecord( |
| input, |
| 'transcript-marker', |
| sessionId, |
| true, |
| input.conversationCopy, |
| ); |
| } |
| |
| private async createWithInitialRecord( |
| input: CreateSessionInput, |
| initialRecord: 'legacy-header' | 'transcript-marker', |
| sessionId?: string, |
| reuseStableTranscript = false, |
| conversationCopy?: SessionConversationCopy, |
| ): Promise<SessionHeader> { |
| if ( |
| input.projectId !== undefined && |
| input.projectId !== null && |
| (typeof input.projectId !== 'string' || input.projectId.length === 0) |
| ) { |
| throw new Error('Invalid project id'); |
| } |
| const now = Date.now(); |
| const id = sessionId ?? randomUUID(); |
| assertSafeSessionId(id); |
| // PR-UI-IPC-2 (@kenji msg 0474c3fe + @xuan msg 88d96a87): |
| // session name write contract. If caller passed undefined, |
| // use the canonical default; otherwise normalize the |
| // user-supplied name through the same `normalizeUserSessionName` |
| // gate that `rename` and `branchFromTurn` use. Empty-after- |
| // sanitize on an explicit input is a REJECT — we do NOT |
| // silently fall back to default, that would swallow the |
| // user's intent (per @xuan caller-semantics lock). |
| let resolvedName: string; |
| if (input.name === undefined) { |
| resolvedName = DEFAULT_SESSION_NAME; |
| } else { |
| const normalized = normalizeUserSessionName(input.name); |
| if (!normalized.ok) { |
| throw new Error(normalized.error); |
| } |
| resolvedName = normalized.value; |
| } |
| const header: SessionHeader = { |
| id, |
| workspaceRoot: this.workspaceRoot, |
| cwd: input.cwd, |
| ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), |
| createdAt: now, |
| lastUsedAt: now, |
| name: resolvedName, |
| titleIsManual: false, |
| isFlagged: false, |
| labels: input.labels ?? [], |
| isArchived: false, |
| status: input.status ?? 'active', |
| ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), |
| statusUpdatedAt: now, |
| ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), |
| ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), |
| ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), |
| ...(input.subagentRuntime ? { subagentRuntime: input.subagentRuntime } : {}), |
| ...(input.subagentSpawn ? { subagentSpawn: input.subagentSpawn } : {}), |
| ...(input.subagentWorkspace ? { subagentWorkspace: input.subagentWorkspace } : {}), |
| ...(conversationCopy ? { conversationCopy } : {}), |
| ...(input.revisionRootSessionId |
| ? { revisionRootSessionId: input.revisionRootSessionId } |
| : {}), |
| ...(input.revisionParentSessionId |
| ? { revisionParentSessionId: input.revisionParentSessionId } |
| : {}), |
| ...(input.revisionOfTurnId ? { revisionOfTurnId: input.revisionOfTurnId } : {}), |
| ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), |
| ...(input.revisionState ? { revisionState: input.revisionState } : {}), |
| hasUnread: false, |
| backend: input.backend, |
| llmConnectionSlug: input.llmConnectionSlug, |
| connectionLocked: false, |
| model: input.model ?? 'default', |
| permissionMode: input.permissionMode, |
| collaborationMode: input.collaborationMode ?? 'agent', |
| orchestrationMode: input.orchestrationMode ?? 'default', |
| ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), |
| schemaVersion: 1, |
| }; |
| |
| assertValidSessionLineage(header); |
| |
| await this.withQueue(id, async () => { |
| await mkdir(this.sessionsRoot, { recursive: true }); |
| if (reuseStableTranscript) { |
| try { |
| await mkdir(this.sessionDir(id)); |
| } catch (error) { |
| if (!hasErrorCode(error, 'EEXIST')) throw error; |
| } |
| await this.ensureMarkerOnlyTranscript(id); |
| return; |
| } |
| await mkdir(this.sessionDir(id)); |
| const firstRecord = |
| initialRecord === 'legacy-header' ? header : createSessionTranscriptMarker(header.id); |
| try { |
| await writeNewTranscript( |
| this.sessionPath(id), |
| JSON.stringify(firstRecord) + '\n', |
| this.durableTranscripts ? this.workspaceRoot : undefined, |
| ); |
| } catch (error) { |
| await rm(this.sessionDir(id), { recursive: true, force: true }).catch(() => {}); |
| throw error; |
| } |
| }); |
| |
| return header; |
| } |
| |
| private async ensureMarkerOnlyTranscript(sessionId: string): Promise<void> { |
| const path = this.sessionPath(sessionId); |
| const marker = JSON.stringify(createSessionTranscriptMarker(sessionId)) + '\n'; |
| const entries = await readdir(this.sessionDir(sessionId)); |
| if (entries.length === 0) { |
| try { |
| await writeNewTranscript( |
| path, |
| marker, |
| this.durableTranscripts ? this.workspaceRoot : undefined, |
| ); |
| return; |
| } catch (error) { |
| if (!hasErrorCode(error, 'EEXIST')) throw error; |
| } |
| } else if (entries.length !== 1 || entries[0] !== 'session.jsonl') { |
| throw new Error(`Session ${sessionId}: stable transcript path is not recoverable`); |
| } |
| |
| const text = await readFile(path, 'utf8'); |
| if (text === marker) { |
| if (this.durableTranscripts) { |
| await stabilizeTranscript(path, this.workspaceRoot); |
| } |
| return; |
| } |
| if (marker.startsWith(text)) { |
| await this.writeAtomic(path, marker); |
| if (this.durableTranscripts) { |
| await stabilizeTranscript(path, this.workspaceRoot); |
| } |
| return; |
| } |
| const records = text.split('\n').filter((line) => line.trim().length > 0); |
| if (records.length !== 1 || !records[0]) { |
| throw new Error(`Session ${sessionId}: stable transcript is not marker-only`); |
| } |
| decodeSessionTranscriptMarker(JSON.parse(records[0]), sessionId); |
| await this.writeAtomic(path, marker); |
| if (this.durableTranscripts) { |
| await stabilizeTranscript(path, this.workspaceRoot); |
| } |
| } |
| |
| async list(filter?: SessionListFilter): Promise<SessionSummary[]> { |
| if (filter?.subagentParentSessionId !== undefined) { |
| throw new Error('Subagent session relation queries require SQLite session metadata'); |
| } |
| let entries; |
| try { |
| entries = await readdir(this.sessionsRoot, { withFileTypes: true }); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; |
| throw error; |
| } |
| |
| // Phase 1: read each header plus a bounded tail preview. That keeps |
| // list() proportional to the number of sessions rather than full |
| // transcript size, while preserving sidebar previews and timestamp |
| // fallback for sessions outside the top few. |
| const withHeaders: Array<{ |
| id: string; |
| header: SessionHeader; |
| previewMessages: StoredMessage[]; |
| }> = []; |
| for (const entry of entries) { |
| if (!entry.isDirectory()) continue; |
| if (!isSafeSessionId(entry.name)) continue; |
| try { |
| const header = await this.readHeaderOnly(entry.name); |
| if (filter?.isArchived !== undefined && header.isArchived !== filter.isArchived) continue; |
| if (filter?.isFlagged !== undefined && header.isFlagged !== filter.isFlagged) continue; |
| if (filter?.labelSlug && !header.labels.includes(filter.labelSlug)) continue; |
| const previewMessages = await this.readTailPreviewMessages(entry.name).catch(() => []); |
| withHeaders.push({ id: entry.name, header, previewMessages }); |
| } catch { |
| // Ignore malformed session folders in the sidebar. |
| } |
| } |
| |
| // Secondary key on id (lexicographic) so sessions with identical |
| // lastMessageAt always sort in the same order - fixtures with |
| // multiple sessions seeded at the same frozen timestamp would |
| // otherwise drift across runs based on filesystem readdir order |
| // (PR108k-yj per @kenji e2e-fixture determinism). Negligible cost |
| // for real users; identical lastMessageAt is rare in production. |
| withHeaders.sort((a, b) => { |
| const aLastMessageAt = maxTimestamp( |
| a.header.lastMessageAt, |
| latestVisibleMessageAt(a.previewMessages), |
| ); |
| const bLastMessageAt = maxTimestamp( |
| b.header.lastMessageAt, |
| latestVisibleMessageAt(b.previewMessages), |
| ); |
| const tsDelta = (bLastMessageAt ?? 0) - (aLastMessageAt ?? 0); |
| if (tsDelta !== 0) return tsDelta; |
| return a.header.id.localeCompare(b.header.id); |
| }); |
| |
| // Phase 2: full detail read only for the most recent 3 sessions. |
| // For those, keep only the last 10 messages as preview. Remaining |
| // sessions use the bounded tail preview from phase 1. |
| const TOP_N = 3; |
| const summaries: SessionSummary[] = []; |
| for (let i = 0; i < withHeaders.length; i++) { |
| const { header, previewMessages } = withHeaders[i]; |
| let messages: StoredMessage[] = previewMessages.slice(-10); |
| if (i < TOP_N) { |
| try { |
| const result = await this.readFilePartsUnlocked(header.id); |
| messages = result.messages.slice(-10); |
| } catch { |
| // Fall through to the bounded tail preview from phase 1. |
| } |
| } |
| summaries.push(toSummary(header, messages)); |
| } |
| return summaries; |
| } |
| |
| async listForRecovery(): Promise<SessionHeader[]> { |
| return this.listHeaders(); |
| } |
| |
| async listHeaders(): Promise<SessionHeader[]> { |
| let entries; |
| try { |
| entries = await readdir(this.sessionsRoot, { withFileTypes: true }); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; |
| throw error; |
| } |
| const headers: SessionHeader[] = []; |
| for (const entry of entries) { |
| if (!entry.isDirectory() || !isSafeSessionId(entry.name)) { |
| throw new Error(`Invalid Session entry: ${entry.name}`); |
| } |
| headers.push(await this.readHeaderOnly(entry.name)); |
| } |
| return headers.sort((a, b) => a.id.localeCompare(b.id)); |
| } |
| |
| async readHeader(sessionId: string): Promise<SessionHeader> { |
| const { header, messages } = await this.readFileParts(sessionId); |
| if (!header.connectionLocked && messages.some((message) => message.type === 'user')) { |
| return this.updateHeader(sessionId, { connectionLocked: true }); |
| } |
| return header; |
| } |
| |
| async readHeaderSnapshot(sessionId: string): Promise<SessionHeader> { |
| try { |
| return await this.readHeaderOnly(sessionId); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { |
| throw new SessionNotFoundError(sessionId); |
| } |
| throw error; |
| } |
| } |
| |
| async readMessages(sessionId: string): Promise<StoredMessage[]> { |
| const { header, messages } = await this.readFileParts(sessionId); |
| if (!header.connectionLocked && messages.some((message) => message.type === 'user')) { |
| await this.updateHeader(sessionId, { connectionLocked: true }); |
| } |
| return messages; |
| } |
| |
| async readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]> { |
| return (await this.readFileParts(sessionId)).messages; |
| } |
| |
| async readPreviewMessages(sessionId: string): Promise<StoredMessage[]> { |
| return this.readTailPreviewMessages(sessionId); |
| } |
| |
| async readTranscriptMessagesSnapshot( |
| sessionId: string, |
| header: SessionHeader, |
| ): Promise<StoredMessage[]> { |
| return this.readTranscriptMessagesUnlocked(sessionId, header); |
| } |
| |
| async readTranscriptMessagesForRecovery( |
| sessionId: string, |
| header: SessionHeader, |
| ): Promise<StoredMessage[]> { |
| return this.readTranscriptMessagesUnlocked(sessionId, header, true); |
| } |
| |
| async readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]> { |
| return (await this.readFilePartsUnlocked(sessionId, true)).messages; |
| } |
| |
| async listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); |
| } |
| |
| async listTurns(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessages(sessionId)); |
| } |
| |
| async appendMessage(sessionId: string, message: StoredMessage): Promise<void> { |
| await this.appendMessages(sessionId, [message]); |
| } |
| |
| async appendMessages(sessionId: string, messages: StoredMessage[]): Promise<void> { |
| if (messages.length === 0) return; |
| await this.withQueue(sessionId, async () => { |
| const payload = messages.map((message) => JSON.stringify(message)).join('\n') + '\n'; |
| await appendJsonl(this.sessionPath(sessionId), payload, { |
| durable: this.durableTranscripts, |
| ...(this.durableTranscripts ? { durabilityRoot: this.workspaceRoot } : {}), |
| requireExistingRecord: true, |
| }); |
| }); |
| } |
| |
| async updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader> { |
| if (Object.prototype.hasOwnProperty.call(patch, 'subagentParent')) { |
| throw new Error('Subagent session parent relation is immutable'); |
| } |
| if (Object.prototype.hasOwnProperty.call(patch, 'subagentRuntime')) { |
| throw new Error('Subagent session runtime snapshot is immutable'); |
| } |
| if (Object.prototype.hasOwnProperty.call(patch, 'subagentSpawn')) { |
| throw new Error('Subagent session spawn identity is immutable'); |
| } |
| if (Object.prototype.hasOwnProperty.call(patch, 'subagentWorkspace')) { |
| throw new Error('Subagent session workspace binding is immutable'); |
| } |
| let nextHeader: SessionHeader | undefined; |
| await this.withQueue(sessionId, async () => { |
| const { header, messages } = await this.readFilePartsUnlocked(sessionId); |
| assertConversationCopyTransition(header, patch); |
| nextHeader = { ...header, ...patch }; |
| assertValidSessionLineage(nextHeader); |
| const lines = [ |
| JSON.stringify(nextHeader), |
| ...messages.map((message) => JSON.stringify(message)), |
| ]; |
| await this.writeAtomic(this.sessionPath(sessionId), lines.join('\n') + '\n'); |
| }); |
| if (!nextHeader) throw new Error(`Failed to update session ${sessionId}`); |
| return nextHeader; |
| } |
| |
| async markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader> { |
| let nextHeader: SessionHeader | undefined; |
| await this.withQueue(sessionId, async () => { |
| const { header, messages } = await this.readFilePartsUnlocked(sessionId); |
| const effectiveLastMessageAt = maxTimestamp( |
| header.lastMessageAt, |
| latestVisibleMessageAt(messages), |
| ); |
| if ( |
| !Number.isFinite(readThroughTs) || |
| !header.hasUnread || |
| (effectiveLastMessageAt !== undefined && effectiveLastMessageAt > readThroughTs) |
| ) { |
| nextHeader = header; |
| return; |
| } |
| nextHeader = { ...header, hasUnread: false }; |
| const lines = [ |
| JSON.stringify(nextHeader), |
| ...messages.map((message) => JSON.stringify(message)), |
| ]; |
| await this.writeAtomic(this.sessionPath(sessionId), lines.join('\n') + '\n'); |
| }); |
| if (!nextHeader) throw new Error(`Failed to update session ${sessionId}`); |
| return nextHeader; |
| } |
| |
| async archive(sessionId: string): Promise<void> { |
| const now = Date.now(); |
| await this.updateHeader(sessionId, { |
| isArchived: true, |
| archivedAt: now, |
| status: 'archived', |
| statusUpdatedAt: now, |
| }); |
| } |
| |
| async unarchive(sessionId: string): Promise<void> { |
| await this.updateHeader(sessionId, { |
| isArchived: false, |
| archivedAt: undefined, |
| status: 'active', |
| blockedReason: undefined, |
| statusUpdatedAt: Date.now(), |
| }); |
| } |
| |
| async setFlagged(sessionId: string, isFlagged: boolean): Promise<void> { |
| await this.updateHeader(sessionId, { isFlagged }); |
| } |
| |
| async rename(sessionId: string, name: string): Promise<void> { |
| // PR-UI-IPC-2: same `normalizeUserSessionName` chokepoint as |
| // create + branch. Replaces the older inline trim + length-80 |
| // cap with the shared helper so all three write paths go |
| // through a single contract (control char strip, bidi/zero- |
| // width defense, NFC, code-point cap, typed reject). |
| const normalized = normalizeUserSessionName(name); |
| if (!normalized.ok) { |
| throw new Error(normalized.error); |
| } |
| await this.updateHeader(sessionId, { name: normalized.value, titleIsManual: true }); |
| } |
| |
| async setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise<SessionHeader | null> { |
| const normalized = normalizeUserSessionName(title); |
| if (!normalized.ok) return null; |
| let nextHeader: SessionHeader | null = null; |
| await this.withQueue(sessionId, async () => { |
| const { header, messages } = await this.readFilePartsUnlocked(sessionId); |
| if (header.titleIsManual || header.name !== DEFAULT_SESSION_NAME) return; |
| if (normalized.value === header.name) return; |
| nextHeader = { ...header, name: normalized.value }; |
| const lines = [ |
| JSON.stringify(nextHeader), |
| ...messages.map((message) => JSON.stringify(message)), |
| ]; |
| await this.writeAtomic(this.sessionPath(sessionId), lines.join('\n') + '\n'); |
| }); |
| return nextHeader; |
| } |
| |
| async remove(sessionId: string): Promise<void> { |
| await this.withQueue(sessionId, async () => { |
| await rm(this.sessionDir(sessionId), { recursive: true, force: true }); |
| }); |
| } |
| |
| private sessionDir(sessionId: string): string { |
| assertSafeSessionId(sessionId); |
| return join(this.sessionsRoot, sessionId); |
| } |
| |
| private sessionPath(sessionId: string): string { |
| return join(this.sessionDir(sessionId), 'session.jsonl'); |
| } |
| |
| private async readHeaderOnly(sessionId: string): Promise<SessionHeader> { |
| // Fast path: read only the first JSON line (the header) without |
| // parsing any message payload. Used by list() to quickly scan |
| // all sessions before deciding which ones need detail reads. |
| const path = this.sessionPath(sessionId); |
| const handle = await open(path, 'r'); |
| try { |
| const chunks: Buffer[] = []; |
| let offset = 0; |
| while (offset < FileSessionStore.MAX_HEADER_BYTES) { |
| const buf = Buffer.alloc( |
| Math.min(FileSessionStore.HEADER_BUDGET, FileSessionStore.MAX_HEADER_BYTES - offset), |
| ); |
| const { bytesRead } = await handle.read(buf, 0, buf.length, offset); |
| if (bytesRead === 0) break; |
| chunks.push(buf.subarray(0, bytesRead)); |
| const region = Buffer.concat(chunks).toString('utf8'); |
| const firstNl = region.indexOf('\n'); |
| if (firstNl !== -1) { |
| return decodeSessionHeader(JSON.parse(region.slice(0, firstNl)), sessionId); |
| } |
| offset += bytesRead; |
| } |
| throw new Error(`Session ${sessionId}: cannot find header line`); |
| } finally { |
| await handle.close(); |
| } |
| } |
| |
| private async readTailPreviewMessages(sessionId: string): Promise<StoredMessage[]> { |
| const path = this.sessionPath(sessionId); |
| const handle = await open(path, 'r'); |
| try { |
| const { size } = await handle.stat(); |
| const start = Math.max(0, size - FileSessionStore.TAIL_PREVIEW_BUDGET); |
| const length = size - start; |
| if (length <= 0) return []; |
| const buf = Buffer.alloc(length); |
| const { bytesRead } = await handle.read(buf, 0, length, start); |
| const text = buf.toString('utf8', 0, bytesRead); |
| const rawLines = text.split('\n'); |
| // The first tail line is either the header (start === 0) or a partial JSONL line. |
| const lines = rawLines.slice(1); |
| const completeLines = text.endsWith('\n') ? lines : lines.slice(0, -1); |
| const messages: StoredMessage[] = []; |
| for (const line of completeLines) { |
| if (line.trim().length === 0) continue; |
| try { |
| messages.push(decodeStoredMessageForRead(JSON.parse(line))); |
| } catch { |
| // Tail previews are best-effort; full reads still surface durable corruption notes. |
| } |
| } |
| return messages; |
| } finally { |
| await handle.close(); |
| } |
| } |
| |
| private async readFileParts( |
| sessionId: string, |
| ): Promise<{ header: SessionHeader; messages: StoredMessage[] }> { |
| return this.readFilePartsUnlocked(sessionId); |
| } |
| |
| private async readFilePartsUnlocked( |
| sessionId: string, |
| strict = false, |
| ): Promise<{ header: SessionHeader; messages: StoredMessage[] }> { |
| const text = await readFile(this.sessionPath(sessionId), 'utf8'); |
| const rawLines = text.split('\n'); |
| const endsWithNewline = text.endsWith('\n'); |
| const lines = rawLines |
| .map((line, index) => ({ line, lineNumber: index + 1 })) |
| .filter((entry) => entry.line.trim().length > 0); |
| if (lines.length === 0 || !lines[0]) throw new Error(`Session ${sessionId} is empty`); |
| const header = decodeSessionHeader(JSON.parse(lines[0].line), sessionId); |
| const messages: StoredMessage[] = []; |
| const lastLineNumber = lines.at(-1)?.lineNumber; |
| for (const entry of lines.slice(1)) { |
| let parsed: unknown; |
| try { |
| parsed = JSON.parse(entry.line); |
| } catch (error) { |
| if ( |
| !endsWithNewline && |
| entry.lineNumber === lastLineNumber && |
| classifyJsonRecord(entry.line) === 'incomplete-prefix' |
| ) |
| continue; |
| if (strict) { |
| const detail = error instanceof Error ? error.message : String(error); |
| throw new Error( |
| `Session ${sessionId} has a corrupt JSONL record at line ${entry.lineNumber}: ${detail}`, |
| ); |
| } |
| messages.push(createJsonlCorruptionNote(header, entry.lineNumber, error)); |
| continue; |
| } |
| try { |
| messages.push( |
| strict ? decodeStoredMessageForRecovery(parsed) : decodeStoredMessageForRead(parsed), |
| ); |
| } catch (error) { |
| if (strict) { |
| const detail = error instanceof Error ? error.message : String(error); |
| throw new Error( |
| `Session ${sessionId} has a corrupt JSONL record at line ${entry.lineNumber}: ${detail}`, |
| ); |
| } |
| messages.push(createJsonlCorruptionNote(header, entry.lineNumber, error)); |
| } |
| } |
| return { header, messages }; |
| } |
| |
| private async readTranscriptMessagesUnlocked( |
| sessionId: string, |
| header: SessionHeader, |
| strict = false, |
| ): Promise<StoredMessage[]> { |
| const text = await readFile(this.sessionPath(sessionId), 'utf8'); |
| const rawLines = text.split('\n'); |
| const endsWithNewline = text.endsWith('\n'); |
| const lines = rawLines |
| .map((line, index) => ({ line, lineNumber: index + 1 })) |
| .filter((entry) => entry.line.trim().length > 0); |
| if (lines.length === 0 || !lines[0]) throw new Error(`Session ${sessionId} is empty`); |
| |
| let firstRecord: unknown; |
| try { |
| firstRecord = JSON.parse(lines[0].line) as unknown; |
| if (isSessionTranscriptMarker(firstRecord)) { |
| decodeSessionTranscriptMarker(firstRecord, sessionId); |
| } else { |
| decodeSessionHeader(firstRecord, sessionId); |
| } |
| } catch (error) { |
| const detail = error instanceof Error ? error.message : String(error); |
| throw new Error(`Session ${sessionId} has an invalid first JSONL record: ${detail}`); |
| } |
| |
| const messages: StoredMessage[] = []; |
| const lastLineNumber = lines.at(-1)?.lineNumber; |
| for (const entry of lines.slice(1)) { |
| let parsed: unknown; |
| try { |
| parsed = JSON.parse(entry.line); |
| } catch (error) { |
| if ( |
| !endsWithNewline && |
| entry.lineNumber === lastLineNumber && |
| classifyJsonRecord(entry.line) === 'incomplete-prefix' |
| ) { |
| continue; |
| } |
| if (strict) { |
| const detail = error instanceof Error ? error.message : String(error); |
| throw new Error( |
| `Session ${sessionId} has a corrupt JSONL record at line ${entry.lineNumber}: ${detail}`, |
| ); |
| } |
| messages.push(createJsonlCorruptionNote(header, entry.lineNumber, error)); |
| continue; |
| } |
| try { |
| messages.push( |
| strict ? decodeStoredMessageForRecovery(parsed) : decodeStoredMessageForRead(parsed), |
| ); |
| } catch (error) { |
| if (strict) { |
| const detail = error instanceof Error ? error.message : String(error); |
| throw new Error( |
| `Session ${sessionId} has a corrupt JSONL record at line ${entry.lineNumber}: ${detail}`, |
| ); |
| } |
| messages.push(createJsonlCorruptionNote(header, entry.lineNumber, error)); |
| } |
| } |
| return messages; |
| } |
| |
| private async writeAtomic(path: string, content: string): Promise<void> { |
| await mkdir(dirname(path), { recursive: true }); |
| const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; |
| await writeFile(tempPath, content, { encoding: 'utf8', mode: 0o600 }); |
| try { |
| await replaceFileWithWindowsReaderRetry(tempPath, path); |
| } finally { |
| await rm(tempPath, { force: true }).catch(() => {}); |
| } |
| } |
| |
| private withQueue(sessionId: string, operation: () => Promise<void>): Promise<void> { |
| assertSafeSessionId(sessionId); |
| return chainWrite(this.writeQueues, sessionId, operation); |
| } |
| } |
| |
| async function replaceFileWithWindowsReaderRetry(tempPath: string, path: string): Promise<void> { |
| const attempts = process.platform === 'win32' ? 6 : 1; |
| for (let attempt = 1; attempt <= attempts; attempt += 1) { |
| try { |
| await rename(tempPath, path); |
| return; |
| } catch (error) { |
| const code = (error as NodeJS.ErrnoException).code; |
| const retryable = process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES'); |
| if (!retryable || attempt === attempts) throw error; |
| await delay(attempt * 10); |
| } |
| } |
| } |
| |
| /** Shared guard for stores that derive filesystem paths from a session id. */ |
| export function assertSafeSessionId(sessionId: string): void { |
| if (!isSafeSessionId(sessionId)) { |
| throw new Error('Invalid session id'); |
| } |
| } |
| |
| export function isSafeSessionId(sessionId: string): boolean { |
| return SESSION_ID_PATTERN.test(sessionId); |
| } |
| |
| type StoredSessionHeader = Omit< |
| SessionHeader, |
| | 'backend' |
| | 'model' |
| | 'permissionMode' |
| | 'collaborationMode' |
| | 'orchestrationMode' |
| | 'status' |
| | 'blockedReason' |
| | 'titleIsManual' |
| > & { |
| backend: string; |
| model?: unknown; |
| permissionMode?: unknown; |
| collaborationMode?: unknown; |
| orchestrationMode?: unknown; |
| status?: unknown; |
| blockedReason?: unknown; |
| titleIsManual?: unknown; |
| /** Accepted only while decoding old session headers and dropped on normalization. */ |
| pendingCwdReminder?: unknown; |
| }; |
| |
| function createJsonlCorruptionNote( |
| header: SessionHeader, |
| lineNumber: number, |
| error: unknown, |
| ): StoredMessage { |
| return { |
| type: 'system_note', |
| id: `jsonl-corrupt-${lineNumber}`, |
| ts: header.lastUsedAt ?? header.createdAt, |
| kind: 'error', |
| data: { |
| code: 'jsonl_parse_error', |
| lineNumber, |
| message: error instanceof Error ? error.message : 'Invalid JSONL message line', |
| }, |
| }; |
| } |
| |
| /** |
| * Decode the legacy line-1 JSONL header into the current canonical shape. |
| * |
| * Kept public for one-way importers so file and SQLite storage apply exactly |
| * the same compatibility defaults and validation rules. |
| */ |
| export function decodeSessionHeader(value: unknown, sessionId: string): SessionHeader { |
| if (!value || typeof value !== 'object' || Array.isArray(value)) { |
| throw new Error(`Invalid session header for session ${sessionId}: expected an object`); |
| } |
| const header = value as StoredSessionHeader; |
| const permissionMode = isPermissionMode(header.permissionMode) ? header.permissionMode : 'ask'; |
| const collaborationMode = isCollaborationMode(header.collaborationMode) |
| ? header.collaborationMode |
| : 'agent'; |
| const orchestrationMode = isOrchestrationMode(header.orchestrationMode) |
| ? header.orchestrationMode |
| : 'default'; |
| const model = |
| typeof header.model === 'string' && header.model.length > 0 ? header.model : 'default'; |
| const status = resolveMigratedStatus(header); |
| const blockedReason = |
| status === 'blocked' && isSessionBlockedReason(header.blockedReason) |
| ? header.blockedReason |
| : undefined; |
| const statusFields = { |
| status, |
| blockedReason, |
| statusUpdatedAt: |
| header.statusUpdatedAt ?? |
| header.archivedAt ?? |
| header.lastMessageAt ?? |
| header.lastUsedAt ?? |
| header.createdAt, |
| }; |
| const titleIsManual = |
| typeof header.titleIsManual === 'boolean' |
| ? header.titleIsManual |
| : normalizeSessionName(header.name) !== DEFAULT_SESSION_NAME; |
| if (header.backend === 'claude') { |
| return normalizeMigratedHeader( |
| { |
| ...header, |
| ...statusFields, |
| titleIsManual, |
| backend: 'ai-sdk', |
| model, |
| permissionMode, |
| collaborationMode, |
| orchestrationMode, |
| }, |
| sessionId, |
| ); |
| } |
| if (header.backend === 'pi-agent') { |
| return normalizeMigratedHeader( |
| { |
| ...header, |
| ...statusFields, |
| titleIsManual, |
| backend: 'pi-agent', |
| model, |
| permissionMode, |
| collaborationMode, |
| orchestrationMode, |
| }, |
| sessionId, |
| ); |
| } |
| if (header.backend === 'pi') { |
| return normalizeMigratedHeader( |
| { |
| ...header, |
| ...statusFields, |
| titleIsManual, |
| backend: 'pi-agent', |
| model, |
| permissionMode, |
| collaborationMode, |
| orchestrationMode, |
| }, |
| sessionId, |
| ); |
| } |
| return normalizeMigratedHeader( |
| { |
| ...header, |
| ...statusFields, |
| titleIsManual, |
| backend: header.backend === 'ai-sdk' ? 'ai-sdk' : 'fake', |
| model, |
| permissionMode, |
| collaborationMode, |
| orchestrationMode, |
| }, |
| sessionId, |
| ); |
| } |
| |
| function resolveMigratedStatus(header: StoredSessionHeader): SessionHeader['status'] { |
| if (header.isArchived) return 'archived'; |
| if (isSessionStatus(header.status) && header.status !== 'archived') return header.status; |
| return 'active'; |
| } |
| |
| function normalizeMigratedHeader( |
| header: SessionHeader & { pendingCwdReminder?: unknown }, |
| sessionId: string, |
| ): SessionHeader { |
| const { pendingCwdReminder: _legacyPendingCwdReminder, ...normalizedHeader } = header; |
| return normalizeSessionHeader(normalizedHeader, sessionId); |
| } |
| |
| /** Validate and normalize a current SessionHeader before canonical persistence. */ |
| export function normalizeSessionHeader( |
| header: SessionHeader, |
| sessionId: string = header.id, |
| ): SessionHeader { |
| const valid = |
| header.id === sessionId && |
| typeof header.workspaceRoot === 'string' && |
| typeof header.cwd === 'string' && |
| (header.projectId === undefined || |
| header.projectId === null || |
| (typeof header.projectId === 'string' && header.projectId.length > 0)) && |
| isFiniteNumber(header.createdAt) && |
| isFiniteNumber(header.lastUsedAt) && |
| (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && |
| typeof header.name === 'string' && |
| typeof header.titleIsManual === 'boolean' && |
| typeof header.isFlagged === 'boolean' && |
| Array.isArray(header.labels) && |
| header.labels.every((label) => typeof label === 'string') && |
| typeof header.isArchived === 'boolean' && |
| (header.archivedAt === undefined || isFiniteNumber(header.archivedAt)) && |
| isSessionStatus(header.status) && |
| (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && |
| (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && |
| (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && |
| (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && |
| isValidConversationCopyLineage(header) && |
| isValidRevisionLineage(header) && |
| isValidSubagentSessionLineage(header) && |
| (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && |
| typeof header.hasUnread === 'boolean' && |
| isBackendKind(header.backend) && |
| typeof header.llmConnectionSlug === 'string' && |
| typeof header.connectionLocked === 'boolean' && |
| typeof header.model === 'string' && |
| isPermissionMode(header.permissionMode) && |
| isCollaborationMode(header.collaborationMode) && |
| isOrchestrationMode(header.orchestrationMode) && |
| header.schemaVersion === 1; |
| if (!valid) { |
| throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); |
| } |
| const normalizedName = normalizeSessionName(header.name); |
| if (header.blockedReason === undefined) { |
| const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; |
| return { ...withoutBlockedReason, name: normalizedName }; |
| } |
| return { ...header, name: normalizedName }; |
| } |
| |
| function isValidRevisionLineage(header: SessionHeader): boolean { |
| const values = [ |
| header.revisionRootSessionId, |
| header.revisionParentSessionId, |
| header.revisionOfTurnId, |
| header.revisionIndex, |
| header.revisionState, |
| ]; |
| if (values.every((value) => value === undefined)) return true; |
| return ( |
| typeof header.revisionRootSessionId === 'string' && |
| isSafeSessionId(header.revisionRootSessionId) && |
| typeof header.revisionParentSessionId === 'string' && |
| isSafeSessionId(header.revisionParentSessionId) && |
| typeof header.revisionOfTurnId === 'string' && |
| header.revisionOfTurnId.length > 0 && |
| header.revisionOfTurnId.length <= 128 && |
| Number.isSafeInteger(header.revisionIndex) && |
| header.revisionIndex! >= 2 && |
| (header.revisionState === 'preparing' || header.revisionState === 'committed') |
| ); |
| } |
| |
| function assertValidSessionLineage(header: SessionHeader): void { |
| if (!isValidConversationCopyLineage(header)) { |
| throw new Error('Invalid Session conversation-copy lineage'); |
| } |
| if (!isValidRevisionLineage(header)) { |
| throw new Error('Invalid session revision lineage'); |
| } |
| if (!isValidSubagentSessionLineage(header)) { |
| throw new Error('Invalid subagent session lineage'); |
| } |
| } |
| |
| function isValidConversationCopyLineage(header: SessionHeader): boolean { |
| const copy = header.conversationCopy; |
| if (copy === undefined) return true; |
| if ( |
| !isSessionConversationCopy(copy) || |
| !isSafeSessionId(copy.sourceSessionId) || |
| copy.sourceSessionId === header.id || |
| header.subagentParent !== undefined |
| ) { |
| return false; |
| } |
| if (copy.kind === 'branch') { |
| return ( |
| header.parentSessionId === copy.sourceSessionId && |
| header.branchOfTurnId === copy.sourceTurnId && |
| header.revisionRootSessionId === undefined && |
| header.revisionParentSessionId === undefined && |
| header.revisionOfTurnId === undefined && |
| header.revisionIndex === undefined && |
| header.revisionState === undefined |
| ); |
| } |
| return ( |
| header.revisionParentSessionId === copy.sourceSessionId && |
| header.revisionOfTurnId === copy.sourceTurnId |
| ); |
| } |
| |
| function assertConversationCopyTransition( |
| current: SessionHeader, |
| patch: Partial<SessionHeader>, |
| ): void { |
| if (!Object.prototype.hasOwnProperty.call(patch, 'conversationCopy')) return; |
| if (!isValidConversationCopyTransition(current, patch.conversationCopy)) { |
| throw new Error('Session conversation-copy identity is immutable'); |
| } |
| } |
| |
| function isValidSubagentSessionLineage(header: SessionHeader): boolean { |
| if (header.subagentParent === undefined) { |
| return ( |
| header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined |
| ); |
| } |
| if ( |
| !isSubagentSessionParent(header.subagentParent) || |
| !isSafeSessionId(header.subagentParent.parentSessionId) || |
| header.parentSessionId !== undefined || |
| header.branchOfTurnId !== undefined || |
| header.revisionRootSessionId !== undefined || |
| header.revisionParentSessionId !== undefined || |
| header.revisionOfTurnId !== undefined || |
| header.revisionIndex !== undefined || |
| header.revisionState !== undefined |
| ) { |
| return false; |
| } |
| return ( |
| (header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined) || |
| (isSubagentSessionRuntime(header.subagentRuntime) && |
| isSubagentSessionSpawn(header.subagentSpawn) && |
| (header.subagentWorkspace === undefined || |
| isSubagentWorkspaceBinding(header.subagentWorkspace))) |
| ); |
| } |
| |
| function isBackendKind(value: unknown): value is SessionHeader['backend'] { |
| return value === 'ai-sdk' || value === 'fake' || value === 'pi-agent'; |
| } |
| |
| function isFiniteNumber(value: unknown): value is number { |
| return typeof value === 'number' && Number.isFinite(value); |
| } |
| |
| function hasErrorCode(error: unknown, code: string): boolean { |
| return (error as NodeJS.ErrnoException | undefined)?.code === code; |
| } |
| |
| function assertNoConversationCopyMetadata(input: CreateSessionInput): void { |
| if (Object.prototype.hasOwnProperty.call(input, 'conversationCopy')) { |
| throw new Error('Conversation copy metadata requires createStableSession()'); |
| } |
| } |
| |
| function projectHeaderSnapshot(record: SessionMetadataRecord): SessionHeaderSnapshot { |
| return { |
| header: record.header, |
| revision: record.metadataVersion, |
| committedAt: record.committedAt, |
| }; |
| } |
| |
| function projectCatalogRevision(state: SessionCatalogRevisionState): `sha256:${string}` { |
| return `sha256:${createHash('sha256') |
| .update(`${state.epoch}:${state.generation}`) |
| .digest('hex')}`; |
| } |
| |
| function projectStableSessionCreateProbe( |
| probe: StableSessionCreateProbe, |
| ): ProbeStableSessionCreateResult { |
| return probe.kind === 'existing' |
| ? { kind: 'existing', record: projectHeaderSnapshot(probe.record) } |
| : probe; |
| } |
| |
| function toSummary(header: SessionHeader, messages: StoredMessage[] = []): SessionSummary { |
| const preview = lastMessagePreviewForMessages(messages); |
| const derivedLastMessageAt = latestVisibleMessageAt(messages); |
| const lastMessageAt = maxTimestamp(header.lastMessageAt, derivedLastMessageAt); |
| return { |
| id: header.id, |
| cwd: header.cwd, |
| ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), |
| name: normalizeSessionName(header.name), |
| isFlagged: header.isFlagged, |
| isArchived: header.isArchived, |
| labels: header.labels, |
| hasUnread: header.hasUnread, |
| lastMessageAt, |
| ...(preview ? { lastMessagePreview: preview } : {}), |
| status: header.status, |
| ...(header.blockedReason ? { blockedReason: header.blockedReason } : {}), |
| ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), |
| ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), |
| ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), |
| ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), |
| ...(header.subagentRuntime |
| ? { subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime) } |
| : {}), |
| ...(header.subagentWorkspace ? { subagentWorkspace: header.subagentWorkspace } : {}), |
| ...(header.revisionRootSessionId |
| ? { revisionRootSessionId: header.revisionRootSessionId } |
| : {}), |
| ...(header.revisionParentSessionId |
| ? { revisionParentSessionId: header.revisionParentSessionId } |
| : {}), |
| ...(header.revisionOfTurnId ? { revisionOfTurnId: header.revisionOfTurnId } : {}), |
| ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), |
| ...(header.revisionState ? { revisionState: header.revisionState } : {}), |
| backend: header.backend, |
| llmConnectionSlug: header.llmConnectionSlug, |
| connectionLocked: header.connectionLocked, |
| model: header.model, |
| permissionMode: header.permissionMode, |
| collaborationMode: header.collaborationMode ?? 'agent', |
| orchestrationMode: header.orchestrationMode ?? 'default', |
| ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), |
| }; |
| } |
| |
| function toCatalogSummary( |
| header: SessionHeader, |
| lastMessagePreview: string | undefined, |
| ): SessionSummary { |
| return { |
| ...toSummary(header), |
| ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), |
| }; |
| } |
| |
| function catalogMessageProjection(messages: StoredMessage[]): { |
| readonly lastMessageAt?: number; |
| readonly lastMessagePreview?: string; |
| } { |
| const lastMessageAt = latestVisibleMessageAt(messages); |
| const lastMessagePreview = lastMessagePreviewForMessages(messages); |
| return { |
| ...(lastMessageAt === undefined ? {} : { lastMessageAt }), |
| ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), |
| }; |
| } |
| |
| function latestVisibleMessageAt(messages: StoredMessage[]): number | undefined { |
| for (let index = messages.length - 1; index >= 0; index -= 1) { |
| const message = messages[index]!; |
| if (isVisibleSessionMessage(message)) return message.ts; |
| } |
| return undefined; |
| } |
| |
| function isVisibleSessionMessage( |
| message: StoredMessage, |
| ): message is Extract<StoredMessage, { type: 'user' | 'assistant' }> { |
| return message.type === 'user' || message.type === 'assistant'; |
| } |
| |
| function maxTimestamp(left: number | undefined, right: number | undefined): number | undefined { |
| if (left === undefined) return right; |
| if (right === undefined) return left; |
| return Math.max(left, right); |
| } |
| |
| function normalizeSessionName(name: string): string { |
| return name === 'New Session' ? DEFAULT_SESSION_NAME : name; |
| } |
| |
| function lastMessagePreviewForMessages(messages: StoredMessage[]): string | undefined { |
| for (let index = messages.length - 1; index >= 0; index -= 1) { |
| const message = messages[index]!; |
| if (message.type === 'user') { |
| // Prefer the human-facing view when the stored model text is a composed |
| // envelope (e.g. explicit skill invocation). |
| const text = normalizePreviewText(message.displayText ?? message.text); |
| if (text) return truncatePreview(text); |
| if (message.attachments && message.attachments.length > 0) return '附件'; |
| } |
| if (message.type === 'assistant') { |
| const text = normalizePreviewText(message.text); |
| if (text) return truncatePreview(text); |
| } |
| } |
| return undefined; |
| } |
| |
| function normalizePreviewText(text: string): string { |
| return text.replace(/\s+/g, ' ').trim(); |
| } |
| |
| function truncatePreview(text: string, maxLength = 96): string { |
| const chars = Array.from(text); |
| if (chars.length <= maxLength) return text; |
| return `${chars.slice(0, maxLength - 1).join('')}…`; |
| } |
| |
| async function writeNewTranscript( |
| path: string, |
| payload: string, |
| durabilityRoot?: string, |
| ): Promise<void> { |
| try { |
| const handle = await open(path, 'wx', 0o600); |
| try { |
| await handle.writeFile(payload, 'utf8'); |
| if (durabilityRoot) await handle.sync(); |
| } finally { |
| await handle.close(); |
| } |
| if (durabilityRoot) { |
| await syncDirectoryChain(dirname(path), durabilityRoot); |
| } |
| } catch (error) { |
| if (!durabilityRoot || error instanceof DurableStoreWriteError) throw error; |
| throw new DurableStoreWriteError( |
| `Durable Session transcript did not reach stable storage: ${path}`, |
| error, |
| ); |
| } |
| } |
| |
| async function stabilizeTranscript(path: string, durabilityRoot: string): Promise<void> { |
| try { |
| await syncFile(path); |
| await syncDirectoryChain(dirname(path), durabilityRoot); |
| } catch (error) { |
| if (error instanceof DurableStoreWriteError) throw error; |
| throw new DurableStoreWriteError( |
| `Session transcript durability could not be re-established: ${path}`, |
| error, |
| ); |
| } |
| } |
| |
| export function createUserMessage(input: { |
| turnId: string; |
| text: string; |
| displayText?: string; |
| attachments?: UserMessage['attachments']; |
| }): UserMessage { |
| return { |
| type: 'user', |
| id: randomUUID(), |
| turnId: input.turnId, |
| ts: Date.now(), |
| text: input.text, |
| ...(input.displayText !== undefined ? { displayText: input.displayText } : {}), |
| attachments: input.attachments, |
| }; |
| } |