blob: 79e970219b214e662a2e5beb4c939c555bc9e7c3 [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 {
requireCount,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
} 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;
/**
* Settled turn to branch through. Absent forks with an empty context — a side
* conversation opened before the source has any completed turn (valid only
* with `intent: 'side_conversation'`).
*/
readonly sourceTurnId?: string;
readonly expectedSourceRevision: number;
readonly intent?: 'side_conversation';
}
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: decodeSessionRevisionCopyInput,
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 decodeSessionRevisionCopyInput(value: unknown): SessionConversationCopyInput {
const input = decodeSessionConversationCopyInput(value);
if (input.intent !== undefined) {
throw invalidProtocolFrame('Session revision copy does not support an intent');
}
// A revision never carries an intent, so the shared decoder above already
// rejected a missing `sourceTurnId` (an empty copy requires the
// side_conversation intent); reaching here guarantees a turn boundary.
return input;
}
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 = requireShapedRecord(
value,
'Session conversation-copy input',
['sourceSessionId', 'targetSessionId', 'expectedSourceRevision'],
['sourceTurnId', 'intent'],
);
const sourceSessionId = requireEntityId(input.sourceSessionId, 'sourceSessionId');
const targetSessionId = requireEntityId(input.targetSessionId, 'targetSessionId');
if (sourceSessionId === targetSessionId) {
throw invalidProtocolFrame('Session conversation copy requires distinct Sessions');
}
if (input.intent !== undefined && input.intent !== 'side_conversation') {
throw invalidProtocolFrame('Invalid Session conversation-copy intent');
}
const sourceTurnId =
input.sourceTurnId === undefined
? undefined
: requireEntityId(input.sourceTurnId, 'sourceTurnId');
if (sourceTurnId === undefined && input.intent !== 'side_conversation') {
throw invalidProtocolFrame('An empty conversation copy requires the side_conversation intent');
}
return {
sourceSessionId,
targetSessionId,
...(sourceTurnId === undefined ? {} : { sourceTurnId }),
expectedSourceRevision: positiveRevision(
input.expectedSourceRevision,
'expected source Session revision',
),
...(input.intent === 'side_conversation' ? { intent: input.intent } : {}),
};
}
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;
}