blob: 3279526ca5cc8f6562c98e2695f6804b2454270d [file]
import {
AUTOMATION_CRON_EXPRESSION_LIMIT,
AUTOMATION_CRON_EXPRESSION_MAX_BYTES as CORE_AUTOMATION_CRON_EXPRESSION_MAX_BYTES,
AUTOMATION_LAST_ERROR_LIMIT,
AUTOMATION_NAME_LIMIT,
AUTOMATION_NAME_MAX_BYTES as CORE_AUTOMATION_NAME_MAX_BYTES,
AUTOMATION_PROMPT_LIMIT,
AUTOMATION_PROMPT_MAX_BYTES as CORE_AUTOMATION_PROMPT_MAX_BYTES,
isAutomationTextWithinLimit,
type AutomationKind,
type AutomationSchedule,
type AutomationStatus,
} from '@maka/core';
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export const AUTOMATION_PAGE_MAX_ITEMS = 64;
export const AUTOMATION_RESULT_MAX_BYTES = 48 * 1024;
export const AUTOMATION_NAME_MAX_BYTES = CORE_AUTOMATION_NAME_MAX_BYTES;
export const AUTOMATION_PROMPT_MAX_BYTES = CORE_AUTOMATION_PROMPT_MAX_BYTES;
export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = CORE_AUTOMATION_CRON_EXPRESSION_MAX_BYTES;
export const AUTOMATION_MAX_FIRES = 10_000;
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'not_found',
'invalid_request',
'internal_failure',
] as const;
const MUTATION_ERRORS = [
...QUERY_ERRORS,
'session_archived',
'session_busy',
'operation_conflict',
'persistence_failed',
] as const;
export interface AutomationProjection {
readonly id: string;
readonly kind: AutomationKind;
readonly name: string;
readonly status: AutomationStatus;
readonly prompt: string;
readonly sessionId: string;
readonly schedule: AutomationSchedule;
readonly createdAt: number;
readonly updatedAt: number;
readonly nextFireAt: number | null;
readonly lastFireAt: number | null;
readonly lastRunId: string | null;
readonly fireCount: number;
readonly maxFires: number | null;
readonly expiresAt: number | null;
readonly lastError: string | null;
readonly consecutiveFailures: number;
readonly durable: boolean;
readonly deferredFireCount: number;
readonly firePending: boolean;
}
export type AutomationQueryInput =
| { readonly kind: 'list_start'; readonly sessionId: string }
| {
readonly kind: 'list_continue';
readonly sessionId: string;
readonly revision: number;
readonly cursor: string;
}
| { readonly kind: 'get'; readonly sessionId: string; readonly automationId: string };
export type AutomationQueryResult =
| {
readonly kind: 'page';
readonly sessionId: string;
readonly revision: number;
readonly automations: readonly AutomationProjection[];
readonly nextCursor: string | null;
}
| {
readonly kind: 'revision_changed';
readonly expected: number;
readonly actual: number;
}
| {
readonly kind: 'automation';
readonly sessionId: string;
readonly revision: number;
readonly automation: AutomationProjection | null;
};
export type AutomationMutateInput =
| {
readonly kind: 'create';
readonly sessionId: string;
readonly automationKind: AutomationKind;
readonly name: string;
readonly prompt: string;
readonly schedule: AutomationSchedule;
readonly maxFires?: number;
readonly durable?: boolean;
}
| {
readonly kind: 'delete' | 'pause' | 'resume';
readonly sessionId: string;
readonly automationId: string;
};
export type AutomationMutationRejection =
| 'not_owned'
| 'not_active'
| 'not_paused'
| 'fire_pending'
| 'fire_budget_exhausted'
| 'limit_reached'
| 'invalid_schedule';
export type AutomationMutateResult =
| {
readonly kind: 'committed';
readonly revision: number;
readonly automation: AutomationProjection | null;
}
| { readonly kind: 'rejected'; readonly reason: AutomationMutationRejection };
export const AUTOMATION_OPERATION_SPECS = {
'automation.query': defineOperation<
AutomationQueryInput,
AutomationQueryResult,
(typeof QUERY_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeAutomationQueryInput,
decodeOutput: decodeAutomationQueryResult,
}),
'automation.mutate': defineOperation<
AutomationMutateInput,
AutomationMutateResult,
(typeof MUTATION_ERRORS)[number]
>({
mode: 'command',
availability: 'ready',
errors: MUTATION_ERRORS,
decodeInput: decodeAutomationMutateInput,
decodeOutput: decodeAutomationMutateResult,
}),
} as const;
export function decodeAutomationQueryInput(value: unknown): AutomationQueryInput {
const record = requireRecord(value, 'Automation query input');
if (record.kind === 'list_start') {
const input = requireExactRecord(record, 'Automation list start input', ['kind', 'sessionId']);
return { kind: 'list_start', sessionId: requireEntityId(input.sessionId, 'sessionId') };
}
if (record.kind === 'list_continue') {
const input = requireExactRecord(record, 'Automation list continuation input', [
'kind',
'sessionId',
'revision',
'cursor',
]);
return {
kind: 'list_continue',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
revision: requireCount(input.revision, 'Automation revision'),
cursor: requireEntityId(input.cursor, 'Automation cursor'),
};
}
if (record.kind === 'get') {
const input = requireExactRecord(record, 'Automation get input', [
'kind',
'sessionId',
'automationId',
]);
return {
kind: 'get',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
automationId: requireEntityId(input.automationId, 'automationId'),
};
}
throw invalidProtocolFrame('Invalid Automation query kind');
}
export function decodeAutomationQueryResult(value: unknown): AutomationQueryResult {
const record = requireRecord(value, 'Automation query result');
if (record.kind === 'revision_changed') {
const result = requireExactRecord(record, 'Automation revision changed result', [
'kind',
'expected',
'actual',
]);
return {
kind: 'revision_changed',
expected: requireCount(result.expected, 'expected Automation revision'),
actual: requireCount(result.actual, 'actual Automation revision'),
};
}
if (record.kind === 'automation') {
const result = requireExactRecord(record, 'Automation get result', [
'kind',
'sessionId',
'revision',
'automation',
]);
const decoded: AutomationQueryResult = {
kind: 'automation',
sessionId: requireEntityId(result.sessionId, 'sessionId'),
revision: requireCount(result.revision, 'Automation revision'),
automation: result.automation === null ? null : decodeAutomationProjection(result.automation),
};
requireEncodedByteLimit(decoded, 'Automation get result', AUTOMATION_RESULT_MAX_BYTES);
return decoded;
}
if (record.kind !== 'page') throw invalidProtocolFrame('Invalid Automation query result kind');
const result = requireExactRecord(record, 'Automation page result', [
'kind',
'sessionId',
'revision',
'automations',
'nextCursor',
]);
if (!Array.isArray(result.automations) || result.automations.length > AUTOMATION_PAGE_MAX_ITEMS) {
throw invalidProtocolFrame('Automation page exceeds item limit');
}
const decoded: AutomationQueryResult = {
kind: 'page',
sessionId: requireEntityId(result.sessionId, 'sessionId'),
revision: requireCount(result.revision, 'Automation revision'),
automations: result.automations.map(decodeAutomationProjection),
nextCursor:
result.nextCursor === null
? null
: requireEntityId(result.nextCursor, 'Automation next cursor'),
};
requireEncodedByteLimit(decoded, 'Automation page result', AUTOMATION_RESULT_MAX_BYTES);
return decoded;
}
export function decodeAutomationMutateInput(value: unknown): AutomationMutateInput {
const record = requireRecord(value, 'Automation mutation input');
if (record.kind === 'create') {
const input = requireShapedRecord(
record,
'Automation create input',
['kind', 'sessionId', 'automationKind', 'name', 'prompt', 'schedule'],
['maxFires', 'durable'],
);
if (input.automationKind !== 'heartbeat' && input.automationKind !== 'cron') {
throw invalidProtocolFrame('Invalid Automation kind');
}
const maxFires =
input.maxFires === undefined ? undefined : positiveCount(input.maxFires, 'maxFires');
if (maxFires !== undefined && maxFires > AUTOMATION_MAX_FIRES) {
throw invalidProtocolFrame('Automation maxFires is out of range');
}
if (!(input.durable === undefined || typeof input.durable === 'boolean')) {
throw invalidProtocolFrame('Invalid durable flag');
}
return {
kind: 'create',
sessionId: requireEntityId(input.sessionId, 'sessionId'),
automationKind: input.automationKind,
name: requireAutomationText(input.name, 'Automation name', AUTOMATION_NAME_LIMIT, true),
prompt: requireAutomationText(
input.prompt,
'Automation prompt',
AUTOMATION_PROMPT_LIMIT,
true,
),
schedule: decodeAutomationSchedule(input.schedule),
...(maxFires === undefined ? {} : { maxFires }),
...(input.durable === undefined ? {} : { durable: input.durable }),
};
}
if (record.kind === 'delete' || record.kind === 'pause' || record.kind === 'resume') {
const input = requireExactRecord(record, 'Automation mutation input', [
'kind',
'sessionId',
'automationId',
]);
return {
kind: record.kind,
sessionId: requireEntityId(input.sessionId, 'sessionId'),
automationId: requireEntityId(input.automationId, 'automationId'),
};
}
throw invalidProtocolFrame('Invalid Automation mutation kind');
}
export function decodeAutomationMutateResult(value: unknown): AutomationMutateResult {
const record = requireRecord(value, 'Automation mutation result');
if (record.kind === 'rejected') {
const result = requireExactRecord(record, 'Automation rejected result', ['kind', 'reason']);
if (!isMutationRejection(result.reason)) {
throw invalidProtocolFrame('Invalid Automation rejection reason');
}
return { kind: 'rejected', reason: result.reason };
}
if (record.kind !== 'committed') {
throw invalidProtocolFrame('Invalid Automation mutation result kind');
}
const result = requireExactRecord(record, 'Automation committed result', [
'kind',
'revision',
'automation',
]);
const decoded: AutomationMutateResult = {
kind: 'committed',
revision: requireCount(result.revision, 'Automation revision'),
automation: result.automation === null ? null : decodeAutomationProjection(result.automation),
};
requireEncodedByteLimit(decoded, 'Automation mutation result', AUTOMATION_RESULT_MAX_BYTES);
return decoded;
}
export function decodeAutomationProjection(value: unknown): AutomationProjection {
const record = requireExactRecord(value, 'Automation projection', [
'id',
'kind',
'name',
'status',
'prompt',
'sessionId',
'schedule',
'createdAt',
'updatedAt',
'nextFireAt',
'lastFireAt',
'lastRunId',
'fireCount',
'maxFires',
'expiresAt',
'lastError',
'consecutiveFailures',
'durable',
'deferredFireCount',
'firePending',
]);
if (record.kind !== 'heartbeat' && record.kind !== 'cron') {
throw invalidProtocolFrame('Invalid Automation projection kind');
}
if (
record.status !== 'active' &&
record.status !== 'paused' &&
record.status !== 'completed' &&
record.status !== 'expired'
) {
throw invalidProtocolFrame('Invalid Automation projection status');
}
if (typeof record.durable !== 'boolean' || typeof record.firePending !== 'boolean') {
throw invalidProtocolFrame('Invalid Automation projection flags');
}
return {
id: requireEntityId(record.id, 'Automation id'),
kind: record.kind,
name: requireAutomationText(record.name, 'Automation name', AUTOMATION_NAME_LIMIT),
status: record.status,
prompt: requireAutomationText(record.prompt, 'Automation prompt', AUTOMATION_PROMPT_LIMIT),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
schedule: decodeAutomationSchedule(record.schedule),
createdAt: requireCount(record.createdAt, 'createdAt'),
updatedAt: requireCount(record.updatedAt, 'updatedAt'),
nextFireAt: nullableCount(record.nextFireAt, 'nextFireAt'),
lastFireAt: nullableCount(record.lastFireAt, 'lastFireAt'),
lastRunId: record.lastRunId === null ? null : requireEntityId(record.lastRunId, 'lastRunId'),
fireCount: requireCount(record.fireCount, 'fireCount'),
maxFires: nullablePositiveCount(record.maxFires, 'maxFires'),
expiresAt: nullableCount(record.expiresAt, 'expiresAt'),
lastError:
record.lastError === null
? null
: requireAutomationText(record.lastError, 'lastError', AUTOMATION_LAST_ERROR_LIMIT),
consecutiveFailures: requireCount(record.consecutiveFailures, 'consecutiveFailures'),
durable: record.durable,
deferredFireCount: requireCount(record.deferredFireCount, 'deferredFireCount'),
firePending: record.firePending,
};
}
function decodeAutomationSchedule(value: unknown): AutomationSchedule {
const record = requireRecord(value, 'Automation schedule');
if (record.type === 'cron') {
const schedule = requireExactRecord(record, 'cron Automation schedule', ['type', 'expression']);
return {
type: 'cron',
expression: requireAutomationText(
schedule.expression,
'cron expression',
AUTOMATION_CRON_EXPRESSION_LIMIT,
),
};
}
if (record.type === 'interval') {
const schedule = requireExactRecord(record, 'interval Automation schedule', [
'type',
'seconds',
]);
const seconds = positiveCount(schedule.seconds, 'interval seconds');
if (seconds < 10 || seconds > 86_400) {
throw invalidProtocolFrame('Automation interval is out of range');
}
return { type: 'interval', seconds };
}
if (record.type === 'once') {
const schedule = requireExactRecord(record, 'one-shot Automation schedule', [
'type',
'delaySeconds',
]);
const delaySeconds = positiveCount(schedule.delaySeconds, 'one-shot delay');
if (delaySeconds < 5 || delaySeconds > 86_400) {
throw invalidProtocolFrame('Automation one-shot delay is out of range');
}
return { type: 'once', delaySeconds };
}
throw invalidProtocolFrame('Invalid Automation schedule kind');
}
function positiveCount(value: unknown, label: string): number {
const count = requireCount(value, label);
if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`);
return count;
}
function requireAutomationText(
value: unknown,
label: string,
limit: Parameters<typeof isAutomationTextWithinLimit>[1],
nonblank = false,
): string {
const decoded = requireUtf8String(value, label, limit.maxBytes);
if (!isAutomationTextWithinLimit(decoded, limit, { nonblank })) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return decoded;
}
function nullableCount(value: unknown, label: string): number | null {
return value === null ? null : requireCount(value, label);
}
function nullablePositiveCount(value: unknown, label: string): number | null {
return value === null ? null : positiveCount(value, label);
}
function isMutationRejection(value: unknown): value is AutomationMutationRejection {
return (
value === 'not_owned' ||
value === 'not_active' ||
value === 'not_paused' ||
value === 'fire_pending' ||
value === 'fire_budget_exhausted' ||
value === 'limit_reached' ||
value === 'invalid_schedule'
);
}