feat(p10): archivage automatique des sessions terminées (rétention configurable)
- DB: migration #10 (sessions.archived_at + index), helpers archive/unarchive/archiveExpired (soft-archive, jamais de DELETE) - core/retention-settings.ts: session_retention_days (défaut 30, 0=jamais, 1-3650) + session_purge_days (off) - core/session-archive.ts: scheduler start/stop/sweep (calqué DiscoveryService), câblé dans runDaemon - pty-manager.list({includeArchived}) + SessionSummary.archived + emitHistoricalUpdate - routes: GET /sessions?includeArchived, POST/DELETE /sessions/:id/archive, POST /sessions/archive-now - protocole additif: message WS session_archived (relayé par la gateway, abonnés 'sessions') - settings: retentionDays/purgeDays exposés et validés dans PATCH /settings - web: store sessions (showArchived + archive/unarchive), SessionsListView (toggle/badge/actions), SettingsView (slider rétention), i18n EN+FR - tests: retention-settings + session-archive (vitest) + settings-routes étendu ; acceptance-p10.mjs (sweep, event WS, resume d'une session archivée → 201, rétention=0)
This commit is contained in:
75
packages/server/src/core/session-archive.ts
Normal file
75
packages/server/src/core/session-archive.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Archivage automatique des sessions terminées (P10). Scheduler calqué sur DiscoveryService :
|
||||
// start()/stop() + setInterval().unref(). Soft-archive uniquement (jamais de DELETE dans cette
|
||||
// itération ; la purge définitive est conçue mais désactivée par défaut, cf. retention-settings).
|
||||
// Démarré dans runDaemon (jamais dans buildApp → ne tourne pas pendant les tests unitaires).
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { archiveExpiredSessions, type Db } from '../db/index.js';
|
||||
import { recordAudit } from './audit-log.js';
|
||||
import { readRetentionDays } from './retention-settings.js';
|
||||
|
||||
/** Balayage toutes les 6 h : la rétention se compte en jours, pas besoin de plus fréquent. */
|
||||
const DEFAULT_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface SessionArchiveEvents {
|
||||
/** une session vient d'être archivée (relayée en `session_archived` par la gateway). */
|
||||
session_archived: [{ id: string; archivedAt: string }];
|
||||
}
|
||||
|
||||
export interface SessionArchiveOptions {
|
||||
db: Db;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export class SessionArchiveService extends EventEmitter<SessionArchiveEvents> {
|
||||
private readonly db: Db;
|
||||
private readonly intervalMs: number;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(opts: SessionArchiveOptions) {
|
||||
super();
|
||||
this.db = opts.db;
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.sweep(); // passage immédiat au boot (synchrone, bon marché : un seul UPDATE borné)
|
||||
this.timer = setInterval(() => this.sweep(), this.intervalMs);
|
||||
this.timer.unref(); // ne maintient pas le process en vie
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive les sessions terminées plus anciennes que la rétention configurée. Retourne le nombre
|
||||
* archivé. Tolérant : ne lève jamais (une erreur ne doit pas tuer le scheduler). `0 jour` = OFF.
|
||||
*/
|
||||
sweep(): number {
|
||||
try {
|
||||
const days = readRetentionDays(this.db);
|
||||
if (days <= 0) return 0; // rétention désactivée
|
||||
const now = new Date();
|
||||
const archivedAt = now.toISOString();
|
||||
const cutoffIso = new Date(now.getTime() - days * MS_PER_DAY).toISOString();
|
||||
const ids = archiveExpiredSessions(this.db, archivedAt, cutoffIso);
|
||||
for (const id of ids) {
|
||||
this.emit('session_archived', { id, archivedAt });
|
||||
recordAudit(this.db, { actor: 'system', action: 'session.autoArchive', resourceId: id });
|
||||
}
|
||||
return ids.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant — ex. route manuelle). */
|
||||
emitArchived(id: string, archivedAt: string): void {
|
||||
this.emit('session_archived', { id, archivedAt });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user