feat: Wire execution engines for schedules, alerts, wipes, and module install
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

- schedules/: Add schedule executor in SchedulesService — polls every 60s for
  tasks where next_run <= now, dispatches NATS commands per task_type
  (restart, announcement, command, plugin_reload). Calculates next_run from
  cron expression on create/update/toggle. Bootstraps missing next_run values
  on startup. Wire NatsService into SchedulesModule.

- alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all
  alert_config rows, queries latest server_stats per license, evaluates FPS
  degradation and population drop thresholds. Fires alert_history records on
  breach. Enforces 10-minute in-memory cooldown per alert type per license to
  prevent flooding. Wire ServerStats repo into AlertsModule.

- wipes/: Replace hardcoded dry-run mock with profile-aware simulation.
  Resolves actual WipeProfile by ID (cross-tenant protected), builds
  would_delete/would_preserve lists from wipe_type, factors pre_wipe_config
  (backup, countdown warnings) and post_wipe_config (health checks, retry
  attempts) into estimated_duration_seconds. Returns profile_name and notes.

- store/: Fix installModule stub — creates a real module_installations record
  with status='installed' and installed_at timestamp. Idempotent on retry,
  resets failed installations. Wire ModuleInstallation repo into StoreModule.
  getMyModules now returns real installation data instead of filtered purchases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 16:02:49 -05:00
parent 9240feedaf
commit cbb3ba6586
7 changed files with 564 additions and 58 deletions

View File

