blob: f8c56cf4a37d2ad597226695718a5cade0dcc930 [file]
import { requireCount, requireEntityId, requireExactRecord, requireRecord } from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js';
const SESSION_COPY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'invalid_request',
'not_found',
'session_busy',
'operation_conflict',
'persistence_failed',
'commit_outcome_unknown',
'internal_failure',
] as const;
export interface SessionConversationCopyInput {
readonly sourceSessionId: string;
readonly targetSessionId: string;
readonly sourceTurnId: string;
readonly expectedSourceRevision: number;
}
export type SessionConversationCopyResult =
| {
readonly kind: 'committed';
readonly session: SessionCatalogItem;
}
| {
readonly kind: 'source_revision_conflict';
readonly expectedRevision: number;
readonly actualRevision: number;
};
export interface SessionRevisionAbandonInput {
readonly targetSessionId: string;
}
export type SessionRevisionAbandonResult =
| { readonly kind: 'abandoned'; readonly sessionId: string }
| { readonly kind: 'retained'; readonly sessionId: string };
export const SESSION_REVISION_OPERATION_SPECS = {
'session.branch.create': defineOperation<
SessionConversationCopyInput,
SessionConversationCopyResult,
(typeof SESSION_COPY_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: SESSION_COPY_ERRORS,
decodeInput: decodeSessionConversationCopyInput,
decodeOutput: decodeSessionConversationCopyResult,
assertOutputForInput: assertConversationCopyOutput,
}),
'session.revision.create': defineOperation<
SessionConversationCopyInput,
SessionConversationCopyResult,
(typeof SESSION_COPY_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: SESSION_COPY_ERRORS,
decodeInput: decodeSessionConversationCopyInput,
decodeOutput: decodeSessionConversationCopyResult,
assertOutputForInput: assertConversationCopyOutput,
}),
'session.revision.abandon': defineOperation<
SessionRevisionAbandonInput,
SessionRevisionAbandonResult,
(typeof SESSION_COPY_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: SESSION_COPY_ERRORS,
decodeInput: decodeSessionRevisionAbandonInput,
decodeOutput: decodeSessionRevisionAbandonResult,
assertOutputForInput: (input, output) => {
if (output.sessionId !== input.targetSessionId) {
throw invalidProtocolFrame('Abandoned Session revision identity does not match request');
}
},
}),
} as const;
function decodeSessionRevisionAbandonInput(value: unknown): SessionRevisionAbandonInput {
const input = requireExactRecord(value, 'Session revision abandon input', ['targetSessionId']);
return { targetSessionId: requireEntityId(input.targetSessionId, 'targetSessionId') };
}
function decodeSessionRevisionAbandonResult(value: unknown): SessionRevisionAbandonResult {
const result = requireExactRecord(value, 'Session revision abandon result', [
'kind',
'sessionId',
]);
if (result.kind !== 'abandoned' && result.kind !== 'retained') {
throw invalidProtocolFrame('Invalid Session revision abandon result kind');
}
return {
kind: result.kind,
sessionId: requireEntityId(result.sessionId, 'sessionId'),
};
}
export function decodeSessionConversationCopyInput(value: unknown): SessionConversationCopyInput {
const input = requireExactRecord(value, 'Session conversation-copy input', [
'sourceSessionId',
'targetSessionId',
'sourceTurnId',
'expectedSourceRevision',
]);
const sourceSessionId = requireEntityId(input.sourceSessionId, 'sourceSessionId');
const targetSessionId = requireEntityId(input.targetSessionId, 'targetSessionId');
if (sourceSessionId === targetSessionId) {
throw invalidProtocolFrame('Session conversation copy requires distinct Sessions');
}
return {
sourceSessionId,
targetSessionId,
sourceTurnId: requireEntityId(input.sourceTurnId, 'sourceTurnId'),
expectedSourceRevision: positiveRevision(
input.expectedSourceRevision,
'expected source Session revision',
),
};
}
export function decodeSessionConversationCopyResult(value: unknown): SessionConversationCopyResult {
const result = requireRecord(value, 'Session conversation-copy result');
if (result.kind === 'committed') {
const exact = requireExactRecord(result, 'committed Session conversation-copy result', [
'kind',
'session',
]);
return {
kind: 'committed',
session: decodeSessionCatalogItem(exact.session),
};
}
if (result.kind === 'source_revision_conflict') {
const exact = requireExactRecord(result, 'Session source revision conflict result', [
'kind',
'expectedRevision',
'actualRevision',
]);
return {
kind: 'source_revision_conflict',
expectedRevision: positiveRevision(exact.expectedRevision, 'expected Session revision'),
actualRevision: positiveRevision(exact.actualRevision, 'actual Session revision'),
};
}
throw invalidProtocolFrame('Invalid Session conversation-copy result kind');
}
function assertConversationCopyOutput(
input: SessionConversationCopyInput,
output: SessionConversationCopyResult,
): void {
if (output.kind === 'committed' && output.session.id !== input.targetSessionId) {
throw invalidProtocolFrame('Session conversation-copy result identity does not match request');
}
if (
output.kind === 'source_revision_conflict' &&
output.expectedRevision !== input.expectedSourceRevision
) {
throw invalidProtocolFrame('Session source revision conflict does not match request');
}
}
function positiveRevision(value: unknown, label: string): number {
const revision = requireCount(value, label);
if (revision < 1) throw invalidProtocolFrame(`Invalid ${label}`);
return revision;
}