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

@@ -23,6 +23,25 @@ const NOTIFY_DEBOUNCE_MS = 1500;
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;
/**
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
* `type` (et non `interface`) pour que TS infère l'index signature implicite → cast direct depuis
* le `Record<string, SQLOutputValue>` de node:sqlite.
*/
type HistoricalRow = {
id: string;
cwd: string;
command: string;
title: string | null;
created_at: string;
ended_at: string | null;
exit_code: number | null;
claude_session_id: string | null;
added_dirs: string | null;
group_id: string | null;
archived_at: string | null;
};
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
function parseAddedDirs(raw: string | null): string[] {
if (!raw) return [];
@@ -246,41 +265,61 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
return set;
}
list(): SessionSummary[] {
list(opts?: { includeArchived?: boolean }): SessionSummary[] {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys());
const rows = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as HistoricalRow[];
const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id))
.map((r) => {
const addedDirs = parseAddedDirs(r.added_dirs);
return {
id: r.id,
cwd: r.cwd,
command: r.command,
title: r.title,
status: 'exited' as const,
live: false,
createdAt: r.created_at,
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
source: 'managed' as const,
claudeSessionId: r.claude_session_id,
pid: null,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
resumable: r.command === 'claude' && r.claude_session_id != null,
attachable: false,
registryStatus: null,
...(addedDirs.length ? { addedDirs } : {}),
groupId: r.group_id,
};
});
// Sessions auto-archivées exclues par défaut (filtre indépendant et cumulable avec hidden).
.filter((r) => (opts?.includeArchived ? true : r.archived_at == null))
.map((r) => this.historicalSummary(r));
return [...liveSummaries, ...historical];
}
/** Construit le SessionSummary d'une ligne historique (session managée terminée). */
private historicalSummary(r: HistoricalRow): SessionSummary {
const addedDirs = parseAddedDirs(r.added_dirs);
return {
id: r.id,
cwd: r.cwd,
command: r.command,
title: r.title,
status: 'exited' as const,
live: false,
createdAt: r.created_at,
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
source: 'managed' as const,
claudeSessionId: r.claude_session_id,
pid: null,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
resumable: r.command === 'claude' && r.claude_session_id != null,
attachable: false,
registryStatus: null,
...(addedDirs.length ? { addedDirs } : {}),
groupId: r.group_id,
archived: r.archived_at != null,
};
}
/**
* Ré-émet un `session_update` pour une session historique (P10) — utilisé au dés-archivage pour
* que tous les clients rafraîchissent le row (le champ `archived` repasse à false). No-op si la
* session est vivante (déjà couverte par le flux live) ou inconnue.
*/
emitHistoricalUpdate(id: string): void {
if (this.live.has(id)) return;
const r = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions WHERE id = ?')
.get(id) as HistoricalRow | undefined;
if (!r) return;
this.emit('session_update', this.historicalSummary(r));
}
get(id: string): SessionSummary | null {
const s = this.live.get(id);
return s ? this.summarize(s) : null;