feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import type { PageEnvelope, PageQuery } from './instances.js';
|
||||
|
||||
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
|
||||
export const SCHEDULED_OPERATION_TYPES = ['restart-service', 'reboot-system', 'send-sms'] 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 = [
|
||||
@@ -13,16 +19,35 @@ export const SCHEDULED_RUN_OUTCOMES = [
|
||||
'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: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
|
||||
| { readonly mode: 'group'; readonly groupId: 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;
|
||||
@@ -32,6 +57,8 @@ export interface ScheduleRetryPolicy {
|
||||
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 {
|
||||
@@ -42,10 +69,13 @@ export interface ScheduledSmsSummary {
|
||||
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;
|
||||
@@ -58,10 +88,12 @@ 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;
|
||||
@@ -74,10 +106,12 @@ 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;
|
||||
@@ -130,10 +164,12 @@ export interface CronPreview {
|
||||
const CREATE_KEYS = new Set([
|
||||
'name',
|
||||
'operationType',
|
||||
'trigger',
|
||||
'cronExpression',
|
||||
'timezone',
|
||||
'targetSelector',
|
||||
'sms',
|
||||
'delaySeconds',
|
||||
'effectiveStartAt',
|
||||
'effectiveEndAt',
|
||||
'misfirePolicy',
|
||||
@@ -204,42 +240,147 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
|
||||
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 === 'all') {
|
||||
exactKeys(source, new Set(['mode']));
|
||||
return { mode: 'all' };
|
||||
}
|
||||
throw new TypeError('targetSelector mode is invalid');
|
||||
}
|
||||
|
||||
const INTERVAL_UNIT_MS: Record<ScheduleIntervalUnit, number> = {
|
||||
mins: 60_000,
|
||||
hours: 3_600_000,
|
||||
days: 86_400_000,
|
||||
};
|
||||
|
||||
export function intervalMilliseconds(
|
||||
trigger: Extract<ScheduleTrigger, { kind: 'interval' }>,
|
||||
): 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']));
|
||||
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 = source.maxRetries;
|
||||
const intervalSeconds = source.intervalSeconds;
|
||||
if (
|
||||
!Number.isSafeInteger(maxRetries) ||
|
||||
(maxRetries as number) < 0 ||
|
||||
(maxRetries as number) > 10
|
||||
)
|
||||
throw new TypeError('retryPolicy.maxRetries must be between 0 and 10');
|
||||
if (
|
||||
!Number.isSafeInteger(intervalSeconds) ||
|
||||
(intervalSeconds as number) < 1 ||
|
||||
(intervalSeconds as number) > 86_400
|
||||
)
|
||||
throw new TypeError('retryPolicy.intervalSeconds must be between 1 and 86400');
|
||||
if (operationType === 'reboot-system' && (maxRetries as number) > 0)
|
||||
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: maxRetries as number, intervalSeconds: intervalSeconds as number };
|
||||
return { maxRetries, intervalSeconds };
|
||||
}
|
||||
|
||||
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
|
||||
@@ -248,6 +389,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
||||
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');
|
||||
@@ -265,13 +412,21 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
||||
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
||||
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
||||
throw new TypeError('enabled must be boolean');
|
||||
const delaySeconds =
|
||||
source.delaySeconds === undefined
|
||||
? undefined
|
||||
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
|
||||
if (delaySeconds !== undefined && operationType !== 'reboot-system')
|
||||
throw new TypeError('delaySeconds is only valid for reboot-system');
|
||||
return {
|
||||
name: string(source.name, 'name', 120),
|
||||
operationType,
|
||||
cronExpression: cronExpression(source.cronExpression),
|
||||
trigger,
|
||||
cronExpression: deriveCronExpression(trigger),
|
||||
timezone: AUTOMATION_TIMEZONE,
|
||||
targetSelector: targetSelector(source.targetSelector),
|
||||
...(smsValue ? { sms: smsValue } : {}),
|
||||
...(delaySeconds === undefined ? {} : { delaySeconds }),
|
||||
...(effectiveStartAt ? { effectiveStartAt } : {}),
|
||||
...(effectiveEndAt ? { effectiveEndAt } : {}),
|
||||
misfirePolicy:
|
||||
@@ -299,8 +454,13 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
||||
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.cronExpression !== undefined)
|
||||
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');
|
||||
@@ -309,6 +469,11 @@ export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduled
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user