blob: de66544d2a2973ef5eb028f5bec6478ee861f5da [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 { isBotDeliveryProvider } from '@maka/core/bot-chat-settings';
import { isCollaborationMode } from '@maka/core/collaboration';
import { isOrchestrationMode } from '@maka/core/orchestration';
import { isPermissionMode } from '@maka/core/permission';
import {
isScheduledTaskStatus,
SCHEDULED_TASK_CHAT_ID_MAX_CHARS,
SCHEDULED_TASK_CRON_MAX_CHARS,
SCHEDULED_TASK_INTENT_MAX_CHARS,
SCHEDULED_TASK_MAX_DELAY_MS,
SCHEDULED_TASK_MAX_INTERVAL_SECONDS,
SCHEDULED_TASK_MIN_INTERVAL_SECONDS,
SCHEDULED_TASK_RUN_HISTORY_LIMIT,
SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS,
SCHEDULED_TASK_SESSION_ID_MAX_CHARS,
SCHEDULED_TASK_TITLE_MAX_CHARS,
type CreateScheduledTaskInput,
type ScheduledTask,
type ScheduledTaskCreatedBy,
type ScheduledTaskEffect,
type ScheduledTaskExecutionTemplate,
type ScheduledTaskRun,
type ScheduledTaskSchedule,
type UpdateScheduledTaskInput,
} from '@maka/core/scheduled-task';
import { isThinkingLevel } from '@maka/core/model-thinking';
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineHostPathOperation, defineOperation } from './operation-spec.js';
export const SCHEDULED_TASK_PAGE_MAX_ITEMS = 64;
export const SCHEDULED_TASK_CATALOG_MAX_ITEMS = 256;
export const SCHEDULED_TASK_RESULT_MAX_BYTES = 88 * 1024;
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'invalid_request',
'persistence_failed',
'internal_failure',
] as const;
const MUTATION_ERRORS = [...QUERY_ERRORS, 'not_found', 'operation_conflict'] as const;
export type ScheduledTaskQueryInput =
| {
readonly kind: 'list';
readonly cursor?: string;
readonly expectedRevision?: number;
}
| { readonly kind: 'get'; readonly taskId: string };
export type ScheduledTaskQueryResult =
| {
readonly kind: 'page';
readonly revision: number;
readonly tasks: readonly ScheduledTask[];
readonly nextCursor: string | null;
}
| {
readonly kind: 'revision_changed';
readonly expected: number;
readonly actual: number;
}
| { readonly kind: 'task'; readonly task: ScheduledTask | null };
export type ScheduledTaskMutateInput =
| {
readonly kind: 'create';
readonly input: Omit<CreateScheduledTaskInput, 'createdBy'>;
}
| { readonly kind: 'update'; readonly taskId: string; readonly patch: UpdateScheduledTaskInput }
| {
readonly kind: 'pause' | 'resume' | 'clear_history' | 'trigger_now' | 'delete';
readonly taskId: string;
}
| { readonly kind: 'snooze'; readonly taskId: string; readonly delayMs: number };
export type ScheduledTaskMutateResult =
| { readonly kind: 'task'; readonly task: ScheduledTask }
| { readonly kind: 'deleted'; readonly taskId: string };
export const SCHEDULED_TASK_OPERATION_SPECS = {
'scheduled-task.query': defineOperation<
ScheduledTaskQueryInput,
ScheduledTaskQueryResult,
(typeof QUERY_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeScheduledTaskQueryInput,
decodeOutput: decodeScheduledTaskQueryResult,
}),
'scheduled-task.mutate': defineHostPathOperation<
ScheduledTaskMutateInput,
ScheduledTaskMutateResult,
(typeof MUTATION_ERRORS)[number]
>(
{
mode: 'command',
availability: 'ready',
errors: MUTATION_ERRORS,
decodeInput: decodeScheduledTaskMutateInput,
decodeOutput: decodeScheduledTaskMutateResult,
},
scheduledTaskMutationUsesHostPath,
),
} as const;
function scheduledTaskMutationUsesHostPath(input: ScheduledTaskMutateInput): boolean {
const effect =
input.kind === 'create'
? input.input.effect
: input.kind === 'update'
? input.patch.effect
: undefined;
return (
effect?.kind === 'agent_run' &&
(effect.execution.projectId == null || effect.execution.projectId === '')
);
}
export function decodeScheduledTaskQueryInput(value: unknown): ScheduledTaskQueryInput {
const record = requireRecord(value, 'ScheduledTask query input');
if (record.kind === 'list') {
const input = requireShapedRecord(
record,
'ScheduledTask list input',
['kind'],
['cursor', 'expectedRevision'],
);
const hasCursor = Object.hasOwn(input, 'cursor');
const hasRevision = Object.hasOwn(input, 'expectedRevision');
if (hasCursor !== hasRevision) {
throw invalidProtocolFrame('ScheduledTask continuation requires cursor and revision');
}
return {
kind: 'list',
...(hasCursor
? {
cursor: decodeCursor(input.cursor),
expectedRevision: requireCount(
input.expectedRevision,
'ScheduledTask expected revision',
),
}
: {}),
};
}
if (record.kind === 'get') {
const input = requireExactRecord(record, 'ScheduledTask get input', ['kind', 'taskId']);
return { kind: 'get', taskId: requireEntityId(input.taskId, 'ScheduledTask id') };
}
throw invalidProtocolFrame('Invalid ScheduledTask query kind');
}
export function decodeScheduledTaskQueryResult(value: unknown): ScheduledTaskQueryResult {
const record = requireRecord(value, 'ScheduledTask query result');
if (record.kind === 'revision_changed') {
const result = requireExactRecord(record, 'ScheduledTask revision changed result', [
'kind',
'expected',
'actual',
]);
return {
kind: 'revision_changed',
expected: requireCount(result.expected, 'ScheduledTask expected revision'),
actual: requireCount(result.actual, 'ScheduledTask actual revision'),
};
}
if (record.kind === 'task') {
const result = requireExactRecord(record, 'ScheduledTask get result', ['kind', 'task']);
const decoded: ScheduledTaskQueryResult = {
kind: 'task',
task: result.task === null ? null : decodeScheduledTask(result.task),
};
requireEncodedByteLimit(decoded, 'ScheduledTask get result', SCHEDULED_TASK_RESULT_MAX_BYTES);
return decoded;
}
if (record.kind !== 'page') throw invalidProtocolFrame('Invalid ScheduledTask query result kind');
const result = requireExactRecord(record, 'ScheduledTask page result', [
'kind',
'revision',
'tasks',
'nextCursor',
]);
if (!Array.isArray(result.tasks) || result.tasks.length > SCHEDULED_TASK_PAGE_MAX_ITEMS) {
throw invalidProtocolFrame('ScheduledTask page exceeds item limit');
}
const decoded: ScheduledTaskQueryResult = {
kind: 'page',
revision: requireCount(result.revision, 'ScheduledTask revision'),
tasks: result.tasks.map(decodeScheduledTask),
nextCursor: result.nextCursor === null ? null : decodeCursor(result.nextCursor),
};
requireEncodedByteLimit(decoded, 'ScheduledTask page result', SCHEDULED_TASK_RESULT_MAX_BYTES);
return decoded;
}
export function decodeScheduledTaskMutateInput(value: unknown): ScheduledTaskMutateInput {
const record = requireRecord(value, 'ScheduledTask mutation input');
if (record.kind === 'create') {
const input = requireExactRecord(record, 'ScheduledTask create input', ['kind', 'input']);
return { kind: 'create', input: decodeCreateInput(input.input) };
}
if (record.kind === 'update') {
const input = requireExactRecord(record, 'ScheduledTask update input', [
'kind',
'taskId',
'patch',
]);
return {
kind: 'update',
taskId: requireEntityId(input.taskId, 'ScheduledTask id'),
patch: decodeUpdateInput(input.patch),
};
}
if (
record.kind === 'pause' ||
record.kind === 'resume' ||
record.kind === 'clear_history' ||
record.kind === 'trigger_now' ||
record.kind === 'delete'
) {
const input = requireExactRecord(record, 'ScheduledTask mutation input', ['kind', 'taskId']);
return { kind: record.kind, taskId: requireEntityId(input.taskId, 'ScheduledTask id') };
}
if (record.kind === 'snooze') {
const input = requireExactRecord(record, 'ScheduledTask snooze input', [
'kind',
'taskId',
'delayMs',
]);
const delayMs = requireCount(input.delayMs, 'ScheduledTask snooze delay');
if (delayMs === 0 || delayMs > SCHEDULED_TASK_MAX_DELAY_MS) {
throw invalidProtocolFrame('Invalid ScheduledTask snooze delay');
}
return {
kind: 'snooze',
taskId: requireEntityId(input.taskId, 'ScheduledTask id'),
delayMs,
};
}
throw invalidProtocolFrame('Invalid ScheduledTask mutation kind');
}
export function decodeScheduledTaskMutateResult(value: unknown): ScheduledTaskMutateResult {
const record = requireRecord(value, 'ScheduledTask mutation result');
if (record.kind === 'deleted') {
const result = requireExactRecord(record, 'ScheduledTask deleted result', ['kind', 'taskId']);
return { kind: 'deleted', taskId: requireEntityId(result.taskId, 'ScheduledTask id') };
}
if (record.kind !== 'task') throw invalidProtocolFrame('Invalid ScheduledTask mutation result');
const result = requireExactRecord(record, 'ScheduledTask committed result', ['kind', 'task']);
const decoded = { kind: 'task' as const, task: decodeScheduledTask(result.task) };
requireEncodedByteLimit(
decoded,
'ScheduledTask mutation result',
SCHEDULED_TASK_RESULT_MAX_BYTES,
);
return decoded;
}
export function decodeScheduledTask(value: unknown): ScheduledTask {
const task = requireExactRecord(value, 'ScheduledTask', [
'id',
'title',
'intent',
'schedule',
'effect',
'status',
'nextFireAt',
'lastFireAt',
'fireCount',
'maxFires',
'expiresAt',
'createdBy',
'createdAt',
'updatedAt',
'runs',
'lastError',
]);
const intent = requireExactRecord(task.intent, 'ScheduledTask intent', ['kind', 'body']);
if (intent.kind !== 'text') throw invalidProtocolFrame('Invalid ScheduledTask intent');
if (!isScheduledTaskStatus(task.status))
throw invalidProtocolFrame('Invalid ScheduledTask status');
if (!Array.isArray(task.runs) || task.runs.length > SCHEDULED_TASK_RUN_HISTORY_LIMIT) {
throw invalidProtocolFrame('Invalid ScheduledTask run history');
}
return {
id: requireEntityId(task.id, 'ScheduledTask id'),
title: boundedText(task.title, 'ScheduledTask title', SCHEDULED_TASK_TITLE_MAX_CHARS, true),
intent: {
kind: 'text',
body: boundedText(intent.body, 'ScheduledTask intent body', SCHEDULED_TASK_INTENT_MAX_CHARS),
},
schedule: decodeSchedule(task.schedule),
effect: decodeEffect(task.effect),
status: task.status,
nextFireAt: nullableCount(task.nextFireAt, 'ScheduledTask nextFireAt'),
lastFireAt: nullableCount(task.lastFireAt, 'ScheduledTask lastFireAt'),
fireCount: requireCount(task.fireCount, 'ScheduledTask fireCount'),
maxFires: nullablePositiveCount(task.maxFires, 'ScheduledTask maxFires'),
expiresAt: nullableCount(task.expiresAt, 'ScheduledTask expiresAt'),
createdBy: decodeCreatedBy(task.createdBy),
createdAt: requireCount(task.createdAt, 'ScheduledTask createdAt'),
updatedAt: requireCount(task.updatedAt, 'ScheduledTask updatedAt'),
runs: task.runs.map(decodeRun),
lastError:
task.lastError === null
? null
: boundedText(
task.lastError,
'ScheduledTask lastError',
SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS,
),
};
}
function decodeCreateInput(value: unknown): Omit<CreateScheduledTaskInput, 'createdBy'> {
const input = requireShapedRecord(
value,
'ScheduledTask create payload',
['title', 'intentBody', 'schedule', 'effect'],
['maxFires', 'expiresAt'],
);
return {
title: boundedText(input.title, 'ScheduledTask title', SCHEDULED_TASK_TITLE_MAX_CHARS, true),
intentBody: boundedText(
input.intentBody,
'ScheduledTask intent body',
SCHEDULED_TASK_INTENT_MAX_CHARS,
),
schedule: decodeSchedule(input.schedule),
effect: decodeEffect(input.effect, { requireConnectionId: true }),
...(Object.hasOwn(input, 'maxFires')
? { maxFires: nullablePositiveCount(input.maxFires, 'ScheduledTask maxFires') }
: {}),
...(Object.hasOwn(input, 'expiresAt')
? { expiresAt: nullableCount(input.expiresAt, 'ScheduledTask expiresAt') }
: {}),
};
}
function decodeUpdateInput(value: unknown): UpdateScheduledTaskInput {
const patch = requireShapedRecord(
value,
'ScheduledTask update payload',
[],
['title', 'intentBody', 'schedule', 'effect', 'maxFires', 'expiresAt'],
);
if (Object.keys(patch).length === 0) throw invalidProtocolFrame('ScheduledTask update is empty');
return {
...(Object.hasOwn(patch, 'title')
? {
title: boundedText(
patch.title,
'ScheduledTask title',
SCHEDULED_TASK_TITLE_MAX_CHARS,
true,
),
}
: {}),
...(Object.hasOwn(patch, 'intentBody')
? {
intentBody: boundedText(
patch.intentBody,
'ScheduledTask intent body',
SCHEDULED_TASK_INTENT_MAX_CHARS,
),
}
: {}),
...(Object.hasOwn(patch, 'schedule') ? { schedule: decodeSchedule(patch.schedule) } : {}),
...(Object.hasOwn(patch, 'effect')
? { effect: decodeEffect(patch.effect, { requireConnectionId: true }) }
: {}),
...(Object.hasOwn(patch, 'maxFires')
? { maxFires: nullablePositiveCount(patch.maxFires, 'ScheduledTask maxFires') }
: {}),
...(Object.hasOwn(patch, 'expiresAt')
? { expiresAt: nullableCount(patch.expiresAt, 'ScheduledTask expiresAt') }
: {}),
};
}
function decodeSchedule(value: unknown): ScheduledTaskSchedule {
const schedule = requireRecord(value, 'ScheduledTask schedule');
if (schedule.kind === 'once') {
const exact = requireExactRecord(schedule, 'ScheduledTask once schedule', ['kind', 'runAt']);
return { kind: 'once', runAt: requireCount(exact.runAt, 'ScheduledTask runAt') };
}
if (schedule.kind === 'interval') {
const exact = requireExactRecord(schedule, 'ScheduledTask interval schedule', [
'kind',
'everySeconds',
'startAt',
]);
const everySeconds = requireCount(exact.everySeconds, 'ScheduledTask interval');
if (
everySeconds < SCHEDULED_TASK_MIN_INTERVAL_SECONDS ||
everySeconds > SCHEDULED_TASK_MAX_INTERVAL_SECONDS
) {
throw invalidProtocolFrame('Invalid ScheduledTask interval');
}
return {
kind: 'interval',
everySeconds,
startAt: requireCount(exact.startAt, 'ScheduledTask startAt'),
};
}
if (schedule.kind === 'calendar') {
const exact = requireExactRecord(schedule, 'ScheduledTask calendar schedule', [
'kind',
'recurrence',
'anchorAt',
]);
if (
exact.recurrence !== 'daily' &&
exact.recurrence !== 'weekly' &&
exact.recurrence !== 'monthly'
) {
throw invalidProtocolFrame('Invalid ScheduledTask calendar recurrence');
}
return {
kind: 'calendar',
recurrence: exact.recurrence,
anchorAt: requireCount(exact.anchorAt, 'ScheduledTask anchorAt'),
};
}
if (schedule.kind === 'cron') {
const exact = requireExactRecord(schedule, 'ScheduledTask cron schedule', [
'kind',
'expression',
'startAt',
]);
return {
kind: 'cron',
expression: boundedText(
exact.expression,
'ScheduledTask cron expression',
SCHEDULED_TASK_CRON_MAX_CHARS,
true,
),
startAt: requireCount(exact.startAt, 'ScheduledTask startAt'),
};
}
throw invalidProtocolFrame('Invalid ScheduledTask schedule');
}
function decodeEffect(
value: unknown,
options: { readonly requireConnectionId?: boolean } = {},
): ScheduledTaskEffect {
const effect = requireRecord(value, 'ScheduledTask effect');
if (effect.kind === 'notify') {
if (effect.channel === 'local') {
requireExactRecord(effect, 'ScheduledTask local notification effect', ['kind', 'channel']);
return { kind: 'notify', channel: 'local' };
}
if (effect.channel === 'bot') {
const exact = requireExactRecord(effect, 'ScheduledTask bot notification effect', [
'kind',
'channel',
'platform',
'chatId',
]);
if (!isBotDeliveryProvider(exact.platform)) {
throw invalidProtocolFrame('Invalid ScheduledTask bot platform');
}
return {
kind: 'notify',
channel: 'bot',
platform: exact.platform,
chatId: boundedText(
exact.chatId,
'ScheduledTask bot chat id',
SCHEDULED_TASK_CHAT_ID_MAX_CHARS,
true,
),
};
}
throw invalidProtocolFrame('Invalid ScheduledTask notification channel');
}
if (effect.kind === 'agent_run') {
const exact = requireExactRecord(effect, 'ScheduledTask Agent run effect', [
'kind',
'execution',
]);
return {
kind: 'agent_run',
execution: decodeExecution(exact.execution, options.requireConnectionId === true),
};
}
if (effect.kind === 'session_resume') {
const exact = requireExactRecord(effect, 'ScheduledTask Session resume effect', [
'kind',
'sessionId',
]);
return {
kind: 'session_resume',
sessionId: boundedText(
exact.sessionId,
'ScheduledTask Session id',
SCHEDULED_TASK_SESSION_ID_MAX_CHARS,
true,
),
};
}
throw invalidProtocolFrame('Invalid ScheduledTask effect');
}
function decodeExecution(
value: unknown,
requireConnectionId = false,
): ScheduledTaskExecutionTemplate {
// `backend` left the template (#3306), but templates frozen by older builds
// still carry it and this is a closed shape: the key must stay tolerated on
// the way in, and it never lands on the decoded value.
const execution = requireShapedRecord(
value,
'ScheduledTask execution template',
[
'cwd',
'llmConnectionSlug',
'model',
'permissionMode',
'collaborationMode',
'orchestrationMode',
],
['projectId', 'thinkingLevel', 'backend', 'llmConnectionId'],
);
if (requireConnectionId && !Object.hasOwn(execution, 'llmConnectionId')) {
throw invalidProtocolFrame('ScheduledTask execution requires Connection id');
}
if (!isPermissionMode(execution.permissionMode)) {
throw invalidProtocolFrame('Invalid ScheduledTask permission mode');
}
if (!isCollaborationMode(execution.collaborationMode)) {
throw invalidProtocolFrame('Invalid ScheduledTask collaboration mode');
}
if (!isOrchestrationMode(execution.orchestrationMode)) {
throw invalidProtocolFrame('Invalid ScheduledTask orchestration mode');
}
if (Object.hasOwn(execution, 'thinkingLevel') && !isThinkingLevel(execution.thinkingLevel)) {
throw invalidProtocolFrame('Invalid ScheduledTask thinking level');
}
if (
Object.hasOwn(execution, 'projectId') &&
execution.projectId !== null &&
typeof execution.projectId !== 'string'
) {
throw invalidProtocolFrame('Invalid ScheduledTask project id');
}
return {
cwd: boundedText(execution.cwd, 'ScheduledTask cwd', 4_096, true),
...(Object.hasOwn(execution, 'projectId')
? { projectId: execution.projectId as string | null }
: {}),
...(Object.hasOwn(execution, 'llmConnectionId')
? {
llmConnectionId: requireEntityId(
execution.llmConnectionId,
'ScheduledTask Connection id',
),
}
: {}),
llmConnectionSlug: boundedText(
execution.llmConnectionSlug,
'ScheduledTask connection slug',
256,
true,
),
model: boundedText(execution.model, 'ScheduledTask model', 512, true),
...(Object.hasOwn(execution, 'thinkingLevel')
? {
thinkingLevel: execution.thinkingLevel as ScheduledTaskExecutionTemplate['thinkingLevel'],
}
: {}),
permissionMode: execution.permissionMode,
collaborationMode: execution.collaborationMode,
orchestrationMode: execution.orchestrationMode,
};
}
function decodeCreatedBy(value: unknown): ScheduledTaskCreatedBy {
const createdBy = requireShapedRecord(value, 'ScheduledTask creator', ['kind'], ['sessionId']);
if (createdBy.kind !== 'user' && createdBy.kind !== 'agent' && createdBy.kind !== 'system') {
throw invalidProtocolFrame('Invalid ScheduledTask creator');
}
return {
kind: createdBy.kind,
...(Object.hasOwn(createdBy, 'sessionId')
? { sessionId: requireEntityId(createdBy.sessionId, 'ScheduledTask creator Session id') }
: {}),
};
}
function decodeRun(value: unknown): ScheduledTaskRun {
const run = requireShapedRecord(
value,
'ScheduledTask run',
['id', 'at', 'outcome', 'message'],
['sessionId', 'runId'],
);
if (run.outcome !== 'ok' && run.outcome !== 'failed' && run.outcome !== 'blocked') {
throw invalidProtocolFrame('Invalid ScheduledTask run outcome');
}
return {
id: requireEntityId(run.id, 'ScheduledTask run id'),
at: requireCount(run.at, 'ScheduledTask run timestamp'),
outcome: run.outcome,
message: boundedText(
run.message,
'ScheduledTask run message',
SCHEDULED_TASK_RUN_MESSAGE_MAX_CHARS,
),
...(Object.hasOwn(run, 'sessionId')
? { sessionId: requireEntityId(run.sessionId, 'ScheduledTask run Session id') }
: {}),
...(Object.hasOwn(run, 'runId')
? { runId: requireEntityId(run.runId, 'ScheduledTask AgentRun id') }
: {}),
};
}
function decodeCursor(value: unknown): string {
const cursor = requireEntityId(value, 'ScheduledTask cursor');
if (!/^\d+$/.test(cursor)) throw invalidProtocolFrame('Invalid ScheduledTask cursor');
return cursor;
}
function boundedText(value: unknown, label: string, maxChars: number, nonblank = false): string {
if (typeof value !== 'string' || [...value].length > maxChars || (nonblank && !value.trim())) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value;
}
function nullableCount(value: unknown, label: string): number | null {
return value === null ? null : requireCount(value, label);
}
function nullablePositiveCount(value: unknown, label: string): number | null {
if (value === null) return null;
const count = requireCount(value, label);
if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`);
return count;
}