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

@@ -142,6 +142,18 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
);
`,
},
{
// P10 — archivage automatique des sessions terminées (rétention configurable, défaut 30 j).
// `archived_at` NULL = active. Soft-archive : JAMAIS de DELETE (resume/fork restent intacts).
// Sémantique distincte de hidden_sessions (#9) : `hidden` est manuel et indexé par
// claude_session_id ; `archived` est automatique par ancienneté, indexé par `id`, et ne
// s'applique qu'aux sessions managées en DB. Les deux filtres sont indépendants et cumulables.
id: 10,
sql: `
ALTER TABLE sessions ADD COLUMN archived_at TEXT;
CREATE INDEX idx_sessions_archived_at ON sessions(archived_at);
`,
},
];
export type Db = DatabaseSync;
@@ -218,3 +230,48 @@ export function hideSession(db: Db, claudeSessionId: string): void {
export function unhideSession(db: Db, claudeSessionId: string): void {
db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId);
}
// ---- Archivage des sessions terminées (par id, soft-archive — P10) ----
/** Archive une session managée (idempotent : ne touche pas une session déjà archivée). */
export function archiveSession(db: Db, id: string, archivedAt: string): void {
db.prepare('UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL').run(archivedAt, id);
}
/** Dés-archive une session (idempotent). La session reste reprenable/forkable (inchangé). */
export function unarchiveSession(db: Db, id: string): void {
db.prepare('UPDATE sessions SET archived_at = NULL WHERE id = ?').run(id);
}
/** true si une session managée avec cet id existe en DB. */
export function sessionExists(db: Db, id: string): boolean {
return db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(id) != null;
}
/** true si la session existe en DB et est archivée. */
export function isSessionArchived(db: Db, id: string): boolean {
const row = db.prepare('SELECT archived_at FROM sessions WHERE id = ?').get(id) as { archived_at: string | null } | undefined;
return row != null && row.archived_at != null;
}
/**
* Archive en masse les sessions terminées plus anciennes que `cutoffIso` (comparaison lexicographique
* sur ended_at, format ISO UTC). Retourne les `id` archivés (pour émettre les events + auditer).
* SELECT puis UPDATE dans une transaction → la liste retournée correspond exactement aux lignes mutées.
* Les sessions vivantes (ended_at NULL) et déjà archivées sont exclues.
*/
export function archiveExpiredSessions(db: Db, archivedAt: string, cutoffIso: string): string[] {
db.exec('BEGIN');
try {
const rows = db
.prepare('SELECT id FROM sessions WHERE ended_at IS NOT NULL AND ended_at < ? AND archived_at IS NULL')
.all(cutoffIso) as Array<{ id: string }>;
const stmt = db.prepare('UPDATE sessions SET archived_at = ? WHERE id = ?');
for (const r of rows) stmt.run(archivedAt, r.id);
db.exec('COMMIT');
return rows.map((r) => r.id);
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}