blob: e369f84e5065b5dff7db2f2ac30184bb56cad514 [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 {
CHECKIN_INSTRUCTION_MAX_CHARS,
CHECKIN_MAX_CARDS,
CHECKIN_QUOTE_MAX_CHARS,
CHECKIN_TITLE_MAX_CHARS,
isCheckinCardAction,
isCheckinEvidenceSource,
type CheckinCard,
} from '@maka/core/session-checkin';
import {
requireCount,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
const SESSION_NAME_MAX_BYTES = 1024;
export type SessionCheckinFailureClass =
| 'aborted'
| 'timeout'
| 'configuration'
| 'provider'
| 'invalid_response'
| 'unknown';
const SESSION_CHECKIN_FAILURE_CLASSES: readonly SessionCheckinFailureClass[] = [
'aborted',
'timeout',
'configuration',
'provider',
'invalid_response',
'unknown',
];
export interface SessionCheckinGenerateInput {
/** Stable identity a Client reuses when it retries the same check-in. */
readonly effectId: string;
/** When this Client's previous check-in ran; null for its first one. */
readonly since: number | null;
}
export type SessionCheckinGenerateResult =
/** No Session qualified; no model call was made. */
| { readonly kind: 'empty'; readonly effectId: string }
| {
readonly kind: 'generated';
readonly effectId: string;
readonly cards: readonly CheckinCard[];
/** Cards the model proposed that validation removed. */
readonly dropped: number;
}
| {
readonly kind: 'failed';
readonly effectId: string;
readonly errorClass: SessionCheckinFailureClass;
};
export const SESSION_CHECKIN_OPERATION_SPECS = {
'session.checkin.generate': defineOperation({
mode: 'command',
availability: 'ready',
errors: [
'host_not_ready',
'host_draining',
'operation_unavailable',
'operation_conflict',
'internal_failure',
],
decodeInput: decodeSessionCheckinGenerateInput,
decodeOutput: decodeSessionCheckinGenerateResult,
assertOutputForInput: (input, output) => {
if (input.effectId !== output.effectId) {
throw invalidProtocolFrame('Session check-in changed effect identity');
}
},
}),
} as const;
export function decodeSessionCheckinGenerateInput(value: unknown): SessionCheckinGenerateInput {
const input = requireExactRecord(value, 'Session check-in input', ['effectId', 'since']);
return {
effectId: requireEntityId(input.effectId, 'effectId'),
since: input.since === null ? null : requireCount(input.since, 'Session check-in since'),
};
}
export function decodeSessionCheckinGenerateResult(value: unknown): SessionCheckinGenerateResult {
const result = requireRecord(value, 'Session check-in result');
if (result.kind === 'empty') {
const empty = requireExactRecord(result, 'Empty Session check-in', ['kind', 'effectId']);
return { kind: 'empty', effectId: requireEntityId(empty.effectId, 'effectId') };
}
if (result.kind === 'failed') {
const failed = requireExactRecord(result, 'Failed Session check-in', [
'kind',
'effectId',
'errorClass',
]);
if (!(SESSION_CHECKIN_FAILURE_CLASSES as readonly unknown[]).includes(failed.errorClass)) {
throw invalidProtocolFrame('Invalid Session check-in failure class');
}
return {
kind: 'failed',
effectId: requireEntityId(failed.effectId, 'effectId'),
errorClass: failed.errorClass as SessionCheckinFailureClass,
};
}
const generated = requireExactRecord(result, 'Generated Session check-in', [
'kind',
'effectId',
'cards',
'dropped',
]);
if (generated.kind !== 'generated') throw invalidProtocolFrame('Invalid Session check-in kind');
if (!Array.isArray(generated.cards) || generated.cards.length > CHECKIN_MAX_CARDS) {
throw invalidProtocolFrame('Invalid Session check-in cards');
}
return {
kind: 'generated',
effectId: requireEntityId(generated.effectId, 'effectId'),
cards: generated.cards.map(decodeCheckinCard),
dropped: requireCount(generated.dropped, 'Session check-in dropped count'),
};
}
function decodeCheckinCard(value: unknown): CheckinCard {
const card = requireShapedRecord(
value,
'Session check-in card',
['sessionId', 'sessionRevision', 'sessionName', 'title', 'evidence', 'action'],
['instruction'],
);
const evidence = requireExactRecord(card.evidence, 'Session check-in evidence', [
'source',
'quote',
]);
if (!isCheckinCardAction(card.action) || !isCheckinEvidenceSource(evidence.source)) {
throw invalidProtocolFrame('Invalid Session check-in card');
}
if (
typeof card.sessionName !== 'string' ||
Buffer.byteLength(card.sessionName) > SESSION_NAME_MAX_BYTES
) {
throw invalidProtocolFrame('Invalid Session check-in Session name');
}
return {
sessionId: requireEntityId(card.sessionId, 'sessionId'),
sessionRevision: requireCount(card.sessionRevision, 'Session check-in revision'),
sessionName: card.sessionName,
title: requireChars(card.title, 'Session check-in title', CHECKIN_TITLE_MAX_CHARS),
evidence: {
source: evidence.source,
quote: requireChars(evidence.quote, 'Session check-in quote', CHECKIN_QUOTE_MAX_CHARS),
},
action: card.action,
...(card.instruction !== undefined
? {
instruction: requireChars(
card.instruction,
'Session check-in instruction',
CHECKIN_INSTRUCTION_MAX_CHARS,
),
}
: {}),
};
}
function requireChars(value: unknown, label: string, maxChars: number): string {
if (typeof value !== 'string' || !value.trim() || Array.from(value).length > maxChars) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}