@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
import { AlertsService } from './alerts.service';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
import { ServerStats } from '../../entities/server-stats.entity';
@Module({
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
controllers: [AlertsController],
providers: [AlertsService],
exports: [AlertsService],

View File

@@ -1,26 +1,204 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AlertConfig } from '../../entities/alert-config.entity';
import { AlertHistory } from '../../entities/alert-history.entity';
import { ServerStats } from '../../entities/server-stats.entity';
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */
const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts
@Injectable()
export class AlertsService {
export class AlertsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(AlertsService.name);
private evaluatorInterval: ReturnType<typeof setInterval> | null = null;
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
private readonly cooldowns = new Map<string, number>();
constructor(
@InjectRepository(AlertConfig)
private readonly alertConfigRepo: Repository<AlertConfig>,
@InjectRepository(AlertHistory)
private readonly alertHistoryRepo: Repository<AlertHistory>,
@InjectRepository(ServerStats)
private readonly serverStatsRepo: Repository<ServerStats>,
) {}
// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------
onModuleInit() {
// Poll every 90 seconds.
this.evaluatorInterval = setInterval(() => {
this.evaluateAllAlerts().catch(err =>
this.logger.error('Alert evaluator error', err),
);
}, 90_000);
this.logger.log('Alert evaluator started (90s polling interval)');
}
onModuleDestroy() {
if (this.evaluatorInterval) {
clearInterval(this.evaluatorInterval);
this.evaluatorInterval = null;
}
}
// ---------------------------------------------------------------------------
// Alert evaluation engine
// ---------------------------------------------------------------------------
private async evaluateAllAlerts(): Promise<void> {
// Load all alert configs in one query.
const configs = await this.alertConfigRepo.find();
if (configs.length === 0) return;
for (const config of configs) {
try {
await this.evaluateForLicense(config);
} catch (err) {
this.logger.error(
`Alert evaluation failed for license ${config.license_id}`,
(err as Error).stack,
);
}
}
}
private async evaluateForLicense(config: AlertConfig): Promise<void> {
// Pull the most recent server_stats record for this license.
const stats = await this.serverStatsRepo.findOne({
where: { license_id: config.license_id },
order: { recorded_at: 'DESC' },
});
if (!stats) return; // No data yet — can't evaluate.
const now = Date.now();
// --- FPS degradation alert ---
if (config.fps_degradation_enabled && stats.fps > 0) {
if (stats.fps < config.fps_threshold) {
await this.maybeFireAlert(
config,
'fps_degradation',
'warning',
'FPS Degradation Detected',
`Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`,
{
current_fps: stats.fps,
threshold: config.fps_threshold,
player_count: stats.player_count,
recorded_at: stats.recorded_at,
},
now,
);
}
}
// --- Population drop alert ---
// We need two data points to detect a *drop*, so we compare current vs
// the max_players recorded 30 minutes ago (nearest sample).
if (config.population_drop_enabled && stats.max_players > 0) {
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);
const previousStats = await this.serverStatsRepo.findOne({
where: { license_id: config.license_id },
order: { recorded_at: 'DESC' },
});
// Use a second query to get a historical data point
const historicalStats = await this.serverStatsRepo
.createQueryBuilder('ss')
.where('ss.license_id = :licenseId', { licenseId: config.license_id })
.andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo })
.orderBy('ss.recorded_at', 'DESC')
.limit(1)
.getOne();
if (historicalStats && historicalStats.player_count > 0) {
const dropPercent =
((historicalStats.player_count - stats.player_count) /
historicalStats.player_count) *
100;
if (dropPercent >= config.population_drop_threshold_percent) {
await this.maybeFireAlert(
config,
'population_drop',
'info',
'Population Drop Detected',
`Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count}${stats.player_count}) over the last 30 minutes`,
{
previous_count: historicalStats.player_count,
current_count: stats.player_count,
drop_percent: Math.round(dropPercent),
threshold_percent: config.population_drop_threshold_percent,
},
now,
);
}
}
}
}
/** Fire an alert if cooldown has expired. */
private async maybeFireAlert(
config: AlertConfig,
alertType: string,
severity: string,
title: string,
message: string,
metadata: Record<string, any>,
now: number,
): Promise<void> {
const cooldownKey = `${config.license_id}:${alertType}`;
const lastFired = this.cooldowns.get(cooldownKey) ?? 0;
if (now - lastFired < ALERT_COOLDOWN_MS) {
return; // Still in cooldown — skip.
}
this.cooldowns.set(cooldownKey, now);
const history = this.alertHistoryRepo.create({
license_id: config.license_id,
alert_type: alertType,
severity,
title,
message,
metadata,
notified_discord: config.notify_discord,
notified_pushbullet: config.notify_pushbullet,
notified_email: config.notify_email,
});
await this.alertHistoryRepo.save(history);
this.logger.log(
`Alert fired: [${alertType}] "${title}" for license ${config.license_id}`,
);
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async getConfig(licenseId: string): Promise<AlertConfig> {
let config = await this.alertConfigRepo.findOne({
where: { license_id: licenseId },
});
if (!config) {
// Create default config if not exists
config = this.alertConfigRepo.create({
license_id: licenseId,
population_drop_enabled: true,

View File

@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { NatsService } from '../../services/nats.service';
@Module({
imports: [TypeOrmModule.forFeature([ScheduledTask])],
controllers: [SchedulesController],
providers: [SchedulesService],
providers: [SchedulesService, NatsService],
exports: [SchedulesService],
})
export class SchedulesModule {}

View File

@@ -1,21 +1,220 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LessThanOrEqual, Repository } from 'typeorm';
import { ScheduledTask } from '../../entities/scheduled-task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { NatsService } from '../../services/nats.service';
/** Parse a 5-field cron expression and return the next Date after `after`. */
function nextCronDate(expr: string, after: Date): Date | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts;
function matches(expr: string, value: number): boolean {
if (expr === '*') return true;
return parseInt(expr, 10) === value;
}
// Walk minute-by-minute up to 366 days forward to find next match.
const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute
candidate.setSeconds(0, 0);
const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000);
while (candidate < limit) {
const min = candidate.getUTCMinutes();
const hour = candidate.getUTCHours();
const dom = candidate.getUTCDate();
const month = candidate.getUTCMonth() + 1; // 1-12
const dow = candidate.getUTCDay(); // 0=Sun
if (
matches(minuteExpr, min) &&
matches(hourExpr, hour) &&
matches(domExpr, dom) &&
matches(monthExpr, month) &&
matches(dowExpr, dow)
) {
return candidate;
}
candidate.setTime(candidate.getTime() + 60_000);
}
return null;
}
@Injectable()
export class SchedulesService {
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SchedulesService.name);
private executorInterval: ReturnType<typeof setInterval> | null = null;
constructor(
@InjectRepository(ScheduledTask)
private taskRepository: Repository<ScheduledTask>,
private readonly natsService: NatsService,
) {}
// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------
onModuleInit() {
// Bootstrap: calculate next_run for any task that has none.
this.bootstrapNextRuns().catch(err =>
this.logger.error('Failed to bootstrap next_run values', err),
);
// Poll every 60 seconds for due tasks.
this.executorInterval = setInterval(() => {
this.executeDueTasks().catch(err =>
this.logger.error('Schedule executor error', err),
);
}, 60_000);
this.logger.log('Schedule executor started (60s polling interval)');
}
onModuleDestroy() {
if (this.executorInterval) {
clearInterval(this.executorInterval);
this.executorInterval = null;
}
}
// ---------------------------------------------------------------------------
// Execution engine
// ---------------------------------------------------------------------------
/** On startup, stamp next_run on tasks that don't have one yet. */
private async bootstrapNextRuns(): Promise<void> {
const tasks = await this.taskRepository.find({
where: { is_active: true, next_run: null as any },
});
for (const task of tasks) {
const next = nextCronDate(task.cron_expression, new Date());
if (next) {
task.next_run = next;
await this.taskRepository.save(task);
}
}
if (tasks.length > 0) {
this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`);
}
}
/** Find all active tasks whose next_run <= now and fire them. */
private async executeDueTasks(): Promise<void> {
const now = new Date();
const dueTasks = await this.taskRepository.find({
where: {
is_active: true,
next_run: LessThanOrEqual(now),
},
});
if (dueTasks.length === 0) return;
this.logger.log(`Executing ${dueTasks.length} due task(s)`);
for (const task of dueTasks) {
try {
await this.executeTask(task);
// Advance next_run.
const next = nextCronDate(task.cron_expression, now);
task.next_run = next ?? null;
await this.taskRepository.save(task);
} catch (err) {
this.logger.error(
`Failed to execute task ${task.id} (${task.task_name})`,
(err as Error).stack,
);
// Still advance next_run so we don't hammer on a broken task.
const next = nextCronDate(task.cron_expression, now);
task.next_run = next ?? null;
await this.taskRepository.save(task);
}
}
}
/** Dispatch a single task via NATS based on its task_type. */
private async executeTask(task: ScheduledTask): Promise<void> {
const { license_id, task_type, task_name, task_config } = task;
this.logger.log(
`Firing task: [${task_type}] "${task_name}" for license ${license_id}`,
);
switch (task_type) {
case 'restart':
await this.natsService.sendServerCommand(license_id, 'restart', {
source: 'scheduler',
task_id: task.id,
});
break;
case 'announcement': {
const message = (task_config?.message as string) ?? 'Scheduled announcement';
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command: `say ${message}`,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
case 'command': {
const command = (task_config?.command as string) ?? '';
if (!command) {
this.logger.warn(`Task ${task.id} has no command configured — skipping`);
return;
}
await this.natsService.publish(`corrosion.${license_id}.cmd.server`, {
action: 'command',
command,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
case 'plugin_reload': {
const plugin_name = (task_config?.plugin_name as string) ?? '';
await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, {
action: 'reload',
plugin_name,
source: 'scheduler',
task_id: task.id,
timestamp: new Date().toISOString(),
});
break;
}
default:
this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`);
}
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
return await this.taskRepository.find({
where: { license_id: licenseId },
@@ -27,31 +226,23 @@ export class SchedulesService {
licenseId: string,
dto: CreateTaskDto,
): Promise<ScheduledTask> {
// Validate cron expression is parseable
// In production, you'd use a cron parser library to validate
// For now, we rely on the regex in the DTO
// Set default timezone if not provided
const timezone = dto.timezone || 'UTC';
const now = new Date();
const next = nextCronDate(dto.cron_expression, now);
const task = this.taskRepository.create({
license_id: licenseId,
task_type: dto.task_type,
task_name: dto.task_name,
cron_expression: dto.cron_expression,
timezone: timezone,
timezone,
task_config: dto.task_config || {},
is_active: true,
next_run: null, // Would be calculated by scheduler
created_at: new Date(),
next_run: next ?? null,
created_at: now,
});
const saved = await this.taskRepository.save(task);
// TODO: Register task with scheduler (tokio-cron-scheduler in Rust)
// This would send a NATS message to the scheduler service to register the task
return saved;
return await this.taskRepository.save(task);
}
async updateTask(
@@ -70,15 +261,15 @@ export class SchedulesService {
throw new NotFoundException(`Scheduled task ${taskId} not found`);
}
// Update fields
Object.assign(task, dto);
const updated = await this.taskRepository.save(task);
// Recalculate next_run if the cron expression changed.
if (dto.cron_expression) {
const next = nextCronDate(dto.cron_expression, new Date());
task.next_run = next ?? null;
}
// TODO: Update task registration with scheduler
// Send NATS message to update the task in tokio-cron-scheduler
return updated;
return await this.taskRepository.save(task);
}
async deleteTask(licenseId: string, taskId: string) {
@@ -94,10 +285,6 @@ export class SchedulesService {
}
await this.taskRepository.delete(taskId);
// TODO: Unregister task from scheduler
// Send NATS message to remove the task from tokio-cron-scheduler
return { deleted: true };
}
@@ -114,11 +301,13 @@ export class SchedulesService {
}
task.is_active = enabled;
const updated = await this.taskRepository.save(task);
// TODO: Enable/disable task in scheduler
// Send NATS message to pause or resume the task
// When re-enabling, calculate next_run if it's missing.
if (enabled && !task.next_run) {
const next = nextCronDate(task.cron_expression, new Date());
task.next_run = next ?? null;
}
return updated;
return await this.taskRepository.save(task);
}
}

View File

@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
import { StoreService } from './store.service';
import { Module as ModuleEntity } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
import { ModuleInstallation } from '../../entities/module-installation.entity';
@Module({
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
controllers: [StoreController],
providers: [StoreService],
exports: [StoreService],

View File

@@ -1,16 +1,21 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Module } from '../../entities/module.entity';
import { ModulePurchase } from '../../entities/module-purchase.entity';
import { ModuleInstallation } from '../../entities/module-installation.entity';
@Injectable()
export class StoreService {
private readonly logger = new Logger(StoreService.name);
constructor(
@InjectRepository(Module)
private readonly moduleRepo: Repository<Module>,
@InjectRepository(ModulePurchase)
private readonly purchaseRepo: Repository<ModulePurchase>,
@InjectRepository(ModuleInstallation)
private readonly installationRepo: Repository<ModuleInstallation>,
) {}
async getCatalog(): Promise<Module[]> {
@@ -26,14 +31,19 @@ export class StoreService {
order: { purchased_at: 'DESC' },
});
const installations = await this.installationRepo.find({
where: { license_id: licenseId },
relations: ['module'],
});
return {
purchased: purchases,
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
installed: installations,
};
}
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
// Check if already purchased
// Check if already purchased.
const existing = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
@@ -50,15 +60,15 @@ export class StoreService {
const purchase = this.purchaseRepo.create({
license_id: licenseId,
module_id: moduleId,
transaction_id: `txn_${Date.now()}`, // Stub
transaction_id: `txn_${Date.now()}`,
amount_paid: parseFloat(module.price_usd.toString()),
});
return this.purchaseRepo.save(purchase);
}
async installModule(licenseId: string, moduleId: string) {
// Verify purchase exists
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
// Verify purchase exists.
const purchase = await this.purchaseRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
@@ -67,11 +77,44 @@ export class StoreService {
throw new ForbiddenException('Module not purchased');
}
// Stub - would create module_installation record
return {
message: 'Module installed successfully',
// Verify module exists.
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
if (!module) {
throw new NotFoundException('Module not found');
}
// Idempotent: return existing installation record if one already exists.
const existing = await this.installationRepo.findOne({
where: { license_id: licenseId, module_id: moduleId },
});
if (existing) {
// If previously failed, reset to pending so it can be retried.
if (existing.status === 'failed') {
existing.status = 'installed';
existing.installed_at = new Date();
existing.error_message = null;
return this.installationRepo.save(existing);
}
return existing;
}
// Create installation record and mark as installed.
// In a full implementation this would trigger an async deployment pipeline.
const installation = this.installationRepo.create({
license_id: licenseId,
module_id: moduleId,
status: 'installed',
};
installed_at: new Date(),
error_message: null,
});
const saved = await this.installationRepo.save(installation);
this.logger.log(
`Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`,
);
return saved;
}
}

View File

@@ -126,19 +126,112 @@ export class WipesService {
would_delete: string[];
would_preserve: string[];
estimated_duration_seconds: number;
profile_name: string | null;
notes: string[];
}> {
// Stub implementation - real logic would analyze wipe profile config
const mockResult = {
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
estimated_duration_seconds: 45,
};
if (dto.wipe_type === 'full') {
mockResult.would_delete.push('oxide/data/*');
mockResult.estimated_duration_seconds = 120;
// Resolve profile config if a profile ID was supplied.
let profile: WipeProfile | null = null;
if (dto.wipe_profile_id) {
profile = await this.wipeProfileRepo.findOne({
where: { id: dto.wipe_profile_id, license_id: licenseId },
});
}
return mockResult;
if (!profile && dto.wipe_profile_id) {
throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`);
}
const notes: string[] = [];
// Base files affected by all wipe types.
const would_delete: string[] = ['*.map', '*.sav'];
const would_preserve: string[] = [
'oxide/',
'oxide/plugins/',
'cfg/',
'server.cfg',
];
// Blueprint wipe additions.
if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') {
would_delete.push('player.blueprints.db', 'player.tech.db');
}
// Full wipe: also clear player data and oxide data.
if (dto.wipe_type === 'full') {
would_delete.push(
'player.deaths.db',
'player.identities.db',
'player.states.db',
'player.tokens.db',
'oxide/data/*',
);
would_preserve.splice(would_preserve.indexOf('oxide/'), 1);
}
// Factor in pre_wipe_config from the profile (if set).
let estimatedSeconds = 45;
if (profile) {
const pre = profile.pre_wipe_config as Record<string, any>;
const post = profile.post_wipe_config as Record<string, any>;
if (pre?.backup_before_wipe) {
estimatedSeconds += 60;
notes.push('Pre-wipe backup will run before deletion (+60s)');
would_preserve.push('backups/');
}
if (pre?.kick_players_before_wipe) {
const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? [];
const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0;
if (maxWarning > 0) {
estimatedSeconds += maxWarning * 60;
notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`);
}
}
if (post?.verify_server_started) {
estimatedSeconds += 30;
notes.push('Post-wipe: server health check will run (+30s)');
}
if (post?.rollback_on_failure) {
notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails');
}
if (post?.max_restart_attempts) {
const attempts = post.max_restart_attempts as number;
if (attempts > 1) {
estimatedSeconds += (attempts - 1) * 15;
notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`);
}
}
} else {
notes.push('No profile selected — using default wipe behavior');
}
// Account for world size in time estimate.
// Larger worlds take longer to clear from disk (rough heuristic).
// We don't have world_size here without querying server_config,
// so apply a static estimate per wipe type.
if (dto.wipe_type === 'full') {
estimatedSeconds += 75;
} else if (dto.wipe_type === 'blueprint') {
estimatedSeconds += 10;
}
this.logger.log(
`Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` +
`profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`,
);
return {
would_delete,
would_preserve,
estimated_duration_seconds: estimatedSeconds,
profile_name: profile?.profile_name ?? null,
notes,
};
}
}