import type { PageEnvelope, PageQuery } from './instances.js'; export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const; export const SCHEDULED_OPERATION_TYPES = [ 'restart-service', 'reboot-system', 'restart-baseband', 'send-sms', 'backup-data', ] as const; export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const; export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const; export const SCHEDULED_RUN_OUTCOMES = [ 'succeeded', 'partially-succeeded', 'failed', 'skipped', 'no-targets', 'needs-attention', ] as const; export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const; export const SCHEDULE_TRIGGER_KINDS = ['cron', 'fixed', 'interval'] as const; export const SCHEDULE_INTERVAL_UNITS = ['mins', 'hours', 'days'] as const; export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number]; export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number]; export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number]; export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number]; export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number]; export type ScheduleTriggerKind = (typeof SCHEDULE_TRIGGER_KINDS)[number]; export type ScheduleIntervalUnit = (typeof SCHEDULE_INTERVAL_UNITS)[number]; export type ScheduleTargetSelector = | { readonly mode: 'fixed'; readonly instanceIds: readonly string[] } | { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] } | { readonly mode: 'group'; readonly groupId: string } | { readonly mode: 'groups'; readonly groupIds: readonly string[] } | { readonly mode: 'all' }; /** * Hub-style scheduling vocabulary. `cron` keeps the existing five-field engine, `fixed` is a set of * weekday/time pairs, and `interval` is a plain wall-clock period. */ export type ScheduleTrigger = | { readonly kind: 'cron'; readonly expression: string } | { readonly kind: 'fixed'; readonly weekdays: readonly number[]; readonly times: readonly string[]; } | { readonly kind: 'interval'; readonly value: number; readonly unit: ScheduleIntervalUnit }; export interface ScheduleRetryPolicy { readonly maxRetries: number; readonly intervalSeconds: number; } export interface ScheduledSmsInput { readonly recipients: readonly string[]; readonly content: string; /** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */ readonly randomDelaySeconds?: number; } export interface ScheduledSmsSummary { readonly configured: boolean; readonly recipientCount: number; } export interface CreateScheduledTaskRequest { readonly name: string; readonly operationType: ScheduledOperationType; /** Optional so legacy cron-only callers keep working; parsers always fill it in. */ readonly trigger?: ScheduleTrigger; readonly cronExpression: string; readonly timezone: typeof AUTOMATION_TIMEZONE; readonly targetSelector: ScheduleTargetSelector; readonly sms?: ScheduledSmsInput; readonly delaySeconds?: number; readonly effectiveStartAt?: string; readonly effectiveEndAt?: string; readonly misfirePolicy: ScheduleMisfirePolicy; readonly overlapPolicy: ScheduleOverlapPolicy; readonly retryPolicy: ScheduleRetryPolicy; readonly enabled: boolean; } export interface UpdateScheduledTaskRequest { readonly version: number; readonly name?: string; readonly operationType?: ScheduledOperationType; readonly trigger?: ScheduleTrigger; readonly cronExpression?: string; readonly timezone?: typeof AUTOMATION_TIMEZONE; readonly targetSelector?: ScheduleTargetSelector; readonly sms?: ScheduledSmsInput | null; readonly delaySeconds?: number | null; readonly effectiveStartAt?: string | null; readonly effectiveEndAt?: string | null; readonly misfirePolicy?: ScheduleMisfirePolicy; readonly overlapPolicy?: ScheduleOverlapPolicy; readonly retryPolicy?: ScheduleRetryPolicy; readonly enabled?: boolean; } export interface ScheduledTask { readonly id: string; readonly name: string; readonly operationType: ScheduledOperationType; readonly trigger?: ScheduleTrigger; readonly cronExpression: string; readonly timezone: typeof AUTOMATION_TIMEZONE; readonly targetSelector: ScheduleTargetSelector; readonly sms?: ScheduledSmsSummary; readonly delaySeconds?: number; readonly effectiveStartAt?: string; readonly effectiveEndAt?: string; readonly misfirePolicy: ScheduleMisfirePolicy; readonly overlapPolicy: ScheduleOverlapPolicy; readonly retryPolicy: ScheduleRetryPolicy; readonly enabled: boolean; readonly version: number; readonly nextDueAt?: string; readonly lastEvaluatedAt?: string; readonly createdBy: string; readonly updatedBy: string; readonly createdAt: string; readonly updatedAt: string; } export interface ScheduledRun { readonly id: string; readonly scheduledTaskId: string; readonly scheduleVersion: number; readonly taskName: string; readonly operationType: ScheduledOperationType; readonly dueAt: string; readonly claimedAt: string; readonly startedAt?: string; readonly finishedAt?: string; readonly targetSnapshot: readonly string[]; readonly outcome?: ScheduledRunOutcome; readonly reason?: string; readonly jobIds: readonly string[]; readonly triggerSource: ScheduledRunTriggerSource; readonly attempt: number; } export type ScheduledTaskPageQuery = PageQuery<'name' | 'nextDueAt' | 'updatedAt'> & { readonly enabled?: boolean; readonly operationType?: ScheduledOperationType; }; export type ScheduledTaskPage = PageEnvelope; export type ScheduledRunPageQuery = PageQuery<'dueAt' | 'finishedAt'> & { readonly scheduledTaskId?: string; readonly outcome?: ScheduledRunOutcome; }; export type ScheduledRunPage = PageEnvelope; export interface CronPreview { readonly timezone: typeof AUTOMATION_TIMEZONE; readonly occurrences: readonly string[]; } const CREATE_KEYS = new Set([ 'name', 'operationType', 'trigger', 'cronExpression', 'timezone', 'targetSelector', 'sms', 'delaySeconds', 'effectiveStartAt', 'effectiveEndAt', 'misfirePolicy', 'overlapPolicy', 'retryPolicy', 'enabled', ]); const UPDATE_KEYS = new Set([...CREATE_KEYS, 'version']); function object(value: unknown, label: string): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError(`${label} must be an object`); return value as Record; } function exactKeys(value: Record, allowed: ReadonlySet): void { const unknown = Object.keys(value).find((key) => !allowed.has(key)); if (unknown) throw new TypeError(`Unknown field: ${unknown}`); } function string(value: unknown, label: string, maximum: number): string { if (typeof value !== 'string') throw new TypeError(`${label} must be a string`); const clean = value.trim(); if (clean.length === 0 || clean.length > maximum) throw new TypeError(`${label} must contain 1-${maximum} characters`); return clean; } function member(value: unknown, values: readonly T[], label: string): T { if (typeof value !== 'string' || !values.includes(value as T)) throw new TypeError(`${label} is invalid`); return value as T; } function strings(value: unknown, label: string, maximum: number): string[] { if (!Array.isArray(value) || value.length === 0 || value.length > maximum) throw new TypeError(`${label} must contain 1-${maximum} values`); const result = value.map((item) => string(item, label, 120)); if (new Set(result).size !== result.length) throw new TypeError(`${label} must be unique`); return result; } function cronExpression(value: unknown): string { const expression = string(value, 'cronExpression', 120).replace(/\s+/g, ' '); if (expression.split(' ').length !== 5) throw new TypeError('cronExpression must use standard five-field syntax'); return expression; } function isoDate(value: unknown, label: string): string { const raw = string(value, label, 64); const time = Date.parse(raw); if (!Number.isFinite(time)) throw new TypeError(`${label} must be an ISO timestamp`); return new Date(time).toISOString(); } function targetSelector(value: unknown): ScheduleTargetSelector { const source = object(value, 'targetSelector'); if (source.mode === 'fixed') { exactKeys(source, new Set(['mode', 'instanceIds'])); return { mode: 'fixed', instanceIds: strings(source.instanceIds, 'instanceIds', 200) }; } if (source.mode === 'tags') { exactKeys(source, new Set(['mode', 'match', 'tags'])); return { mode: 'tags', match: member(source.match, ['any', 'all'] as const, 'tag match'), tags: strings(source.tags, 'tags', 200), }; } if (source.mode === 'group') { exactKeys(source, new Set(['mode', 'groupId'])); return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) }; } if (source.mode === 'groups') { exactKeys(source, new Set(['mode', 'groupIds'])); return { mode: 'groups', groupIds: strings(source.groupIds, 'groupIds', 200) }; } if (source.mode === 'all') { exactKeys(source, new Set(['mode'])); return { mode: 'all' }; } throw new TypeError('targetSelector mode is invalid'); } const INTERVAL_UNIT_MS: Record = { mins: 60_000, hours: 3_600_000, days: 86_400_000, }; export function intervalMilliseconds( trigger: Extract, ): number { return trigger.value * INTERVAL_UNIT_MS[trigger.unit]; } function timeOfDay(value: unknown): string { const raw = string(value, 'time', 5); const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(raw); const hour = match?.[1]; const minute = match?.[2]; if (!hour || !minute) throw new TypeError('time must use HH:MM syntax'); return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; } function weekdays(value: unknown): number[] { if (!Array.isArray(value) || value.length === 0 || value.length > 7) throw new TypeError('weekdays must contain 1-7 values'); const result = value.map((item) => { if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 7) throw new TypeError('weekdays must be between Monday (1) and Sunday (7)'); return item as number; }); if (new Set(result).size !== result.length) throw new TypeError('weekdays must be unique'); return result.sort((left, right) => left - right); } function times(value: unknown): string[] { if (!Array.isArray(value) || value.length === 0 || value.length > 48) throw new TypeError('times must contain 1-48 values'); const result = value.map(timeOfDay); if (new Set(result).size !== result.length) throw new TypeError('times must be unique'); return result.sort((left, right) => left.localeCompare(right)); } export function parseScheduleTrigger(value: unknown): ScheduleTrigger { const source = object(value, 'trigger'); if (source.kind === 'cron') { exactKeys(source, new Set(['kind', 'expression'])); return { kind: 'cron', expression: cronExpression(source.expression) }; } if (source.kind === 'fixed') { exactKeys(source, new Set(['kind', 'weekdays', 'times'])); return { kind: 'fixed', weekdays: weekdays(source.weekdays), times: times(source.times) }; } if (source.kind === 'interval') { exactKeys(source, new Set(['kind', 'value', 'unit'])); if (!Number.isSafeInteger(source.value) || (source.value as number) < 1) throw new TypeError('interval value must be a positive integer'); if ((source.value as number) > 10_000) throw new TypeError('interval value must be at most 10000'); return { kind: 'interval', value: source.value as number, unit: member(source.unit, SCHEDULE_INTERVAL_UNITS, 'interval unit'), }; } throw new TypeError('trigger kind is invalid'); } /** * Canonical five-field cron kept for the legacy `cron_expression` column and for display. Only the * `cron` kind is exact; `fixed` collapses to its earliest pair and `interval` to its step form. */ export function deriveCronExpression(trigger: ScheduleTrigger): string { if (trigger.kind === 'cron') return trigger.expression; if (trigger.kind === 'interval') { if (trigger.unit === 'mins') return trigger.value === 1 ? '* * * * *' : `*/${trigger.value} * * * *`; if (trigger.unit === 'hours') return trigger.value === 1 ? '0 * * * *' : `0 */${trigger.value} * * *`; return trigger.value === 1 ? '0 0 * * *' : `0 0 */${trigger.value} * *`; } const earliest = trigger.times[0] ?? '00:00'; const [hour, minute] = earliest.split(':'); const days = trigger.weekdays.length === 7 ? '*' : trigger.weekdays.join(','); return `${Number(minute)} ${Number(hour)} * * ${days}`; } /** Resolve the effective trigger for a stored or legacy task. */ export function scheduleTriggerOf(task: { readonly trigger?: ScheduleTrigger; readonly cronExpression: string; }): ScheduleTrigger { return task.trigger ?? { kind: 'cron', expression: task.cronExpression }; } function sms(value: unknown): ScheduledSmsInput { const source = object(value, 'sms'); exactKeys(source, new Set(['recipients', 'content', 'randomDelaySeconds'])); const randomDelaySeconds = source.randomDelaySeconds === undefined ? undefined : numberInRange(source.randomDelaySeconds, 'sms.randomDelaySeconds', 0, 600); return { recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => { if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid'); return recipient; }), content: string(source.content, 'SMS content', 2_000), ...(randomDelaySeconds === undefined ? {} : { randomDelaySeconds }), }; } function numberInRange(value: unknown, label: string, minimum: number, maximum: number): number { if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) throw new TypeError(`${label} must be between ${minimum} and ${maximum}`); return value as number; } function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy { if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 }; const source = object(value, 'retryPolicy'); exactKeys(source, new Set(['maxRetries', 'intervalSeconds'])); const maxRetries = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10); const intervalSeconds = numberInRange( source.intervalSeconds, 'retryPolicy.intervalSeconds', 1, 86_400, ); if (operationType === 'reboot-system' && maxRetries > 0) throw new TypeError('System reboot automatic retries are not supported'); return { maxRetries, intervalSeconds }; } export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest { const source = object(value, 'schedule'); exactKeys(source, CREATE_KEYS); const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType'); const timezone = source.timezone ?? AUTOMATION_TIMEZONE; if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai'); if (source.trigger === undefined && source.cronExpression === undefined) throw new TypeError('trigger or cronExpression is required'); const trigger = source.trigger === undefined ? ({ kind: 'cron', expression: cronExpression(source.cronExpression) } as const) : parseScheduleTrigger(source.trigger); const smsValue = source.sms === undefined ? undefined : sms(source.sms); if (operationType === 'send-sms' && !smsValue) throw new TypeError('SMS configuration is required'); if (operationType !== 'send-sms' && smsValue) throw new TypeError('SMS is only valid for send-sms'); const effectiveStartAt = source.effectiveStartAt === undefined ? undefined : isoDate(source.effectiveStartAt, 'effectiveStartAt'); const effectiveEndAt = source.effectiveEndAt === undefined ? undefined : isoDate(source.effectiveEndAt, 'effectiveEndAt'); if (effectiveStartAt && effectiveEndAt && effectiveEndAt <= effectiveStartAt) throw new TypeError('effectiveEndAt must be later than effectiveStartAt'); if (source.enabled !== undefined && typeof source.enabled !== 'boolean') throw new TypeError('enabled must be boolean'); // The upstream execution baseline pins reboot delay_seconds to 3; accepting a // wider range here would only produce tasks that can never run. const delaySeconds = source.delaySeconds === undefined ? undefined : numberInRange(source.delaySeconds, 'delaySeconds', 3, 3); if (delaySeconds !== undefined && operationType !== 'reboot-system') throw new TypeError('delaySeconds is only valid for reboot-system'); return { name: string(source.name, 'name', 120), operationType, trigger, cronExpression: deriveCronExpression(trigger), timezone: AUTOMATION_TIMEZONE, targetSelector: targetSelector(source.targetSelector), ...(smsValue ? { sms: smsValue } : {}), ...(delaySeconds === undefined ? {} : { delaySeconds }), ...(effectiveStartAt ? { effectiveStartAt } : {}), ...(effectiveEndAt ? { effectiveEndAt } : {}), misfirePolicy: source.misfirePolicy === undefined ? 'skip' : member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy'), overlapPolicy: source.overlapPolicy === undefined ? 'skip' : member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy'), retryPolicy: retryPolicy(source.retryPolicy, operationType), enabled: source.enabled === undefined ? true : source.enabled, }; } export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduledTaskRequest { const source = object(value, 'schedule update'); exactKeys(source, UPDATE_KEYS); if (!Number.isSafeInteger(source.version) || (source.version as number) < 1) throw new TypeError('version must be a positive integer'); if (Object.keys(source).length === 1) throw new TypeError('At least one editable field is required'); const result: Record = { version: source.version }; if (source.name !== undefined) result.name = string(source.name, 'name', 120); if (source.operationType !== undefined) result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType'); if (source.trigger !== undefined) { result.trigger = parseScheduleTrigger(source.trigger); result.cronExpression = deriveCronExpression(result.trigger as ScheduleTrigger); } else if (source.cronExpression !== undefined) { result.cronExpression = cronExpression(source.cronExpression); result.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger; } if (source.timezone !== undefined) { if (source.timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai'); result.timezone = AUTOMATION_TIMEZONE; } if (source.targetSelector !== undefined) result.targetSelector = targetSelector(source.targetSelector); if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms); if (source.delaySeconds !== undefined) result.delaySeconds = source.delaySeconds === null ? null : numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600); if (source.effectiveStartAt !== undefined) result.effectiveStartAt = source.effectiveStartAt === null ? null : isoDate(source.effectiveStartAt, 'effectiveStartAt'); if (source.effectiveEndAt !== undefined) result.effectiveEndAt = source.effectiveEndAt === null ? null : isoDate(source.effectiveEndAt, 'effectiveEndAt'); if (source.misfirePolicy !== undefined) result.misfirePolicy = member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy'); if (source.overlapPolicy !== undefined) result.overlapPolicy = member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy'); if (source.retryPolicy !== undefined) result.retryPolicy = retryPolicy( source.retryPolicy, (result.operationType ?? 'restart-service') as ScheduledOperationType, ); if (source.enabled !== undefined) { if (typeof source.enabled !== 'boolean') throw new TypeError('enabled must be boolean'); result.enabled = source.enabled; } return result as unknown as UpdateScheduledTaskRequest; }