diff --git a/.gitignore b/.gitignore index eb2e638..83fe6a9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ npm-debug.log* coverage/ apps/web/dist/ .hermes/ +.local-run/ +artifacts/ diff --git a/apps/api/package.json b/apps/api/package.json index ced6ca0..803f124 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "@multi-simadmin/contracts": "workspace:*", "@multi-simadmin/operation-registry": "workspace:*", "better-sqlite3": "12.11.1", + "cron-parser": "^5.6.2", "drizzle-orm": "0.45.2", "fastify": "5.10.0", "tsx": "4.22.4" diff --git a/apps/api/src/application/automation/schedule-time.test.ts b/apps/api/src/application/automation/schedule-time.test.ts new file mode 100644 index 0000000..c0ff8b1 --- /dev/null +++ b/apps/api/src/application/automation/schedule-time.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { nextOccurrence, previewCron, reconcileOccurrence } from './schedule-time.js'; + +describe('schedule time', () => { + it('calculates future occurrences in Beijing time across the UTC day boundary', () => { + expect(previewCron('0 9 * * *', 2, new Date('2026-07-30T00:30:00.000Z'))).toEqual([ + '2026-07-30T01:00:00.000Z', + '2026-07-31T01:00:00.000Z', + ]); + expect(nextOccurrence('30 0 * * *', new Date('2026-07-30T15:59:00.000Z'))).toBe( + '2026-07-30T16:30:00.000Z', + ); + }); + + it('rejects seconds fields and invalid cron grammar', () => { + expect(() => previewCron('* * * * * *', 1)).toThrow(/five-field/i); + expect(() => previewCron('99 99 * * *', 1)).toThrow(/cron/i); + }); + + it('reconciles a missed due time according to the per-task policy', () => { + const common = { + cronExpression: '*/10 * * * *', + nextDueAt: '2026-07-30T01:00:00.000Z', + }; + expect( + reconcileOccurrence( + { ...common, misfirePolicy: 'skip' }, + new Date('2026-07-30T01:25:00.000Z'), + ), + ).toEqual({ + action: 'skip', + dueAt: '2026-07-30T01:20:00.000Z', + nextDueAt: '2026-07-30T01:30:00.000Z', + }); + expect( + reconcileOccurrence( + { ...common, misfirePolicy: 'catch-up-once' }, + new Date('2026-07-30T01:25:00.000Z'), + ), + ).toEqual({ + action: 'run', + dueAt: '2026-07-30T01:20:00.000Z', + nextDueAt: '2026-07-30T01:30:00.000Z', + }); + }); +}); diff --git a/apps/api/src/application/automation/schedule-time.ts b/apps/api/src/application/automation/schedule-time.ts new file mode 100644 index 0000000..cefb621 --- /dev/null +++ b/apps/api/src/application/automation/schedule-time.ts @@ -0,0 +1,65 @@ +import type { ScheduleMisfirePolicy } from '@multi-simadmin/contracts'; +import { CronExpressionParser } from 'cron-parser'; + +const TIMEZONE = 'Asia/Shanghai'; + +function validateExpression(expression: string): string { + const clean = expression.trim().replace(/\s+/g, ' '); + if (clean.split(' ').length !== 5) + throw new TypeError('Cron must use standard five-field syntax'); + return clean; +} + +function parser(expression: string, currentDate: Date) { + try { + return CronExpressionParser.parse(validateExpression(expression), { + currentDate, + tz: TIMEZONE, + }); + } catch (error) { + if (error instanceof TypeError && /five-field/.test(error.message)) throw error; + throw new TypeError('Cron expression is invalid'); + } +} + +export function previewCron( + expression: string, + count: number, + from = new Date(), +): readonly string[] { + if (!Number.isSafeInteger(count) || count < 1 || count > 10) + throw new TypeError('Cron preview count must be between 1 and 10'); + const interval = parser(expression, from); + return Array.from({ length: count }, () => interval.next().toDate().toISOString()); +} + +export function nextOccurrence(expression: string, after: Date): string { + return parser(expression, after).next().toDate().toISOString(); +} + +export type ReconciledOccurrence = + | { readonly action: 'wait'; readonly dueAt: string; readonly nextDueAt: string } + | { readonly action: 'run' | 'skip'; readonly dueAt: string; readonly nextDueAt: string }; + +export function reconcileOccurrence( + task: { + readonly cronExpression: string; + readonly nextDueAt: string; + readonly misfirePolicy: ScheduleMisfirePolicy; + }, + now: Date, +): ReconciledOccurrence { + const nextDue = new Date(task.nextDueAt); + if (!Number.isFinite(nextDue.getTime())) throw new TypeError('nextDueAt is invalid'); + if (nextDue.getTime() > now.getTime()) + return { action: 'wait', dueAt: task.nextDueAt, nextDueAt: task.nextDueAt }; + + const interval = parser(task.cronExpression, now); + const dueAt = interval.prev().toDate().toISOString(); + const nextDueAt = nextOccurrence(task.cronExpression, now); + return { + action: task.misfirePolicy === 'catch-up-once' ? 'run' : 'skip', + dueAt, + nextDueAt, + }; +} diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts new file mode 100644 index 0000000..4e3d39b --- /dev/null +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts @@ -0,0 +1,125 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it, vi } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; +import { ScheduledOperationDispatcher } from './scheduled-operation-dispatcher.js'; + +describe('ScheduledOperationDispatcher', () => { + it('executes restart targets independently and aggregates partial success', async () => { + const db = new Database(':memory:'); + migrateDatabase(db); + const execute = vi + .fn() + .mockResolvedValueOnce({ id: 'job-a', status: 'succeeded' }) + .mockResolvedValueOnce({ id: 'job-b', status: 'failed' }); + const dispatcher = new ScheduledOperationDispatcher({ + db, + operations: { + prepare: vi.fn(async (input) => ({ + id: `prep-${input.targets[0]?.instanceId}`, + confirmationToken: 'token', + })), + execute, + }, + messages: { send: vi.fn() }, + store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() }, + repository: new ScheduledTaskRepository(db), + }); + const result = await dispatcher.dispatch( + { + id: 'task-1', + name: 'Restart', + operationType: 'restart-service', + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] }, + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, + version: 1, + createdBy: 'operator', + updatedBy: 'operator', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + }, + [ + { id: 'a', revision: 1 }, + { id: 'b', revision: 2 }, + ], + { actor: 'operator', requestId: 'request-1' }, + ); + expect(result).toEqual({ outcome: 'partially-succeeded', jobIds: ['job-a', 'job-b'] }); + expect(execute).toHaveBeenCalledTimes(2); + db.close(); + }); + + it('retries only failed SMS recipients using the task retry interval', async () => { + const db = new Database(':memory:'); + migrateDatabase(db); + db.prepare( + 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', + ).run( + 'a', + 'Alpha', + 'http://10.0.0.1', + 1, + 1, + '2026-07-30T00:00:00.000Z', + '2026-07-30T00:00:00.000Z', + ); + const repository = new ScheduledTaskRepository(db); + const task = repository.create({ + id: 'task-sms', + task: { + name: 'Notify lab', + operationType: 'send-sms', + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['a'] }, + sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance' }, + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 1, intervalSeconds: 30 }, + enabled: true, + }, + smsSecretReference: 'memory://sms', + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + }); + const attempts = new Map(); + const send = vi.fn(async (_instanceId: string, input: { phoneNumber: string }) => { + const count = (attempts.get(input.phoneNumber) ?? 0) + 1; + attempts.set(input.phoneNumber, count); + if (input.phoneNumber === '13800138000' && count === 1) throw new Error('temporary'); + return { sent: true as const }; + }); + const sleep = vi.fn(async () => undefined); + const dispatcher = new ScheduledOperationDispatcher({ + db, + operations: { prepare: vi.fn(), execute: vi.fn() }, + messages: { send }, + store: { + set: vi.fn(), + get: vi.fn(async () => + JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }), + ), + delete: vi.fn(), + }, + repository, + sleep, + }); + + const result = await dispatcher.dispatch(task, [{ id: 'a', revision: 1 }], { + actor: 'operator', + requestId: 'request-1', + }); + + expect(result.outcome).toBe('succeeded'); + expect(send).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledWith(30_000); + db.close(); + }); +}); diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts new file mode 100644 index 0000000..91ef23f --- /dev/null +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts @@ -0,0 +1,240 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Job, PrepareOperationRequest, ScheduledTask } from '@multi-simadmin/contracts'; + +import type { InstanceMessageService } from '../messages/instance-message-service.js'; +import type { SecureOperationExecution } from '../operations/secure-operation-execution.js'; +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; +import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; +import type { DispatchResult } from './scheduler-coordinator.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; +import type { ResolvedTarget } from './target-resolver.js'; + +interface OperationExecutor { + prepare( + input: PrepareOperationRequest, + requestId?: string, + ): Promise<{ id: string; confirmationToken: string }>; + execute( + input: { preparationId: string; confirmationToken: string }, + actor: string, + requestId: string, + ): Promise>; +} + +interface MessageExecutor { + send( + instanceId: string, + input: { phoneNumber: string; content: string }, + ): Promise<{ readonly sent: true }>; +} + +export interface ScheduledOperationDispatcherOptions { + readonly db: SqliteDatabase; + readonly operations: Pick | OperationExecutor; + readonly messages: Pick | MessageExecutor; + readonly store: SecretStore; + readonly repository: ScheduledTaskRepository; + readonly now?: () => Date; + readonly id?: () => string; + readonly sleep?: (milliseconds: number) => Promise; +} + +function aggregate(successes: number, failures: number): DispatchResult['outcome'] { + if (successes > 0 && failures > 0) return 'partially-succeeded'; + return failures > 0 ? 'failed' : 'succeeded'; +} + +export class ScheduledOperationDispatcher { + private readonly now: () => Date; + private readonly id: () => string; + private readonly sleep: (milliseconds: number) => Promise; + + constructor(private readonly options: ScheduledOperationDispatcherOptions) { + this.now = options.now ?? (() => new Date()); + this.id = options.id ?? randomUUID; + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + async dispatch( + task: ScheduledTask, + targets: readonly ResolvedTarget[], + context: { readonly actor: string; readonly requestId: string }, + ): Promise { + if (task.operationType === 'send-sms') return this.dispatchSms(task, targets, context); + const operationId = + task.operationType === 'restart-service' ? 'postServiceRestart' : 'postSystemReboot'; + const schema = + task.operationType === 'restart-service' + ? 'simadmin.58e2204.postServiceRestart.parameters.v1' + : 'simadmin.58e2204.postSystemReboot.parameters.v1'; + const fields = + task.operationType === 'reboot-system' + ? [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }] + : []; + const jobIds: string[] = []; + let successes = 0; + let failures = 0; + for (const target of targets) { + let succeeded = false; + for (let attempt = 0; attempt <= task.retryPolicy.maxRetries; attempt += 1) { + try { + const preparation = await this.options.operations.prepare( + { + operationId, + targets: [{ instanceId: target.id, revision: target.revision }], + parameters: { parameterSchemaId: schema, fields }, + }, + context.requestId, + ); + const job = await this.options.operations.execute( + { preparationId: preparation.id, confirmationToken: preparation.confirmationToken }, + context.actor, + context.requestId, + ); + jobIds.push(job.id); + if (job.status === 'succeeded') { + succeeded = true; + break; + } + } catch { + // Continue only within the task's explicit bounded retry policy. + } + if (!succeeded && attempt < task.retryPolicy.maxRetries) + await this.sleep(task.retryPolicy.intervalSeconds * 1_000); + } + if (succeeded) successes += 1; + else failures += 1; + } + return { outcome: aggregate(successes, failures), jobIds }; + } + + private async dispatchSms( + task: ScheduledTask, + targets: readonly ResolvedTarget[], + context: { readonly actor: string; readonly requestId: string }, + ): Promise { + const reference = this.options.repository.getSmsSecretReference(task.id); + if (!reference) + return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] }; + const secret = await this.options.store.get(reference); + if (!secret) return { outcome: 'needs-attention', reason: 'SMS secret is missing', jobIds: [] }; + let payload: { recipients: string[]; content: string }; + try { + payload = JSON.parse(secret) as typeof payload; + if ( + !Array.isArray(payload.recipients) || + !payload.recipients.length || + typeof payload.content !== 'string' || + !payload.content + ) + throw new Error('invalid'); + } catch { + return { outcome: 'needs-attention', reason: 'SMS secret is invalid', jobIds: [] }; + } + const jobIds: string[] = []; + let successes = 0; + let failures = 0; + for (const target of targets) { + const result = await this.recordSmsJob(target.id, payload, task.retryPolicy, context); + jobIds.push(result.id); + if (result.succeeded) successes += 1; + else failures += 1; + } + return { outcome: aggregate(successes, failures), jobIds }; + } + + private async recordSmsJob( + instanceId: string, + payload: { recipients: readonly string[]; content: string }, + retryPolicy: ScheduledTask['retryPolicy'], + context: { readonly actor: string; readonly requestId: string }, + ): Promise<{ readonly id: string; readonly succeeded: boolean }> { + const ids = { job: this.id(), item: this.id(), attempt: this.id(), audit: this.id() }; + const startedAt = this.now().toISOString(); + const digest = createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + this.options.db.transaction(() => { + this.options.db + .prepare( + `INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at) + VALUES (?,?,'sendSms','R2','running',?,?,?,?,?,?)`, + ) + .run( + ids.job, + ids.job, + context.actor, + context.requestId, + digest, + startedAt, + startedAt, + startedAt, + ); + this.options.db + .prepare( + "INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)", + ) + .run(ids.attempt, ids.job, startedAt, startedAt); + this.options.db + .prepare( + "INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES (?,?,?,1,'running',?,?,?)", + ) + .run(ids.item, ids.job, instanceId, startedAt, startedAt, startedAt); + })(); + let succeeded = true; + for (const recipient of payload.recipients) { + let delivered = false; + for (let attempt = 0; attempt <= retryPolicy.maxRetries; attempt += 1) { + try { + await this.options.messages.send(instanceId, { + phoneNumber: recipient, + content: payload.content, + }); + delivered = true; + break; + } catch { + delivered = false; + } + if (!delivered && attempt < retryPolicy.maxRetries) + await this.sleep(retryPolicy.intervalSeconds * 1_000); + } + if (!delivered) succeeded = false; + } + const finishedAt = this.now().toISOString(); + const status = succeeded ? 'succeeded' : 'failed'; + this.options.db.transaction(() => { + this.options.db + .prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?') + .run(status, finishedAt, finishedAt, ids.job); + this.options.db + .prepare( + 'UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE id=?', + ) + .run(status, succeeded ? 'SMS_SENT' : 'SMS_FAILED', finishedAt, finishedAt, ids.item); + this.options.db + .prepare('UPDATE job_attempts SET status=?,finished_at=? WHERE id=?') + .run(status, finishedAt, ids.attempt); + this.options.db + .prepare( + `INSERT INTO audit_events + (id,instance_id,job_id,actor,operation_id,risk_level,request_id,parameters_summary_json,body_digest,result_code,duration_ms,created_at) + VALUES (?,?,?,?,'sendSms','R2',?,?,?,?,0,?)`, + ) + .run( + ids.audit, + instanceId, + ids.job, + context.actor, + context.requestId, + JSON.stringify([ + { fieldId: 'recipients', displayValue: '[REDACTED]', redacted: true }, + { fieldId: 'content', displayValue: '[REDACTED]', redacted: true }, + ]), + digest, + succeeded ? 'SMS_SENT' : 'SMS_FAILED', + finishedAt, + ); + })(); + return { id: ids.job, succeeded }; + } +} diff --git a/apps/api/src/application/automation/scheduled-task-repository.test.ts b/apps/api/src/application/automation/scheduled-task-repository.test.ts new file mode 100644 index 0000000..5124536 --- /dev/null +++ b/apps/api/src/application/automation/scheduled-task-repository.test.ts @@ -0,0 +1,173 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; + +function fixture() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + return { db, repository: new ScheduledTaskRepository(db) }; +} + +const task = { + name: 'Morning restart', + operationType: 'restart-service' as const, + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai' as const, + targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a', 'instance-b'] }, + misfirePolicy: 'skip' as const, + overlapPolicy: 'skip' as const, + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, +}; + +describe('ScheduledTaskRepository', () => { + it('round-trips a versioned task without exposing secret payload data', () => { + const { db, repository } = fixture(); + const created = repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + nextDueAt: '2026-07-30T01:00:00.000Z', + }); + + expect(created).toMatchObject({ + id: 'task-1', + version: 1, + nextDueAt: '2026-07-30T01:00:00.000Z', + }); + expect(repository.get('task-1')).toEqual(created); + expect(JSON.stringify(created)).not.toMatch(/secretReference|content|recipient/i); + db.close(); + }); + + it('claims the same scheduled occurrence once and preserves its immutable snapshot', () => { + const { db, repository } = fixture(); + repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + }); + const dueAt = '2026-07-30T01:00:00.000Z'; + const first = repository.claimOccurrence({ + id: 'run-1', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt, + claimedAt: '2026-07-30T01:00:01.000Z', + triggerSource: 'scheduled', + targetSnapshot: ['instance-a', 'instance-b'], + taskSnapshot: task, + }); + const duplicate = repository.claimOccurrence({ + id: 'run-2', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt, + claimedAt: '2026-07-30T01:00:02.000Z', + triggerSource: 'scheduled', + targetSnapshot: ['changed'], + taskSnapshot: task, + }); + + expect(first).toMatchObject({ id: 'run-1', targetSnapshot: ['instance-a', 'instance-b'] }); + expect(duplicate).toBeNull(); + expect(repository.getRun('run-1')?.targetSnapshot).toEqual(['instance-a', 'instance-b']); + db.close(); + }); + + it('rolls back due-time advancement when scheduled claim preparation fails', () => { + const { db, repository } = fixture(); + repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + nextDueAt: '2026-07-30T01:00:00.000Z', + }); + + expect(() => + repository.claimScheduledOccurrence({ + id: 'run-1', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt: '2026-07-30T01:00:00.000Z', + nextDueAt: '2026-07-31T01:00:00.000Z', + claimedAt: '2026-07-30T01:00:00.000Z', + taskSnapshot: task, + overlapPolicy: 'skip', + resolveTargets: () => { + throw new Error('target resolution failed'); + }, + }), + ).toThrow('target resolution failed'); + expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z'); + expect(repository.listRuns()).toHaveLength(0); + db.close(); + }); + + it('soft deletes task configuration while retaining completed run history', () => { + const { db, repository } = fixture(); + repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + }); + repository.claimOccurrence({ + id: 'run-1', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt: '2026-07-30T01:00:00.000Z', + claimedAt: '2026-07-30T01:00:01.000Z', + triggerSource: 'scheduled', + targetSnapshot: [], + taskSnapshot: task, + }); + repository.finishRun('run-1', { + outcome: 'no-targets', + reason: 'No enabled instances matched', + jobIds: [], + finishedAt: '2026-07-30T01:00:02.000Z', + }); + repository.softDelete('task-1', 1, 'operator', '2026-07-30T02:00:00.000Z'); + + expect(repository.get('task-1')).toBeNull(); + expect(repository.getRun('run-1')).toMatchObject({ outcome: 'no-targets' }); + db.close(); + }); + + it('reconciles interrupted started runs to needs-attention', () => { + const { db, repository } = fixture(); + repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + }); + repository.claimOccurrence({ + id: 'run-1', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt: '2026-07-30T01:00:00.000Z', + claimedAt: '2026-07-30T01:00:00.000Z', + triggerSource: 'scheduled', + targetSnapshot: ['instance-a'], + taskSnapshot: task, + }); + repository.startRun('run-1', '2026-07-30T01:00:01.000Z'); + + expect(repository.reconcileInterruptedRuns('2026-07-30T02:00:00.000Z')).toBe(1); + expect(repository.getRun('run-1')).toMatchObject({ + outcome: 'needs-attention', + reason: 'Scheduler stopped before the run outcome was known', + finishedAt: '2026-07-30T02:00:00.000Z', + }); + expect(repository.hasActiveRun('task-1')).toBe(false); + db.close(); + }); +}); diff --git a/apps/api/src/application/automation/scheduled-task-repository.ts b/apps/api/src/application/automation/scheduled-task-repository.ts new file mode 100644 index 0000000..d9b4cd2 --- /dev/null +++ b/apps/api/src/application/automation/scheduled-task-repository.ts @@ -0,0 +1,485 @@ +import type { + CreateScheduledTaskRequest, + ScheduledRun, + ScheduledRunOutcome, + ScheduledRunTriggerSource, + ScheduledTask, +} from '@multi-simadmin/contracts'; + +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; + +interface ScheduledTaskRow { + id: string; + name: string; + operation_type: ScheduledTask['operationType']; + enabled: number; + version: number; + cron_expression: string; + timezone: ScheduledTask['timezone']; + target_selector_json: string; + sms_secret_reference: string | null; + sms_recipient_count: number | null; + effective_start_at: string | null; + effective_end_at: string | null; + misfire_policy: ScheduledTask['misfirePolicy']; + overlap_policy: ScheduledTask['overlapPolicy']; + retry_policy_json: string; + next_due_at: string | null; + last_evaluated_at: string | null; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; +} + +interface ScheduledRunRow { + id: string; + scheduled_task_id: string; + schedule_version: number; + task_snapshot_json: string; + due_at: string; + claimed_at: string; + started_at: string | null; + finished_at: string | null; + target_snapshot_json: string; + outcome: ScheduledRunOutcome | null; + reason: string | null; + job_ids_json: string; + trigger_source: ScheduledRunTriggerSource; + attempt: number; +} + +export interface CreateTaskRecord { + readonly id: string; + readonly task: CreateScheduledTaskRequest; + readonly smsSecretReference?: string; + readonly createdBy: string; + readonly now: string; + readonly nextDueAt?: string; +} + +export interface UpdateTaskRecord { + readonly id: string; + readonly version: number; + readonly task: CreateScheduledTaskRequest; + readonly smsSecretReference?: string; + readonly updatedBy: string; + readonly now: string; + readonly nextDueAt?: string; +} + +export interface ClaimOccurrenceRecord { + readonly id: string; + readonly scheduledTaskId: string; + readonly scheduleVersion: number; + readonly dueAt: string; + readonly claimedAt: string; + readonly triggerSource: ScheduledRunTriggerSource; + readonly targetSnapshot: readonly string[]; + readonly taskSnapshot: CreateScheduledTaskRequest; + readonly attempt?: number; +} + +export interface ScheduledClaimTarget { + readonly id: string; + readonly revision: number; +} + +export interface ClaimScheduledOccurrenceRecord { + readonly id: string; + readonly scheduledTaskId: string; + readonly scheduleVersion: number; + readonly dueAt: string; + readonly nextDueAt: string; + readonly claimedAt: string; + readonly taskSnapshot: CreateScheduledTaskRequest; + readonly overlapPolicy: ScheduledTask['overlapPolicy']; + readonly resolveTargets: () => { + readonly targets: readonly ScheduledClaimTarget[]; + readonly targetSnapshot?: readonly string[]; + readonly attentionReason?: string; + }; + readonly terminal?: { + readonly outcome: ScheduledRunOutcome; + readonly reason: string; + }; +} + +export interface ScheduledClaimResult { + readonly run: ScheduledRun; + readonly targets: readonly ScheduledClaimTarget[]; + readonly disposition: 'execute' | 'queued' | 'finished'; +} + +export interface FinishRunRecord { + readonly outcome: ScheduledRunOutcome; + readonly reason?: string; + readonly jobIds: readonly string[]; + readonly finishedAt: string; +} + +function parseJson(value: string, label: string): T { + try { + return JSON.parse(value) as T; + } catch { + throw new Error(`Stored ${label} is invalid`); + } +} + +function projectTask(row: ScheduledTaskRow): ScheduledTask { + return { + id: row.id, + name: row.name, + operationType: row.operation_type, + cronExpression: row.cron_expression, + timezone: row.timezone, + targetSelector: parseJson(row.target_selector_json, 'target selector'), + ...(row.sms_secret_reference + ? { sms: { configured: true, recipientCount: row.sms_recipient_count ?? 0 } } + : {}), + ...(row.effective_start_at ? { effectiveStartAt: row.effective_start_at } : {}), + ...(row.effective_end_at ? { effectiveEndAt: row.effective_end_at } : {}), + misfirePolicy: row.misfire_policy, + overlapPolicy: row.overlap_policy, + retryPolicy: parseJson(row.retry_policy_json, 'retry policy'), + enabled: row.enabled === 1, + version: row.version, + ...(row.next_due_at ? { nextDueAt: row.next_due_at } : {}), + ...(row.last_evaluated_at ? { lastEvaluatedAt: row.last_evaluated_at } : {}), + createdBy: row.created_by, + updatedBy: row.updated_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function projectRun(row: ScheduledRunRow): ScheduledRun { + const task = parseJson(row.task_snapshot_json, 'task snapshot'); + return { + id: row.id, + scheduledTaskId: row.scheduled_task_id, + scheduleVersion: row.schedule_version, + taskName: task.name, + operationType: task.operationType, + dueAt: row.due_at, + claimedAt: row.claimed_at, + ...(row.started_at ? { startedAt: row.started_at } : {}), + ...(row.finished_at ? { finishedAt: row.finished_at } : {}), + targetSnapshot: parseJson(row.target_snapshot_json, 'target snapshot'), + ...(row.outcome ? { outcome: row.outcome } : {}), + ...(row.reason ? { reason: row.reason } : {}), + jobIds: parseJson(row.job_ids_json, 'job ids'), + triggerSource: row.trigger_source, + attempt: row.attempt, + }; +} + +export class ScheduledTaskRepository { + constructor(private readonly db: SqliteDatabase) {} + + create(input: CreateTaskRecord): ScheduledTask { + const task = input.task; + this.db + .prepare( + `INSERT INTO scheduled_tasks + (id,name,operation_type,enabled,version,cron_expression,timezone,target_selector_json, + sms_secret_reference,sms_recipient_count,effective_start_at,effective_end_at,misfire_policy, + overlap_policy,retry_policy_json,next_due_at,created_by,updated_by,created_at,updated_at) + VALUES (?,?,?,?,1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) + .run( + input.id, + task.name, + task.operationType, + task.enabled ? 1 : 0, + task.cronExpression, + task.timezone, + JSON.stringify(task.targetSelector), + input.smsSecretReference ?? null, + task.sms?.recipients.length ?? null, + task.effectiveStartAt ?? null, + task.effectiveEndAt ?? null, + task.misfirePolicy, + task.overlapPolicy, + JSON.stringify(task.retryPolicy), + input.nextDueAt ?? null, + input.createdBy, + input.createdBy, + input.now, + input.now, + ); + const created = this.get(input.id); + if (!created) throw new Error('Scheduled task was not persisted'); + return created; + } + + update(input: UpdateTaskRecord): ScheduledTask { + const task = input.task; + const result = this.db + .prepare( + `UPDATE scheduled_tasks + SET name = ?, operation_type = ?, enabled = ?, version = version + 1, + cron_expression = ?, timezone = ?, target_selector_json = ?, + sms_secret_reference = ?, sms_recipient_count = ?, effective_start_at = ?, + effective_end_at = ?, misfire_policy = ?, overlap_policy = ?, retry_policy_json = ?, + next_due_at = ?, last_evaluated_at = NULL, updated_by = ?, updated_at = ? + WHERE id = ? AND version = ? AND deleted_at IS NULL`, + ) + .run( + task.name, + task.operationType, + task.enabled ? 1 : 0, + task.cronExpression, + task.timezone, + JSON.stringify(task.targetSelector), + input.smsSecretReference ?? null, + task.sms?.recipients.length ?? null, + task.effectiveStartAt ?? null, + task.effectiveEndAt ?? null, + task.misfirePolicy, + task.overlapPolicy, + JSON.stringify(task.retryPolicy), + input.nextDueAt ?? null, + input.updatedBy, + input.now, + input.id, + input.version, + ); + if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed'); + const updated = this.get(input.id); + if (!updated) throw new Error('Scheduled task was not persisted'); + return updated; + } + + get(id: string): ScheduledTask | null { + const row = this.db + .prepare('SELECT * FROM scheduled_tasks WHERE id = ? AND deleted_at IS NULL') + .get(id) as ScheduledTaskRow | undefined; + return row ? projectTask(row) : null; + } + + list(): readonly ScheduledTask[] { + return ( + this.db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE deleted_at IS NULL ORDER BY updated_at DESC, id ASC', + ) + .all() as ScheduledTaskRow[] + ).map(projectTask); + } + + getSmsSecretReference(id: string): string | undefined { + const row = this.db + .prepare( + 'SELECT sms_secret_reference FROM scheduled_tasks WHERE id = ? AND deleted_at IS NULL', + ) + .get(id) as { sms_secret_reference: string | null } | undefined; + return row?.sms_secret_reference ?? undefined; + } + + setEnabled( + id: string, + version: number, + enabled: boolean, + actor: string, + now: string, + ): ScheduledTask { + const result = this.db + .prepare( + `UPDATE scheduled_tasks + SET enabled = ?, version = version + 1, updated_by = ?, updated_at = ? + WHERE id = ? AND version = ? AND deleted_at IS NULL`, + ) + .run(enabled ? 1 : 0, actor, now, id, version); + if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed'); + const task = this.get(id); + if (!task) throw new Error('Scheduled task was not persisted'); + return task; + } + + advanceNextDue(id: string, version: number, nextDueAt: string, evaluatedAt: string): boolean { + return ( + this.db + .prepare( + `UPDATE scheduled_tasks SET next_due_at = ?, last_evaluated_at = ? + WHERE id = ? AND version = ? AND enabled = 1 AND deleted_at IS NULL`, + ) + .run(nextDueAt, evaluatedAt, id, version).changes === 1 + ); + } + + startRun(id: string, startedAt: string): void { + const result = this.db + .prepare( + 'UPDATE scheduled_runs SET started_at = ? WHERE id = ? AND started_at IS NULL AND finished_at IS NULL', + ) + .run(startedAt, id); + if (result.changes !== 1) throw new Error('Scheduled run is missing or already started'); + } + + hasActiveRun(scheduledTaskId: string): boolean { + return !!this.db + .prepare( + 'SELECT 1 FROM scheduled_runs WHERE scheduled_task_id = ? AND started_at IS NOT NULL AND finished_at IS NULL LIMIT 1', + ) + .get(scheduledTaskId); + } + + reconcileInterruptedRuns(finishedAt: string): number { + return this.db + .prepare( + `UPDATE scheduled_runs + SET outcome = 'needs-attention', + reason = 'Scheduler stopped before the run outcome was known', + finished_at = ? + WHERE started_at IS NOT NULL AND finished_at IS NULL`, + ) + .run(finishedAt).changes; + } + + getQueuedRun(scheduledTaskId: string): ScheduledRun | null { + const row = this.db + .prepare( + `SELECT * FROM scheduled_runs + WHERE scheduled_task_id = ? AND started_at IS NULL AND finished_at IS NULL + ORDER BY claimed_at ASC, id ASC LIMIT 1`, + ) + .get(scheduledTaskId) as ScheduledRunRow | undefined; + return row ? projectRun(row) : null; + } + + claimOccurrence(input: ClaimOccurrenceRecord): ScheduledRun | null { + const result = this.db + .prepare( + `INSERT OR IGNORE INTO scheduled_runs + (id,scheduled_task_id,schedule_version,task_snapshot_json,due_at,claimed_at,target_snapshot_json, + job_ids_json,trigger_source,attempt) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ) + .run( + input.id, + input.scheduledTaskId, + input.scheduleVersion, + JSON.stringify(input.taskSnapshot), + input.dueAt, + input.claimedAt, + JSON.stringify(input.targetSnapshot), + '[]', + input.triggerSource, + input.attempt ?? 1, + ); + return result.changes === 0 ? null : this.getRun(input.id); + } + + claimScheduledOccurrence(input: ClaimScheduledOccurrenceRecord): ScheduledClaimResult | null { + return this.db.transaction((): ScheduledClaimResult | null => { + const advanced = this.db + .prepare( + `UPDATE scheduled_tasks SET next_due_at = ?, last_evaluated_at = ? + WHERE id = ? AND version = ? AND enabled = 1 AND deleted_at IS NULL AND next_due_at = ?`, + ) + .run( + input.nextDueAt, + input.claimedAt, + input.scheduledTaskId, + input.scheduleVersion, + input.dueAt, + ); + if (advanced.changes !== 1) return null; + + const resolution = input.terminal + ? { targets: [] as readonly ScheduledClaimTarget[] } + : input.resolveTargets(); + const targets = [...resolution.targets]; + let disposition: ScheduledClaimResult['disposition'] = 'execute'; + let terminal = + input.terminal ?? + (resolution.attentionReason + ? { outcome: 'needs-attention' as const, reason: resolution.attentionReason } + : undefined); + if (!terminal && this.hasActiveRun(input.scheduledTaskId)) { + const queued = this.getQueuedRun(input.scheduledTaskId); + if (input.overlapPolicy === 'queue-once' && !queued) { + disposition = 'queued'; + } else { + disposition = 'finished'; + terminal = { + outcome: 'skipped', + reason: + input.overlapPolicy === 'queue-once' + ? 'Overlap queue is full' + : 'Overlapping occurrence skipped by task policy', + }; + } + } + + const claimed = this.claimOccurrence({ + id: input.id, + scheduledTaskId: input.scheduledTaskId, + scheduleVersion: input.scheduleVersion, + dueAt: input.dueAt, + claimedAt: input.claimedAt, + triggerSource: 'scheduled', + targetSnapshot: resolution.targetSnapshot ?? targets.map((target) => target.id), + taskSnapshot: input.taskSnapshot, + }); + if (!claimed) return null; + if (terminal) { + return { + run: this.finishRun(claimed.id, { + outcome: terminal.outcome, + reason: terminal.reason, + jobIds: [], + finishedAt: input.claimedAt, + }), + targets, + disposition: 'finished', + }; + } + return { run: claimed, targets, disposition }; + })(); + } + + getRun(id: string): ScheduledRun | null { + const row = this.db.prepare('SELECT * FROM scheduled_runs WHERE id = ?').get(id) as + | ScheduledRunRow + | undefined; + return row ? projectRun(row) : null; + } + + listRuns(scheduledTaskId?: string): readonly ScheduledRun[] { + const rows = scheduledTaskId + ? (this.db + .prepare( + 'SELECT * FROM scheduled_runs WHERE scheduled_task_id = ? ORDER BY due_at DESC, id ASC', + ) + .all(scheduledTaskId) as ScheduledRunRow[]) + : (this.db + .prepare('SELECT * FROM scheduled_runs ORDER BY due_at DESC, id ASC') + .all() as ScheduledRunRow[]); + return rows.map(projectRun); + } + + finishRun(id: string, input: FinishRunRecord): ScheduledRun { + const result = this.db + .prepare( + `UPDATE scheduled_runs SET outcome = ?, reason = ?, job_ids_json = ?, finished_at = ? + WHERE id = ? AND finished_at IS NULL`, + ) + .run(input.outcome, input.reason ?? null, JSON.stringify(input.jobIds), input.finishedAt, id); + if (result.changes !== 1) throw new Error('Scheduled run is missing or already finished'); + const run = this.getRun(id); + if (!run) throw new Error('Scheduled run was not persisted'); + return run; + } + + softDelete(id: string, version: number, actor: string, now: string): void { + const result = this.db + .prepare( + `UPDATE scheduled_tasks SET enabled = 0, version = version + 1, updated_by = ?, updated_at = ?, deleted_at = ? + WHERE id = ? AND version = ? AND deleted_at IS NULL`, + ) + .run(actor, now, now, id, version); + if (result.changes !== 1) throw new Error('Scheduled task was not found or version changed'); + } +} diff --git a/apps/api/src/application/automation/scheduled-task-service.test.ts b/apps/api/src/application/automation/scheduled-task-service.test.ts new file mode 100644 index 0000000..b9d4b82 --- /dev/null +++ b/apps/api/src/application/automation/scheduled-task-service.test.ts @@ -0,0 +1,235 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; +import { ScheduledTaskService } from './scheduled-task-service.js'; + +class MemorySecrets implements SecretStore { + readonly values = new Map(); + readonly setCalls: Array<{ key: SecretKey; value: string }> = []; + blockRotations = false; + private rotationWaiters: Array<() => void> = []; + + async set(key: SecretKey, value: string): Promise { + const reference = `memory://${key.instanceId}/${key.purpose}/${key.slot ?? 'default'}`; + this.setCalls.push({ key, value }); + this.values.set(reference, value); + if (this.blockRotations && key.slot) { + await new Promise((resolve) => { + this.rotationWaiters.push(resolve); + if (this.rotationWaiters.length === 2) { + for (const waiter of this.rotationWaiters.splice(0)) waiter(); + } + }); + } + return reference; + } + async get(reference: string): Promise { + return this.values.get(reference); + } + async delete(reference: string): Promise { + return this.values.delete(reference); + } +} + +function fixture() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + const store = new MemorySecrets(); + const repository = new ScheduledTaskRepository(db); + let id = 0; + const service = new ScheduledTaskService({ + repository, + store, + now: () => new Date('2026-07-30T00:00:00.000Z'), + id: () => `task-${++id}`, + }); + return { db, repository, service, store }; +} + +const base = { + name: 'Morning SMS', + operationType: 'send-sms', + cronExpression: '0 9 * * *', + targetSelector: { mode: 'tags', match: 'all', tags: ['lab'] }, + sms: { recipients: ['13800138000', '13900139000'], content: 'Maintenance complete' }, +}; + +describe('ScheduledTaskService', () => { + it('stores SMS payload only in the secret store and returns a redacted summary', async () => { + const { db, service, store } = fixture(); + const created = await service.create('operator', base); + + expect(created.sms).toEqual({ configured: true, recipientCount: 2 }); + expect(JSON.stringify(created)).not.toContain('13800138000'); + expect(JSON.stringify(created)).not.toContain('Maintenance complete'); + expect(store.setCalls[0]?.value).toBe( + JSON.stringify({ + recipients: ['13800138000', '13900139000'], + content: 'Maintenance complete', + }), + ); + const persisted = db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get('task-1'); + expect(JSON.stringify(persisted)).not.toContain('13800138000'); + expect(JSON.stringify(persisted)).not.toContain('Maintenance complete'); + db.close(); + }); + + it('increments the optimistic version when pausing and rejects a stale update', async () => { + const { db, service } = fixture(); + const created = await service.create('operator', { + ...base, + operationType: 'restart-service', + sms: undefined, + }); + expect(service.setEnabled('operator', created.id, created.version, false).version).toBe(2); + expect(() => service.setEnabled('operator', created.id, created.version, true)).toThrow( + /version/i, + ); + db.close(); + }); + + it('does not schedule the first occurrence before the effective start', async () => { + const { db, service } = fixture(); + const created = await service.create('operator', { + ...base, + operationType: 'restart-service', + sms: undefined, + effectiveStartAt: '2026-08-05T00:00:00.000Z', + }); + + expect(created.nextDueAt).toBe('2026-08-05T01:00:00.000Z'); + db.close(); + }); + + it('keeps immutable run history after deleting its schedule', async () => { + const { db, repository, service } = fixture(); + const created = await service.create('operator', { + ...base, + operationType: 'restart-service', + sms: undefined, + }); + repository.claimOccurrence({ + id: 'run-1', + scheduledTaskId: created.id, + scheduleVersion: created.version, + dueAt: '2026-07-30T01:00:00.000Z', + claimedAt: '2026-07-30T01:00:00.000Z', + triggerSource: 'manual', + targetSnapshot: [], + taskSnapshot: { + name: base.name, + cronExpression: base.cronExpression, + targetSelector: { mode: 'tags' as const, match: 'all' as const, tags: ['lab'] }, + operationType: 'restart-service', + timezone: 'Asia/Shanghai', + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, + }, + }); + await service.remove('operator', created.id, created.version); + + expect(service.get(created.id)).toBeNull(); + expect(repository.getRun('run-1')).not.toBeNull(); + db.close(); + }); + + it('updates the complete task, preserves an unchanged SMS secret, and advances the version', async () => { + const { db, service, store } = fixture(); + const created = await service.create('operator', base); + const reference = store.setCalls[0]?.key; + + const updated = await service.update('editor', created.id, created.version, { + name: 'Evening SMS', + cronExpression: '30 18 * * *', + effectiveStartAt: '2026-08-01T00:00:00.000Z', + effectiveEndAt: '2026-09-01T00:00:00.000Z', + overlapPolicy: 'queue-once', + }); + + expect(updated).toMatchObject({ + name: 'Evening SMS', + cronExpression: '30 18 * * *', + overlapPolicy: 'queue-once', + version: 2, + updatedBy: 'editor', + sms: { configured: true, recipientCount: 2 }, + }); + expect(store.setCalls).toHaveLength(1); + expect(store.setCalls[0]?.key).toEqual(reference); + db.close(); + }); + + it('rotates edited SMS secrets and validates retry policy against the effective operation', async () => { + const { db, service, store } = fixture(); + const created = await service.create('operator', base); + const oldReference = [...store.values.keys()][0]; + + const updated = await service.update('editor', created.id, created.version, { + sms: { recipients: ['13700137000'], content: 'Updated content' }, + }); + expect(updated.sms).toEqual({ configured: true, recipientCount: 1 }); + expect(store.setCalls).toHaveLength(2); + expect(oldReference ? store.values.has(oldReference) : true).toBe(false); + + const reboot = await service.create('operator', { + ...base, + name: 'Reboot', + operationType: 'reboot-system', + sms: undefined, + }); + await expect( + service.update('editor', reboot.id, reboot.version, { + retryPolicy: { maxRetries: 1, intervalSeconds: 60 }, + }), + ).rejects.toThrow(/reboot/i); + db.close(); + }); + + it('does not delete the winning SMS secret when concurrent updates race', async () => { + const { db, repository, service, store } = fixture(); + const created = await service.create('operator', base); + store.blockRotations = true; + + const results = await Promise.allSettled([ + service.update('editor-a', created.id, created.version, { + sms: { recipients: ['13700137000'], content: 'Update A' }, + }), + service.update('editor-b', created.id, created.version, { + sms: { recipients: ['13600136000'], content: 'Update B' }, + }), + ]); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + const currentReference = repository.getSmsSecretReference(created.id); + expect(currentReference).toBeDefined(); + expect(currentReference ? store.values.has(currentReference) : false).toBe(true); + expect(store.values).toHaveLength(1); + db.close(); + }); + + it('duplicates configuration and SMS secrets into a disabled independent task', async () => { + const { db, service, store } = fixture(); + const created = await service.create('operator', base); + + const duplicate = await service.duplicate('operator', created.id, created.version); + + expect(duplicate).toMatchObject({ + id: 'task-2', + name: 'Morning SMS copy', + operationType: 'send-sms', + enabled: false, + version: 1, + sms: { configured: true, recipientCount: 2 }, + }); + expect(store.setCalls).toHaveLength(2); + expect(store.setCalls[1]?.value).toBe(store.setCalls[0]?.value); + db.close(); + }); +}); diff --git a/apps/api/src/application/automation/scheduled-task-service.ts b/apps/api/src/application/automation/scheduled-task-service.ts new file mode 100644 index 0000000..7bfc6c7 --- /dev/null +++ b/apps/api/src/application/automation/scheduled-task-service.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto'; +import { + parseCreateScheduledTaskRequest, + parseUpdateScheduledTaskRequest, + type CreateScheduledTaskRequest, + type ScheduledTask, + type ScheduledSmsInput, +} from '@multi-simadmin/contracts'; + +import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; +import { nextOccurrence, previewCron } from './schedule-time.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; + +export interface ScheduledTaskServiceOptions { + readonly repository: ScheduledTaskRepository; + readonly store: SecretStore; + readonly now?: () => Date; + readonly id?: () => string; + readonly secretSlot?: () => string; +} + +export class ScheduledTaskService { + private readonly repository: ScheduledTaskRepository; + private readonly store: SecretStore; + private readonly now: () => Date; + private readonly id: () => string; + private readonly secretSlot: () => string; + + constructor(options: ScheduledTaskServiceOptions) { + this.repository = options.repository; + this.store = options.store; + this.now = options.now ?? (() => new Date()); + this.id = options.id ?? randomUUID; + this.secretSlot = options.secretSlot ?? randomUUID; + } + + async create(actor: string, value: unknown): Promise { + const task = parseCreateScheduledTaskRequest(value); + const now = this.now(); + previewCron(task.cronExpression, 1, now); + const id = this.id(); + let smsSecretReference: string | undefined; + if (task.sms) { + smsSecretReference = await this.store.set( + { instanceId: id, purpose: 'scheduled-sms' }, + JSON.stringify(task.sms), + ); + } + try { + return this.repository.create({ + id, + task, + ...(smsSecretReference ? { smsSecretReference } : {}), + createdBy: actor, + now: now.toISOString(), + nextDueAt: this.nextDueAt(task, now), + }); + } catch (error) { + if (smsSecretReference) await this.store.delete(smsSecretReference).catch(() => false); + throw error; + } + } + + async update(actor: string, id: string, version: number, value: unknown): Promise { + const source = this.updateObject(value); + const change = parseUpdateScheduledTaskRequest({ ...source, version }); + const current = this.repository.get(id); + if (!current || current.version !== version) + throw new Error('Scheduled task was not found or version changed'); + + const oldReference = this.repository.getSmsSecretReference(id); + const operationType = change.operationType ?? current.operationType; + let smsPayload: ScheduledSmsInput | undefined; + if (operationType === 'send-sms') { + if (change.sms === null) throw new TypeError('SMS configuration is required'); + if (change.sms) smsPayload = change.sms; + else smsPayload = await this.readSms(oldReference); + } else if (change.sms) { + throw new TypeError('SMS is only valid for send-sms'); + } + + const merged = parseCreateScheduledTaskRequest({ + name: change.name ?? current.name, + operationType, + cronExpression: change.cronExpression ?? current.cronExpression, + timezone: change.timezone ?? current.timezone, + targetSelector: change.targetSelector ?? current.targetSelector, + ...(smsPayload ? { sms: smsPayload } : {}), + ...(change.effectiveStartAt === null + ? {} + : change.effectiveStartAt + ? { effectiveStartAt: change.effectiveStartAt } + : current.effectiveStartAt + ? { effectiveStartAt: current.effectiveStartAt } + : {}), + ...(change.effectiveEndAt === null + ? {} + : change.effectiveEndAt + ? { effectiveEndAt: change.effectiveEndAt } + : current.effectiveEndAt + ? { effectiveEndAt: current.effectiveEndAt } + : {}), + misfirePolicy: change.misfirePolicy ?? current.misfirePolicy, + overlapPolicy: change.overlapPolicy ?? current.overlapPolicy, + retryPolicy: change.retryPolicy ?? current.retryPolicy, + enabled: change.enabled ?? current.enabled, + }); + const now = this.now(); + previewCron(merged.cronExpression, 1, now); + + let nextReference = operationType === 'send-sms' ? oldReference : undefined; + let wroteReference: string | undefined; + if (operationType === 'send-sms' && change.sms) { + wroteReference = await this.store.set( + { instanceId: id, purpose: 'scheduled-sms', slot: this.secretSlot() }, + JSON.stringify(change.sms), + ); + nextReference = wroteReference; + } + if (operationType === 'send-sms' && !nextReference) + throw new Error('Scheduled SMS secret is missing'); + + try { + const updated = this.repository.update({ + id, + version, + task: merged, + ...(nextReference ? { smsSecretReference: nextReference } : {}), + updatedBy: actor, + now: now.toISOString(), + nextDueAt: this.nextDueAt(merged, now), + }); + if (oldReference && oldReference !== nextReference) + await this.store.delete(oldReference).catch(() => false); + return updated; + } catch (error) { + if (wroteReference) await this.store.delete(wroteReference).catch(() => false); + throw error; + } + } + + async duplicate(actor: string, id: string, version: number): Promise { + const current = this.repository.get(id); + if (!current || current.version !== version) + throw new Error('Scheduled task was not found or version changed'); + const smsPayload = + current.operationType === 'send-sms' + ? await this.readSms(this.repository.getSmsSecretReference(id)) + : undefined; + const suffix = ' copy'; + return this.create(actor, { + name: `${current.name.slice(0, 120 - suffix.length)}${suffix}`, + operationType: current.operationType, + cronExpression: current.cronExpression, + timezone: current.timezone, + targetSelector: current.targetSelector, + ...(smsPayload ? { sms: smsPayload } : {}), + ...(current.effectiveStartAt ? { effectiveStartAt: current.effectiveStartAt } : {}), + ...(current.effectiveEndAt ? { effectiveEndAt: current.effectiveEndAt } : {}), + misfirePolicy: current.misfirePolicy, + overlapPolicy: current.overlapPolicy, + retryPolicy: current.retryPolicy, + enabled: false, + }); + } + + get(id: string): ScheduledTask | null { + return this.repository.get(id); + } + + list(): readonly ScheduledTask[] { + return this.repository.list(); + } + + preview(cronExpression: string, count = 5, from = this.now()): readonly string[] { + return previewCron(cronExpression, count, from); + } + + setEnabled(actor: string, id: string, version: number, enabled: boolean): ScheduledTask { + return this.repository.setEnabled(id, version, enabled, actor, this.now().toISOString()); + } + + async remove(actor: string, id: string, version: number): Promise { + const reference = this.repository.getSmsSecretReference(id); + this.repository.softDelete(id, version, actor, this.now().toISOString()); + if (reference) await this.store.delete(reference).catch(() => false); + } + + private updateObject(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Schedule update must be an object'); + return value as Record; + } + + private async readSms(reference: string | undefined): Promise { + if (!reference) throw new Error('Scheduled SMS secret is missing'); + const value = await this.store.get(reference); + if (!value) throw new Error('Scheduled SMS secret is missing'); + try { + const parsed = JSON.parse(value) as unknown; + return parseCreateScheduledTaskRequest({ + name: 'SMS validation', + operationType: 'send-sms', + cronExpression: '0 0 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['secret-validation'] }, + sms: parsed, + }).sms as ScheduledSmsInput; + } catch { + throw new Error('Scheduled SMS secret is invalid'); + } + } + + private nextDueAt(task: CreateScheduledTaskRequest, now: Date): string { + const anchor = task.effectiveStartAt + ? new Date(Math.max(now.getTime(), Date.parse(task.effectiveStartAt))) + : now; + return nextOccurrence(task.cronExpression, anchor); + } +} diff --git a/apps/api/src/application/automation/scheduler-coordinator.test.ts b/apps/api/src/application/automation/scheduler-coordinator.test.ts new file mode 100644 index 0000000..3d6e0b2 --- /dev/null +++ b/apps/api/src/application/automation/scheduler-coordinator.test.ts @@ -0,0 +1,321 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it, vi } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; +import { SchedulerCoordinator } from './scheduler-coordinator.js'; +import type { ScheduledTask } from '@multi-simadmin/contracts'; +import type { ResolvedTarget } from './target-resolver.js'; + +function fixture() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + const now = '2026-07-30T01:00:00.000Z'; + db.prepare( + 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', + ).run('a', 'Alpha', 'http://10.0.0.1', 1, 2, now, now); + db.prepare( + 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', + ).run('b', 'Beta', 'http://10.0.0.2', 1, 3, now, now); + const capability = db.prepare( + "INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,'postServiceRestart','supported',?,?,?)", + ); + capability.run('a', now, now, now); + capability.run('b', now, now, now); + const repository = new ScheduledTaskRepository(db); + repository.create({ + id: 'task-1', + task: { + name: 'Restart lab', + operationType: 'restart-service', + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] }, + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, + }, + createdBy: 'operator', + now, + nextDueAt: now, + }); + return { db, repository }; +} + +describe('SchedulerCoordinator', () => { + it('resolves multiple targets, persists the snapshot, and finishes a manual run', async () => { + const { db, repository } = fixture(); + const dispatch = vi.fn< + ( + task: ScheduledTask, + targets: readonly ResolvedTarget[], + ) => Promise<{ outcome: 'succeeded'; jobIds: string[] }> + >(async () => ({ + outcome: 'succeeded', + jobIds: ['job-a', 'job-b'], + })); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'run-1', + }); + + const run = await coordinator.runNow('task-1', 'operator', 'request-1'); + expect(run).toMatchObject({ + id: 'run-1', + triggerSource: 'manual', + targetSnapshot: ['a', 'b'], + outcome: 'succeeded', + jobIds: ['job-a', 'job-b'], + }); + expect(dispatch.mock.calls[0]?.[1]).toEqual([ + { id: 'a', revision: 2 }, + { id: 'b', revision: 3 }, + ]); + db.close(); + }); + + it('does not manually dispatch when a selected target lacks the required capability', async () => { + const { db, repository } = fixture(); + db.prepare( + "UPDATE capabilities SET state = 'unsupported' WHERE instance_id = 'b' AND operation_id = 'postServiceRestart'", + ).run(); + const dispatch = vi.fn(); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'manual-capability-run', + }); + + const run = await coordinator.runNow('task-1', 'operator', 'request-1'); + expect(run).toMatchObject({ outcome: 'needs-attention', targetSnapshot: ['a', 'b'] }); + expect(dispatch).not.toHaveBeenCalled(); + db.close(); + }); + + it('records no-targets without dispatching and prevents duplicate scheduled claims', async () => { + const { db, repository } = fixture(); + db.prepare('UPDATE instances SET enabled = 0').run(); + const dispatch = vi.fn(); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'run-1', + }); + const first = await coordinator.tick(); + const second = await coordinator.tick(); + expect(first).toHaveLength(1); + expect(first[0]).toMatchObject({ outcome: 'no-targets' }); + expect(second).toHaveLength(0); + expect(dispatch).not.toHaveBeenCalled(); + db.close(); + }); + + it('records needs-attention when a matched target loses its required capability', async () => { + const { db, repository } = fixture(); + db.prepare( + "UPDATE capabilities SET state = 'unsupported' WHERE instance_id = 'b' AND operation_id = 'postServiceRestart'", + ).run(); + const dispatch = vi.fn(); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'capability-run', + }); + + const runs = await coordinator.tick(); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + outcome: 'needs-attention', + targetSnapshot: ['a', 'b'], + }); + expect(runs[0]?.reason).toContain('b'); + expect(dispatch).not.toHaveBeenCalled(); + db.close(); + }); + + it('queues one overlapping occurrence, skips a second, and dispatches the queued run later', async () => { + const { db, repository } = fixture(); + db.prepare("UPDATE scheduled_tasks SET overlap_policy = 'queue-once'").run(); + const task = repository.get('task-1')!; + const taskSnapshot = { + name: task.name, + operationType: task.operationType, + cronExpression: task.cronExpression, + timezone: task.timezone, + targetSelector: task.targetSelector, + misfirePolicy: task.misfirePolicy, + overlapPolicy: task.overlapPolicy, + retryPolicy: task.retryPolicy, + enabled: task.enabled, + }; + repository.claimOccurrence({ + id: 'active-run', + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt: '2026-07-30T00:00:00.000Z', + claimedAt: '2026-07-30T00:00:00.000Z', + triggerSource: 'manual', + targetSnapshot: ['a'], + taskSnapshot, + }); + repository.startRun('active-run', '2026-07-30T00:00:00.000Z'); + let now = new Date('2026-07-30T01:00:00.000Z'); + let id = 0; + const dispatch = vi.fn(async () => ({ outcome: 'succeeded' as const, jobIds: ['job-1'] })); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => now, + id: () => `queued-${++id}`, + }); + + expect(await coordinator.tick()).toHaveLength(0); + expect(repository.getQueuedRun('task-1')).toMatchObject({ + id: 'queued-1', + targetSnapshot: ['a', 'b'], + }); + + now = new Date('2026-07-31T01:00:00.000Z'); + const queueFull = await coordinator.tick(); + expect(queueFull).toHaveLength(1); + expect(queueFull[0]).toMatchObject({ outcome: 'skipped', reason: 'Overlap queue is full' }); + expect(dispatch).not.toHaveBeenCalled(); + + repository.finishRun('active-run', { + outcome: 'succeeded', + jobIds: [], + finishedAt: now.toISOString(), + }); + const completed = await coordinator.tick(); + expect(completed[0]).toMatchObject({ id: 'queued-1', outcome: 'succeeded' }); + expect(dispatch).toHaveBeenCalledTimes(1); + db.close(); + }); + + it('records and advances an occurrence outside the effective window', async () => { + const { db, repository } = fixture(); + db.prepare("UPDATE scheduled_tasks SET effective_end_at = '2026-07-29T23:59:00.000Z'").run(); + const dispatch = vi.fn(); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'window-skip', + }); + + const runs = await coordinator.tick(); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + outcome: 'skipped', + reason: 'Occurrence is outside the effective window', + }); + expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-31T01:00:00.000Z'); + expect(dispatch).not.toHaveBeenCalled(); + db.close(); + }); + + it('does not advance a due occurrence when its claim cannot be persisted', async () => { + const { db, repository } = fixture(); + vi.spyOn(repository, 'claimOccurrence').mockImplementationOnce(() => { + throw new Error('claim failed'); + }); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch: vi.fn(), + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'failed-claim', + }); + + await expect(coordinator.tick()).rejects.toThrow('claim failed'); + expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z'); + db.close(); + }); + + it('executes at most the queued occurrence for a task during one tick', async () => { + const { db, repository } = fixture(); + const task = repository.get('task-1')!; + repository.claimOccurrence({ + id: 'queued-run', + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt: '2026-07-29T01:00:00.000Z', + claimedAt: '2026-07-29T01:00:00.000Z', + triggerSource: 'scheduled', + targetSnapshot: ['a'], + taskSnapshot: { + name: task.name, + operationType: task.operationType, + cronExpression: task.cronExpression, + timezone: task.timezone, + targetSelector: task.targetSelector, + misfirePolicy: task.misfirePolicy, + overlapPolicy: task.overlapPolicy, + retryPolicy: task.retryPolicy, + enabled: task.enabled, + }, + }); + let id = 0; + const dispatch = vi.fn(async () => ({ outcome: 'succeeded' as const, jobIds: ['job-1'] })); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => `run-${++id}`, + }); + + const completed = await coordinator.tick(); + expect(completed).toHaveLength(1); + expect(completed[0]?.id).toBe('queued-run'); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(repository.get('task-1')?.nextDueAt).toBe('2026-07-30T01:00:00.000Z'); + db.close(); + }); + + it('does not reschedule after stop while a tick is still running', async () => { + vi.useFakeTimers(); + const { db, repository } = fixture(); + let finishDispatch!: (value: { outcome: 'succeeded'; jobIds: string[] }) => void; + const dispatch = vi.fn( + () => + new Promise<{ outcome: 'succeeded'; jobIds: string[] }>((resolve) => { + finishDispatch = resolve; + }), + ); + const coordinator = new SchedulerCoordinator({ + db, + repository, + dispatch, + now: () => new Date('2026-07-30T01:00:00.000Z'), + id: () => 'in-flight-run', + intervalMs: 1_000, + }); + + try { + coordinator.start(); + expect(dispatch).toHaveBeenCalledTimes(1); + coordinator.stop(); + finishDispatch({ outcome: 'succeeded', jobIds: ['job-1'] }); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + coordinator.stop(); + vi.useRealTimers(); + db.close(); + } + }); +}); diff --git a/apps/api/src/application/automation/scheduler-coordinator.ts b/apps/api/src/application/automation/scheduler-coordinator.ts new file mode 100644 index 0000000..02ced99 --- /dev/null +++ b/apps/api/src/application/automation/scheduler-coordinator.ts @@ -0,0 +1,326 @@ +import { randomUUID } from 'node:crypto'; +import type { + CreateScheduledTaskRequest, + ScheduledRun, + ScheduledRunOutcome, + ScheduledTask, +} from '@multi-simadmin/contracts'; + +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; +import { nextOccurrence, reconcileOccurrence } from './schedule-time.js'; +import { ScheduledTaskRepository } from './scheduled-task-repository.js'; +import { resolveOperationTargets, type ResolvedTarget } from './target-resolver.js'; + +export interface DispatchResult { + readonly outcome: ScheduledRunOutcome; + readonly reason?: string; + readonly jobIds: readonly string[]; +} + +export type ScheduledDispatch = ( + task: ScheduledTask, + targets: readonly ResolvedTarget[], + run: ScheduledRun, + context: { readonly actor: string; readonly requestId: string }, +) => Promise; + +export interface SchedulerCoordinatorOptions { + readonly db: SqliteDatabase; + readonly repository: ScheduledTaskRepository; + readonly dispatch: ScheduledDispatch; + readonly now?: () => Date; + readonly id?: () => string; + readonly intervalMs?: number; +} + +function snapshot(task: ScheduledTask): CreateScheduledTaskRequest { + return { + name: task.name, + operationType: task.operationType, + cronExpression: task.cronExpression, + timezone: task.timezone, + targetSelector: task.targetSelector, + ...(task.effectiveStartAt ? { effectiveStartAt: task.effectiveStartAt } : {}), + ...(task.effectiveEndAt ? { effectiveEndAt: task.effectiveEndAt } : {}), + misfirePolicy: task.misfirePolicy, + overlapPolicy: task.overlapPolicy, + retryPolicy: task.retryPolicy, + enabled: task.enabled, + }; +} + +export class SchedulerCoordinator { + private readonly now: () => Date; + private readonly id: () => string; + private readonly intervalMs: number; + private timer: ReturnType | undefined; + private started = false; + private lifecycle = 0; + + constructor(private readonly options: SchedulerCoordinatorOptions) { + this.now = options.now ?? (() => new Date()); + this.id = options.id ?? randomUUID; + this.intervalMs = options.intervalMs ?? 30_000; + } + + start(): void { + if (this.started) return; + this.started = true; + const lifecycle = ++this.lifecycle; + const schedule = () => { + if (!this.started || lifecycle !== this.lifecycle) return; + this.timer = setTimeout(() => { + this.timer = undefined; + void this.tick().finally(schedule); + }, this.intervalMs); + this.timer.unref?.(); + }; + void this.tick().finally(schedule); + } + + stop(): void { + this.started = false; + this.lifecycle += 1; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + } + + async runNow(taskId: string, actor: string, requestId: string): Promise { + const task = this.options.repository.get(taskId); + if (!task) throw new Error('Scheduled task was not found'); + return this.runOccurrence(task, this.now().toISOString(), 'manual', actor, requestId); + } + + async tick(): Promise { + const now = this.now(); + const nowIso = now.toISOString(); + const completed: ScheduledRun[] = []; + for (const task of this.options.repository.list()) { + const queued = this.options.repository.getQueuedRun(task.id); + if (queued && !this.options.repository.hasActiveRun(task.id)) { + if (queued.scheduleVersion !== task.version) { + completed.push( + this.options.repository.finishRun(queued.id, { + outcome: 'needs-attention', + reason: 'Queued occurrence belongs to an outdated schedule version', + jobIds: [], + finishedAt: nowIso, + }), + ); + } else if (!task.enabled) { + completed.push( + this.options.repository.finishRun(queued.id, { + outcome: 'skipped', + reason: 'Schedule was disabled while the occurrence was queued', + jobIds: [], + finishedAt: nowIso, + }), + ); + } else { + const resolution = resolveOperationTargets( + this.options.db, + { mode: 'fixed', instanceIds: queued.targetSnapshot }, + task.operationType, + ); + if (resolution.unavailableInstanceIds.length > 0) { + completed.push( + this.options.repository.finishRun(queued.id, { + outcome: 'needs-attention', + reason: this.capabilityReason(resolution.unavailableInstanceIds), + jobIds: [], + finishedAt: nowIso, + }), + ); + continue; + } + completed.push( + await this.executeClaimed( + task, + queued, + resolution.targets, + 'scheduled-automation', + `queue:${queued.id}`, + ), + ); + } + continue; + } + if (!task.enabled || !task.nextDueAt || task.nextDueAt > nowIso) continue; + const outsideWindow = + (task.effectiveStartAt !== undefined && nowIso < task.effectiveStartAt) || + (task.effectiveEndAt !== undefined && nowIso > task.effectiveEndAt); + if (outsideWindow) { + const anchor = + task.effectiveStartAt && nowIso < task.effectiveStartAt + ? new Date(task.effectiveStartAt) + : now; + const nextDueAt = nextOccurrence(task.cronExpression, anchor); + const claimed = this.options.repository.claimScheduledOccurrence({ + id: this.id(), + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt: task.nextDueAt, + nextDueAt, + claimedAt: nowIso, + taskSnapshot: snapshot(task), + overlapPolicy: task.overlapPolicy, + resolveTargets: () => ({ targets: [] }), + terminal: { + outcome: 'skipped', + reason: 'Occurrence is outside the effective window', + }, + }); + if (claimed) completed.push(claimed.run); + continue; + } + + const exact = task.nextDueAt === nowIso; + const reconciled = exact + ? { + action: 'run' as const, + dueAt: task.nextDueAt, + nextDueAt: nextOccurrence(task.cronExpression, now), + } + : reconcileOccurrence( + { + cronExpression: task.cronExpression, + nextDueAt: task.nextDueAt, + misfirePolicy: task.misfirePolicy, + }, + now, + ); + if (reconciled.action === 'wait') continue; + if (reconciled.action === 'skip') { + const claimed = this.options.repository.claimScheduledOccurrence({ + id: this.id(), + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt: reconciled.dueAt, + nextDueAt: reconciled.nextDueAt, + claimedAt: nowIso, + taskSnapshot: snapshot(task), + overlapPolicy: task.overlapPolicy, + resolveTargets: () => ({ targets: [] }), + terminal: { + outcome: 'skipped', + reason: 'Missed occurrence skipped by task policy', + }, + }); + if (claimed) completed.push(claimed.run); + continue; + } + const claimed = this.options.repository.claimScheduledOccurrence({ + id: this.id(), + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt: reconciled.dueAt, + nextDueAt: reconciled.nextDueAt, + claimedAt: nowIso, + taskSnapshot: snapshot(task), + overlapPolicy: task.overlapPolicy, + resolveTargets: () => { + const resolution = resolveOperationTargets( + this.options.db, + task.targetSelector, + task.operationType, + ); + return { + targets: resolution.targets, + targetSnapshot: resolution.targetSnapshot, + ...(resolution.unavailableInstanceIds.length > 0 + ? { + attentionReason: this.capabilityReason(resolution.unavailableInstanceIds), + } + : {}), + }; + }, + }); + if (!claimed || claimed.disposition === 'queued') continue; + if (claimed.disposition === 'finished') { + completed.push(claimed.run); + continue; + } + completed.push( + await this.executeClaimed( + task, + claimed.run, + claimed.targets, + 'scheduled-automation', + `schedule:${task.id}:${reconciled.dueAt}`, + ), + ); + } + return completed; + } + + private async runOccurrence( + task: ScheduledTask, + dueAt: string, + triggerSource: 'scheduled' | 'manual', + actor: string, + requestId: string, + ): Promise { + const resolution = resolveOperationTargets( + this.options.db, + task.targetSelector, + task.operationType, + ); + const claimed = this.options.repository.claimOccurrence({ + id: this.id(), + scheduledTaskId: task.id, + scheduleVersion: task.version, + dueAt, + claimedAt: this.now().toISOString(), + triggerSource, + targetSnapshot: resolution.targetSnapshot, + taskSnapshot: snapshot(task), + }); + if (!claimed) throw new Error('Scheduled occurrence was already claimed'); + if (resolution.unavailableInstanceIds.length > 0) + return this.options.repository.finishRun(claimed.id, { + outcome: 'needs-attention', + reason: this.capabilityReason(resolution.unavailableInstanceIds), + jobIds: [], + finishedAt: this.now().toISOString(), + }); + return this.executeClaimed(task, claimed, resolution.targets, actor, requestId); + } + + private capabilityReason(instanceIds: readonly string[]): string { + return `Required capability is unavailable for: ${instanceIds.join(', ')}`; + } + + private async executeClaimed( + task: ScheduledTask, + claimed: ScheduledRun, + targets: readonly ResolvedTarget[], + actor: string, + requestId: string, + ): Promise { + const now = this.now().toISOString(); + if (targets.length === 0) + return this.options.repository.finishRun(claimed.id, { + outcome: 'no-targets', + reason: 'No enabled instances matched the current selector', + jobIds: [], + finishedAt: now, + }); + this.options.repository.startRun(claimed.id, now); + try { + const result = await this.options.dispatch(task, targets, claimed, { actor, requestId }); + return this.options.repository.finishRun(claimed.id, { + outcome: result.outcome, + ...(result.reason ? { reason: result.reason } : {}), + jobIds: result.jobIds, + finishedAt: this.now().toISOString(), + }); + } catch { + return this.options.repository.finishRun(claimed.id, { + outcome: 'failed', + reason: 'Scheduled dispatch failed', + jobIds: [], + finishedAt: this.now().toISOString(), + }); + } + } +} diff --git a/apps/api/src/application/automation/target-resolver.test.ts b/apps/api/src/application/automation/target-resolver.test.ts new file mode 100644 index 0000000..a8bdef4 --- /dev/null +++ b/apps/api/src/application/automation/target-resolver.test.ts @@ -0,0 +1,85 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { resolveOperationTargets, resolveTargets } from './target-resolver.js'; + +function fixture() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + migrateDatabase(db); + const now = '2026-07-30T00:00:00.000Z'; + const insert = db.prepare( + 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', + ); + insert.run('a', 'Alpha', 'http://10.0.0.1', 1, 3, now, now); + insert.run('b', 'Beta', 'http://10.0.0.2', 1, 2, now, now); + insert.run('c', 'Disabled', 'http://10.0.0.3', 0, 1, now, now); + const tag = db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)'); + tag.run('a', 'lab', now); + tag.run('a', 'east', now); + tag.run('b', 'lab', now); + tag.run('c', 'east', now); + return db; +} + +describe('resolveTargets', () => { + it('resolves enabled fixed targets in selector order with current revisions', () => { + const db = fixture(); + expect(resolveTargets(db, { mode: 'fixed', instanceIds: ['b', 'c', 'a'] })).toEqual([ + { id: 'b', revision: 2 }, + { id: 'a', revision: 3 }, + ]); + db.close(); + }); + + it('resolves dynamic all and any tag matches on every call', () => { + const db = fixture(); + expect(resolveTargets(db, { mode: 'tags', match: 'all', tags: ['lab', 'east'] })).toEqual([ + { id: 'a', revision: 3 }, + ]); + expect(resolveTargets(db, { mode: 'tags', match: 'any', tags: ['lab', 'east'] })).toEqual([ + { id: 'a', revision: 3 }, + { id: 'b', revision: 2 }, + ]); + db.prepare( + 'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', + ).run( + 'd', + 'Dynamic', + 'http://10.0.0.4', + 1, + 1, + '2026-07-30T00:00:00.000Z', + '2026-07-30T00:00:00.000Z', + ); + db.prepare('INSERT INTO instance_tags (instance_id,tag,created_at) VALUES (?,?,?)').run( + 'd', + 'east', + '2026-07-30T00:00:00.000Z', + ); + expect( + resolveTargets(db, { mode: 'tags', match: 'any', tags: ['east'] }).map((x) => x.id), + ).toEqual(['a', 'd']); + db.close(); + }); + + it('separates targets whose required operation capability is unavailable', () => { + const db = fixture(); + const now = '2026-07-30T00:00:00.000Z'; + db.prepare( + 'INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,?,?,?,?,?)', + ).run('a', 'postServiceRestart', 'supported', now, now, now); + db.prepare( + 'INSERT INTO capabilities (instance_id,operation_id,state,observed_at,created_at,updated_at) VALUES (?,?,?,?,?,?)', + ).run('b', 'postServiceRestart', 'unsupported', now, now, now); + expect( + resolveOperationTargets(db, { mode: 'fixed', instanceIds: ['a', 'b'] }, 'restart-service'), + ).toEqual({ + targets: [{ id: 'a', revision: 3 }], + targetSnapshot: ['a', 'b'], + unavailableInstanceIds: ['b'], + }); + db.close(); + }); +}); diff --git a/apps/api/src/application/automation/target-resolver.ts b/apps/api/src/application/automation/target-resolver.ts new file mode 100644 index 0000000..67371f3 --- /dev/null +++ b/apps/api/src/application/automation/target-resolver.ts @@ -0,0 +1,91 @@ +import type { ScheduledOperationType, ScheduleTargetSelector } from '@multi-simadmin/contracts'; + +import type { SqliteDatabase } from '../../infrastructure/database/database.js'; + +export interface ResolvedTarget { + readonly id: string; + readonly revision: number; +} + +interface TargetRow { + id: string; + config_revision: number; +} + +export interface OperationTargetResolution { + readonly targets: readonly ResolvedTarget[]; + readonly targetSnapshot: readonly string[]; + readonly unavailableInstanceIds: readonly string[]; +} + +const operationIds: Record = { + 'restart-service': 'postServiceRestart', + 'reboot-system': 'postSystemReboot', + 'send-sms': 'postSmsSend', +}; + +export function resolveTargets( + db: SqliteDatabase, + selector: ScheduleTargetSelector, +): readonly ResolvedTarget[] { + if (selector.mode === 'fixed') { + const placeholders = selector.instanceIds.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT id, config_revision FROM instances + WHERE enabled = 1 AND id IN (${placeholders})`, + ) + .all(...selector.instanceIds) as TargetRow[]; + const byId = new Map(rows.map((row) => [row.id, row])); + return selector.instanceIds.flatMap((id) => { + const row = byId.get(id); + return row ? [{ id: row.id, revision: row.config_revision }] : []; + }); + } + + const placeholders = selector.tags.map(() => '?').join(','); + const comparison = selector.match === 'all' ? '= ?' : '> 0'; + const parameters: Array = [...selector.tags]; + if (selector.match === 'all') parameters.push(selector.tags.length); + const rows = db + .prepare( + `SELECT i.id, i.config_revision + FROM instances i + JOIN instance_tags t ON t.instance_id = i.id + WHERE i.enabled = 1 AND t.tag IN (${placeholders}) + GROUP BY i.id, i.config_revision + HAVING COUNT(DISTINCT t.tag) ${comparison} + ORDER BY i.id ASC`, + ) + .all(...parameters) as TargetRow[]; + return rows.map((row) => ({ id: row.id, revision: row.config_revision })); +} + +export function resolveOperationTargets( + db: SqliteDatabase, + selector: ScheduleTargetSelector, + operationType: ScheduledOperationType, +): OperationTargetResolution { + const candidates = resolveTargets(db, selector); + if (candidates.length === 0) + return { targets: [], targetSnapshot: [], unavailableInstanceIds: [] }; + const placeholders = candidates.map(() => '?').join(','); + const rows = db + .prepare( + `SELECT instance_id, state FROM capabilities + WHERE operation_id = ? AND instance_id IN (${placeholders})`, + ) + .all(operationIds[operationType], ...candidates.map((target) => target.id)) as Array<{ + instance_id: string; + state: string; + }>; + const states = new Map(rows.map((row) => [row.instance_id, row.state])); + const availableStates = new Set(['supported', 'auth-required', 'degraded']); + return { + targets: candidates.filter((target) => availableStates.has(states.get(target.id) ?? 'unknown')), + targetSnapshot: candidates.map((target) => target.id), + unavailableInstanceIds: candidates + .filter((target) => !availableStates.has(states.get(target.id) ?? 'unknown')) + .map((target) => target.id), + }; +} diff --git a/apps/api/src/application/messages/instance-message-service.test.ts b/apps/api/src/application/messages/instance-message-service.test.ts index 950d27b..b24fe50 100644 --- a/apps/api/src/application/messages/instance-message-service.test.ts +++ b/apps/api/src/application/messages/instance-message-service.test.ts @@ -4,12 +4,18 @@ import { InstanceMessageService, MessageServiceError, parseMessageList, + validMessageContent, } from './instance-message-service.js'; const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' }; const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) }; describe('InstanceMessageService', () => { + it('accepts the automation contract maximum of 2,000 SMS characters', () => { + expect(validMessageContent('字'.repeat(2_000))).toBe(true); + expect(validMessageContent('字'.repeat(2_001))).toBe(false); + }); + it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => { const messages = parseMessageList( { @@ -51,6 +57,43 @@ describe('InstanceMessageService', () => { expect(JSON.stringify(messages)).not.toContain('secret'); }); + it('accepts the current SimAdmin SMS shape without the removed transport field', () => { + expect( + parseMessageList( + { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + messages: [ + { + id: 42, + direction: 'incoming', + phone_number: '10086', + content: 'current payload', + timestamp: '2026-07-29 12:30:00', + status: 'received', + }, + ], + }, + }), + }, + 10, + ), + ).toEqual([ + { + id: '42', + direction: 'incoming', + phoneNumber: '10086', + content: 'current payload', + timestamp: '2026-07-29 12:30:00', + status: 'received', + transport: 'modem', + }, + ]); + }); + it('uses exact owner, bounded query and matching optional session cookie', async () => { const sessions = new InstanceSessionStore(); sessions.set('alpha', instance.origin, 'simadmin_session=opaque'); diff --git a/apps/api/src/application/messages/instance-message-service.ts b/apps/api/src/application/messages/instance-message-service.ts index f3f684d..e3efc2d 100644 --- a/apps/api/src/application/messages/instance-message-service.ts +++ b/apps/api/src/application/messages/instance-message-service.ts @@ -55,8 +55,8 @@ export function validPhoneNumber(value: string): boolean { export function validMessageContent(value: string): boolean { return ( value.length >= 1 && - value.length <= 1600 && - Buffer.byteLength(value, 'utf8') <= 6400 && + value.length <= 2000 && + Buffer.byteLength(value, 'utf8') <= 8000 && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value) ); } @@ -87,10 +87,12 @@ export function parseMessageList( const value = record(item); const id = bounded(value?.id, 128); const phoneNumber = bounded(value?.phone_number, 32); - const content = bounded(value?.content, 1600); + const content = bounded(value?.content, 2000); const timestamp = bounded(value?.timestamp, 64); const status = bounded(value?.status, 32); - const transport = bounded(value?.transport, 32); + // Current SimAdmin SmsMessage has no transport field; older captures sometimes did. + // Keep the aggregate contract stable without dropping every current message. + const transport = bounded(value?.transport, 32) ?? 'modem'; const rawDirection = bounded(value?.direction, 16); if ( !id || @@ -98,8 +100,7 @@ export function parseMessageList( !validPhoneNumber(phoneNumber) || content === undefined || timestamp === undefined || - status === undefined || - transport === undefined + status === undefined ) continue; const direction: MessageDirection = @@ -133,6 +134,11 @@ export class InstanceMessageService { readonly instances: InstanceService; readonly sessions: InstanceSessionStore; readonly request: UpstreamSessionClientOptions['request']; + readonly ensureSession?: ( + instanceId: string, + origin: string, + force?: boolean, + ) => Promise; }, ) {} @@ -142,7 +148,14 @@ export class InstanceMessageService { const session = this.options.sessions.sessionFor(instanceId); if (session && session.origin !== instance.origin) throw new MessageServiceError('SESSION_INVALID'); - return { instance, session }; + if (!session && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin); + } catch { + // No saved credential means this may be a passwordless instance; read anonymously. + } + } + return { instance, session: this.options.sessions.sessionFor(instanceId) }; } async list( @@ -163,11 +176,17 @@ export class InstanceMessageService { throw new MessageServiceError('VALIDATION_FAILED'); const { instance, session } = await this.owner(instanceId); const direction = query.direction ? `&direction=${query.direction}` : ''; - const response = await this.options.request({ - url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`, - method: 'GET', - headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) }, - }); + const request = (cookie?: string) => + this.options.request({ + url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`, + method: 'GET', + headers: { accept: 'application/json', ...(cookie ? { cookie } : {}) }, + }); + let response = await request(session?.cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instanceId, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instanceId)?.cookie); + } return { messages: parseMessageList(response, query.limit) }; } @@ -175,16 +194,22 @@ export class InstanceMessageService { if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content)) throw new MessageServiceError('VALIDATION_FAILED'); const { instance, session } = await this.owner(instanceId); - const response = await this.options.request({ - url: `${instance.origin}/api/sms/send`, - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - ...(session ? { cookie: session.cookie } : {}), - }, - sms: { phoneNumber: input.phoneNumber, content: input.content }, - }); + const request = (cookie?: string) => + this.options.request({ + url: `${instance.origin}/api/sms/send`, + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + sms: { phoneNumber: input.phoneNumber, content: input.content }, + }); + let response = await request(session?.cookie); + if ([401, 403].includes(response.status) && this.options.ensureSession) { + await this.options.ensureSession(instanceId, instance.origin, true); + response = await request(this.options.sessions.sessionFor(instanceId)?.cookie); + } parseSendSuccess(response); return { sent: true }; } diff --git a/apps/api/src/application/operations/secure-operation-execution.test.ts b/apps/api/src/application/operations/secure-operation-execution.test.ts index ffabd20..3021fdb 100644 --- a/apps/api/src/application/operations/secure-operation-execution.test.ts +++ b/apps/api/src/application/operations/secure-operation-execution.test.ts @@ -130,6 +130,7 @@ describe('SecureOperationExecution generic R2 slice', () => { query: '', contentType: '', body: undefined, + instanceId: 'i-1', }); await expectCode( execution.execute( @@ -244,10 +245,11 @@ describe('SecureOperationExecution R3 restart slice', () => { query: '', contentType: '', body: undefined, + instanceId: 'i-1', + }); + expect(db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id)).toEqual({ + risk_level: 'R3', }); - expect( - db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id), - ).toEqual({ risk_level: 'R3' }); }); it('prepares and executes system reboot only with fixed delay_seconds=3', async () => { @@ -283,6 +285,7 @@ describe('SecureOperationExecution R3 restart slice', () => { query: '', contentType: 'application/json', body: '{"delay_seconds":3}', + instanceId: 'i-1', }); }); }); diff --git a/apps/api/src/application/operations/secure-operation-execution.ts b/apps/api/src/application/operations/secure-operation-execution.ts index 873478e..d7782c1 100644 --- a/apps/api/src/application/operations/secure-operation-execution.ts +++ b/apps/api/src/application/operations/secure-operation-execution.ts @@ -71,6 +71,8 @@ export interface SafeOperationTransportRequest { readonly contentType: string; /** Serialized JSON body for audited JSON operations; undefined for zero-body POSTs. */ readonly body: string | undefined; + /** Bound target used only for origin-scoped session attachment; never logged. */ + readonly instanceId: string; } export interface SafeOperationTransport { request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>; @@ -199,7 +201,8 @@ export class SecureOperationExecution { let contentType = ''; let serializedBody: string | undefined; if (ZERO_BODY_OPERATIONS.has(descriptor.operationId)) { - if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0) this.validation(); + if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0) + this.validation(); } else if (descriptor.operationId === SYSTEM_REBOOT_OPERATION) { if (descriptor.requestContentType !== 'application/json' || parameters.fields.length !== 1) this.validation(); @@ -412,6 +415,7 @@ export class SecureOperationExecution { bound.operation_id === SYSTEM_REBOOT_OPERATION ? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS }) : undefined, + instanceId: bound.target_instance_id, }); state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed'; code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED'; diff --git a/apps/api/src/application/resources/instance-resource-service.test.ts b/apps/api/src/application/resources/instance-resource-service.test.ts index 09feecb..b9ea204 100644 --- a/apps/api/src/application/resources/instance-resource-service.test.ts +++ b/apps/api/src/application/resources/instance-resource-service.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { InstanceResourceService, parseSim, parseStats } from './instance-resource-service.js'; +import { + InstanceResourceService, + parseHealth, + parseSim, + parseStats, +} from './instance-resource-service.js'; const response = (body: unknown) => ({ status: 200, @@ -27,6 +32,27 @@ describe('instance resource allowlist parsing', () => { ).toEqual({ cpuPercent: 23.4, memoryPercent: 67.8, maxTemperatureCelsius: 52.6 }); }); + it('falls back to version metadata exposed by stats on older SimAdmin builds', () => { + expect( + parseStats( + response({ + data: { + cpu_load: { load_percent: 10 }, + system: { app_version: '1.8.7', architecture: 'aarch64' }, + }, + }), + ), + ).toMatchObject({ cpuPercent: 10, version: '1.8.7', platform: 'aarch64' }); + }); + + it('extracts the upstream SimAdmin version from the health endpoint', () => { + expect( + parseHealth( + response({ status: 'ok', version: '1.9.4', platform: 'linux-aarch64', secret: 'drop' }), + ), + ).toEqual({ version: '1.9.4', platform: 'linux-aarch64' }); + }); + it('extracts, validates, deduplicates and bounds phone numbers only', () => { expect( parseSim( @@ -51,7 +77,9 @@ describe('instance resource allowlist parsing', () => { return response( request.url.endsWith('/api/stats') ? { data: { cpu_load: { load_percent: 12 }, memory: { used_percent: 34 } } } - : { data: { phone_numbers: ['13800000000'] } }, + : request.url.endsWith('/api/sim') + ? { data: { phone_numbers: ['13800000000'] } } + : { status: 'ok', version: '2.0.1', platform: 'linux' }, ); }, }); @@ -59,10 +87,13 @@ describe('instance resource allowlist parsing', () => { cpuPercent: 12, memoryPercent: 34, phoneNumbers: ['13800000000'], + version: '2.0.1', + platform: 'linux', }); expect(requests.map((request) => request.headers)).toEqual([ { accept: 'application/json' }, { accept: 'application/json' }, + { accept: 'application/json' }, ]); }); diff --git a/apps/api/src/application/resources/instance-resource-service.ts b/apps/api/src/application/resources/instance-resource-service.ts index 68c6ec9..c3b0509 100644 --- a/apps/api/src/application/resources/instance-resource-service.ts +++ b/apps/api/src/application/resources/instance-resource-service.ts @@ -10,6 +10,8 @@ export interface InstanceResources { readonly memoryPercent?: number; readonly maxTemperatureCelsius?: number; readonly phoneNumbers?: readonly string[]; + readonly version?: string; + readonly platform?: string; } const MAX_BODY_BYTES = 32_768; @@ -32,6 +34,31 @@ function data(response: UpstreamResponse): Record | undefined { } } +const safeText = (value: unknown, maximum = 128): string | undefined => + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + !/[\u0000-\u001f\u007f]/u.test(value) + ? value + : undefined; + +export function parseHealth(response: UpstreamResponse): InstanceResources { + if (response.status < 200 || response.status >= 300) return {}; + if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return {}; + try { + const root = record(JSON.parse(response.body)); + if (!root) return {}; + const version = safeText(root.version); + const platform = safeText(root.platform); + return { + ...(version ? { version } : {}), + ...(platform ? { platform } : {}), + }; + } catch { + return {}; + } +} + export function parseStats(response: UpstreamResponse): InstanceResources { const value = data(response); if (!value) return {}; @@ -43,10 +70,20 @@ export function parseStats(response: UpstreamResponse): InstanceResources { .filter((item): item is number => item !== undefined) : []; const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined; + const system = record(value.system); + const version = + safeText(value.version) ?? + safeText(value.current_version) ?? + safeText(system?.version) ?? + safeText(system?.app_version); + const platform = + safeText(value.platform) ?? safeText(system?.platform) ?? safeText(system?.architecture); return { ...(cpuPercent === undefined ? {} : { cpuPercent }), ...(memoryPercent === undefined ? {} : { memoryPercent }), ...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }), + ...(version ? { version } : {}), + ...(platform ? { platform } : {}), }; } @@ -69,6 +106,11 @@ export class InstanceResourceService { readonly instances: InstanceService; readonly sessions: InstanceSessionStore; readonly request: UpstreamSessionClientOptions['request']; + readonly ensureSession?: ( + instanceId: string, + origin: string, + force?: boolean, + ) => Promise; }, ) {} @@ -79,19 +121,48 @@ export class InstanceResourceService { ]); if (!instance) return {}; if (session && session.origin !== instance.origin) return {}; - const get = (path: '/api/stats' | '/api/sim') => - this.options.request({ - url: `${instance.origin}${path}`, - method: 'GET', - headers: { - accept: 'application/json', - ...(session ? { cookie: session.cookie } : {}), - }, - }); - const [stats, sim] = await Promise.allSettled([get('/api/stats'), get('/api/sim')]); + if (!session && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin); + } catch { + // Passwordless and temporarily unavailable credentials still permit anonymous reads. + } + } + const paths = ['/api/stats', '/api/sim', '/api/health'] as const; + const readAll = () => { + const activeSession = this.options.sessions.sessionFor(instanceId); + return Promise.allSettled( + paths.map((path) => + this.options.request({ + url: `${instance.origin}${path}`, + method: 'GET', + headers: { + accept: 'application/json', + ...(activeSession?.origin === instance.origin + ? { cookie: activeSession.cookie } + : {}), + }, + }), + ), + ); + }; + let results = await readAll(); + const unauthorized = results.some( + (result) => result.status === 'fulfilled' && [401, 403].includes(result.value.status), + ); + if (unauthorized && this.options.ensureSession) { + try { + await this.options.ensureSession(instanceId, instance.origin, true); + results = await readAll(); + } catch { + // Return the safe partial result when re-authentication is unavailable. + } + } + const [stats, sim, health] = results; return { - ...(stats.status === 'fulfilled' ? parseStats(stats.value) : {}), - ...(sim.status === 'fulfilled' ? parseSim(sim.value) : {}), + ...(stats?.status === 'fulfilled' ? parseStats(stats.value) : {}), + ...(sim?.status === 'fulfilled' ? parseSim(sim.value) : {}), + ...(health?.status === 'fulfilled' ? parseHealth(health.value) : {}), }; } } diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 99bc2b6..372b1b4 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; import { buildControlPlaneApp } from './control-plane.js'; +import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js'; import { migrateDatabase } from './infrastructure/database/migrations.js'; import { UpstreamError } from './infrastructure/transport/upstream-error.js'; import { SafeInstanceTransport } from './infrastructure/transport/safe-instance-transport.js'; @@ -12,17 +13,83 @@ afterEach(async () => { for (const db of dbs.splice(0)) db.close(); }); class Store implements SecretStore { - async set() { - return ''; + private readonly values = new Map(); + async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) { + const account = Buffer.from( + JSON.stringify( + key.slot === undefined + ? [key.instanceId, key.purpose] + : [key.instanceId, key.purpose, key.slot], + ), + 'utf8', + ).toString('base64url'); + const reference = `keychain://multi-simadmin/${account}`; + this.values.set(reference, value); + return reference; } - async get() { - return '[REDACTED]'; + async get(reference: string) { + return this.values.get(reference); } - async delete() { - return false; + async delete(reference: string) { + return this.values.delete(reference); } } describe('buildControlPlaneApp', () => { + it('reconciles interrupted scheduled runs before starting the scheduler', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const repository = new ScheduledTaskRepository(db); + const task = { + name: 'Interrupted restart', + operationType: 'restart-service' as const, + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai' as const, + targetSelector: { mode: 'fixed' as const, instanceIds: ['instance-a'] }, + misfirePolicy: 'skip' as const, + overlapPolicy: 'skip' as const, + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: false, + }; + repository.create({ + id: 'task-1', + task, + createdBy: 'operator', + now: '2026-07-30T00:00:00.000Z', + }); + repository.claimOccurrence({ + id: 'run-1', + scheduledTaskId: 'task-1', + scheduleVersion: 1, + dueAt: '2026-07-30T01:00:00.000Z', + claimedAt: '2026-07-30T01:00:00.000Z', + triggerSource: 'scheduled', + targetSnapshot: [], + taskSnapshot: task, + }); + repository.startRun('run-1', '2026-07-30T01:00:01.000Z'); + + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async () => ({ status: 200, headers: {}, body: '' }), + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + now: () => new Date('2026-07-30T02:00:00.000Z'), + }); + + expect(repository.getRun('run-1')).toMatchObject({ + outcome: 'needs-attention', + finishedAt: '2026-07-30T02:00:00.000Z', + }); + await app.close(); + }); + it('registers the durable event route fail-closed when no authentication dependency is supplied', async () => { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); @@ -127,14 +194,178 @@ describe('buildControlPlaneApp', () => { url: '/api/v1/instances/' + created.json().id + '/login', payload: { password: 'never-leak-this' }, }); - expect(insecureLogin.statusCode).toBe(400); + // Literal private HTTP origins are allowed for LAN instances; secrets still never leak. + expect(insecureLogin.statusCode).toBe(200); expect(insecureLogin.json()).toMatchObject({ - title: 'Bad Request', - status: 400, - code: 'UPSTREAM_INSECURE_AUTH', + authenticated: true, + instanceId: created.json().id, }); expect(insecureLogin.body).not.toContain('never-leak-this'); - expect(insecureLogin.body).not.toContain('192.168.1.10'); + expect(insecureLogin.body).not.toContain('simadmin_session'); + await app.close(); + }); + + it('automatically logs in with saved credentials before aggregate messages and resource reads', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const calls: Array<{ url: string; cookie?: string }> = []; + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + calls.push({ + url: request.url, + ...(request.headers.cookie ? { cookie: request.headers.cookie } : {}), + }); + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=aggregate-session' }, + body: '{"status":"success"}', + }; + if (request.url.includes('/api/sms/list')) + return { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + messages: [ + { + id: 7, + direction: 'incoming', + phone_number: '10086', + content: 'hi', + timestamp: '2026-07-29 12:00:00', + status: 'received', + }, + ], + }, + }), + }; + if (request.url.endsWith('/api/health')) + return { + status: 200, + headers: {}, + body: '{"status":"ok","version":"2.3.4","platform":"linux"}', + }; + return { status: 200, headers: {}, body: '{"status":"success","data":{}}' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), + }, + }); + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name: 'Protected', + origin: 'http://192.168.1.20:3000', + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + const id = created.json().id as string; + const messages = await app.inject({ + method: 'GET', + url: `/api/v1/instances/${id}/messages?limit=1&offset=0`, + }); + expect(messages.statusCode).toBe(200); + expect(messages.json().messages).toEqual([ + expect.objectContaining({ id: '7', phoneNumber: '10086', content: 'hi' }), + ]); + const resources = await app.inject({ method: 'GET', url: `/api/v1/instances/${id}/resources` }); + expect(resources.statusCode).toBe(200); + expect(resources.json()).toMatchObject({ version: '2.3.4', platform: 'linux' }); + expect(calls.filter((call) => call.url.endsWith('/api/auth/login'))).toHaveLength(1); + for (const call of calls.filter((item) => !item.url.endsWith('/api/auth/login'))) + expect(call.cookie).toBe('simadmin_session=aggregate-session'); + await app.close(); + }); + + it('attaches an origin-bound session cookie when executing restart operations', async () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const calls: unknown[] = []; + const app = buildControlPlaneApp({ + db, + store: new Store(), + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async (request) => { + calls.push({ kind: 'auth', request }); + if (request.url.endsWith('/api/auth/login')) + return { + status: 200, + headers: { 'set-cookie': 'simadmin_session=opaque-token' }, + body: '', + }; + return { status: 200, headers: {}, body: '' }; + }, + postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async (origin, cookie) => { + calls.push({ kind: 'restart', origin, cookie }); + return { status: 200 }; + }, + postSystemReboot: async (origin, delaySeconds, cookie) => { + calls.push({ kind: 'reboot', origin, delaySeconds, cookie }); + return { status: 200 }; + }, + }, + now: () => new Date('2026-07-16T12:00:00.000Z'), + }); + const created = await app.inject({ + method: 'POST', + url: '/api/v1/instances', + payload: { + name: 'Restart Target', + origin: 'http://192.168.1.10:8080', + password: { action: 'set', password: '[REDACTED]' }, + }, + }); + expect(created.statusCode).toBe(201); + const instanceId = created.json().id as string; + const revision = created.json().revision as number; + const prepared = await app.inject({ + method: 'POST', + url: '/api/v1/operations/prepare', + payload: { + operationId: 'postServiceRestart', + targets: [{ instanceId, revision }], + parameters: { + parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', + fields: [], + }, + }, + }); + expect(prepared.statusCode).toBe(200); + const executed = await app.inject({ + method: 'POST', + url: '/api/v1/operations/execute', + payload: { + preparationId: prepared.json().id, + confirmationToken: prepared.json().confirmationToken, + }, + }); + expect(executed.statusCode).toBe(202); + expect(executed.json()).toMatchObject({ + operationId: 'postServiceRestart', + status: 'succeeded', + }); + expect(calls).toContainEqual({ + kind: 'restart', + origin: 'http://192.168.1.10:8080', + cookie: 'simadmin_session=opaque-token', + }); + // Auth request body uses the redacted marker; the actual secret must never appear. + expect(JSON.stringify(calls)).toContain('"[REDACTED]"'); + expect(JSON.stringify(calls)).not.toMatch(/never-leak|password":"[^[]/); await app.close(); }); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index ad46d19..de0e1e6 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -32,12 +32,21 @@ import { InstanceResourceService } from './application/resources/instance-resour import { ConsoleAuthService } from './application/auth/console-auth-service.js'; import { registerConsoleAuth } from './interface/http/console-auth-routes.js'; import { InstanceMessageService } from './application/messages/instance-message-service.js'; +import { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js'; +import { ScheduledTaskService } from './application/automation/scheduled-task-service.js'; +import { registerAutomationRoutes } from './interface/http/automation-routes.js'; +import { ScheduledOperationDispatcher } from './application/automation/scheduled-operation-dispatcher.js'; +import { SchedulerCoordinator } from './application/automation/scheduler-coordinator.js'; export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; - postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>; - postServiceRestart(origin: string): Promise<{ readonly status: number }>; - postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>; + postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postSystemReboot( + origin: string, + delaySeconds: number, + cookie?: string, + ): Promise<{ readonly status: number }>; } export interface ControlPlaneOptions { readonly db: Database.Database; @@ -54,6 +63,13 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane const eventJournal = new EventJournal(options.db); const jobs = new JobQueryService(options.db); const audit = new AuditQueryService(options.db); + const scheduledTasks = new ScheduledTaskRepository(options.db); + scheduledTasks.reconcileInterruptedRuns((options.now?.() ?? new Date()).toISOString()); + const automation = new ScheduledTaskService({ + repository: scheduledTasks, + store: options.store, + ...(options.now ? { now: options.now } : {}), + }); const instances = options.now ? new InstanceService({ db: options.db, store: options.store, now: options.now }) : new InstanceService({ db: options.db, store: options.store }); @@ -67,41 +83,91 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane : new ConnectionProbe({ db: options.db, instances, transport: options.upstream }); const sessions = new InstanceSessionStore(); const client = new UpstreamSessionClient({ sessions, request: options.upstream.request }); + const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store }); + const login = options.now + ? new InstanceLoginService({ db: options.db, client, resolver, now: options.now }) + : new InstanceLoginService({ db: options.db, client, resolver }); + const pendingLogins = new Map>(); + const ensureSession = async ( + instanceId: string, + origin: string, + force = false, + ): Promise => { + const existing = sessions.sessionFor(instanceId); + if (!force && existing?.origin === origin) return; + if (existing) sessions.clear(instanceId); + const current = pendingLogins.get(instanceId); + if (current) return current; + const pending = login + .login(instanceId) + .then((result) => { + if (!result.authenticated) throw new Error('INSTANCE_AUTHENTICATION_FAILED'); + }) + .finally(() => pendingLogins.delete(instanceId)); + pendingLogins.set(instanceId, pending); + return pending; + }; const resources = new InstanceResourceService({ instances, sessions, request: options.upstream.request, + ensureSession, }); const messages = new InstanceMessageService({ instances, sessions, request: options.upstream.request, + ensureSession, }); - const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store }); - const login = options.now - ? new InstanceLoginService({ db: options.db, client, resolver, now: options.now }) - : new InstanceLoginService({ db: options.db, client, resolver }); const deletion = options.now ? new DeleteInstanceOperation({ db: options.db, instances, now: options.now }) : new DeleteInstanceOperation({ db: options.db, instances }); deletion.reconcileInterruptedJobs(); + const resolveOperationCookie = async ( + instanceId: string, + origin: string, + ): Promise => { + // Attach only an origin-bound in-memory session. Never invent cookies, never log them. + const existing = sessions.sessionFor(instanceId); + if ( + existing && + existing.origin === origin && + /^simadmin_session=[^;\s,]+$/u.test(existing.cookie) + ) + return existing.cookie; + // Password-protected instances need a session for reboot/restart. Best-effort Keychain login + // keeps the control plane from silently dispatching unauthenticated upstream mutations. + try { + const result = await login.login(instanceId); + if (!result.authenticated) return undefined; + } catch { + return undefined; + } + const refreshed = sessions.sessionFor(instanceId); + return refreshed && + refreshed.origin === origin && + /^simadmin_session=[^;\s,]+$/u.test(refreshed.cookie) + ? refreshed.cookie + : undefined; + }; const secureExecution = new SecureOperationExecution({ db: options.db, registry: secureOperationRegistry, transport: { - request: async ({ origin, path, body, contentType }) => { + request: async ({ origin, path, body, contentType, instanceId }) => { + const cookie = await resolveOperationCookie(instanceId, origin); if (path === '/api/network/register-auto') { - const response = await options.upstream.postNetworkRegisterAuto(origin); + const response = await options.upstream.postNetworkRegisterAuto(origin, cookie); return { status: response.status }; } if (path === '/api/service/restart') { - const response = await options.upstream.postServiceRestart(origin); + const response = await options.upstream.postServiceRestart(origin, cookie); return { status: response.status }; } if (path === '/api/system/reboot') { if (contentType !== 'application/json' || body !== JSON.stringify({ delay_seconds: 3 })) throw new Error('UPSTREAM_REQUEST_INVALID'); - const response = await options.upstream.postSystemReboot(origin, 3); + const response = await options.upstream.postSystemReboot(origin, 3, cookie); return { status: response.status }; } throw new Error('UPSTREAM_REQUEST_INVALID'); @@ -110,6 +176,21 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane ...(options.now ? { now: options.now } : {}), }); secureExecution.reconcileInterruptedJobs(); + const scheduledDispatcher = new ScheduledOperationDispatcher({ + db: options.db, + operations: secureExecution, + messages, + store: options.store, + repository: scheduledTasks, + ...(options.now ? { now: options.now } : {}), + }); + const scheduler = new SchedulerCoordinator({ + db: options.db, + repository: scheduledTasks, + dispatch: (task, targets, _run, context) => + scheduledDispatcher.dispatch(task, targets, context), + ...(options.now ? { now: options.now } : {}), + }); const auth = new ConsoleAuthService({ db: options.db, ...(options.now ? { now: options.now } : {}), @@ -130,6 +211,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); registerJobRoutes(app, { jobs }); registerAuditRoutes(app, { audit }); + registerAutomationRoutes(app, { + service: automation, + repository: scheduledTasks, + runNow: (taskId, actor, requestId) => scheduler.runNow(taskId, actor, requestId), + }); registerEventRoutes(app, { journal: eventJournal, ...(options.authenticateEventStream @@ -141,5 +227,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane Object.assign(app, { retryPendingSecretCleanup: () => instances.retryPendingSecretCleanup(), }); + app.addHook('onClose', async () => scheduler.stop()); + scheduler.start(); return app as ControlPlaneApp; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 84a1e3c..4bd7ccc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -66,4 +66,6 @@ export type { InstanceServiceOptions, } from './application/instances/instance-service.js'; export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js'; +export { ScheduledTaskRepository } from './application/automation/scheduled-task-repository.js'; +export { ScheduledTaskService } from './application/automation/scheduled-task-service.js'; export * as databaseSchema from './infrastructure/database/schema.js'; diff --git a/apps/api/src/infrastructure/database/database.test.ts b/apps/api/src/infrastructure/database/database.test.ts index e81b614..e746442 100644 --- a/apps/api/src/infrastructure/database/database.test.ts +++ b/apps/api/src/infrastructure/database/database.test.ts @@ -71,6 +71,8 @@ describe('database migrations', () => { 'job_items', 'jobs', 'operation_preparations', + 'scheduled_runs', + 'scheduled_tasks', 'schema_migrations', 'secret_cleanup_tasks', 'secret_references', diff --git a/apps/api/src/infrastructure/database/migrations.ts b/apps/api/src/infrastructure/database/migrations.ts index aa04e93..6cf03ee 100644 --- a/apps/api/src/infrastructure/database/migrations.ts +++ b/apps/api/src/infrastructure/database/migrations.ts @@ -304,6 +304,58 @@ export const MIGRATIONS: readonly Migration[] = [ 'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)', ], }, + { + id: 8, + name: 'scheduled-automation', + statements: [ + `CREATE TABLE scheduled_tasks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + operation_type TEXT NOT NULL CHECK (operation_type IN ('restart-service','reboot-system','send-sms')), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), + cron_expression TEXT NOT NULL, + timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'), + target_selector_json TEXT NOT NULL, + sms_secret_reference TEXT, + sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0), + effective_start_at TEXT, + effective_end_at TEXT, + misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')), + overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')), + retry_policy_json TEXT NOT NULL, + next_due_at TEXT, + last_evaluated_at TEXT, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)), + CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL)) + )`, + `CREATE TABLE scheduled_runs ( + id TEXT PRIMARY KEY, + scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT, + schedule_version INTEGER NOT NULL CHECK (schedule_version > 0), + task_snapshot_json TEXT NOT NULL, + due_at TEXT NOT NULL, + claimed_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + target_snapshot_json TEXT NOT NULL, + outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')), + reason TEXT, + job_ids_json TEXT NOT NULL DEFAULT '[]', + trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')), + attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0), + UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source) + )`, + 'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL', + 'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)', + 'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)', + ], + }, ]; const createMigrationsTable = `CREATE TABLE schema_migrations ( diff --git a/apps/api/src/infrastructure/database/schema.ts b/apps/api/src/infrastructure/database/schema.ts index 712e13b..3778336 100644 --- a/apps/api/src/infrastructure/database/schema.ts +++ b/apps/api/src/infrastructure/database/schema.ts @@ -255,3 +255,67 @@ export const secretCleanupTasks = sqliteTable( }, (table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)], ); + +export const scheduledTasks = sqliteTable( + 'scheduled_tasks', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + operationType: text('operation_type').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + version: integer('version').notNull().default(1), + cronExpression: text('cron_expression').notNull(), + timezone: text('timezone').notNull(), + targetSelectorJson: text('target_selector_json').notNull(), + smsSecretReference: text('sms_secret_reference'), + smsRecipientCount: integer('sms_recipient_count'), + effectiveStartAt: text('effective_start_at'), + effectiveEndAt: text('effective_end_at'), + misfirePolicy: text('misfire_policy').notNull(), + overlapPolicy: text('overlap_policy').notNull(), + retryPolicyJson: text('retry_policy_json').notNull(), + nextDueAt: text('next_due_at'), + lastEvaluatedAt: text('last_evaluated_at'), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull(), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + deletedAt: text('deleted_at'), + }, + (table) => [ + check('scheduled_tasks_enabled_check', sql`${table.enabled} in (0, 1)`), + check('scheduled_tasks_version_check', sql`${table.version} > 0`), + index('idx_scheduled_tasks_enabled_next_due').on(table.enabled, table.nextDueAt), + ], +); + +export const scheduledRuns = sqliteTable( + 'scheduled_runs', + { + id: text('id').primaryKey(), + scheduledTaskId: text('scheduled_task_id') + .notNull() + .references(() => scheduledTasks.id, { onDelete: 'restrict' }), + scheduleVersion: integer('schedule_version').notNull(), + taskSnapshotJson: text('task_snapshot_json').notNull(), + dueAt: text('due_at').notNull(), + claimedAt: text('claimed_at').notNull(), + startedAt: text('started_at'), + finishedAt: text('finished_at'), + targetSnapshotJson: text('target_snapshot_json').notNull(), + outcome: text('outcome'), + reason: text('reason'), + jobIdsJson: text('job_ids_json').notNull().default('[]'), + triggerSource: text('trigger_source').notNull(), + attempt: integer('attempt').notNull().default(1), + }, + (table) => [ + unique('scheduled_runs_occurrence_unique').on( + table.scheduledTaskId, + table.scheduleVersion, + table.dueAt, + table.triggerSource, + ), + index('idx_scheduled_runs_task_due').on(table.scheduledTaskId, desc(table.dueAt)), + ], +); diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts index e20b324..484b6c4 100644 --- a/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts @@ -151,7 +151,23 @@ describe('MacOSKeychainSecretStore', () => { } }); - it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(4097)])( + it('stores a maximum-sized scheduled SMS payload', async () => { + const runner = new FakeRunner(); + const store = new MacOSKeychainSecretStore(runner); + const payload = JSON.stringify({ + recipients: Array.from( + { length: 50 }, + (_, index) => `1380013${String(index).padStart(4, '0')}`, + ), + content: '字'.repeat(2_000), + }); + + await expect( + store.set({ instanceId: 'task-1', purpose: 'scheduled-sms', slot: 'rotation-1' }, payload), + ).resolves.toMatch(/^keychain:/); + }); + + it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(16_385)])( 'rejects non-line-safe or oversized secret input before invoking the runner', async (value) => { const runner = new FakeRunner(); diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts index 2d9bf6c..7baa8cf 100644 --- a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts @@ -181,7 +181,7 @@ export class MacOSKeychainSecretStore implements SecretStore { typeof value !== 'string' || value.length === 0 || /[\0\r\n]/.test(value) || - Buffer.byteLength(value, 'utf8') > 4096 + Buffer.byteLength(value, 'utf8') > 16_384 ) { throw new SecretStoreError('INVALID_SECRET', 'Secret is not valid for Keychain storage'); } diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts index 2302238..05749a8 100644 --- a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts @@ -4,9 +4,13 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; - postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>; - postServiceRestart(origin: string): Promise<{ readonly status: number }>; - postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>; + postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>; + postSystemReboot( + origin: string, + delaySeconds: number, + cookie?: string, + ): Promise<{ readonly status: number }>; } export function createSafeControlPlaneUpstream( transport: SafeUpstreamTransport, @@ -15,8 +19,9 @@ export function createSafeControlPlaneUpstream( return { get: (url) => transport.get(url), request: (request) => gateway.request(request), - postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin), - postServiceRestart: (origin) => gateway.postServiceRestart(origin), - postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds), + postNetworkRegisterAuto: (origin, cookie) => gateway.postNetworkRegisterAuto(origin, cookie), + postServiceRestart: (origin, cookie) => gateway.postServiceRestart(origin, cookie), + postSystemReboot: (origin, delaySeconds, cookie) => + gateway.postSystemReboot(origin, delaySeconds, cookie), }; } diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts index 66800fd..cb82930 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -65,7 +65,7 @@ describe('SafeUpstreamGateway', () => { }); await expect( gateway.request({ - url: 'http://192.168.1.20:8080/api/auth/login', + url: 'http://example.com/api/auth/login', method: 'POST', headers: { 'content-type': 'application/json' }, secret: '[REDACTED]', @@ -74,13 +74,50 @@ describe('SafeUpstreamGateway', () => { ).rejects.toThrow('UPSTREAM_INSECURE_AUTH'); await expect( gateway.request({ - url: 'http://192.168.1.20:8080/api/auth/logout', + url: 'http://example.com/api/auth/logout', method: 'POST', headers: { cookie: 'simadmin_session=opaque' }, }), ).rejects.toThrow('UPSTREAM_INSECURE_AUTH'); }); + it('allows HTTP auth only for literal private instance hosts', async () => { + const calls: unknown[] = []; + const gateway = new SafeUpstreamGateway({ + transport: { + get: async () => ({ status: 200, headers: {}, body: '' }), + post: async (url, headers, body) => { + calls.push({ url, headers, body }); + return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' }; + }, + }, + }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/auth/login', + method: 'POST', + headers: { 'content-type': 'application/json' }, + secret: '[REDACTED]', + body: '[REDACTED]', + }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/auth/logout', + method: 'POST', + headers: { cookie: 'simadmin_session=opaque' }, + }); + expect(calls).toEqual([ + { + url: 'http://192.168.1.20:8080/api/auth/login', + headers: { 'content-type': 'application/json' }, + body: '{"password":"[REDACTED]"}', + }, + { + url: 'http://192.168.1.20:8080/api/auth/logout', + headers: { cookie: 'simadmin_session=opaque' }, + body: '', + }, + ]); + }); + it('allows audited resource GETs without a cookie for passwordless instances', async () => { const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); const gateway = new SafeUpstreamGateway({ @@ -121,6 +158,23 @@ describe('SafeUpstreamGateway', () => { ); }); + it('dispatches an SMS at the 2,000-character automation limit', async () => { + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ + transport: { get: vi.fn(), post }, + }); + const content = '字'.repeat(2_000); + + await gateway.request({ + url: 'http://192.168.1.20:8080/api/sms/send', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + sms: { phoneNumber: '+15550199', content }, + }); + + expect(post).toHaveBeenCalledOnce(); + }); + it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => { const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); @@ -184,4 +238,35 @@ describe('SafeUpstreamGateway', () => { }, ]); }); + + it('forwards only a canonical origin-bound simadmin_session cookie on restart ops', async () => { + const calls: unknown[] = []; + const gateway = new SafeUpstreamGateway({ + transport: { + get: async () => ({ status: 200, headers: {}, body: '' }), + post: async (url, headers, body) => { + calls.push({ url, headers, body }); + return { status: 204, headers: {}, body: '' }; + }, + }, + }); + const cookie = 'simadmin_session=opaque-token'; + await gateway.postServiceRestart('http://192.168.1.20:8080', cookie); + await gateway.postSystemReboot('http://192.168.1.20:8080', 3, cookie); + expect(calls).toEqual([ + { + url: 'http://192.168.1.20:8080/api/service/restart', + headers: { cookie }, + body: '', + }, + { + url: 'http://192.168.1.20:8080/api/system/reboot', + headers: { 'content-type': 'application/json', cookie }, + body: '{"delay_seconds":3}', + }, + ]); + await expect( + gateway.postServiceRestart('http://192.168.1.20:8080', 'simadmin_session=bad; extra'), + ).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false }); + }); }); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts index 9b28338..0f4506f 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -14,6 +14,25 @@ export interface SafeUpstreamTransport { body: string, ): Promise; } + +/** Literal private hosts only — hostnames that still need DNS stay HTTPS for auth secrets. */ +const isLiteralPrivateHost = (hostname: string): boolean => { + const host = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (host.includes(':')) return host.startsWith('fc') || host.startsWith('fd'); + const parts = host.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) + return false; + const [a, b, c] = parts; + if (a === undefined || b === undefined || c === undefined) return false; + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + (a === 192 && b === 0 && c === 0) + ); +}; + export class SafeUpstreamGateway { constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {} private assertOrigin(origin: string): string { @@ -35,11 +54,24 @@ export class SafeUpstreamGateway { return parsed.origin; } - private async postZeroBody(origin: string, path: string): Promise { + private sessionHeaders( + cookie: string | undefined, + extra: Readonly> = {}, + ): Readonly> { + if (cookie === undefined) return extra; + if (!/^simadmin_session=[^;\s,]+$/u.test(cookie)) throw this.notDispatched(); + return { ...extra, cookie }; + } + + private async postZeroBody( + origin: string, + path: string, + cookie?: string, + ): Promise { const base = this.assertOrigin(origin); const url = `${base}${path}`; try { - return await this.options.transport.post(url, {}, ''); + return await this.options.transport.post(url, this.sessionHeaders(cookie), ''); } catch (error) { if ( error instanceof UpstreamError && @@ -50,22 +82,26 @@ export class SafeUpstreamGateway { } } - async postNetworkRegisterAuto(origin: string): Promise { - return this.postZeroBody(origin, '/api/network/register-auto'); + async postNetworkRegisterAuto(origin: string, cookie?: string): Promise { + return this.postZeroBody(origin, '/api/network/register-auto', cookie); } - async postServiceRestart(origin: string): Promise { - return this.postZeroBody(origin, '/api/service/restart'); + async postServiceRestart(origin: string, cookie?: string): Promise { + return this.postZeroBody(origin, '/api/service/restart', cookie); } - async postSystemReboot(origin: string, delaySeconds: number): Promise { + async postSystemReboot( + origin: string, + delaySeconds: number, + cookie?: string, + ): Promise { if (delaySeconds !== 3) throw this.notDispatched(); const base = this.assertOrigin(origin); const url = `${base}/api/system/reboot`; try { return await this.options.transport.post( url, - { 'content-type': 'application/json' }, + this.sessionHeaders(cookie, { 'content-type': 'application/json' }), JSON.stringify({ delay_seconds: 3 }), ); } catch (error) { @@ -92,7 +128,10 @@ export class SafeUpstreamGateway { request.body !== undefined || request.sms !== undefined || (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') || - (url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) || + (url.pathname !== '/api/stats' && + url.pathname !== '/api/sim' && + url.pathname !== '/api/health' && + !smsQuery) || (!smsList && url.search) || url.hash || url.username || @@ -114,8 +153,8 @@ export class SafeUpstreamGateway { (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') || !/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) || sms.content.length < 1 || - sms.content.length > 1600 || - Buffer.byteLength(sms.content, 'utf8') > 6400 || + sms.content.length > 2000 || + Buffer.byteLength(sms.content, 'utf8') > 8000 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) || url.search || url.hash || @@ -129,7 +168,9 @@ export class SafeUpstreamGateway { JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }), ); } - if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH'); + // Auth secrets may travel over HTTPS anywhere, or over HTTP only to literal private hosts. + // Public/cleartext auth is still rejected here; SSRF private-only dial remains in transport. + if (!this.allowsAuthProtocol(url)) throw new UpstreamError('UPSTREAM_INSECURE_AUTH'); if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); if (request.url.endsWith('/api/auth/login')) { if (typeof request.secret !== 'string' || request.body !== '[REDACTED]') @@ -147,6 +188,12 @@ export class SafeUpstreamGateway { } throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); } + + private allowsAuthProtocol(url: URL): boolean { + if (url.protocol === 'https:') return true; + if (url.protocol !== 'http:') return false; + return isLiteralPrivateHost(url.hostname); + } private notDispatched(): OperationNotDispatchedError { return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID'); } diff --git a/apps/api/src/interface/http/automation-routes.test.ts b/apps/api/src/interface/http/automation-routes.test.ts new file mode 100644 index 0000000..262558f --- /dev/null +++ b/apps/api/src/interface/http/automation-routes.test.ts @@ -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(); + async set(key: SecretKey, value: string): Promise { + 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(); + }); +}); diff --git a/apps/api/src/interface/http/automation-routes.ts b/apps/api/src/interface/http/automation-routes.ts new file mode 100644 index 0000000..424f2c5 --- /dev/null +++ b/apps/api/src/interface/http/automation-routes.ts @@ -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; +} + +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 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Request body must be an object'); + return value as Record; +} + +function pagination( + value: unknown, + extraKeys: readonly string[] = [], +): { readonly page: number; readonly pageSize: number; readonly query: Record } { + 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(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, +) { + 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); + }); + }); +} diff --git a/apps/api/src/interface/http/instance-routes.ts b/apps/api/src/interface/http/instance-routes.ts index a6de158..9e1cd27 100644 --- a/apps/api/src/interface/http/instance-routes.ts +++ b/apps/api/src/interface/http/instance-routes.ts @@ -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 }, }, }, }, diff --git a/apps/api/src/production-control-plane.test.ts b/apps/api/src/production-control-plane.test.ts index 5b2362f..d3219b2 100644 --- a/apps/api/src/production-control-plane.test.ts +++ b/apps/api/src/production-control-plane.test.ts @@ -31,8 +31,8 @@ async function fixtureOptions(): Promise { get: async () => ({ status: 200, headers: {}, body: '' }), request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), - postServiceRestart: async () => ({ status: 200 }), - postSystemReboot: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), }, keychainMetadataCheck: async () => true, }; diff --git a/apps/web/index.html b/apps/web/index.html index 3a84cd0..fbc75cd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,7 +4,12 @@ - 多实例 SimAdmin 管理台 + + + SimAdmin Nexus · 多节点控制台
diff --git a/apps/web/package.json b/apps/web/package.json index 3c456e9..ed8c09b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@multi-simadmin/contracts": "workspace:*", + "animal-island-ui": "1.3.0", + "classnames": "2.5.1", "react": "19.2.4", "react-dom": "19.2.4" }, diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 13a6787..d6458ef 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AppShell, type InstanceContext } from './app-shell.js'; import type { AuditDataSource } from './audit/audit-page.js'; +import type { AutomationDataSource as ScheduleDataSource } from './automation/automation-page.js'; import type { EventStreamClient } from './events/event-stream-client.js'; import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js'; import type { FleetMessagesDataSource } from './fleet/fleet-messages-api-data-source.js'; @@ -70,6 +71,20 @@ const quietEventStreamClient: EventStreamClient = { const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } }; +function emptyScheduleDataSource(): ScheduleDataSource { + return { + listSchedules: vi.fn().mockResolvedValue([]), + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + duplicateSchedule: vi.fn(), + setEnabled: vi.fn(), + removeSchedule: vi.fn(), + previewCron: vi.fn().mockResolvedValue([]), + runNow: vi.fn(), + listRuns: vi.fn().mockResolvedValue([]), + }; +} + describe('React AppShell and Fleet vertical slice', () => { it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -84,7 +99,9 @@ describe('React AppShell and Fleet vertical slice', () => { const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ }); expect( - within(alpha).getByRole('link', { name: /打开 Alpha 实例仪表盘/ }).getAttribute('href'), + within(alpha) + .getByRole('link', { name: /打开 Alpha 实例仪表盘/ }) + .getAttribute('href'), ).toBe('/instances/alpha/overview'); expect( within(screen.getByRole('article', { name: /Bravo 实例概览/ })).getByText( @@ -101,6 +118,7 @@ describe('React AppShell and Fleet vertical slice', () => { }); it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => { + const user = userEvent.setup(); const fleetMessagesDataSource: FleetMessagesDataSource = { load: vi.fn(async (instanceId) => instanceId === 'bravo' @@ -131,11 +149,11 @@ describe('React AppShell and Fleet vertical slice', () => { ); const card = screen.getByRole('article', { name: 'Bravo 实例概览' }); expect(within(card).queryByText('延迟')).toBeNull(); - expect(within(card).queryByText('版本')).toBeNull(); expect(within(card).queryByText('新鲜度')).toBeNull(); expect(within(card).queryByText('40 ms')).toBeNull(); - expect(within(card).queryByText('2.0')).toBeNull(); + expect(within(card).getByText('SimAdmin 2.0')).toBeTruthy(); expect(within(card).queryByText('可能过期')).toBeNull(); + expect(screen.queryByRole('region', { name: '节点资源健康' })).toBeNull(); expect(within(card).getByText('18.4%')).toBeTruthy(); expect(within(card).getByText('63.2%')).toBeTruthy(); expect(within(card).getByText('46.7 °C')).toBeTruthy(); @@ -152,9 +170,14 @@ describe('React AppShell and Fleet vertical slice', () => { expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull(); expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull(); expect(within(card).queryByText('bravo')).toBeNull(); - expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy(); - expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy(); - expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy(); + expect(within(card).queryByRole('group', { name: '实例运维操作' })).toBeNull(); + expect(within(card).queryByRole('button', { name: '重启服务 Bravo' })).toBeNull(); + expect(within(card).queryByRole('button', { name: '系统重启 Bravo' })).toBeNull(); + + await user.click(within(card).getByRole('button', { name: '实例操作 Bravo' })); + + expect(within(card).getByRole('menuitem', { name: '重启服务 Bravo' })).toBeTruthy(); + expect(within(card).getByRole('menuitem', { name: '系统重启 Bravo' })).toBeTruthy(); }); it('keeps message read failures separate from instance reachability and reports each once', async () => { @@ -171,34 +194,64 @@ describe('React AppShell and Fleet vertical slice', () => { expect(within(bravo).getByText('需要认证')).toBeTruthy(); }); - it('keeps Jobs and Audit routes compatible without left nav, settings in top bar only', async () => { + it('keeps every global workspace reachable and exposes settings subsections', async () => { const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) }; + const scheduleDataSource: ScheduleDataSource = { + listSchedules: vi.fn().mockResolvedValue([]), + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + duplicateSchedule: vi.fn(), + setEnabled: vi.fn(), + removeSchedule: vi.fn(), + previewCron: vi.fn().mockResolvedValue([]), + runNow: vi.fn(), + listRuns: vi.fn().mockResolvedValue([]), + }; const { rerender } = render( , ); expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy(); - expect(screen.queryByRole('navigation', { name: '全局导航' })).toBeNull(); - expect(screen.queryByRole('link', { name: '任务' })).toBeNull(); - expect(screen.queryByRole('link', { name: '审计' })).toBeNull(); - expect(screen.queryByRole('link', { name: '实例总览' })).toBeNull(); - expect(screen.getByRole('link', { name: '设置' }).getAttribute('href')).toBe( - '/settings/system', + const navigation = screen.getByRole('navigation', { name: '全局导航' }); + expect(within(navigation).getByRole('link', { name: '节点' }).getAttribute('href')).toBe( + '/fleet', ); + expect(within(navigation).getByRole('link', { name: '自动化' }).getAttribute('href')).toBe( + '/automation', + ); + expect(within(navigation).getByRole('link', { name: '设置' }).getAttribute('href')).toBe( + '/settings/instances', + ); + expect( + within(navigation).getByRole('link', { name: '自动化' }).getAttribute('aria-current'), + ).toBe('page'); + expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull(); + expect(within(navigation).getAllByRole('link')).toHaveLength(3); const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) }; rerender( , ); expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy(); + + rerender(); + const settingsNavigation = screen.getByRole('navigation', { name: '设置导航' }); + expect(within(settingsNavigation).getByRole('link', { name: '实例管理' })).toBeTruthy(); + expect( + within(settingsNavigation) + .getByRole('link', { name: '系统与安全' }) + .getAttribute('aria-current'), + ).toBe('page'); }); it('hides placeholder dev version badge in the top bar', () => { @@ -229,7 +282,9 @@ describe('React AppShell and Fleet vertical slice', () => { , ); - expect(screen.getByRole('status').textContent).toContain('正在加载实例'); + expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain( + '正在加载实例', + ); expect(await screen.findByText('Bravo')).toBeTruthy(); expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy(); expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe( @@ -283,6 +338,7 @@ describe('React AppShell and Fleet vertical slice', () => { await user.click(screen.getByRole('button', { name: /需认证/ })); expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull(); expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '批量选择' })); await user.click(screen.getByRole('button', { name: '全选本页' })); expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe( true, @@ -333,11 +389,12 @@ describe('React AppShell and Fleet vertical slice', () => { expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull(); expect(screen.queryByRole('table')).toBeNull(); + await user.click(screen.getByRole('button', { name: '批量选择' })); await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' })); - expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe( - false, - ); - await user.click(screen.getByRole('button', { name: '批量操作' })); + expect( + (screen.getByRole('button', { name: '批量重启服务' }) as HTMLButtonElement).disabled, + ).toBe(false); + await user.click(screen.getByRole('button', { name: '批量重启服务' })); expect(screen.getByText('已选择 1 项。重启将逐个实例安全确认后执行。')).toBeTruthy(); expect(screen.getByRole('button', { name: '批量重启服务' })).toBeTruthy(); expect(screen.getByRole('button', { name: '批量系统重启' })).toBeTruthy(); @@ -357,6 +414,7 @@ describe('React AppShell and Fleet vertical slice', () => { expect(screen.queryByRole('article', { name: /Instance 11 实例概览/ })).toBeNull(); expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '批量选择' })); fireEvent.click(screen.getByRole('button', { name: '全选本页' })); expect(screen.getByText('已选择 10 项')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '下一页' })); @@ -383,7 +441,9 @@ describe('React AppShell and Fleet vertical slice', () => { rerender(); expect(screen.queryByText('Owner modem')).toBeNull(); - expect(screen.getByRole('status').textContent).toContain('正在加载实例'); + expect(screen.getByRole('status', { name: '实例加载状态' }).textContent).toContain( + '正在加载实例', + ); }); it.each([ @@ -408,7 +468,13 @@ describe('React AppShell and Fleet vertical slice', () => { ); vi.stubGlobal('fetch', fetcher); - render(); + render( + , + ); expect(await screen.findByText(emptyMessage)).toBeTruthy(); expect(fetcher).toHaveBeenCalledTimes(1); @@ -446,6 +512,7 @@ describe('React AppShell and Fleet vertical slice', () => { > = { '/fleet': 'fleet', + '/automation': 'automation', '/instances/new': 'instance-new', '/jobs': 'jobs', '/audit': 'audit', @@ -162,8 +172,8 @@ export function resolveRoute(input: string): ResolvedRoute { function section(route: ResolvedRoute): GlobalSection | undefined { if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-')) return 'fleet'; - if (route.kind.startsWith('job')) return 'jobs'; - if (route.kind.startsWith('audit')) return 'audit'; + if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit')) + return 'automation'; if (route.kind.startsWith('settings')) return 'settings'; return undefined; } @@ -188,6 +198,7 @@ function Page({ otaDataSource, jobsDataSource, auditDataSource, + scheduleDataSource, fleetRefreshSignal, detailRefreshSignal, }: { @@ -210,6 +221,7 @@ function Page({ otaDataSource: OtaDataSource | undefined; jobsDataSource: JobsDataSource | undefined; auditDataSource: AuditDataSource | undefined; + scheduleDataSource: ScheduleDataSource; fleetRefreshSignal: number; detailRefreshSignal: number; }): ReactNode { @@ -237,18 +249,25 @@ function Page({ {...(instanceDataSource ? { dataSource: instanceDataSource } : {})} /> ); - if (route.kind === 'jobs') + if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit') return ( - - ); - if (route.kind === 'audit') - return ( - + } + recordsContent={ + + } /> ); if (route.kind === 'settings-instances') @@ -405,6 +424,33 @@ function displayConsoleVersion(version: string | undefined): string | undefined return trimmed; } +const GLOBAL_NAVIGATION: readonly { + section: GlobalSection; + href: string; + label: string; + icon: 'grid' | 'jobs' | 'settings'; +}[] = [ + { section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' }, + { section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' }, + { section: 'settings', href: '/settings/instances', label: '设置', icon: 'settings' }, +]; + +const STREAM_LABELS = { + connecting: '正在连接', + open: '实时连接正常', + reconnecting: '正在重新连接', + resetting: '正在同步状态', + closed: '实时连接已关闭', +} as const; + +const STREAM_COLORS = { + connecting: 'app-yellow', + open: 'app-teal', + reconnecting: 'app-orange', + resetting: 'app-yellow', + closed: 'app-red', +} as const; + export function AppShell({ pathname, version = 'dev', @@ -426,6 +472,7 @@ export function AppShell({ otaDataSource, jobsDataSource, auditDataSource, + scheduleDataSource, eventStreamClient, }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); @@ -441,6 +488,10 @@ export function AppShell({ () => auditDataSource ?? createAuditApiDataSource(), [auditDataSource], ); + const resolvedScheduleDataSource = useMemo( + () => scheduleDataSource ?? createAutomationApiDataSource(), + [scheduleDataSource], + ); const resolved = resolveRoute(pathname); const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved; const routeInstanceId = route.params?.instanceId; @@ -598,29 +649,71 @@ export function AppShell({ 跳到主要内容
- - 多实例 SimAdmin 管理台 - -
- 控制台 - {consoleVersion ? ( - - v{consoleVersion} + + + + SimAdmin Control + 多节点蜂窝设备控制中心 + + + +
+ - 设置 - + + {STREAM_LABELS[refresh.stream]} + + + {consoleVersion ? ( + + v{consoleVersion} + + ) : null}
+ {currentSection === 'settings' ? ( + + ) : null} {routeInstanceId && instanceLoading ? ( -

正在加载实例…

+

+ 正在加载实例… +

) : routeInstanceId && instanceLoadFailed ? (

无法加载此实例,请返回总览后重试。 @@ -646,12 +739,20 @@ export function AppShell({ otaDataSource={otaDataSource} jobsDataSource={resolvedJobsDataSource} auditDataSource={resolvedAuditDataSource} + scheduleDataSource={resolvedScheduleDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> )}

+
+

+ SimAdmin 聚合控制台 · 界面组件来自{' '} + animal-island-ui + (CC BY-NC 4.0,仅限非商业使用) +

+
); } diff --git a/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-400-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-400-normal.woff2 new file mode 100644 index 0000000..99c8c93 Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-400-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-500-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-500-normal.woff2 new file mode 100644 index 0000000..5801a8f Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-500-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-700-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-700-normal.woff2 new file mode 100644 index 0000000..d4d08e2 Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-chinese-simplified-700-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/noto-sans-sc-latin-400-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-latin-400-normal.woff2 new file mode 100644 index 0000000..334db56 Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-latin-400-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/noto-sans-sc-latin-500-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-latin-500-normal.woff2 new file mode 100644 index 0000000..489fc76 Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-latin-500-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/noto-sans-sc-latin-700-normal.woff2 b/apps/web/src/assets/fonts/noto-sans-sc-latin-700-normal.woff2 new file mode 100644 index 0000000..386faff Binary files /dev/null and b/apps/web/src/assets/fonts/noto-sans-sc-latin-700-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/nunito-latin-500-normal.woff2 b/apps/web/src/assets/fonts/nunito-latin-500-normal.woff2 new file mode 100644 index 0000000..7a8b449 Binary files /dev/null and b/apps/web/src/assets/fonts/nunito-latin-500-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/nunito-latin-700-normal.woff2 b/apps/web/src/assets/fonts/nunito-latin-700-normal.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/apps/web/src/assets/fonts/nunito-latin-700-normal.woff2 differ diff --git a/apps/web/src/assets/fonts/nunito-latin-900-normal.woff2 b/apps/web/src/assets/fonts/nunito-latin-900-normal.woff2 new file mode 100644 index 0000000..13ec320 Binary files /dev/null and b/apps/web/src/assets/fonts/nunito-latin-900-normal.woff2 differ diff --git a/apps/web/src/assets/simadmin-logo.svg b/apps/web/src/assets/simadmin-logo.svg new file mode 100644 index 0000000..e5d7220 --- /dev/null +++ b/apps/web/src/assets/simadmin-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/audit/audit-page.tsx b/apps/web/src/audit/audit-page.tsx index 1dc4f3c..a00ceda 100644 --- a/apps/web/src/audit/audit-page.tsx +++ b/apps/web/src/audit/audit-page.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from 'animal-island-ui'; import { AUDIT_OUTCOMES, @@ -287,9 +288,9 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {

审计

只读运维审计事件。

- +
{identifiers.map(([field, label]) => ( diff --git a/apps/web/src/auth/console-auth-settings.tsx b/apps/web/src/auth/console-auth-settings.tsx index 4fcfb41..cd7bde1 100644 --- a/apps/web/src/auth/console-auth-settings.tsx +++ b/apps/web/src/auth/console-auth-settings.tsx @@ -1,4 +1,5 @@ import { type FormEvent, useEffect, useState } from 'react'; +import { Button, Card, Title } from 'animal-island-ui'; import type { ConsoleAuthDataSource, ConsoleAuthStatus } from './console-auth.js'; import { createConsoleAuthApiDataSource } from './console-auth.js'; @@ -70,66 +71,70 @@ export function ConsoleAuthSettings({

SYSTEM SETTINGS

-

密码保护

+

+ 密码保护 +

可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。

当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。

参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。

{error && !status ?

{error}

: null} {status ? ( -
void save(event)}> - - {enabled && !status.configured ? ( -
- + + void save(event)}> +
- ) : null} - {status.configured ?

访问密码已配置,不会在页面或 API 中回显。

: null} - {error ?

{error}

: null} - {notice ?

{notice}

: null} - - {status.protectionEnabled && status.authenticated ? ( - - ) : null} -
+ + {enabled && !status.configured ? ( +
+ + setPassword(event.target.value)} + /> + + setConfirmation(event.target.value)} + /> + 至少 8 位,同时包含字母和数字。密码仅保存为不可逆哈希。 +
+ ) : null} + {status.configured ?

访问密码已配置,不会在页面或 API 中回显。

: null} + {error ?

{error}

: null} + {notice ?

{notice}

: null} + + {status.protectionEnabled && status.authenticated ? ( + + ) : null} + + ) : !error ? (

正在读取设置…

) : null} diff --git a/apps/web/src/automation/automation-api-data-source.test.ts b/apps/web/src/automation/automation-api-data-source.test.ts new file mode 100644 index 0000000..2b700a7 --- /dev/null +++ b/apps/web/src/automation/automation-api-data-source.test.ts @@ -0,0 +1,95 @@ +import type { CreateScheduledTaskRequest, ScheduledTask } from '@multi-simadmin/contracts'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAutomationApiDataSource } from './automation-api-data-source.js'; + +const input: CreateScheduledTaskRequest = { + name: 'Morning restart', + operationType: 'restart-service', + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai', + targetSelector: { mode: 'fixed', instanceIds: ['alpha'] }, + misfirePolicy: 'skip', + overlapPolicy: 'skip', + retryPolicy: { maxRetries: 1, retryIntervalSeconds: 30 }, + enabled: true, +}; + +const task: ScheduledTask = { + ...input, + id: 'schedule/a', + version: 3, + createdBy: 'operator', + updatedBy: 'operator', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', +}; + +describe('Automation API data source', () => { + it('uses the authenticated schedule endpoints and optimistic version headers', async () => { + const fetcher = vi.fn(async (request: RequestInfo | URL, init?: RequestInit) => { + const url = String(request); + if (url.endsWith('/cron/preview')) { + return Response.json({ occurrences: ['2026-07-30T01:00:00.000Z'] }); + } + if (url.endsWith('/runs')) return Response.json({ items: [] }); + if (init?.method === 'DELETE') return new Response(null, { status: 204 }); + if (url.endsWith('/run')) return Response.json({ runId: 'run-1' }); + if (url.endsWith('/schedules') && !init?.method) return Response.json({ items: [task] }); + return Response.json(task); + }); + const source = createAutomationApiDataSource(fetcher as typeof fetch); + const controller = new AbortController(); + + await expect(source.listSchedules(controller.signal)).resolves.toEqual([task]); + await expect(source.createSchedule(input)).resolves.toEqual(task); + await expect( + source.updateSchedule(task.id, task.version, { name: 'Updated' }), + ).resolves.toEqual(task); + await expect(source.duplicateSchedule(task.id, task.version)).resolves.toEqual(task); + await expect(source.setEnabled(task.id, task.version, false)).resolves.toEqual(task); + await expect(source.previewCron(input.cronExpression)).resolves.toEqual([ + '2026-07-30T01:00:00.000Z', + ]); + await expect(source.runNow(task.id)).resolves.toEqual({ runId: 'run-1' }); + await expect(source.listRuns(controller.signal)).resolves.toEqual([]); + await expect(source.removeSchedule(task.id, task.version)).resolves.toBeUndefined(); + + expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/state', { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'if-match': '"version-3"', + }, + body: JSON.stringify({ enabled: false }), + }); + expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'if-match': '"version-3"', + }, + body: JSON.stringify({ name: 'Updated' }), + }); + expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa/duplicate', { + method: 'POST', + headers: { accept: 'application/json', 'if-match': '"version-3"' }, + }); + expect(fetcher).toHaveBeenCalledWith('/api/v1/automation/schedules/schedule%2Fa', { + method: 'DELETE', + headers: { 'if-match': '"version-3"' }, + }); + }); + + it('rejects non-success responses without exposing response bodies', async () => { + const fetcher = vi.fn(async () => + Response.json({ detail: 'secret backend detail' }, { status: 500 }), + ); + const source = createAutomationApiDataSource(fetcher as typeof fetch); + + await expect(source.listSchedules()).rejects.toThrow('Automation request failed (500)'); + await expect(source.listSchedules()).rejects.not.toThrow('secret backend detail'); + }); +}); diff --git a/apps/web/src/automation/automation-api-data-source.ts b/apps/web/src/automation/automation-api-data-source.ts new file mode 100644 index 0000000..2bf5e40 --- /dev/null +++ b/apps/web/src/automation/automation-api-data-source.ts @@ -0,0 +1,102 @@ +import type { + CreateScheduledTaskRequest, + ScheduledRun, + ScheduledTask, +} from '@multi-simadmin/contracts'; + +import type { AutomationDataSource } from './automation-page.js'; + +async function json(response: Response): Promise { + if (!response.ok) throw new Error(`Automation request failed (${response.status})`); + return (await response.json()) as T; +} + +export function createAutomationApiDataSource(fetcher: typeof fetch = fetch): AutomationDataSource { + return { + async listSchedules(signal) { + const page = await json<{ items: ScheduledTask[] }>( + await fetcher('/api/v1/automation/schedules', { + ...(signal ? { signal } : {}), + headers: { accept: 'application/json' }, + }), + ); + return page.items; + }, + async createSchedule(input) { + return json( + await fetcher('/api/v1/automation/schedules', { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(input satisfies CreateScheduledTaskRequest), + }), + ); + }, + async updateSchedule(id, version, input) { + return json( + await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'if-match': `"version-${version}"`, + }, + body: JSON.stringify(input), + }), + ); + }, + async duplicateSchedule(id, version) { + return json( + await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/duplicate`, { + method: 'POST', + headers: { accept: 'application/json', 'if-match': `"version-${version}"` }, + }), + ); + }, + async setEnabled(id, version, enabled) { + return json( + await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/state`, { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'if-match': `"version-${version}"`, + }, + body: JSON.stringify({ enabled }), + }), + ); + }, + async removeSchedule(id, version) { + const response = await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { 'if-match': `"version-${version}"` }, + }); + if (!response.ok) throw new Error(`Automation request failed (${response.status})`); + }, + async previewCron(cronExpression) { + const preview = await json<{ occurrences: string[] }>( + await fetcher('/api/v1/automation/cron/preview', { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ cronExpression, count: 5 }), + }), + ); + return preview.occurrences; + }, + async runNow(id) { + return json( + await fetcher(`/api/v1/automation/schedules/${encodeURIComponent(id)}/run`, { + method: 'POST', + }), + ); + }, + async listRuns(signal) { + const page = await json<{ items: ScheduledRun[] }>( + await fetcher('/api/v1/automation/runs', { + ...(signal ? { signal } : {}), + headers: { accept: 'application/json' }, + }), + ); + return page.items; + }, + }; +} diff --git a/apps/web/src/automation/automation-page.test.tsx b/apps/web/src/automation/automation-page.test.tsx new file mode 100644 index 0000000..a3a0315 --- /dev/null +++ b/apps/web/src/automation/automation-page.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AutomationPage, type AutomationDataSource } from './automation-page.js'; + +afterEach(cleanup); + +function source(): AutomationDataSource { + return { + listSchedules: async () => [], + createSchedule: async (input) => ({ + id: 'task-1', + version: 1, + createdBy: 'operator', + updatedBy: 'operator', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + nextDueAt: '2026-07-30T01:00:00.000Z', + ...input, + }), + updateSchedule: async () => undefined as never, + duplicateSchedule: async () => undefined as never, + setEnabled: async () => undefined, + removeSchedule: async () => undefined, + previewCron: async () => [ + '2026-07-30T01:00:00.000Z', + '2026-07-31T01:00:00.000Z', + '2026-08-01T01:00:00.000Z', + '2026-08-02T01:00:00.000Z', + '2026-08-03T01:00:00.000Z', + ], + runNow: async () => undefined, + listRuns: async () => [], + }; +} + +describe('AutomationPage', () => { + it('presents schedules, runs, and operation records as one Chinese workspace', async () => { + render(); + const tabs = screen.getByRole('tablist', { name: '自动化视图' }); + expect(within(tabs).getByRole('tab', { name: '计划任务' }).getAttribute('aria-selected')).toBe( + 'true', + ); + expect(within(tabs).getByRole('tab', { name: '执行记录' })).not.toBeNull(); + expect(within(tabs).getByRole('tab', { name: '操作审计' })).not.toBeNull(); + expect(await screen.findByText('暂无计划任务')).not.toBeNull(); + }); + + it('opens a progressive editor with dynamic tags and no timezone selector', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: '创建任务' })); + const dialog = screen.getByRole('dialog', { name: '创建任务' }); + expect(dialog).not.toBeNull(); + expect(within(dialog).getByRole('button', { name: '关闭任务编辑器' })).not.toBeNull(); + await user.selectOptions(within(dialog).getByLabelText('目标方式'), 'tags'); + expect(within(dialog).getByLabelText('标签匹配')).not.toBeNull(); + expect(within(dialog).getByLabelText('标签')).not.toBeNull(); + expect(within(dialog).queryByLabelText('时区')).toBeNull(); + expect(within(dialog).getByText(/北京时间/)).not.toBeNull(); + }); + + it('closes the editor with Escape and restores focus to its trigger', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: '创建任务' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: '创建任务' })).not.toBeNull(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', { name: '创建任务' })).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('shows SMS fields and previews five-field Cron occurrences', async () => { + const user = userEvent.setup(); + const dataSource = source(); + render(); + await user.click(screen.getByRole('button', { name: '创建任务' })); + const dialog = screen.getByRole('dialog', { name: '创建任务' }); + await user.selectOptions(within(dialog).getByLabelText('操作类型'), 'send-sms'); + expect(within(dialog).getByLabelText('收件号码')).not.toBeNull(); + expect(within(dialog).getByLabelText('短信内容')).not.toBeNull(); + await user.click(within(dialog).getByRole('button', { name: '预览后续执行' })); + expect(await within(dialog).findByText(/2026-07-30 09:00/)).not.toBeNull(); + }); + + it('requires a final operation, target, and risk confirmation before creating', async () => { + const user = userEvent.setup(); + const createSchedule = vi.fn(source().createSchedule); + render(); + await user.click(screen.getByRole('button', { name: '创建任务' })); + const dialog = screen.getByRole('dialog', { name: '创建任务' }); + await user.type(within(dialog).getByLabelText('任务名称'), 'Night restart'); + await user.type(within(dialog).getByLabelText('实例 ID'), 'alpha, beta'); + + await user.click(within(dialog).getByRole('button', { name: '检查并继续' })); + + expect(createSchedule).not.toHaveBeenCalled(); + const confirmation = within(dialog).getByRole('group', { name: '最终确认' }); + expect(within(confirmation).getByText('重启 SimAdmin 服务')).not.toBeNull(); + expect(within(confirmation).getByText('2 个固定实例')).not.toBeNull(); + expect(within(confirmation).getByText('R2')).not.toBeNull(); + await user.click(within(confirmation).getByRole('checkbox', { name: /我已核对/ })); + await user.click(within(dialog).getByRole('button', { name: '确认并创建' })); + expect(createSchedule).toHaveBeenCalledOnce(); + }); + + it('edits effective windows and duplicates an existing schedule', async () => { + const user = userEvent.setup(); + const existing = { + id: 'task-1', + name: 'Morning restart', + operationType: 'restart-service' as const, + cronExpression: '0 9 * * *', + timezone: 'Asia/Shanghai' as const, + targetSelector: { mode: 'fixed' as const, instanceIds: ['alpha', 'beta'] }, + effectiveStartAt: '2026-08-01T01:00:00.000Z', + effectiveEndAt: '2026-09-01T01:00:00.000Z', + misfirePolicy: 'skip' as const, + overlapPolicy: 'skip' as const, + retryPolicy: { maxRetries: 0, intervalSeconds: 60 }, + enabled: true, + version: 4, + createdBy: 'operator', + updatedBy: 'operator', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + }; + const updateSchedule = vi.fn(async (_id, _version, input) => ({ + ...existing, + ...input, + version: 5, + })); + const duplicateSchedule = vi.fn(async () => ({ + ...existing, + id: 'task-2', + name: 'Morning restart copy', + enabled: false, + version: 1, + })); + const dataSource: AutomationDataSource = { + ...source(), + listSchedules: async () => [existing], + updateSchedule, + duplicateSchedule, + }; + render(); + + await screen.findByText('Morning restart'); + expect(screen.queryByRole('menuitem', { name: '编辑 Morning restart' })).toBeNull(); + await user.click(screen.getByRole('button', { name: '任务操作 Morning restart' })); + await user.click(screen.getByRole('menuitem', { name: '编辑 Morning restart' })); + const dialog = screen.getByRole('dialog', { name: '编辑任务' }); + expect(within(dialog).getByLabelText('生效开始(可选)')).not.toBeNull(); + expect(within(dialog).getByLabelText('生效结束(可选)')).not.toBeNull(); + const name = within(dialog).getByLabelText('任务名称'); + await user.clear(name); + await user.type(name, 'Updated restart'); + await user.click(within(dialog).getByRole('button', { name: '检查并继续' })); + await user.click(within(dialog).getByRole('checkbox', { name: /我已核对/ })); + await user.click(within(dialog).getByRole('button', { name: '确认并保存' })); + + expect(updateSchedule).toHaveBeenCalledWith( + 'task-1', + 4, + expect.objectContaining({ + name: 'Updated restart', + effectiveStartAt: '2026-08-01T01:00:00.000Z', + effectiveEndAt: '2026-09-01T01:00:00.000Z', + }), + ); + await user.click(screen.getByRole('button', { name: '任务操作 Updated restart' })); + await user.click(screen.getByRole('menuitem', { name: '复制 Updated restart' })); + expect(duplicateSchedule).toHaveBeenCalledWith('task-1', 5); + expect(await screen.findByText('Morning restart copy')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/automation/automation-page.tsx b/apps/web/src/automation/automation-page.tsx new file mode 100644 index 0000000..c62bb96 --- /dev/null +++ b/apps/web/src/automation/automation-page.tsx @@ -0,0 +1,880 @@ +import type { + CreateScheduledTaskRequest, + ScheduledRun, + ScheduledTask, + UpdateScheduledTaskRequest, +} from '@multi-simadmin/contracts'; +import { + useEffect, + useRef, + useState, + type FormEvent, + type KeyboardEvent, + type ReactNode, +} from 'react'; + +import { Icon } from '../ui/icon.js'; + +export type AutomationTab = 'schedules' | 'runs' | 'records'; +export type ScheduleUpdateInput = Omit; + +export interface AutomationDataSource { + listSchedules(signal?: AbortSignal): Promise; + createSchedule(input: CreateScheduledTaskRequest): Promise; + updateSchedule(id: string, version: number, input: ScheduleUpdateInput): Promise; + duplicateSchedule(id: string, version: number): Promise; + setEnabled(id: string, version: number, enabled: boolean): Promise; + removeSchedule(id: string, version: number): Promise; + previewCron(expression: string): Promise; + runNow(id: string): Promise; + listRuns(signal?: AbortSignal): Promise; +} + +export interface AutomationPageProps { + readonly dataSource: AutomationDataSource; + readonly initialTab?: AutomationTab; + readonly runsContent?: ReactNode; + readonly recordsContent?: ReactNode; +} + +function beijingTime(value: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(new Date(value)); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ''; + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`; +} + +function beijingLocal(value?: string): string { + return value ? beijingTime(value).replace(' ', 'T') : ''; +} + +function beijingIso(value: string): string | undefined { + return value ? new Date(`${value}:00+08:00`).toISOString() : undefined; +} + +function operationLabel(value: ScheduledTask['operationType']): string { + return { + 'restart-service': '重启 SimAdmin 服务', + 'reboot-system': '重启设备系统', + 'send-sms': '发送短信', + }[value]; +} + +function targetLabel(task: ScheduledTask): string { + return task.targetSelector.mode === 'fixed' + ? `${task.targetSelector.instanceIds.length} 个固定实例` + : `${task.targetSelector.match === 'all' ? '全部匹配' : '任一匹配'}:${task.targetSelector.tags.join('、')}`; +} + +function outcomeLabel(value: ScheduledRun['outcome']): string { + if (!value) return '执行中'; + return { + succeeded: '成功', + 'partially-succeeded': '部分成功', + failed: '失败', + skipped: '已跳过', + 'no-targets': '无匹配实例', + 'needs-attention': '需要处理', + }[value]; +} + +interface EditorForm { + name: string; + operationType: ScheduledTask['operationType']; + targetMode: 'fixed' | 'tags'; + fixedIds: string; + tags: string; + tagMatch: 'any' | 'all'; + recipients: string; + content: string; + cronExpression: string; + misfirePolicy: ScheduledTask['misfirePolicy']; + overlapPolicy: ScheduledTask['overlapPolicy']; + maxRetries: number; + retryInterval: number; + effectiveStart: string; + effectiveEnd: string; +} + +const defaults: EditorForm = { + name: '', + operationType: 'restart-service' as ScheduledTask['operationType'], + targetMode: 'fixed' as 'fixed' | 'tags', + fixedIds: '', + tags: '', + tagMatch: 'any' as 'any' | 'all', + recipients: '', + content: '', + cronExpression: '0 9 * * *', + misfirePolicy: 'skip', + overlapPolicy: 'skip', + maxRetries: 0, + retryInterval: 60, + effectiveStart: '', + effectiveEnd: '', +}; + +export function AutomationPage({ + dataSource, + initialTab = 'schedules', + runsContent, + recordsContent, +}: AutomationPageProps) { + const [tab, setTab] = useState(initialTab); + const [tasks, setTasks] = useState([]); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [editorOpen, setEditorOpen] = useState(false); + const [editingTask, setEditingTask] = useState(); + const [form, setForm] = useState(defaults); + const [preview, setPreview] = useState([]); + const [saving, setSaving] = useState(false); + const [confirmedFrequency, setConfirmedFrequency] = useState(false); + const [reviewing, setReviewing] = useState(false); + const [confirmedDefinition, setConfirmedDefinition] = useState(false); + const [actionMenuId, setActionMenuId] = useState(); + const drawerRef = useRef(null); + const returnFocusRef = useRef(null); + + useEffect(() => setTab(initialTab), [initialTab]); + + const load = () => { + const controller = new AbortController(); + setLoading(true); + setError(''); + void Promise.all([ + dataSource.listSchedules(controller.signal), + dataSource.listRuns(controller.signal), + ]).then( + ([nextTasks, nextRuns]) => { + setTasks(nextTasks); + setRuns(nextRuns); + setLoading(false); + }, + () => { + setError('无法加载自动化数据,请稍后重试。'); + setLoading(false); + }, + ); + return () => controller.abort(); + }; + + useEffect(load, [dataSource]); + + useEffect(() => { + if (!editorOpen) return; + drawerRef.current?.querySelector('input, select, textarea, button')?.focus(); + }, [editorOpen]); + + const highFrequencyRestart = + form.operationType === 'restart-service' && + /^\*|^\*\/([1-5])(?:\s|$)/.test(form.cronExpression); + + function field(key: K, value: (typeof defaults)[K]) { + setForm((current) => ({ ...current, [key]: value })); + setReviewing(false); + setConfirmedDefinition(false); + } + + function closeEditor() { + setEditorOpen(false); + setEditingTask(undefined); + setForm(defaults); + setPreview([]); + setConfirmedFrequency(false); + setReviewing(false); + setConfirmedDefinition(false); + queueMicrotask(() => returnFocusRef.current?.focus()); + } + + function createTask() { + returnFocusRef.current = document.activeElement as HTMLElement | null; + setEditingTask(undefined); + setForm(defaults); + setPreview([]); + setConfirmedFrequency(false); + setReviewing(false); + setConfirmedDefinition(false); + setEditorOpen(true); + } + + function editTask(task: ScheduledTask) { + setEditingTask(task); + setForm({ + name: task.name, + operationType: task.operationType, + targetMode: task.targetSelector.mode, + fixedIds: + task.targetSelector.mode === 'fixed' ? task.targetSelector.instanceIds.join(', ') : '', + tags: task.targetSelector.mode === 'tags' ? task.targetSelector.tags.join(', ') : '', + tagMatch: task.targetSelector.mode === 'tags' ? task.targetSelector.match : 'any', + recipients: '', + content: '', + cronExpression: task.cronExpression, + misfirePolicy: task.misfirePolicy, + overlapPolicy: task.overlapPolicy, + maxRetries: task.retryPolicy.maxRetries, + retryInterval: task.retryPolicy.intervalSeconds, + effectiveStart: beijingLocal(task.effectiveStartAt), + effectiveEnd: beijingLocal(task.effectiveEndAt), + }); + setPreview([]); + setConfirmedFrequency(false); + setReviewing(false); + setConfirmedDefinition(false); + setEditorOpen(true); + } + + function handleEditorKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + closeEditor(); + return; + } + if (event.key !== 'Tab') return; + const focusable = drawerRef.current?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + ); + if (!focusable?.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (highFrequencyRestart && (preview.length !== 5 || !confirmedFrequency)) return; + const split = (value: string) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + const effectiveStartAt = beijingIso(form.effectiveStart); + const effectiveEndAt = beijingIso(form.effectiveEnd); + const smsChanged = form.recipients.trim().length > 0 || form.content.length > 0; + const request: CreateScheduledTaskRequest = { + name: form.name.trim(), + operationType: form.operationType, + cronExpression: form.cronExpression.trim(), + timezone: 'Asia/Shanghai', + targetSelector: + form.targetMode === 'fixed' + ? { mode: 'fixed', instanceIds: split(form.fixedIds) } + : { mode: 'tags', match: form.tagMatch, tags: split(form.tags) }, + ...(form.operationType === 'send-sms' && (!editingTask?.sms || smsChanged) + ? { sms: { recipients: split(form.recipients), content: form.content } } + : {}), + ...(effectiveStartAt ? { effectiveStartAt } : {}), + ...(effectiveEndAt ? { effectiveEndAt } : {}), + misfirePolicy: form.misfirePolicy, + overlapPolicy: form.overlapPolicy, + retryPolicy: { maxRetries: form.maxRetries, intervalSeconds: form.retryInterval }, + enabled: editingTask?.enabled ?? true, + }; + if (!reviewing) { + setReviewing(true); + setConfirmedDefinition(false); + return; + } + if (!confirmedDefinition) return; + setSaving(true); + setError(''); + try { + if (editingTask) { + const { sms: requestSms, ...common } = request; + const updated = await dataSource.updateSchedule(editingTask.id, editingTask.version, { + ...common, + ...(requestSms ? { sms: requestSms } : {}), + effectiveStartAt: effectiveStartAt ?? null, + effectiveEndAt: effectiveEndAt ?? null, + }); + setTasks((current) => current.map((item) => (item.id === updated.id ? updated : item))); + } else { + const created = await dataSource.createSchedule(request); + setTasks((current) => [created, ...current]); + } + closeEditor(); + } catch { + setError('任务保存失败,请检查表单后重试。'); + } finally { + setSaving(false); + } + } + + async function toggle(task: ScheduledTask, enabled: boolean) { + const updated = await dataSource.setEnabled(task.id, task.version, enabled); + if (updated) + setTasks((current) => current.map((item) => (item.id === task.id ? updated : item))); + } + + async function remove(task: ScheduledTask) { + await dataSource.removeSchedule(task.id, task.version); + setTasks((current) => current.filter((item) => item.id !== task.id)); + } + + async function duplicate(task: ScheduledTask) { + const created = await dataSource.duplicateSchedule(task.id, task.version); + setTasks((current) => [created, ...current]); + } + + const tabs: readonly [AutomationTab, string][] = [ + ['schedules', '计划任务'], + ['runs', '执行记录'], + ['records', '操作审计'], + ]; + + return ( +
+
+
+

自动化

+

按北京时间(UTC+8)统一调度多实例运维操作。

+
+ {tab === 'schedules' ? ( + + ) : null} +
+ +
+ {tabs.map(([value, label]) => ( + + ))} +
+ + {error ? ( +

+ {error} +

+ ) : null} + {tab === 'schedules' ? ( +
+ {loading ?

正在加载计划任务...

: null} + {!loading && tasks.length === 0 ? ( +
+ 暂无计划任务 + 创建定时重启、系统重启或短信发送任务。 +
+ ) : null} + {tasks.length > 0 ? ( +
+ + + + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + + + ))} + +
名称操作目标Cron下次执行启用 + 操作 +
+ {task.name} + v{task.version} + + + {operationLabel(task.operationType)} + + {targetLabel(task)} + {task.cronExpression} + + {task.enabled && task.nextDueAt + ? `${beijingTime(task.nextDueAt)} CST` + : '已暂停'} + + + +
+ + {actionMenuId === task.id ? ( +
+ + + + +
+ ) : null} +
+
+
+ ) : null} +
+ ) : null} + {tab === 'runs' ? ( +
+ {runsContent ?? + (runs.length === 0 ? ( +
+ 暂无执行记录 + 任务执行后,结果会显示在这里。 +
+ ) : ( +
+ + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + ))} + +
计划时间任务操作实例数结果
{beijingTime(run.dueAt)} CST{run.taskName}{operationLabel(run.operationType)}{run.targetSnapshot.length}{outcomeLabel(run.outcome)}
+
+ ))} +
+ ) : null} + {tab === 'records' ? ( +
+ {recordsContent ?? ( +
+ 暂无操作审计 + 安全追踪与故障排查记录会显示在这里。 +
+ )} +
+ ) : null} + + {editorOpen ? ( +
{ + if (event.target === event.currentTarget) closeEditor(); + }} + > +