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

@@ -2,13 +2,24 @@ import type { FastifyInstance } from 'fastify';
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionsListResponse } from '@arboretum/shared';
import type { PtyManager } from '../core/pty-manager.js';
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
import { hideSession, unhideSession, type Db } from '../db/index.js';
import type { SessionArchiveService } from '../core/session-archive.js';
import { archiveSession, hideSession, sessionExists, unarchiveSession, unhideSession, type Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js';
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService, db: Db): void {
export function registerSessionRoutes(
app: FastifyInstance,
manager: PtyManager,
discovery: DiscoveryService,
sessionArchive: SessionArchiveService,
db: Db,
): void {
app.get('/api/v1/sessions', async (req): Promise<SessionsListResponse> => {
const includeHidden = (req.query as { includeHidden?: string }).includeHidden === 'true';
const all = mergeSessions(manager.list(), discovery.list());
const q = req.query as { includeHidden?: string; includeArchived?: string };
const includeHidden = q.includeHidden === 'true';
const includeArchived = q.includeArchived === 'true';
// `includeArchived` ne concerne QUE les sessions managées (manager.list) ; les découvertes
// n'ont pas de notion d'archivage. `hidden` est filtré ensuite, indépendamment.
const all = mergeSessions(manager.list({ includeArchived }), discovery.list());
return { sessions: includeHidden ? all : all.filter((s) => !s.hidden) };
});
@@ -135,6 +146,40 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
return reply.send(res);
});
// Archive manuellement une session managée terminée (soft-archive). Exclue de la liste par défaut,
// reste reprenable/forkable. 404 si l'id n'est pas une session managée connue en DB.
app.post('/api/v1/sessions/:id/archive', async (req, reply) => {
const { id } = req.params as { id: string };
if (!sessionExists(db, id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No managed session with this id to archive' } });
}
const archivedAt = new Date().toISOString();
archiveSession(db, id, archivedAt);
sessionArchive.emitArchived(id, archivedAt); // diffuse aux clients abonnés (session_archived)
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.archive', resourceId: id });
return reply.send({ ok: true });
});
// Dés-archive une session : ré-émet un session_update pour que tous les clients ré-affichent le row.
app.delete('/api/v1/sessions/:id/archive', async (req, reply) => {
const { id } = req.params as { id: string };
if (!sessionExists(db, id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No managed session with this id to unarchive' } });
}
unarchiveSession(db, id);
manager.emitHistoricalUpdate(id);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.unarchive', resourceId: id });
return reply.send({ ok: true });
});
// Déclenche un balayage d'archivage immédiat (rétention configurée). Renvoie le nombre archivé.
// Pratique pour l'UX et pour une acceptance déterministe (sans attendre le scheduler ~6 h).
app.post('/api/v1/sessions/archive-now', async (req, reply) => {
const archived = sessionArchive.sweep();
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.archiveNow', resourceId: null, details: { archived } });
return reply.send({ archived });
});
app.delete('/api/v1/sessions/:id', async (req, reply) => {
const { id } = req.params as { id: string };
if (!manager.kill(id)) {

View File

@@ -24,6 +24,14 @@ import {
readClaudeBinPath,
readClaudeHome,
} from '../core/claude-settings.js';
import {
PURGE_DAYS_KEY,
RETENTION_DAYS_KEY,
normalizePurgeDays,
normalizeRetentionDays,
readPurgeDays,
readRetentionDays,
} from '../core/retention-settings.js';
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
export function registerSettingsRoutes(
@@ -51,6 +59,8 @@ export function registerSettingsRoutes(
scanIntervalMin: readScanIntervalMin(db),
claudeBinPath: readClaudeBinPath(db),
claudeHome: readClaudeHome(db),
retentionDays: readRetentionDays(db),
purgeDays: readPurgeDays(db),
},
server: serverInfo(),
});
@@ -87,6 +97,20 @@ export function registerSettingsRoutes(
}
setSetting(db, CLAUDE_HOME_KEY, value);
}
if ('retentionDays' in body) {
const value = normalizeRetentionDays(body.retentionDays);
if (value === null) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'retentionDays must be 0 (never) or an integer between 1 and 3650' } });
}
setSetting(db, RETENTION_DAYS_KEY, String(value));
}
if ('purgeDays' in body) {
const value = normalizePurgeDays(body.purgeDays);
if (value === null) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'purgeDays must be 0 (disabled) or an integer between 1 and 3650' } });
}
setSetting(db, PURGE_DAYS_KEY, String(value));
}
recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown',
action: 'settings.update',