blob: ac0bbe60e98d5375ec5da91e8f4b7fd3937cfe43 [file]
import {
GOAL_CONDITION_TEXT_LIMIT,
GOAL_REASON_TEXT_LIMIT,
isGoalStatus,
type GoalStatus,
} from '@maka/core';
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export const GOAL_RESULT_MAX_BYTES = 8 * 1024;
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'internal_failure',
] as const;
const CONTROL_ERRORS = [...QUERY_ERRORS, 'session_archived', 'operation_conflict'] as const;
export type GoalControlAction = 'pause' | 'resume' | 'clear';
export interface GoalProjection {
readonly goalId: string;
readonly revision: number;
readonly sessionId: string;
readonly condition: string;
readonly status: GoalStatus;
readonly setAt: number;
readonly iterations: number;
readonly maxIterations: number;
readonly consecutiveNoProgress: number;
readonly blockCap: number;
readonly tokenBudget: number | null;
readonly tokensSpent: number;
readonly lastReason: string | null;
readonly achievedAt: number | null;
readonly pausedAt: number | null;
}
export interface GoalQueryInput {
readonly sessionId: string;
}
export interface GoalQueryResult {
readonly sessionId: string;
readonly goal: GoalProjection | null;
}
export interface GoalControlInput {
readonly sessionId: string;
readonly goalId: string;
readonly expectedRevision: number;
readonly action: GoalControlAction;
}
export interface GoalControlResult {
readonly sessionId: string;
readonly goal: GoalProjection;
}
export const GOAL_OPERATION_SPECS = {
'goal.query': defineOperation({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeGoalQueryInput,
decodeOutput: decodeGoalQueryResult,
assertOutputForInput(input, output) {
if (input.sessionId !== output.sessionId) {
throw invalidProtocolFrame('Goal query result belongs to a different Session');
}
},
}),
'goal.control': defineOperation({
mode: 'control',
availability: 'ready',
errors: CONTROL_ERRORS,
decodeInput: decodeGoalControlInput,
decodeOutput: decodeGoalControlResult,
assertOutputForInput(input, output) {
if (input.sessionId !== output.sessionId || input.goalId !== output.goal.goalId) {
throw invalidProtocolFrame('Goal control result belongs to a different Goal');
}
},
}),
} as const;
export function decodeGoalProjection(value: unknown): GoalProjection {
const record = requireExactRecord(value, 'Goal projection', [
'goalId',
'revision',
'sessionId',
'condition',
'status',
'setAt',
'iterations',
'maxIterations',
'consecutiveNoProgress',
'blockCap',
'tokenBudget',
'tokensSpent',
'lastReason',
'achievedAt',
'pausedAt',
]);
const condition = requireUtf8String(
record.condition,
'Goal condition',
GOAL_CONDITION_TEXT_LIMIT.utf8Bytes,
);
if (condition.length > GOAL_CONDITION_TEXT_LIMIT.codeUnits) {
throw invalidProtocolFrame('Invalid Goal condition');
}
const lastReason = requireNullableGoalReason(record.lastReason);
return {
goalId: requireEntityId(record.goalId, 'goalId'),
revision: requireCount(record.revision, 'Goal revision'),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
condition,
status: requireGoalStatus(record.status),
setAt: requireCount(record.setAt, 'Goal setAt'),
iterations: requireCount(record.iterations, 'Goal iterations'),
maxIterations: requirePositiveCount(record.maxIterations, 'Goal maxIterations'),
consecutiveNoProgress: requireCount(record.consecutiveNoProgress, 'Goal consecutiveNoProgress'),
blockCap: requirePositiveCount(record.blockCap, 'Goal blockCap'),
tokenBudget: requireNullablePositiveCount(record.tokenBudget, 'Goal tokenBudget'),
tokensSpent: requireCount(record.tokensSpent, 'Goal tokensSpent'),
lastReason,
achievedAt: requireNullableCount(record.achievedAt, 'Goal achievedAt'),
pausedAt: requireNullableCount(record.pausedAt, 'Goal pausedAt'),
};
}
function decodeGoalQueryInput(value: unknown): GoalQueryInput {
const record = requireExactRecord(value, 'goal.query input', ['sessionId']);
return { sessionId: requireEntityId(record.sessionId, 'sessionId') };
}
function decodeGoalQueryResult(value: unknown): GoalQueryResult {
requireEncodedByteLimit(value, 'Goal query result', GOAL_RESULT_MAX_BYTES);
const record = requireExactRecord(value, 'goal.query result', ['sessionId', 'goal']);
const sessionId = requireEntityId(record.sessionId, 'sessionId');
const goal = record.goal === null ? null : decodeGoalProjection(record.goal);
if (goal && goal.sessionId !== sessionId) {
throw invalidProtocolFrame('Goal query result contains a Goal from another Session');
}
return { sessionId, goal };
}
function decodeGoalControlInput(value: unknown): GoalControlInput {
const record = requireExactRecord(value, 'goal.control input', [
'sessionId',
'goalId',
'expectedRevision',
'action',
]);
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
goalId: requireEntityId(record.goalId, 'goalId'),
expectedRevision: requireCount(record.expectedRevision, 'expectedRevision'),
action: requireGoalControlAction(record.action),
};
}
function decodeGoalControlResult(value: unknown): GoalControlResult {
requireEncodedByteLimit(value, 'Goal control result', GOAL_RESULT_MAX_BYTES);
const record = requireExactRecord(value, 'goal.control result', ['sessionId', 'goal']);
const sessionId = requireEntityId(record.sessionId, 'sessionId');
const goal = decodeGoalProjection(record.goal);
if (goal.sessionId !== sessionId) {
throw invalidProtocolFrame('Goal control result contains a Goal from another Session');
}
return { sessionId, goal };
}
function requireGoalStatus(value: unknown): GoalStatus {
if (!isGoalStatus(value)) {
throw invalidProtocolFrame('Invalid Goal status');
}
return value;
}
function requireGoalControlAction(value: unknown): GoalControlAction {
if (value !== 'pause' && value !== 'resume' && value !== 'clear') {
throw invalidProtocolFrame('Invalid Goal control action');
}
return value;
}
function requireNullableGoalReason(value: unknown): string | null {
if (value === null) return null;
const reason = requireUtf8String(value, 'Goal lastReason', GOAL_REASON_TEXT_LIMIT.utf8Bytes);
if (reason.length > GOAL_REASON_TEXT_LIMIT.codeUnits) {
throw invalidProtocolFrame('Invalid Goal lastReason');
}
return reason;
}
function requirePositiveCount(value: unknown, label: string): number {
const count = requireCount(value, label);
if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`);
return count;
}
function requireNullablePositiveCount(value: unknown, label: string): number | null {
if (value === null) return null;
return requirePositiveCount(value, label);
}
function requireNullableCount(value: unknown, label: string): number | null {
if (value === null) return null;
return requireCount(value, label);
}