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
@@ -0,0 +1,185 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { buildApp } from '../../app.js';
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerAutomationRoutes } from './automation-routes.js';
class MemorySecrets implements SecretStore {
readonly values = new Map<string, string>();
async set(key: SecretKey, value: string): Promise<string> {
const reference = `memory://${key.instanceId}/${key.purpose}`;
this.values.set(reference, value);
return reference;
}
async get(reference: string) {
return this.values.get(reference);
}
async delete(reference: string) {
return this.values.delete(reference);
}
}
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const repository = new ScheduledTaskRepository(db);
let id = 0;
const service = new ScheduledTaskService({
repository,
store: new MemorySecrets(),
now: () => new Date('2026-07-30T00:00:00.000Z'),
id: () => `task-${++id}`,
});
const app = buildApp({
registerRoutes: (scope) => registerAutomationRoutes(scope, { service, repository }),
});
return { app, db };
}
const restart = {
name: 'Morning restart',
operationType: 'restart-service',
cronExpression: '0 9 * * *',
targetSelector: { mode: 'fixed', instanceIds: ['instance-a'] },
};
describe('automation HTTP routes', () => {
it('previews Beijing-time Cron and creates a redacted schedule', async () => {
const { app, db } = fixture();
const preview = await app.inject({
method: 'POST',
url: '/api/v1/automation/cron/preview',
payload: { cronExpression: '0 9 * * *', count: 2 },
});
expect(preview.statusCode).toBe(200);
expect(preview.json()).toEqual({
timezone: 'Asia/Shanghai',
occurrences: ['2026-07-30T01:00:00.000Z', '2026-07-31T01:00:00.000Z'],
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: restart,
});
expect(created.statusCode).toBe(201);
expect(created.headers.etag).toBe('"version-1"');
expect(created.json()).toMatchObject({ id: 'task-1', timezone: 'Asia/Shanghai' });
expect(created.body).not.toMatch(/confirmationToken|smsSecretReference/i);
await app.close();
db.close();
});
it('requires optimistic version headers for state changes and preserves run history on delete', async () => {
const { app, db } = fixture();
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
const missing = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1/state',
payload: { enabled: false },
});
expect(missing.statusCode).toBe(428);
const paused = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1/state',
headers: { 'if-match': '"version-1"' },
payload: { enabled: false },
});
expect(paused.statusCode).toBe(200);
expect(paused.json()).toMatchObject({ enabled: false, version: 2 });
const removed = await app.inject({
method: 'DELETE',
url: '/api/v1/automation/schedules/task-1',
headers: { 'if-match': '"version-2"' },
});
expect(removed.statusCode).toBe(204);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/automation/schedules' })).json(),
).toMatchObject({ items: [] });
await app.close();
db.close();
});
it('rejects alternate timezone and unknown preview keys with sanitized problems', async () => {
const { app, db } = fixture();
const invalid = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: { ...restart, timezone: 'UTC' },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
expect(invalid.body).not.toContain('stack');
await app.close();
db.close();
});
it('updates and duplicates schedules with optimistic version checks', async () => {
const { app, db } = fixture();
await app.inject({ method: 'POST', url: '/api/v1/automation/schedules', payload: restart });
const missingVersion = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1',
payload: { name: 'Updated restart' },
});
expect(missingVersion.statusCode).toBe(428);
const updated = await app.inject({
method: 'PATCH',
url: '/api/v1/automation/schedules/task-1',
headers: { 'if-match': '"version-1"' },
payload: { name: 'Updated restart', effectiveEndAt: '2026-08-30T00:00:00.000Z' },
});
expect(updated.statusCode).toBe(200);
expect(updated.headers.etag).toBe('"version-2"');
expect(updated.json()).toMatchObject({ name: 'Updated restart', version: 2 });
const duplicated = await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules/task-1/duplicate',
headers: { 'if-match': '"version-2"' },
});
expect(duplicated.statusCode).toBe(201);
expect(duplicated.json()).toMatchObject({
id: 'task-2',
name: 'Updated restart copy',
enabled: false,
});
await app.close();
db.close();
});
it('bounds and validates schedule pagination', async () => {
const { app, db } = fixture();
for (const name of ['One', 'Two', 'Three'])
await app.inject({
method: 'POST',
url: '/api/v1/automation/schedules',
payload: { ...restart, name },
});
const page = await app.inject({
method: 'GET',
url: '/api/v1/automation/schedules?page=2&pageSize=1',
});
expect(page.statusCode).toBe(200);
expect(page.json()).toMatchObject({
items: [{ id: 'task-2' }],
page: { page: 2, pageSize: 1, total: 3 },
});
const invalid = await app.inject({
method: 'GET',
url: '/api/v1/automation/schedules?pageSize=101',
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: 'AUTOMATION_VALIDATION_FAILED' });
await app.close();
db.close();
});
});
@@ -0,0 +1,239 @@
import { AUTOMATION_TIMEZONE } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ScheduledTaskRepository } from '../../application/automation/scheduled-task-repository.js';
import { ScheduledTaskService } from '../../application/automation/scheduled-task-service.js';
export interface AutomationRoutesOptions {
readonly service: ScheduledTaskService;
readonly repository: ScheduledTaskRepository;
readonly runNow?: (taskId: string, actor: string, requestId: string) => Promise<unknown>;
}
function problem(
request: FastifyRequest,
reply: FastifyReply,
status: number,
code: string,
detail: string,
) {
return reply
.code(status)
.type('application/problem+json')
.send({
type: 'about:blank',
title: status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request',
status,
code,
detail,
requestId: request.id,
});
}
function version(request: FastifyRequest, reply: FastifyReply): number | undefined {
const value = request.headers['if-match'];
const match = typeof value === 'string' ? /^"version-([1-9]\d*)"$/.exec(value) : null;
if (!match) {
problem(request, reply, 428, 'VERSION_REQUIRED', 'A current schedule version is required.');
return undefined;
}
return Number(match[1]);
}
function object(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError('Request body must be an object');
return value as Record<string, unknown>;
}
function pagination(
value: unknown,
extraKeys: readonly string[] = [],
): { readonly page: number; readonly pageSize: number; readonly query: Record<string, unknown> } {
const query = object(value);
const allowed = new Set(['page', 'pageSize', ...extraKeys]);
if (Object.keys(query).some((key) => !allowed.has(key)))
throw new TypeError('Unknown automation list query');
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw))
throw new TypeError(`${key} is invalid`);
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new TypeError(`${key} is invalid`);
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const page = integer('page', 1, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1);
return { page, pageSize, query };
}
function pageItems<T>(items: readonly T[], page: number, pageSize: number) {
const offset = (page - 1) * pageSize;
return {
items: items.slice(offset, offset + pageSize),
page: { page, pageSize, total: items.length },
};
}
async function action(
request: FastifyRequest,
reply: FastifyReply,
operation: () => unknown | Promise<unknown>,
) {
try {
return await operation();
} catch (error) {
if (error instanceof TypeError)
return problem(
request,
reply,
400,
'AUTOMATION_VALIDATION_FAILED',
'The automation request is invalid.',
);
if (error instanceof Error && /not found|version changed/i.test(error.message))
return problem(
request,
reply,
409,
'SCHEDULE_VERSION_CONFLICT',
'The schedule changed or no longer exists.',
);
throw error;
}
}
const etag = (version: number) => `"version-${version}"`;
export function registerAutomationRoutes(
app: FastifyInstance,
options: AutomationRoutesOptions,
): void {
app.post('/api/v1/automation/cron/preview', async (request, reply) =>
action(request, reply, () => {
const body = object(request.body);
if (Object.keys(body).some((key) => key !== 'cronExpression' && key !== 'count'))
throw new TypeError('Unknown preview field');
if (typeof body.cronExpression !== 'string')
throw new TypeError('cronExpression is required');
const count = body.count === undefined ? 5 : body.count;
if (!Number.isSafeInteger(count)) throw new TypeError('count is invalid');
return {
timezone: AUTOMATION_TIMEZONE,
occurrences: options.service.preview(body.cronExpression, count as number),
};
}),
);
app.get('/api/v1/automation/schedules', async (request, reply) =>
action(request, reply, () => {
const { page, pageSize } = pagination(request.query);
return pageItems(options.service.list(), page, pageSize);
}),
);
app.get('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const task = options.service.get((request.params as { taskId: string }).taskId);
if (!task)
return problem(request, reply, 404, 'SCHEDULE_NOT_FOUND', 'The schedule does not exist.');
return reply.header('ETag', etag(task.version)).send(task);
});
app.post('/api/v1/automation/schedules', async (request, reply) =>
action(request, reply, async () => {
const task = await options.service.create('console-operator', request.body);
return reply.code(201).header('ETag', etag(task.version)).send(task);
}),
);
app.patch('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
const task = await options.service.update(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
request.body,
);
return reply.header('ETag', etag(task.version)).send(task);
});
});
app.post('/api/v1/automation/schedules/:taskId/duplicate', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
const task = await options.service.duplicate(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
);
return reply.code(201).header('ETag', etag(task.version)).send(task);
});
});
app.patch('/api/v1/automation/schedules/:taskId/state', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, () => {
const body = object(request.body);
if (Object.keys(body).length !== 1 || typeof body.enabled !== 'boolean')
throw new TypeError('enabled is required');
const task = options.service.setEnabled(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
body.enabled,
);
return reply.header('ETag', etag(task.version)).send(task);
});
});
app.delete('/api/v1/automation/schedules/:taskId', async (request, reply) => {
const currentVersion = version(request, reply);
if (currentVersion === undefined) return reply;
return action(request, reply, async () => {
await options.service.remove(
'console-operator',
(request.params as { taskId: string }).taskId,
currentVersion,
);
return reply.code(204).send();
});
});
app.get('/api/v1/automation/runs', async (request, reply) =>
action(request, reply, () => {
const { page, pageSize, query } = pagination(request.query, ['scheduledTaskId']);
const scheduledTaskId =
typeof query.scheduledTaskId === 'string' && query.scheduledTaskId
? query.scheduledTaskId
: undefined;
if (query.scheduledTaskId !== undefined && !scheduledTaskId)
throw new TypeError('scheduledTaskId is invalid');
return pageItems(options.repository.listRuns(scheduledTaskId), page, pageSize);
}),
);
app.post('/api/v1/automation/schedules/:taskId/run', async (request, reply) => {
const runNow = options.runNow;
if (!runNow)
return problem(
request,
reply,
409,
'SCHEDULER_UNAVAILABLE',
'The scheduler is not available.',
);
return action(request, reply, async () => {
const run = await runNow(
(request.params as { taskId: string }).taskId,
'console-operator',
request.id,
);
return reply.code(202).send(run);
});
});
}
@@ -400,7 +400,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
required: ['phoneNumber', 'content'],
properties: {
phoneNumber: { type: 'string', minLength: 3, maxLength: 32 },
content: { type: 'string', minLength: 1, maxLength: 1600 },
content: { type: 'string', minLength: 1, maxLength: 2000 },
},
},
},