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:
@@ -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.',
|
||||
|
||||
@@ -49,6 +49,11 @@ const fr: typeof en = {
|
||||
hideDiscovered: 'Masquer l’historique 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 n’est supprimé — une session archivée reste reprenable et forkable.',
|
||||
retentionDays: 'Auto-archiver après (jours)',
|
||||
retentionDaysHint: '0 n’archive 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 n’inclut pas ~/.local/bin.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user