refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)

Scheduled-operation action metadata was duplicated across the backend route
validator, the DatabaseService action union, the desktop action picker, the
Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant
editing all of them.

Introduce one registry per package as the single source within that package:

- backend/src/services/scheduledActionRegistry.ts owns the action list and
  target-type validation; routes/scheduledTasks.ts and DatabaseService import
  from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget).
- frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short
  labels, categories, tones, target/node/stack/service flags, helper text) and
  drives the create-flow picker, the All Tasks label, the Timeline lanes, and
  the mobile schedule view.

Timeline lanes now group by semantic category (Lifecycle, Updates, Security,
Maintenance, Backups) sourced from the registry. The update-fleet UI alias is
made explicit via a backendAction field. Backend validation stays authoritative;
parity tests on each side keep the action sets in lockstep.
This commit is contained in:
Anso
2026-06-24 20:09:13 -04:00
committed by GitHub
parent 330f9f1acd
commit 0af7ad1df2
11 changed files with 551 additions and 123 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { isSeverityAtLeast } from '../utils/severity';
import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import type { BackendScheduledAction } from './scheduledActionRegistry';
function isPilotMode(): boolean {
return process.env.SENCHO_MODE === 'pilot';
@@ -505,7 +506,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
action: BackendScheduledAction;
cron_expression: string;
enabled: number;
created_by: string;
@@ -0,0 +1,79 @@
/**
* Single source of truth for scheduled-operation action metadata that the
* backend needs for validation. The route layer derives its allow-list and
* action/target compatibility checks from this table, so adding a new action
* means adding one entry here (plus its execution logic in SchedulerService).
*
* The frontend keeps its own richer registry (labels, categories, tones) in
* `frontend/src/lib/scheduledActions.ts`; the two cannot share a module because
* the packages build in isolation. The shapes are kept in lockstep by tests on
* each side.
*/
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
export type TargetType = typeof VALID_TARGET_TYPES[number];
interface BackendScheduledActionDefinition {
readonly id: string;
/** Target types this action accepts. `update` is the only multi-target action. */
readonly targetTypes: readonly TargetType[];
}
/**
* Ordered so the `VALID_ACTIONS` list matches the human-readable error message
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
*/
export const BACKEND_SCHEDULED_ACTIONS = [
{ id: 'restart', targetTypes: ['stack'] },
{ id: 'snapshot', targetTypes: ['fleet'] },
{ id: 'prune', targetTypes: ['system'] },
{ id: 'update', targetTypes: ['stack', 'fleet'] },
{ id: 'scan', targetTypes: ['system'] },
{ id: 'auto_backup', targetTypes: ['stack'] },
{ id: 'auto_stop', targetTypes: ['stack'] },
{ id: 'auto_down', targetTypes: ['stack'] },
{ id: 'auto_start', targetTypes: ['stack'] },
] as const satisfies readonly BackendScheduledActionDefinition[];
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
export const VALID_ACTIONS: readonly BackendScheduledAction[] =
BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
/**
* Human-readable allow-list for the route's 400 response. Built from
* VALID_ACTIONS so a new action cannot leave this enumeration stale.
*/
export const INVALID_ACTION_MESSAGE =
`Invalid action. Must be ${VALID_ACTIONS.join(', ').replace(/, ([^,]+)$/, ', or $1')}.`;
const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefinition>(
BACKEND_SCHEDULED_ACTIONS.map(a => [a.id, a]),
);
/**
* Per-action mismatch message. The wording differs per action and is part of
* the API contract, so it is kept explicit rather than templated.
*/
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
};
/**
* Validate that the target_type is compatible with the action. Returns an error
* message on mismatch and null otherwise. Callers must already have confirmed
* the action is in `VALID_ACTIONS`.
*/
export function validateActionTarget(action: BackendScheduledAction, targetType: TargetType): string | null {
const def = ACTION_BY_ID.get(action);
if (!def) return null;
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
}