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

@@ -47,6 +47,11 @@ export default {
hideDiscovered: 'Hide external history',
showHidden: 'Show hidden',
hideHidden: 'Hide hidden',
archive: 'Archive',
unarchive: 'Unarchive',
archivedBadge: 'archived',
showArchived: 'Show archived',
hideArchived: 'Hide archived',
clients: 'no client | 1 client | {n} clients',
exitCode: 'exit code {code}',
statusLabel: {
@@ -342,6 +347,8 @@ export default {
sessionHidden: 'Session hidden',
sessionUnhidden: 'Session shown',
sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden',
sessionArchived: 'Session archived',
sessionUnarchived: 'Session unarchived',
repoAdded: 'Repository added',
repoRemoved: 'Repository removed',
worktreeCreated: 'Worktree created',
@@ -413,6 +420,11 @@ export default {
removeRoot: 'Remove',
scanInterval: 'Re-scan interval (minutes)',
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
// Sessions (rétention / archivage auto)
sessions: 'Sessions',
sessionsHint: 'Finished sessions older than the retention window are automatically archived (hidden from the list by default). Nothing is deleted — archived sessions stay resumable and forkable.',
retentionDays: 'Auto-archive after (days)',
retentionDaysHint: '0 never archives. Archived sessions are hidden by default; toggle "Show archived" in the sessions list to see them.',
// Claude CLI
claudeCli: 'Claude CLI',
claudeCliHint: 'Where Arboretum finds the `claude` binary and its data. Handy when the daemon runs as a service with a minimal PATH that does not include ~/.local/bin.',

View File

@@ -49,6 +49,11 @@ const fr: typeof en = {
hideDiscovered: 'Masquer lhistorique externe',
showHidden: 'Afficher les masquées',
hideHidden: 'Masquer les masquées',
archive: 'Archiver',
unarchive: 'Désarchiver',
archivedBadge: 'archivée',
showArchived: 'Afficher les archivées',
hideArchived: 'Masquer les archivées',
clients: 'aucun client | 1 client | {n} clients',
exitCode: 'code de sortie {code}',
statusLabel: {
@@ -345,6 +350,8 @@ const fr: typeof en = {
sessionHidden: 'Session masquée',
sessionUnhidden: 'Session réaffichée',
sessionsHidden: 'aucune session masquée | 1 session masquée | {n} sessions masquées',
sessionArchived: 'Session archivée',
sessionUnarchived: 'Session désarchivée',
repoAdded: 'Dépôt ajouté',
repoRemoved: 'Dépôt retiré',
worktreeCreated: 'Worktree créé',
@@ -416,6 +423,11 @@ const fr: typeof en = {
removeRoot: 'Retirer',
scanInterval: 'Intervalle de re-scan (minutes)',
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
// Sessions (rétention / archivage auto)
sessions: 'Sessions',
sessionsHint: 'Les sessions terminées plus anciennes que la fenêtre de rétention sont archivées automatiquement (masquées de la liste par défaut). Rien nest supprimé — une session archivée reste reprenable et forkable.',
retentionDays: 'Auto-archiver après (jours)',
retentionDaysHint: '0 narchive jamais. Les sessions archivées sont masquées par défaut ; activez « Afficher les archivées » dans la liste pour les voir.',
// Claude CLI
claudeCli: 'CLI Claude',
claudeCliHint: 'Où Arboretum trouve le binaire `claude` et ses données. Utile quand le daemon tourne en service avec un PATH minimal qui ninclut pas ~/.local/bin.',

View File

@@ -26,7 +26,7 @@ export interface TerminalSink {
onControlChanged(controlling: boolean): void;
}
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' | 'session_archived' }>;
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
@@ -470,7 +470,8 @@ export class WsClient {
return;
}
case 'session_update':
case 'session_exit': {
case 'session_exit':
case 'session_archived': {
for (const cb of this.sessionListeners) cb(msg);
return;
}

View File

@@ -10,6 +10,9 @@ export const useSessionsStore = defineStore('sessions', () => {
const loadError = ref<string | null>(null);
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
const showHidden = ref(false);
// false (défaut) : les sessions auto-archivées sont exclues ; true : on les inclut (P10). Filtre
// indépendant et cumulable avec showHidden.
const showArchived = ref(false);
let unsubscribe: (() => void) | null = null;
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
@@ -28,6 +31,11 @@ export const useSessionsStore = defineStore('sessions', () => {
remove(session.id);
return;
}
// idem pour les sessions auto-archivées quand « afficher les archivées » est inactif.
if (session.archived && !showArchived.value) {
remove(session.id);
return;
}
const idx = sessions.value.findIndex((s) => s.id === session.id);
if (idx >= 0) sessions.value.splice(idx, 1, session);
else sessions.value.push(session);
@@ -39,6 +47,12 @@ export const useSessionsStore = defineStore('sessions', () => {
upsert(e.session);
return;
}
if (e.type === 'session_archived') {
// marque la session archivée puis upsert (qui la retire si « afficher les archivées » est off).
const current = sessions.value.find((s) => s.id === e.sessionId);
if (current) upsert({ ...current, archived: true });
return;
}
const current = sessions.value.find((s) => s.id === e.sessionId);
if (!current) return;
upsert({
@@ -55,8 +69,11 @@ export const useSessionsStore = defineStore('sessions', () => {
loading.value = true;
loadError.value = null;
try {
const url = showHidden.value ? '/api/v1/sessions?includeHidden=true' : '/api/v1/sessions';
const res = await api.get<SessionsListResponse>(url);
const params = new URLSearchParams();
if (showHidden.value) params.set('includeHidden', 'true');
if (showArchived.value) params.set('includeArchived', 'true');
const qs = params.toString();
const res = await api.get<SessionsListResponse>(`/api/v1/sessions${qs ? `?${qs}` : ''}`);
sessions.value = sorted(res.sessions);
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
@@ -70,6 +87,24 @@ export const useSessionsStore = defineStore('sessions', () => {
await fetchSessions();
}
async function setShowArchived(value: boolean): Promise<void> {
showArchived.value = value;
await fetchSessions();
}
// Archive une session managée terminée (retrait optimiste si on n'affiche pas les archivées).
async function archiveSession(id: string): Promise<void> {
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/archive`);
if (!showArchived.value) remove(id);
}
// Dés-archive une session (un session_update WS la ré-affichera ; mise à jour optimiste locale).
async function unarchiveSession(id: string): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}/archive`);
const current = sessions.value.find((s) => s.id === id);
if (current) upsert({ ...current, archived: false });
}
// Masque une session découverte (retrait optimiste si on n'affiche pas les masquées).
async function hideSession(id: string): Promise<void> {
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
@@ -136,8 +171,10 @@ export const useSessionsStore = defineStore('sessions', () => {
loading,
loadError,
showHidden,
showArchived,
fetchSessions,
setShowHidden,
setShowArchived,
startRealtime,
stopRealtime,
createSession,
@@ -148,5 +185,7 @@ export const useSessionsStore = defineStore('sessions', () => {
hideSession,
unhideSession,
hideDiscovered,
archiveSession,
unarchiveSession,
};
});

View File

@@ -11,6 +11,8 @@ export const useSettingsStore = defineStore('settings', () => {
const scanIntervalMin = ref(0);
const claudeBinPath = ref<string | null>(null);
const claudeHome = ref<string | null>(null);
const retentionDays = ref(0);
const purgeDays = ref(0);
const loaded = ref(false);
const saving = ref(false);
@@ -20,6 +22,8 @@ export const useSettingsStore = defineStore('settings', () => {
scanIntervalMin.value = res.settings.scanIntervalMin;
claudeBinPath.value = res.settings.claudeBinPath;
claudeHome.value = res.settings.claudeHome;
retentionDays.value = res.settings.retentionDays;
purgeDays.value = res.settings.purgeDays;
loaded.value = true;
}
@@ -36,5 +40,5 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, loaded, saving, fetch, save };
return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, retentionDays, purgeDays, loaded, saving, fetch, save };
});

View File

@@ -9,6 +9,9 @@
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="store.showArchived ? ArchiveX : Archive" @click="onToggleArchived">
{{ t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
{{ t('common.refresh') }}
</BaseButton>
@@ -54,6 +57,7 @@
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber">{{ t('sessions.archivedBadge') }}</BaseBadge>
<SessionStateBadge :session="s" />
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
</div>
@@ -87,6 +91,9 @@
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
<BaseButton v-if="s.hidden" variant="ghost" size="sm" :icon="Eye" :loading="hidingId === s.id" @click="onUnhide(s.id)">{{ t('sessions.unhide') }}</BaseButton>
<BaseButton v-else-if="s.source === 'discovered'" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingId === s.id" @click="onHide(s.id)">{{ t('sessions.hide') }}</BaseButton>
<!-- archivage : ne concerne que les sessions managées terminées (jamais les vivantes/découvertes) -->
<BaseButton v-if="s.archived" variant="ghost" size="sm" :icon="ArchiveRestore" :loading="archivingId === s.id" @click="onUnarchive(s.id)">{{ t('sessions.unarchive') }}</BaseButton>
<BaseButton v-else-if="s.source === 'managed' && !s.live" variant="ghost" size="sm" :icon="Archive" :loading="archivingId === s.id" @click="onArchive(s.id)">{{ t('sessions.archive') }}</BaseButton>
</div>
</div>
</li>
@@ -106,7 +113,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search, Archive, ArchiveX, ArchiveRestore } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
@@ -147,6 +154,7 @@ const killArmedId = ref<string | null>(null);
const killing = ref(false);
const actingId = ref<string | null>(null);
const hidingId = ref<string | null>(null);
const archivingId = ref<string | null>(null);
const hidingAll = ref(false);
// au moins une session externe visible → propose le masquage de masse
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
@@ -261,4 +269,36 @@ async function onToggleHidden(): Promise<void> {
toasts.error(err);
}
}
async function onToggleArchived(): Promise<void> {
try {
await store.setShowArchived(!store.showArchived);
} catch (err) {
toasts.error(err);
}
}
async function onArchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.archiveSession(id);
toasts.success(t('toast.sessionArchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
async function onUnarchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.unarchiveSession(id);
toasts.success(t('toast.sessionUnarchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
</script>

View File

@@ -175,6 +175,26 @@
</div>
</section>
<!-- Sessions (rétention / archivage auto) -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Archive :size="16" /> {{ t('settings.sessions') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.sessionsHint') }}</p>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.retentionDays') }}</span>
<input v-model.number="retentionDraft" type="number" min="0" max="3650" class="input w-32" />
<span class="text-xs text-zinc-500">{{ t('settings.retentionDaysHint') }}</span>
</label>
<div>
<BaseButton variant="primary" :loading="settings.saving" :disabled="!retentionDirty" @click="saveRetention">
{{ t('settings.save') }}
</BaseButton>
</div>
</section>
<!-- Sécurité & conformité -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -240,7 +260,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertCircle, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
import type {
AuditLogEntry,
AuditLogsResponse,
@@ -387,6 +407,22 @@ async function saveClaude(): Promise<void> {
}
}
// ---- Sessions (rétention / archivage auto) ----
const retentionDraft = ref(0);
const retentionDirty = computed(() => retentionDraft.value !== settings.retentionDays);
function syncRetentionDraft(): void {
retentionDraft.value = settings.retentionDays;
}
async function saveRetention(): Promise<void> {
try {
await settings.save({ retentionDays: retentionDraft.value });
syncRetentionDraft();
toasts.success(t('settings.saved'));
} catch (e) {
toasts.error(e);
}
}
// ---- Sécurité & conformité (audit / RGPD) ----
const audit = ref<AuditLogEntry[]>([]);
const auditing = ref(false);
@@ -438,6 +474,7 @@ onMounted(async () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
syncDiscoveryDraft();
syncClaudeDraft();
syncRetentionDraft();
void push.refresh();
});
</script>