feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { parseCreateScheduledTaskRequest, parseUpdateScheduledTaskRequest } from './automation.js';
const restartRequest = {
name: 'Morning restart',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
targetSelector: { mode: 'fixed', instanceIds: ['instance-a', 'instance-b'] },
};
describe('automation contracts', () => {
it('applies the approved timezone and safe default policies', () => {
expect(parseCreateScheduledTaskRequest(restartRequest)).toMatchObject({
timezone: 'Asia/Shanghai',
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
});
});
it('rejects alternate timezones, seconds fields, and unknown keys', () => {
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, timezone: 'UTC' })).toThrow(
/timezone/i,
);
expect(() =>
parseCreateScheduledTaskRequest({ ...restartRequest, cronExpression: '* * * * * *' }),
).toThrow(/five-field/i);
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, surprise: true })).toThrow(
/unknown/i,
);
});
it('validates fixed and dynamic target selectors without silently dropping values', () => {
expect(
parseCreateScheduledTaskRequest({
...restartRequest,
targetSelector: { mode: 'tags', match: 'all', tags: ['lab', 'east'] },
}).targetSelector,
).toEqual({ mode: 'tags', match: 'all', tags: ['lab', 'east'] });
expect(() =>
parseCreateScheduledTaskRequest({
...restartRequest,
targetSelector: { mode: 'fixed', instanceIds: [] },
}),
).toThrow(/instance/i);
});
it('requires bounded recipients and content only for SMS schedules', () => {
expect(
parseCreateScheduledTaskRequest({
...restartRequest,
operationType: 'send-sms',
sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' },
}).sms,
).toEqual({ recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' });
expect(() =>
parseCreateScheduledTaskRequest({
...restartRequest,
operationType: 'send-sms',
sms: { recipients: [], content: '' },
}),
).toThrow(/sms/i);
expect(() =>
parseCreateScheduledTaskRequest({
...restartRequest,
operationType: 'restart-service',
sms: { recipients: ['13800138000'], content: 'not allowed' },
}),
).toThrow(/sms/i);
});
it('defaults system reboot to no retry and accepts bounded per-task policies', () => {
expect(
parseCreateScheduledTaskRequest({
...restartRequest,
operationType: 'reboot-system',
misfirePolicy: 'catch-up-once',
overlapPolicy: 'queue-once',
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
}),
).toMatchObject({
misfirePolicy: 'catch-up-once',
overlapPolicy: 'queue-once',
retryPolicy: { maxRetries: 0, intervalSeconds: 300 },
});
});
it('requires an optimistic version and at least one editable field for updates', () => {
expect(parseUpdateScheduledTaskRequest({ version: 2, name: 'Updated name' })).toEqual({
version: 2,
name: 'Updated name',
});
expect(() => parseUpdateScheduledTaskRequest({ version: 2 })).toThrow(/field/i);
expect(() => parseUpdateScheduledTaskRequest({ version: 0, name: 'bad' })).toThrow(/version/i);
});
it('rejects non-boolean enabled values instead of coercing them to false', () => {
expect(() => parseCreateScheduledTaskRequest({ ...restartRequest, enabled: 'false' })).toThrow(
/enabled/i,
);
});
});
+334
View File
@@ -0,0 +1,334 @@
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 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 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 ScheduleTargetSelector =
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] };
export interface ScheduleRetryPolicy {
readonly maxRetries: number;
readonly intervalSeconds: number;
}
export interface ScheduledSmsInput {
readonly recipients: readonly string[];
readonly content: string;
}
export interface ScheduledSmsSummary {
readonly configured: boolean;
readonly recipientCount: number;
}
export interface CreateScheduledTaskRequest {
readonly name: string;
readonly operationType: ScheduledOperationType;
readonly cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput;
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 cronExpression?: string;
readonly timezone?: typeof AUTOMATION_TIMEZONE;
readonly targetSelector?: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput | 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 cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsSummary;
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<ScheduledTask>;
export type ScheduledRunPageQuery = PageQuery<'dueAt' | 'finishedAt'> & {
readonly scheduledTaskId?: string;
readonly outcome?: ScheduledRunOutcome;
};
export type ScheduledRunPage = PageEnvelope<ScheduledRun>;
export interface CronPreview {
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly occurrences: readonly string[];
}
const CREATE_KEYS = new Set([
'name',
'operationType',
'cronExpression',
'timezone',
'targetSelector',
'sms',
'effectiveStartAt',
'effectiveEndAt',
'misfirePolicy',
'overlapPolicy',
'retryPolicy',
'enabled',
]);
const UPDATE_KEYS = new Set([...CREATE_KEYS, 'version']);
function object(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError(`${label} must be an object`);
return value as Record<string, unknown>;
}
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): 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<T extends string>(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),
};
}
throw new TypeError('targetSelector mode is invalid');
}
function sms(value: unknown): ScheduledSmsInput {
const source = object(value, 'sms');
exactKeys(source, new Set(['recipients', 'content']));
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),
};
}
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)
throw new TypeError('System reboot automatic retries are not supported');
return { maxRetries: maxRetries as number, intervalSeconds: intervalSeconds as number };
}
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');
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');
return {
name: string(source.name, 'name', 120),
operationType,
cronExpression: cronExpression(source.cronExpression),
timezone: AUTOMATION_TIMEZONE,
targetSelector: targetSelector(source.targetSelector),
...(smsValue ? { sms: smsValue } : {}),
...(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<string, unknown> = { 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.cronExpression !== undefined)
result.cronExpression = cronExpression(source.cronExpression);
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.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;
}
+1
View File
@@ -1,4 +1,5 @@
export * from './audit.js';
export * from './automation.js';
export * from './errors.js';
export * from './instances.js';
export * from './jobs.js';