blob: 81c7ac3467abaed08a8ec7c6827a5e1c5d98a68b [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
assertCoordinationIdentityPairing,
buildSessionHeader,
toSummary,
} from './session-store-values.js';
export {
normalizeSessionHeader,
decodePersistedSessionHeader,
createUserMessage,
} from './session-store-values.js';
import {
type VersionedSessionIdentity,
SessionMetadataVersionConflictError,
type SessionHeaderSnapshot,
type ProbeSessionRemovalResult,
type SessionCatalogRecord,
type SessionCatalogPageCursor,
EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS,
EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS,
type ExternalSessionImportLookupResult,
type SessionCatalogPageResult,
type CreateStableSessionRequest,
type WorkHubMessageAssignmentRequest,
type WorkHubMessageAssignmentResult,
type CreateStableSessionResult,
type ProbeStableSessionCreateResult,
type UpdateSessionConfigurationRequest,
type SessionTranscriptMessageLookupRequest,
type SessionMessageScanRequest,
type SessionMessageScanPage,
type CoordinationTranscriptReference,
type CoordinationTranscriptIndexRecord,
type CoordinationTranscriptIndexState,
type SessionAuthorityStore,
} from './session-store-contract.js';
export {
isSafeSessionId,
assertSafeSessionId,
SessionNotFoundError,
isSessionNotFoundError,
type SessionHeaderSnapshot,
type ProbeSessionRemovalResult,
type SessionCatalogRecord,
type SessionCatalogPageCursor,
EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS,
EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS,
type ExternalSessionImportLookupResult,
type SessionCatalogPageResult,
type CreateStableSessionRequest,
type WorkHubMessageAssignmentRequest,
type WorkHubMessageAssignmentResult,
type StableSessionCreateInput,
type CreateStableSessionResult,
type ProbeStableSessionCreateResult,
type UpdateSessionConfigurationRequest,
type SessionTranscriptStorageFragment,
type SessionTranscriptMessageLookupRequest,
type SessionMessageScanRequest,
type SessionMessageScanRecord,
type SessionMessageScanPage,
type SessionTranscriptPageRequest,
type SessionTranscriptStoragePage,
type SessionTranscriptRecordScanRequest,
type SessionTranscriptRecordScanPage,
type SessionTurnContribution,
type SessionTurnContributionPage,
type SessionStore,
type CoordinationTranscriptReference,
type CoordinationTranscriptIndexRecord,
type CoordinationTranscriptIndexState,
type SessionAuthorityStore,
} from './session-store-contract.js';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import {
createSqliteSessionMetadataStore,
type SessionCatalogRevisionState,
type SessionMetadataRecord,
type SessionRemovalProbe,
type SqliteSessionMetadataStore,
type StableSessionCreateProbe,
} from './sqlite-session-metadata-store.js';
import { isDiscardableConversationCopy } from './session-conversation-copy.js';
import {
acquireOperationalStateDatabase,
OPERATIONAL_STATE_DATABASE_NAME,
} from './operational-state-store.js';
import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/session-name';
import {
decodeCanonicalMessage,
deriveTurnRecords,
WORKHUB_COORDINATION_SESSION_ID,
} from '@maka/core/session';
import type {
AgentGraphOperatorProvisionRequest,
AgentGraphOperatorProvisionResult,
} from '@maka/core/agent-graph-topology';
import type {
CreateSandboxBoundaryRequest,
ExecutionBoundary,
SandboxBoundaryRequest,
SandboxBoundarySettlement,
SettleSandboxBoundaryRequest,
} from '@maka/core/sandbox-boundary';
import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs';
import type {
SessionHeader,
SessionHeaderPatch,
SessionExternalOrigin,
SessionSummary,
StoredMessage,
TurnRecord,
AssistantMessage,
UserMessage,
WorkHubDelegationAssignedMessage,
WorkHubDelegationReplacementAbortedMessage,
WorkHubDelegationReplacementRequestedMessage,
WorkHubActionClaim,
WorkHubActionClaimOutcome,
WorkHubDelegationStopRequestedMessage,
WorkHubDelegationStopResolvedMessage,
WorkHubDelegationSupersededMessage,
} from '@maka/core/session';
import type {
MarkMessagesHandedOffInput,
PendingMessageAdmission,
} from './message-admission-store.js';
import { projectSessionCatalogMessages } from './session-message-projection.js';
export { projectSessionCatalogMessages };
export function createSessionStore(workspaceRoot: string): SessionAuthorityStore {
return new SqliteSessionStore(workspaceRoot);
}
class SqliteSessionStore implements SessionAuthorityStore {
private readonly metadata: SqliteSessionMetadataStore;
private readonly workspaceRoot: string;
private readonly transcriptChangeListeners = new Set<(sessionId: string) => void>();
private closePromise: Promise<void> | null = null;
constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
const databaseLease = acquireOperationalStateDatabase(workspaceRoot);
this.metadata = createSqliteSessionMetadataStore(
join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME),
{ databaseLease },
);
}
private ensureReady(): Promise<void> {
return Promise.resolve();
}
ready(): Promise<void> {
return this.ensureReady();
}
async create(
input: CreateSessionInput,
initialBoundary?: ExecutionBoundary,
): Promise<SessionHeader> {
await this.ensureReady();
assertNoConversationCopyMetadata(input);
if (input.subagentSpawn) {
throw new Error('Subagent spawn metadata requires createSubagent()');
}
return (
await this.metadata.create(buildSessionHeader(this.workspaceRoot, input), initialBoundary)
).header;
}
async createImportedSession(
input: CreateSessionInput,
messages: readonly StoredMessage[],
externalOrigin: SessionExternalOrigin,
options: { readonly onCommitStarted?: () => void } = {},
): Promise<SessionHeader> {
await this.ensureReady();
assertNoConversationCopyMetadata(input);
if (input.subagentSpawn) {
throw new Error('Subagent spawn metadata requires createSubagent()');
}
const canonicalMessages = messages.map((message) =>
decodeCanonicalMessage(JSON.parse(JSON.stringify(message)) as unknown),
);
const header: SessionHeader = {
...buildSessionHeader(this.workspaceRoot, input),
externalOrigin,
transcriptLedgerVersion: 0,
};
options.onCommitStarted?.();
const outcome = await this.metadata.importSession(
header,
canonicalMessages,
projectSessionCatalogMessages(canonicalMessages),
);
if (outcome !== 'imported') {
throw new Error(`Generated Session id already exists: ${header.id}`);
}
return (await this.metadata.read(header.id)).header;
}
async lookupExternalSessionImports(
adapterId: string,
sourceSessionIds: readonly string[],
recentSessionIdLimit: number,
): Promise<readonly ExternalSessionImportLookupResult[]> {
await this.ensureReady();
if (typeof adapterId !== 'string' || adapterId.trim().length === 0) {
throw new Error('External Session import lookup adapter id must not be empty');
}
if (
!Array.isArray(sourceSessionIds) ||
sourceSessionIds.length > EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS
) {
throw new Error(
`External Session import lookup accepts at most ${EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS} source ids`,
);
}
const uniqueSourceSessionIds: string[] = [];
const seen = new Set<string>();
for (const sourceSessionId of sourceSessionIds) {
if (typeof sourceSessionId !== 'string' || sourceSessionId.length === 0) {
throw new Error('External Session import lookup source id must not be empty');
}
if (!seen.has(sourceSessionId)) {
seen.add(sourceSessionId);
uniqueSourceSessionIds.push(sourceSessionId);
}
}
if (
!Number.isSafeInteger(recentSessionIdLimit) ||
recentSessionIdLimit < 1 ||
recentSessionIdLimit > EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS
) {
throw new Error(
`External Session import lookup recent id limit must be between 1 and ${EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS}`,
);
}
if (uniqueSourceSessionIds.length === 0) return [];
return this.metadata.lookupExternalSessionImports(
adapterId,
uniqueSourceSessionIds,
recentSessionIdLimit,
);
}
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();
// Asserted here as well as in the header builder so a malformed request is
// refused before claimStableSessionCreate() writes a durable claim for the
// identity it names.
assertCoordinationIdentityPairing(request.sessionId, request.input.role);
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 result = await this.metadata.createStableSession(
buildSessionHeader(
this.workspaceRoot,
request.input,
request.sessionId,
request.input.conversationCopy,
),
request.requestFingerprint,
initialBoundary,
);
return result.kind === 'created' || result.kind === 'existing'
? { kind: result.kind, record: projectHeaderSnapshot(result.record) }
: result;
}
async assignWorkHubMessage(
request: WorkHubMessageAssignmentRequest,
): Promise<WorkHubMessageAssignmentResult> {
await this.ensureReady();
const create = request.create;
if (create) {
assertCoordinationIdentityPairing(create.sessionId, create.input.role);
if (create.sessionId !== request.assignment.targetSessionId) {
throw new Error('WorkHub assignment create identity does not match its target');
}
}
const result = await this.metadata.assignWorkHubMessage({
assignment: request.assignment,
admission: request.admission,
projection: projectSessionCatalogMessages([request.assignment]),
...(request.supersession ? { supersession: request.supersession } : {}),
...(create
? {
create: {
header: buildSessionHeader(
this.workspaceRoot,
create.input,
create.sessionId,
create.input.conversationCopy,
),
requestFingerprint: create.requestFingerprint,
},
}
: {}),
});
if (result.kind === 'assigned') {
for (const listener of this.transcriptChangeListeners) {
listener(WORKHUB_COORDINATION_SESSION_ID);
}
}
return result;
}
async readWorkHubAssignment(
actionId: string,
): Promise<WorkHubDelegationAssignedMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`wha_${workHubIdentitySuffix(actionId)}`,
);
return message?.type === 'workhub_coordination' && message.kind === 'delegation_assigned'
? message
: undefined;
}
async readActiveWorkHubAssignmentsByTarget(
targetSessionIds: readonly string[],
maxAssignmentsPerTarget?: number,
): Promise<readonly WorkHubDelegationAssignedMessage[]> {
await this.ensureReady();
return this.metadata.readActiveWorkHubAssignmentsByTarget(
targetSessionIds,
maxAssignmentsPerTarget,
);
}
async readWorkHubReplacement(
delegationId: string,
): Promise<WorkHubDelegationReplacementRequestedMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`whp_${workHubIdentitySuffix(delegationId)}`,
);
return message?.type === 'workhub_coordination' &&
message.kind === 'delegation_replacement_requested'
? message
: undefined;
}
async readWorkHubReplacementAbort(
delegationId: string,
): Promise<WorkHubDelegationReplacementAbortedMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`whb_${workHubIdentitySuffix(delegationId)}`,
);
return message?.type === 'workhub_coordination' &&
message.kind === 'delegation_replacement_aborted'
? message
: undefined;
}
async readWorkHubSupersession(
delegationId: string,
): Promise<WorkHubDelegationSupersededMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`whx_${workHubIdentitySuffix(delegationId)}`,
);
return message?.type === 'workhub_coordination' && message.kind === 'delegation_superseded'
? message
: undefined;
}
async readWorkHubStopRequest(
delegationId: string,
): Promise<WorkHubDelegationStopRequestedMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`whq_${workHubIdentitySuffix(delegationId)}`,
);
return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested'
? message
: undefined;
}
async readWorkHubStopResolution(
delegationId: string,
): Promise<WorkHubDelegationStopResolvedMessage | undefined> {
const message = await this.readWorkHubCoordinationMessage(
`whz_${workHubIdentitySuffix(delegationId)}`,
);
return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved'
? message
: undefined;
}
async claimWorkHubAction(claim: WorkHubActionClaim): Promise<WorkHubActionClaimOutcome> {
await this.ensureReady();
return this.metadata.claimWorkHubAction(claim);
}
async readWorkHubActionClaim(actionId: string): Promise<WorkHubActionClaim | undefined> {
await this.ensureReady();
return this.metadata.readWorkHubActionClaim(actionId);
}
private async readWorkHubCoordinationMessage(
messageId: string,
): Promise<StoredMessage | undefined> {
await this.ensureReady();
return this.metadata.readMessageById(WORKHUB_COORDINATION_SESSION_ID, messageId);
}
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');
}
}
return this.metadata.discardStableSessionCreate(sessionId, requestFingerprint);
}
async createSubagent(
input: CreateSessionInput,
initialBoundary?: ExecutionBoundary,
): Promise<{ header: SessionHeader; created: boolean }> {
await this.ensureReady();
assertNoConversationCopyMetadata(input);
const result = await this.metadata.createSubagent(
buildSessionHeader(this.workspaceRoot, input),
initialBoundary,
);
return { header: result.record.header, created: result.created };
}
async createAgentGraphOperator(
input: CreateSessionInput,
request: AgentGraphOperatorProvisionRequest,
expectedRevision: number,
initialBoundary?: ExecutionBoundary,
): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult> {
await this.ensureReady();
assertNoConversationCopyMetadata(input);
const result = await this.metadata.createAgentGraphOperator(
buildSessionHeader(this.workspaceRoot, input),
request,
expectedRevision,
initialBoundary,
);
return {
header: result.record.header,
provision: result.provision,
created: result.created,
};
}
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 readSandboxBoundaryRequest(
sessionId: string,
requestId: string,
): Promise<SandboxBoundaryRequest | undefined> {
await this.ensureReady();
return this.metadata.readSandboxBoundaryRequest(sessionId, requestId);
}
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 hasExplicitSandboxBoundaryDenial(
identities: readonly { sessionId: string; runId: string; turnId: string }[],
): Promise<boolean> {
await this.ensureReady();
return this.metadata.hasExplicitSandboxBoundaryDenial(identities);
}
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();
return (await this.metadata.list(filter, 'ordinary'))
.filter((record) => record.header.conversationCopy?.state !== 'preparing')
.map((record) => toCatalogSummary(record.header, record.lastMessagePreview));
}
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),
activityAt: record.activityAt,
summary: toCatalogSummary(record.header, record.lastMessagePreview),
})),
hasMore: page.hasMore,
};
}
async listForRecovery(): Promise<SessionHeader[]> {
return this.listHeaders();
}
async listHeaders(): Promise<SessionHeader[]> {
await this.ensureReady();
return (await this.metadata.list(undefined, 'recoverable'))
.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();
// `maka --resume <legacy-id>` reads the header before any list; the
// import runs in ensureReady, so the first post-upgrade resume of a
// pre-cutover session sees its imported rows.
return projectHeaderSnapshot(await this.metadata.read(sessionId));
}
async readCatalogRecord(
sessionId: string,
roleScope: 'ordinary' | 'recoverable' = 'ordinary',
): Promise<SessionCatalogRecord> {
await this.ensureCatalogProjectionReadable();
const record = await this.metadata.readCatalogRecord(sessionId, roleScope);
return {
...projectHeaderSnapshot(record),
activityAt: record.activityAt,
summary: toCatalogSummary(record.header, record.lastMessagePreview),
};
}
async readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]> {
await this.ensureReady();
return this.metadata.readMessages(sessionId);
}
async readTranscriptMessagesSnapshot(
sessionId: string,
request: SessionTranscriptMessageLookupRequest,
): Promise<StoredMessage[]> {
await this.ensureReady();
return this.metadata.readTranscriptMessages(sessionId, request);
}
async readTranscriptHighWaterSnapshot(sessionId: string): Promise<number | null> {
await this.ensureReady();
return this.metadata.readTranscriptHighWater(sessionId);
}
async readCoordinationTranscriptIndexState(): Promise<CoordinationTranscriptIndexState> {
await this.ensureReady();
return this.metadata.readCoordinationTranscriptIndexState();
}
async appendCoordinationTranscriptIndex(
records: readonly CoordinationTranscriptReference[],
): Promise<void> {
await this.ensureReady();
return this.metadata.appendCoordinationTranscriptIndex(records);
}
async readCoordinationTranscriptIndex(request: {
direction: 'older' | 'newer';
throughSequence: number;
position: number;
limit: number;
}): Promise<readonly CoordinationTranscriptIndexRecord[]> {
await this.ensureReady();
return this.metadata.readCoordinationTranscriptIndex(request);
}
async listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]> {
return deriveTurnRecords(await this.readMessagesSnapshot(sessionId));
}
async readHeader(sessionId: string): Promise<SessionHeader> {
return this.readHeaderSnapshot(sessionId);
}
async readMessages(sessionId: string): Promise<StoredMessage[]> {
return this.readMessagesSnapshot(sessionId);
}
async listLegacyTranscriptCandidateSessions(
sessionIds: readonly string[],
terms: readonly string[],
): Promise<string[] | undefined> {
await this.ensureReady();
return this.metadata.listLegacyTranscriptCandidateSessions(sessionIds, terms);
}
async countLegacyTranscriptMessages(sessionIds: readonly string[]): Promise<number> {
await this.ensureReady();
return this.metadata.countLegacyTranscriptMessages(sessionIds);
}
async readMessagesAfter(
sessionId: string,
request: SessionMessageScanRequest,
): Promise<SessionMessageScanPage> {
await this.ensureReady();
return this.metadata.readMessagesAfter(sessionId, request);
}
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.ensureReady();
await this.metadata.appendMessages(
sessionId,
messages,
projectSessionCatalogMessages(messages),
);
for (const listener of this.transcriptChangeListeners) listener(sessionId);
}
/** @see SqliteSessionMetadataStore.commitMessageCatalogProjection */
async commitMessageCatalogProjection(
sessionId: string,
message: UserMessage | AssistantMessage,
): Promise<void> {
await this.ensureReady();
await this.metadata.commitMessageCatalogProjection(sessionId, message);
}
async commitMessageAdmission(
admission: PendingMessageAdmission,
): Promise<PendingMessageAdmission> {
await this.ensureReady();
return this.metadata.commitMessageAdmission(admission);
}
async readMessageAdmission(
sessionId: string,
messageId: string,
): Promise<PendingMessageAdmission | undefined> {
await this.ensureReady();
return this.metadata.readMessageAdmission(sessionId, messageId);
}
async hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise<boolean> {
await this.ensureReady();
return this.metadata.hasCancelledMessageAdmission(sessionId, messageId);
}
async claimMessageAdmissionCancellation(sessionId: string, messageId: string, claimId: string) {
await this.ensureReady();
return this.metadata.claimMessageAdmissionCancellation(sessionId, messageId, claimId);
}
async listMessageAdmissions(sessionId: string): Promise<readonly PendingMessageAdmission[]> {
await this.ensureReady();
return this.metadata.listMessageAdmissions(sessionId);
}
async markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise<void> {
await this.ensureReady();
await this.metadata.markMessagesHandedOff(input);
for (const listener of this.transcriptChangeListeners) listener(input.sessionId);
}
async updateMessageAdmission(admission: PendingMessageAdmission): Promise<void> {
await this.ensureReady();
await this.metadata.updateMessageAdmission(admission);
}
async reorderMessageAdmissions(
sessionId: string,
messageIds: readonly string[],
disposition: 'steering' | 'followup' = 'followup',
): Promise<void> {
await this.ensureReady();
await this.metadata.reorderMessageAdmissions(sessionId, messageIds, disposition);
}
async cancelMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise<void> {
await this.ensureReady();
await this.metadata.cancelMessageAdmissions(sessionId, messageIds);
}
subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void {
this.transcriptChangeListeners.add(listener);
return () => this.transcriptChangeListeners.delete(listener);
}
async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise<SessionHeader> {
await this.ensureReady();
return (await this.metadata.update(sessionId, patch)).header;
}
async updateHeaderVersioned(
sessionId: string,
patch: SessionHeaderPatch,
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 probeSessionRemoval(sessionId: string): Promise<ProbeSessionRemovalResult> {
await this.ensureReady();
return projectRemovalProbe(await this.metadata.probeRemoval(sessionId));
}
async setSessionsArchivedVersioned(
sessions: readonly VersionedSessionIdentity[],
isArchived: boolean,
): Promise<SessionHeaderSnapshot[]> {
await this.ensureReady();
return (await this.metadata.setArchivedVersioned(sessions, isArchived)).map(
projectHeaderSnapshot,
);
}
async removeSessionsVersioned(
sessions: readonly VersionedSessionIdentity[],
archiveSessions: readonly VersionedSessionIdentity[] = [],
): Promise<string[]> {
await this.ensureReady();
return this.metadata.removeVersioned(sessions, archiveSessions);
}
async reconcileOrphanedAgentGraphRetirements(): Promise<string[]> {
await this.ensureReady();
return this.metadata.reconcileOrphanedAgentGraphRetirements();
}
async listPendingSessionRetirementCleanupIds(sessionId?: string): Promise<string[]> {
await this.ensureReady();
return this.metadata.listPendingSessionRetirementCleanupIds(sessionId);
}
async completeSessionRetirementCleanup(sessionId: string): Promise<void> {
await this.ensureReady();
await this.metadata.completeSessionRetirementCleanup(sessionId);
}
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;
// A generated title only ever fills an absence. Writing at the revision the
// check read makes a rename that lands between the two a winner rather than
// something this silently overwrites; a revision that moved for any other
// reason is re-read, so losing the race stays the only way to answer null.
for (let attempt = 0; attempt < 3; attempt += 1) {
const record = await this.readHeaderRecordSnapshot(sessionId);
const current = record.header;
if (
current.titleIsManual ||
current.name !== DEFAULT_SESSION_NAME ||
normalized.value === current.name
) {
return null;
}
try {
return (
await this.updateHeaderVersioned(sessionId, { name: normalized.value }, record.revision)
).header;
} catch (error) {
if (!(error instanceof SessionMetadataVersionConflictError)) throw error;
}
}
// Losing the race every attempt reads the same as losing it once: the
// Session keeps whichever name the writer that won gave it.
return null;
}
async remove(sessionId: string): Promise<void> {
await this.ensureReady();
await this.metadata.remove(sessionId);
}
close(): Promise<void> {
this.closePromise ??= this.closeAfterReady();
return this.closePromise;
}
private async closeAfterReady(): Promise<void> {
// Ensure the one-time import has settled before closing the database so
// a concurrent close cannot race an in-flight migration.
await this.ensureReady();
this.metadata.close();
}
private async ensureCatalogProjectionReadable(): Promise<void> {
await this.ensureReady();
}
}
function workHubIdentitySuffix(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 48);
}
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 projectRemovalProbe(probe: SessionRemovalProbe): ProbeSessionRemovalResult {
return probe.kind === 'present'
? { kind: 'present', record: projectHeaderSnapshot(probe.record) }
: probe;
}
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 toCatalogSummary(
header: SessionHeader,
lastMessagePreview: string | undefined,
): SessionSummary {
return {
...toSummary(header),
...(lastMessagePreview === undefined ? {} : { lastMessagePreview }),
};
}