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:
2026-06-27 13:31:28 +02:00
parent be43911dc0
commit c8dd539571
22 changed files with 818 additions and 42 deletions

View File

@@ -0,0 +1,63 @@
// Réglages de rétention des sessions (P10) : validateurs + lecture/écriture DB.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { openDb, setSetting, type Db } from '../src/db/index.js';
import {
DEFAULT_PURGE_DAYS,
DEFAULT_RETENTION_DAYS,
PURGE_DAYS_KEY,
RETENTION_DAYS_KEY,
normalizePurgeDays,
normalizeRetentionDays,
readPurgeDays,
readRetentionDays,
} from '../src/core/retention-settings.js';
let db: Db;
beforeEach(() => {
db = openDb(':memory:');
});
afterEach(() => db.close());
describe('normalizeRetentionDays', () => {
it('accepte 0 (= jamais)', () => {
expect(normalizeRetentionDays(0)).toBe(0);
});
it('accepte une valeur dans les bornes', () => {
expect(normalizeRetentionDays(30)).toBe(30);
expect(normalizeRetentionDays(1)).toBe(1);
expect(normalizeRetentionDays(3650)).toBe(3650);
});
it('rejette hors bornes, non-entier, non-nombre', () => {
expect(normalizeRetentionDays(-1)).toBeNull();
expect(normalizeRetentionDays(3651)).toBeNull();
expect(normalizeRetentionDays(1.5)).toBeNull();
expect(normalizeRetentionDays('30')).toBeNull();
expect(normalizeRetentionDays(null)).toBeNull();
});
it('purge partage exactement les mêmes règles', () => {
expect(normalizePurgeDays).toBe(normalizeRetentionDays);
});
});
describe('readRetentionDays / readPurgeDays', () => {
it('défaut quand absent', () => {
expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS);
expect(readPurgeDays(db)).toBe(DEFAULT_PURGE_DAYS);
});
it('lit une valeur posée', () => {
setSetting(db, RETENTION_DAYS_KEY, '7');
setSetting(db, PURGE_DAYS_KEY, '90');
expect(readRetentionDays(db)).toBe(7);
expect(readPurgeDays(db)).toBe(90);
});
it('0 est une valeur valide (désactivé)', () => {
setSetting(db, RETENTION_DAYS_KEY, '0');
expect(readRetentionDays(db)).toBe(0);
});
it('tolère une valeur corrompue → défaut', () => {
setSetting(db, RETENTION_DAYS_KEY, 'not-a-number');
expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS);
setSetting(db, RETENTION_DAYS_KEY, '99999');
expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS);
});
});

View File

@@ -0,0 +1,69 @@
// Archivage automatique des sessions terminées (P10) : scheduler sweep() sur base :memory:.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isSessionArchived, openDb, setSetting, type Db } from '../src/db/index.js';
import { RETENTION_DAYS_KEY } from '../src/core/retention-settings.js';
import { SessionArchiveService } from '../src/core/session-archive.js';
let db: Db;
function insertSession(id: string, endedAt: string | null): void {
db.prepare('INSERT INTO sessions (id, cwd, command, created_at, ended_at) VALUES (?, ?, ?, ?, ?)').run(
id,
'/tmp/x',
'bash',
'2020-01-01T00:00:00.000Z',
endedAt,
);
}
beforeEach(() => {
db = openDb(':memory:');
insertSession('old', '2020-01-01T00:00:00.000Z'); // terminée il y a longtemps
insertSession('recent', new Date().toISOString()); // terminée à l'instant
insertSession('live', null); // vivante (jamais archivée)
});
afterEach(() => db.close());
describe('SessionArchiveService.sweep', () => {
it('archive seulement les sessions terminées plus anciennes que la rétention', () => {
setSetting(db, RETENTION_DAYS_KEY, '30');
const svc = new SessionArchiveService({ db });
const events: string[] = [];
svc.on('session_archived', (e) => events.push(e.id));
const n = svc.sweep();
expect(n).toBe(1);
expect(events).toEqual(['old']);
expect(isSessionArchived(db, 'old')).toBe(true);
expect(isSessionArchived(db, 'recent')).toBe(false);
expect(isSessionArchived(db, 'live')).toBe(false);
});
it('est idempotent : un second balayage narchive rien de plus', () => {
setSetting(db, RETENTION_DAYS_KEY, '30');
const svc = new SessionArchiveService({ db });
expect(svc.sweep()).toBe(1);
const events: string[] = [];
svc.on('session_archived', (e) => events.push(e.id));
expect(svc.sweep()).toBe(0);
expect(events).toEqual([]);
});
it('rétention = 0 → no-op (archivage désactivé)', () => {
setSetting(db, RETENTION_DAYS_KEY, '0');
const svc = new SessionArchiveService({ db });
expect(svc.sweep()).toBe(0);
expect(isSessionArchived(db, 'old')).toBe(false);
});
it('start() déclenche un balayage immédiat puis stop() est sûr', () => {
setSetting(db, RETENTION_DAYS_KEY, '30');
const svc = new SessionArchiveService({ db, intervalMs: 60_000 });
svc.start();
expect(isSessionArchived(db, 'old')).toBe(true);
svc.stop();
svc.stop(); // idempotent
});
});

View File

@@ -84,6 +84,9 @@ describe('GET /api/v1/settings', () => {
expect(body.settings.claudeHome).toBeNull();
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
// rétention des sessions (P10) : défaut 30 j d'archivage, purge désactivée (0).
expect(body.settings.retentionDays).toBe(30);
expect(body.settings.purgeDays).toBe(0);
});
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
@@ -187,3 +190,24 @@ describe('PATCH /api/v1/settings — Claude CLI', () => {
}
});
});
describe('PATCH /api/v1/settings — rétention des sessions (P10)', () => {
it('enregistre une rétention valide et lexpose', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 7 } });
expect(res.statusCode).toBe(200);
expect((res.json() as SettingsResponse).settings.retentionDays).toBe(7);
});
it('accepte 0 (= jamais archiver)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 0 } });
expect(res.statusCode).toBe(200);
expect((res.json() as SettingsResponse).settings.retentionDays).toBe(0);
});
it('rejette une rétention hors borne ou non-entière (400)', async () => {
for (const retentionDays of [-1, 3651, 1.5]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays } });
expect(res.statusCode).toBe(400);
}
});
});