From c8dd5395716de610af9144ca56c83dc31bd9e6a0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Sat, 27 Jun 2026 13:31:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(p10):=20archivage=20automatique=20des=20se?= =?UTF-8?q?ssions=20termin=C3=A9es=20(r=C3=A9tention=20configurable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/server/scripts/acceptance-p10.mjs | 159 ++++++++++++++++++ packages/server/src/app.ts | 10 +- packages/server/src/core/pty-manager.ts | 93 +++++++--- .../server/src/core/retention-settings.ts | 48 ++++++ packages/server/src/core/session-archive.ts | 75 +++++++++ packages/server/src/db/index.ts | 57 +++++++ packages/server/src/index.ts | 4 +- packages/server/src/routes/sessions.ts | 53 +++++- packages/server/src/routes/settings.ts | 24 +++ packages/server/src/ws/gateway.ts | 8 + .../server/test/retention-settings.test.ts | 63 +++++++ packages/server/test/session-archive.test.ts | 69 ++++++++ packages/server/test/settings-routes.test.ts | 24 +++ packages/shared/src/api.ts | 8 + packages/shared/src/protocol.ts | 6 + packages/web/src/i18n/en.ts | 12 ++ packages/web/src/i18n/fr.ts | 12 ++ packages/web/src/lib/ws-client.ts | 5 +- packages/web/src/stores/sessions.ts | 43 ++++- packages/web/src/stores/settings.ts | 6 +- packages/web/src/views/SessionsListView.vue | 42 ++++- packages/web/src/views/SettingsView.vue | 39 ++++- 22 files changed, 818 insertions(+), 42 deletions(-) create mode 100644 packages/server/scripts/acceptance-p10.mjs create mode 100644 packages/server/src/core/retention-settings.ts create mode 100644 packages/server/src/core/session-archive.ts create mode 100644 packages/server/test/retention-settings.test.ts create mode 100644 packages/server/test/session-archive.test.ts diff --git a/packages/server/scripts/acceptance-p10.mjs b/packages/server/scripts/acceptance-p10.mjs new file mode 100644 index 0000000..eaefdff --- /dev/null +++ b/packages/server/scripts/acceptance-p10.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// Acceptation P10 (sans navigateur, sans quota Claude) : archivage automatique des sessions terminées. +// Vrai daemon + faux binaire `claude` (echo+sleep, comme p2) + 2e connexion sqlite (WAL) pour fabriquer +// des sessions managées terminées avec un ended_at ancien. Couvre : sweep archive-now (archived_at posé, +// event WS session_archived), exclusion par défaut + inclusion via ?includeArchived, archive/unarchive +// manuels, rétention=0 (no-op), et — preuve clé « pas de perte » — resume d'une session ARCHIVÉE → 201. +import { spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { DatabaseSync } from 'node:sqlite'; + +const require = createRequire(import.meta.url); +const WebSocket = require('ws'); + +const PORT = 7551; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const OLD = '2020-01-01T00:00:00.000Z'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p10-')); +const claudeHome = join(tmp, 'claude'); +const workDir = join(tmp, 'work'); +const fakeBin = join(tmp, 'bin'); +const dbPath = join(tmp, 'a.db'); +mkdirSync(join(claudeHome, 'projects'), { recursive: true }); +mkdirSync(join(claudeHome, 'sessions'), { recursive: true }); +mkdirSync(workDir, { recursive: true }); +mkdirSync(fakeBin, { recursive: true }); +writeFileSync(join(fakeBin, 'claude'), '#!/usr/bin/env bash\necho "FAKE-CLAUDE args=[$*] cwd=$(pwd)"\nsleep 30\n'); +chmodSync(join(fakeBin, 'claude'), 0o755); + +// Insère une session managée « claude » terminée directement en DB (2e connexion WAL). +function insertManagedDeadSession(id, claudeSid, endedAt) { + const db = new DatabaseSync(dbPath); + db.prepare( + 'INSERT INTO sessions (id, cwd, command, created_at, ended_at, claude_session_id) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, workDir, 'claude', OLD, endedAt, claudeSid); + db.close(); +} + +const srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', dbPath, '--claude-home', claudeHome, '--no-discover'], + { env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'] }, +); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +function wsClient(cookie) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); + const state = { msgs: [] }; + ws.on('message', (data, isBinary) => { + if (!isBinary) state.msgs.push(JSON.parse(String(data))); + }); + const waitMsg = async (pred, timeout = 6000) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const m = state.msgs.find(pred); + if (m) return m; + await sleep(50); + } + return null; + }; + return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; +} + +const j = (path, method, cookie, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +const listSessions = async (cookie, q = '') => (await (await j(`/api/v1/sessions${q}`, 'GET', cookie)).json()).sessions ?? []; + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200); + + const c = wsClient(cookie); + await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej))); + c.send({ type: 'hello', protocol: 1 }); + await c.waitMsg((m) => m.type === 'hello_ok'); + c.send({ type: 'sub', topics: ['sessions'] }); + + // ---- A. Auto-archivage (sweep via archive-now) ---- + insertManagedDeadSession('arch-old', 'sid-old', OLD); + const before = await listSessions(cookie); + check('session terminée ancienne visible par défaut (avant archivage)', before.some((s) => s.id === 'arch-old' && !s.archived)); + + c.state.msgs.length = 0; + const now = await (await j('/api/v1/sessions/archive-now', 'POST', cookie)).json(); + check('POST /sessions/archive-now → ≥ 1 archivée', now.archived >= 1, `archived=${now.archived}`); + const evt = await c.waitMsg((m) => m.type === 'session_archived' && m.sessionId === 'arch-old'); + check('event WS session_archived reçu', !!evt); + + const def = await listSessions(cookie); + check('GET /sessions exclut l’archivée par défaut', !def.some((s) => s.id === 'arch-old')); + const inc = await listSessions(cookie, '?includeArchived=true'); + check('GET /sessions?includeArchived=true inclut l’archivée (archived=true)', inc.some((s) => s.id === 'arch-old' && s.archived === true)); + + // ---- B. Preuve « pas de perte » : resume d'une session ARCHIVÉE → 201 ---- + const resume = await j('/api/v1/sessions/arch-old/resume', 'POST', cookie); + const resumed = await resume.json(); + check('resume d’une session archivée → 201 (PTY managé claude)', resume.status === 201 && resumed.session?.command === 'claude'); + + // ---- C. Archive / unarchive manuels ---- + insertManagedDeadSession('manual1', 'sid-man', new Date().toISOString()); // récente → le sweep ne la touche pas + c.state.msgs.length = 0; + const arch = await j('/api/v1/sessions/manual1/archive', 'POST', cookie); + check('POST /sessions/:id/archive → 200', arch.status === 200); + check('archive manuel → event session_archived', !!(await c.waitMsg((m) => m.type === 'session_archived' && m.sessionId === 'manual1'))); + check('archive manuel → exclue par défaut', !(await listSessions(cookie)).some((s) => s.id === 'manual1')); + + const unarch = await j('/api/v1/sessions/manual1/archive', 'DELETE', cookie); + check('DELETE /sessions/:id/archive → 200', unarch.status === 200); + check('unarchive → ré-affichée par défaut (archived=false)', (await listSessions(cookie)).some((s) => s.id === 'manual1' && !s.archived)); + + const missing = await j('/api/v1/sessions/does-not-exist/archive', 'POST', cookie); + check('archive d’un id inconnu → 404', missing.status === 404); + + // ---- D. Rétention = 0 → archivage désactivé (no-op) ---- + const patch = await j('/api/v1/settings', 'PATCH', cookie, { retentionDays: 0 }); + check('PATCH retentionDays=0 → 200', patch.status === 200); + insertManagedDeadSession('arch-old2', 'sid-old2', OLD); + const now0 = await (await j('/api/v1/sessions/archive-now', 'POST', cookie)).json(); + check('rétention=0 → sweep n’archive rien', now0.archived === 0); + check('session ancienne reste visible (archivage désactivé)', (await listSessions(cookie)).some((s) => s.id === 'arch-old2' && !s.archived)); + + c.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? '\nACCEPTANCE P10: ALL GREEN' : `\nACCEPTANCE P10: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 5c639aa..a5e1fce 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -10,6 +10,7 @@ import type { Db } from './db/index.js'; import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js'; import { PtyManager } from './core/pty-manager.js'; import { DiscoveryService } from './core/discovery-service.js'; +import { SessionArchiveService } from './core/session-archive.js'; import { WorktreeManager } from './core/worktree-manager.js'; import { RepoDiscoveryService } from './core/repo-discovery.js'; import { GroupManager } from './core/group-manager.js'; @@ -73,6 +74,7 @@ export interface AppBundle { auth: AuthService; manager: PtyManager; discovery: DiscoveryService; + sessionArchive: SessionArchiveService; repoDiscovery: RepoDiscoveryService; worktrees: WorktreeManager; groups: GroupManager; @@ -94,6 +96,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund projectsDir: config.claudeProjectsDir, sessionsDir: config.claudeSessionsDir, }); + // Archivage auto des sessions terminées (P10). Démarré dans runDaemon() — jamais ici (tests). + const sessionArchive = new SessionArchiveService({ db }); // Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes // WS watch/unwatch. closeAll() au drain (libération des descripteurs). const fsWatcher = new FsWatcherService(); @@ -177,7 +181,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund }); registerAuthRoutes(app, auth, limiter, serverVersion, db); - registerSessionRoutes(app, manager, discovery, db); + registerSessionRoutes(app, manager, discovery, sessionArchive, db); registerProjectRoutes(app, manager, db); registerRepoRoutes(app, worktrees, db); registerGroupRoutes(app, groups, db, worktrees, manager); @@ -192,7 +196,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund // La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // encapsulé) — sinon le handler reçoit la signature REST (request, reply). void app.register(async (scoped) => { - registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion); + registerWsGateway(scoped, manager, discovery, sessionArchive, worktrees, groups, serverVersion); }); // SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front) @@ -207,5 +211,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund }); } - return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push, fsWatcher }; + return { app, auth, manager, discovery, sessionArchive, repoDiscovery, worktrees, groups, push, fsWatcher }; } diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index 4544ad2..e41734a 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -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` 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 { 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; diff --git a/packages/server/src/core/retention-settings.ts b/packages/server/src/core/retention-settings.ts new file mode 100644 index 0000000..0ab9ebd --- /dev/null +++ b/packages/server/src/core/retention-settings.ts @@ -0,0 +1,48 @@ +// Réglages de rétention des sessions terminées (archivage auto), persistés dans `settings`. +// Frontière de sécurité : clés NON sensibles, n'entrent dans l'allow-list du PATCH /api/v1/settings +// que via les validateurs ci-dessous. Calqué sur scan-settings.ts. +import { getSetting } from '../db/index.js'; +import type { Db } from '../db/index.js'; + +export const RETENTION_DAYS_KEY = 'session_retention_days'; +export const PURGE_DAYS_KEY = 'session_purge_days'; + +/** Au-delà de N jours après la fin, une session managée est auto-archivée. 0 = jamais. */ +export const DEFAULT_RETENTION_DAYS = 30; +/** Purge définitive (DELETE) après N jours. 0 = désactivée (conçue mais OFF par défaut). */ +export const DEFAULT_PURGE_DAYS = 0; +export const MIN_RETENTION_DAYS = 1; +export const MAX_RETENTION_DAYS = 3650; + +/** Rétention en jours (0 = jamais archiver). Défaut DEFAULT_RETENTION_DAYS. Lecture tolérante. */ +export function readRetentionDays(db: Db): number { + return readDaysSetting(db, RETENTION_DAYS_KEY, DEFAULT_RETENTION_DAYS); +} + +/** Purge définitive en jours (0 = désactivée). Défaut DEFAULT_PURGE_DAYS. Lecture tolérante. */ +export function readPurgeDays(db: Db): number { + return readDaysSetting(db, PURGE_DAYS_KEY, DEFAULT_PURGE_DAYS); +} + +function readDaysSetting(db: Db, key: string, fallback: number): number { + const raw = getSetting(db, key); + if (raw === null) return fallback; + const n = Number(raw); + return isValidDays(n) ? n : fallback; +} + +/** + * Valide un nombre de jours : entier `0` (= désactivé) OU dans [MIN, MAX]. Retourne null si invalide + * (⇒ 400). Le même validateur sert pour la rétention et la purge (mêmes bornes, même sémantique de 0). + */ +export function normalizeRetentionDays(raw: unknown): number | null { + return typeof raw === 'number' && isValidDays(raw) ? raw : null; +} + +/** Alias sémantique pour la purge (mêmes règles). */ +export const normalizePurgeDays = normalizeRetentionDays; + +function isValidDays(n: number): boolean { + if (!Number.isInteger(n)) return false; + return n === 0 || (n >= MIN_RETENTION_DAYS && n <= MAX_RETENTION_DAYS); +} diff --git a/packages/server/src/core/session-archive.ts b/packages/server/src/core/session-archive.ts new file mode 100644 index 0000000..338f18f --- /dev/null +++ b/packages/server/src/core/session-archive.ts @@ -0,0 +1,75 @@ +// Archivage automatique des sessions terminées (P10). Scheduler calqué sur DiscoveryService : +// start()/stop() + setInterval().unref(). Soft-archive uniquement (jamais de DELETE dans cette +// itération ; la purge définitive est conçue mais désactivée par défaut, cf. retention-settings). +// Démarré dans runDaemon (jamais dans buildApp → ne tourne pas pendant les tests unitaires). +import { EventEmitter } from 'node:events'; +import { archiveExpiredSessions, type Db } from '../db/index.js'; +import { recordAudit } from './audit-log.js'; +import { readRetentionDays } from './retention-settings.js'; + +/** Balayage toutes les 6 h : la rétention se compte en jours, pas besoin de plus fréquent. */ +const DEFAULT_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface SessionArchiveEvents { + /** une session vient d'être archivée (relayée en `session_archived` par la gateway). */ + session_archived: [{ id: string; archivedAt: string }]; +} + +export interface SessionArchiveOptions { + db: Db; + intervalMs?: number; +} + +export class SessionArchiveService extends EventEmitter { + private readonly db: Db; + private readonly intervalMs: number; + private timer: NodeJS.Timeout | null = null; + + constructor(opts: SessionArchiveOptions) { + super(); + this.db = opts.db; + this.intervalMs = opts.intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS; + } + + start(): void { + if (this.timer) return; + this.sweep(); // passage immédiat au boot (synchrone, bon marché : un seul UPDATE borné) + this.timer = setInterval(() => this.sweep(), this.intervalMs); + this.timer.unref(); // ne maintient pas le process en vie + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** + * Archive les sessions terminées plus anciennes que la rétention configurée. Retourne le nombre + * archivé. Tolérant : ne lève jamais (une erreur ne doit pas tuer le scheduler). `0 jour` = OFF. + */ + sweep(): number { + try { + const days = readRetentionDays(this.db); + if (days <= 0) return 0; // rétention désactivée + const now = new Date(); + const archivedAt = now.toISOString(); + const cutoffIso = new Date(now.getTime() - days * MS_PER_DAY).toISOString(); + const ids = archiveExpiredSessions(this.db, archivedAt, cutoffIso); + for (const id of ids) { + this.emit('session_archived', { id, archivedAt }); + recordAudit(this.db, { actor: 'system', action: 'session.autoArchive', resourceId: id }); + } + return ids.length; + } catch { + return 0; + } + } + + /** Diffuse l'archivage d'une session (déjà archivée + auditée par l'appelant — ex. route manuelle). */ + emitArchived(id: string, archivedAt: string): void { + this.emit('session_archived', { id, archivedAt }); + } +} diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 7bf6b30..f9e96ff 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -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; + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 199ef43..dd76f98 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -30,11 +30,12 @@ function applyClaudeHomeOverride(config: Config, db: Db): void { export async function runDaemon(config: Config): Promise { const db = openDb(config.dbPath); applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services - const { app, auth, manager, discovery, repoDiscovery, fsWatcher } = buildApp(config, db, pkg.version); + const { app, auth, manager, discovery, sessionArchive, repoDiscovery, fsWatcher } = buildApp(config, db, pkg.version); const bootstrapToken = auth.ensureBootstrapToken(); await app.listen({ port: config.port, host: config.bind }); discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes + sessionArchive.start(); // archivage auto des sessions terminées (rétention configurable) if (config.autoDiscover) repoDiscovery.start(); // découverte auto des repos : scan au boot + re-scan périodique const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`; @@ -52,6 +53,7 @@ export async function runDaemon(config: Config): Promise { shuttingDown = true; app.log.info(`${signal} received — draining sessions then exiting`); discovery.stop(); + sessionArchive.stop(); repoDiscovery.stop(); void fsWatcher.closeAll(); // libère les descripteurs des watchers FS manager.shutdown(); diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 7da6b64..2c16c7b 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -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 => { - 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)) { diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 66b42ee..6b03815 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -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', diff --git a/packages/server/src/ws/gateway.ts b/packages/server/src/ws/gateway.ts index 7a5ba81..e4f044f 100644 --- a/packages/server/src/ws/gateway.ts +++ b/packages/server/src/ws/gateway.ts @@ -13,6 +13,7 @@ import { } from '@arboretum/shared'; import type { ClientBinding, PtyManager } from '../core/pty-manager.js'; import type { DiscoveryService } from '../core/discovery-service.js'; +import type { SessionArchiveService } from '../core/session-archive.js'; import type { WorktreeManager } from '../core/worktree-manager.js'; import type { GroupManager } from '../core/group-manager.js'; @@ -27,6 +28,7 @@ export function registerWsGateway( app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService, + sessionArchive: SessionArchiveService, worktrees: WorktreeManager, groups: GroupManager, serverVersion: string, @@ -57,6 +59,10 @@ export function registerWsGateway( const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => { if (subscribedSessions) send({ type: 'session_exit', ...e }); }; + // P10 — une session managée vient d'être archivée (auto par ancienneté ou manuellement). + const onSessionArchived = (e: { id: string; archivedAt: string }): void => { + if (subscribedSessions) send({ type: 'session_archived', sessionId: e.id }); + }; // Les sessions découvertes (DiscoveryService) sont relayées sur le même flux que les managées. const onDiscoveryUpdate = (session: SessionSummary): void => { if (subscribedSessions) send({ type: 'session_update', session }); @@ -85,6 +91,7 @@ export function registerWsGateway( }; manager.on('session_update', onSessionUpdate); manager.on('session_exit', onSessionExit); + sessionArchive.on('session_archived', onSessionArchived); discovery.on('discovery_update', onDiscoveryUpdate); worktrees.on('repo_update', onRepoUpdate); worktrees.on('repo_removed', onRepoRemoved); @@ -234,6 +241,7 @@ export function registerWsGateway( clearInterval(heartbeat); manager.off('session_update', onSessionUpdate); manager.off('session_exit', onSessionExit); + sessionArchive.off('session_archived', onSessionArchived); discovery.off('discovery_update', onDiscoveryUpdate); worktrees.off('repo_update', onRepoUpdate); worktrees.off('repo_removed', onRepoRemoved); diff --git a/packages/server/test/retention-settings.test.ts b/packages/server/test/retention-settings.test.ts new file mode 100644 index 0000000..abe3c27 --- /dev/null +++ b/packages/server/test/retention-settings.test.ts @@ -0,0 +1,63 @@ +// Réglages de rétention des sessions (P10) : validateurs + lecture/écriture DB. +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { openDb, setSetting, type Db } from '../src/db/index.js'; +import { + DEFAULT_PURGE_DAYS, + DEFAULT_RETENTION_DAYS, + PURGE_DAYS_KEY, + RETENTION_DAYS_KEY, + normalizePurgeDays, + normalizeRetentionDays, + readPurgeDays, + readRetentionDays, +} from '../src/core/retention-settings.js'; + +let db: Db; +beforeEach(() => { + db = openDb(':memory:'); +}); +afterEach(() => db.close()); + +describe('normalizeRetentionDays', () => { + it('accepte 0 (= jamais)', () => { + expect(normalizeRetentionDays(0)).toBe(0); + }); + it('accepte une valeur dans les bornes', () => { + expect(normalizeRetentionDays(30)).toBe(30); + expect(normalizeRetentionDays(1)).toBe(1); + expect(normalizeRetentionDays(3650)).toBe(3650); + }); + it('rejette hors bornes, non-entier, non-nombre', () => { + expect(normalizeRetentionDays(-1)).toBeNull(); + expect(normalizeRetentionDays(3651)).toBeNull(); + expect(normalizeRetentionDays(1.5)).toBeNull(); + expect(normalizeRetentionDays('30')).toBeNull(); + expect(normalizeRetentionDays(null)).toBeNull(); + }); + it('purge partage exactement les mêmes règles', () => { + expect(normalizePurgeDays).toBe(normalizeRetentionDays); + }); +}); + +describe('readRetentionDays / readPurgeDays', () => { + it('défaut quand absent', () => { + expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS); + expect(readPurgeDays(db)).toBe(DEFAULT_PURGE_DAYS); + }); + it('lit une valeur posée', () => { + setSetting(db, RETENTION_DAYS_KEY, '7'); + setSetting(db, PURGE_DAYS_KEY, '90'); + expect(readRetentionDays(db)).toBe(7); + expect(readPurgeDays(db)).toBe(90); + }); + it('0 est une valeur valide (désactivé)', () => { + setSetting(db, RETENTION_DAYS_KEY, '0'); + expect(readRetentionDays(db)).toBe(0); + }); + it('tolère une valeur corrompue → défaut', () => { + setSetting(db, RETENTION_DAYS_KEY, 'not-a-number'); + expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS); + setSetting(db, RETENTION_DAYS_KEY, '99999'); + expect(readRetentionDays(db)).toBe(DEFAULT_RETENTION_DAYS); + }); +}); diff --git a/packages/server/test/session-archive.test.ts b/packages/server/test/session-archive.test.ts new file mode 100644 index 0000000..1cfb7cc --- /dev/null +++ b/packages/server/test/session-archive.test.ts @@ -0,0 +1,69 @@ +// Archivage automatique des sessions terminées (P10) : scheduler sweep() sur base :memory:. +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isSessionArchived, openDb, setSetting, type Db } from '../src/db/index.js'; +import { RETENTION_DAYS_KEY } from '../src/core/retention-settings.js'; +import { SessionArchiveService } from '../src/core/session-archive.js'; + +let db: Db; + +function insertSession(id: string, endedAt: string | null): void { + db.prepare('INSERT INTO sessions (id, cwd, command, created_at, ended_at) VALUES (?, ?, ?, ?, ?)').run( + id, + '/tmp/x', + 'bash', + '2020-01-01T00:00:00.000Z', + endedAt, + ); +} + +beforeEach(() => { + db = openDb(':memory:'); + insertSession('old', '2020-01-01T00:00:00.000Z'); // terminée il y a longtemps + insertSession('recent', new Date().toISOString()); // terminée à l'instant + insertSession('live', null); // vivante (jamais archivée) +}); +afterEach(() => db.close()); + +describe('SessionArchiveService.sweep', () => { + it('archive seulement les sessions terminées plus anciennes que la rétention', () => { + setSetting(db, RETENTION_DAYS_KEY, '30'); + const svc = new SessionArchiveService({ db }); + const events: string[] = []; + svc.on('session_archived', (e) => events.push(e.id)); + + const n = svc.sweep(); + + expect(n).toBe(1); + expect(events).toEqual(['old']); + expect(isSessionArchived(db, 'old')).toBe(true); + expect(isSessionArchived(db, 'recent')).toBe(false); + expect(isSessionArchived(db, 'live')).toBe(false); + }); + + it('est idempotent : un second balayage n’archive rien de plus', () => { + setSetting(db, RETENTION_DAYS_KEY, '30'); + const svc = new SessionArchiveService({ db }); + expect(svc.sweep()).toBe(1); + + const events: string[] = []; + svc.on('session_archived', (e) => events.push(e.id)); + expect(svc.sweep()).toBe(0); + expect(events).toEqual([]); + }); + + it('rétention = 0 → no-op (archivage désactivé)', () => { + setSetting(db, RETENTION_DAYS_KEY, '0'); + const svc = new SessionArchiveService({ db }); + expect(svc.sweep()).toBe(0); + expect(isSessionArchived(db, 'old')).toBe(false); + }); + + it('start() déclenche un balayage immédiat puis stop() est sûr', () => { + setSetting(db, RETENTION_DAYS_KEY, '30'); + const svc = new SessionArchiveService({ db, intervalMs: 60_000 }); + svc.start(); + expect(isSessionArchived(db, 'old')).toBe(true); + svc.stop(); + svc.stop(); // idempotent + }); +}); diff --git a/packages/server/test/settings-routes.test.ts b/packages/server/test/settings-routes.test.ts index a1d37bb..6f52199 100644 --- a/packages/server/test/settings-routes.test.ts +++ b/packages/server/test/settings-routes.test.ts @@ -84,6 +84,9 @@ describe('GET /api/v1/settings', () => { expect(body.settings.claudeHome).toBeNull(); expect(body.server.claudeHome).toBe(join(dir, 'claude')); expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true }); + // rétention des sessions (P10) : défaut 30 j d'archivage, purge désactivée (0). + expect(body.settings.retentionDays).toBe(30); + expect(body.settings.purgeDays).toBe(0); }); it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => { @@ -187,3 +190,24 @@ describe('PATCH /api/v1/settings — Claude CLI', () => { } }); }); + +describe('PATCH /api/v1/settings — rétention des sessions (P10)', () => { + it('enregistre une rétention valide et l’expose', async () => { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 7 } }); + expect(res.statusCode).toBe(200); + expect((res.json() as SettingsResponse).settings.retentionDays).toBe(7); + }); + + it('accepte 0 (= jamais archiver)', async () => { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays: 0 } }); + expect(res.statusCode).toBe(200); + expect((res.json() as SettingsResponse).settings.retentionDays).toBe(0); + }); + + it('rejette une rétention hors borne ou non-entière (400)', async () => { + for (const retentionDays of [-1, 3651, 1.5]) { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { retentionDays } }); + expect(res.statusCode).toBe(400); + } + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 1a7796b..e052bd9 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -417,6 +417,10 @@ export interface SettingsResponse { claudeBinPath: string | null; /** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */ claudeHome: string | null; + /** rétention des sessions terminées : auto-archivage après N jours ; 0 = jamais (P10). */ + retentionDays: number; + /** purge définitive après N jours ; 0 = désactivée (conçue mais off par défaut). */ + purgeDays: number; }; server: ServerInfo; } @@ -429,6 +433,10 @@ export interface UpdateSettingsRequest { claudeBinPath?: string; /** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */ claudeHome?: string; + /** auto-archivage des sessions terminées après N jours (0 = jamais ; sinon 1–3650). */ + retentionDays?: number; + /** purge définitive après N jours (0 = désactivée ; sinon 1–3650). */ + purgeDays?: number; } // ---- Journal d'audit (conformité entreprise) ---- diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 36909ba..7342e0d 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -125,6 +125,9 @@ export interface SessionSummary { // ---- Masquage (additif) ---- /** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */ hidden?: boolean; + // ---- Archivage auto (P10, additif) ---- + /** true = session managée auto-archivée par ancienneté (exclue de la liste sauf includeArchived). */ + archived?: boolean; } // ---- Worktrees & repos (P3) ---- @@ -232,6 +235,9 @@ export type ServerMessage = | { type: 'control_changed'; channel: number; controlling: boolean } | { type: 'session_update'; session: SessionSummary } | { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null } + // P10 — une session managée vient d'être archivée (auto par ancienneté ou manuellement). Signal + // léger : le client met à jour son row (badge archived) et le retire si « Show archived » est off. + | { type: 'session_archived'; sessionId: string } | { type: 'repo_update'; repo: RepoSummary } | { type: 'repo_removed'; repoId: string } | { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary } diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index afaa97b..15bb659 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -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.', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 6fc3e7f..fce2d53 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -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.', diff --git a/packages/web/src/lib/ws-client.ts b/packages/web/src/lib/ws-client.ts index 8c4fc2d..fa3d134 100644 --- a/packages/web/src/lib/ws-client.ts +++ b/packages/web/src/lib/ws-client.ts @@ -26,7 +26,7 @@ export interface TerminalSink { onControlChanged(controlling: boolean): void; } -export type SessionEvent = Extract; +export type SessionEvent = Extract; export type WorktreeEvent = Extract; export type GroupEvent = Extract; /** 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; } diff --git a/packages/web/src/stores/sessions.ts b/packages/web/src/stores/sessions.ts index 38ae48c..eaa7c6a 100644 --- a/packages/web/src/stores/sessions.ts +++ b/packages/web/src/stores/sessions.ts @@ -10,6 +10,9 @@ export const useSessionsStore = defineStore('sessions', () => { const loadError = ref(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(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(`/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 { + 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 { + 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 { + 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 { 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, }; }); diff --git a/packages/web/src/stores/settings.ts b/packages/web/src/stores/settings.ts index beee8df..9e6c95f 100644 --- a/packages/web/src/stores/settings.ts +++ b/packages/web/src/stores/settings.ts @@ -11,6 +11,8 @@ export const useSettingsStore = defineStore('settings', () => { const scanIntervalMin = ref(0); const claudeBinPath = ref(null); const claudeHome = ref(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 }; }); diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue index 922b902..2ec71f1 100644 --- a/packages/web/src/views/SessionsListView.vue +++ b/packages/web/src/views/SessionsListView.vue @@ -9,6 +9,9 @@ {{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }} + + {{ t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived') }} + {{ t('common.refresh') }} @@ -54,6 +57,7 @@ {{ s.command }} {{ t('sessions.sourceDiscovered') }} {{ t('sessions.hiddenBadge') }} + {{ t('sessions.archivedBadge') }} {{ formatDate(s.createdAt) }} @@ -87,6 +91,9 @@ {{ t('sessions.unhide') }} {{ t('sessions.hide') }} + + {{ t('sessions.unarchive') }} + {{ t('sessions.archive') }} @@ -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(null); const killing = ref(false); const actingId = ref(null); const hidingId = ref(null); +const archivingId = ref(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 { toasts.error(err); } } + +async function onToggleArchived(): Promise { + try { + await store.setShowArchived(!store.showArchived); + } catch (err) { + toasts.error(err); + } +} + +async function onArchive(id: string): Promise { + 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 { + archivingId.value = id; + try { + await store.unarchiveSession(id); + toasts.success(t('toast.sessionUnarchived')); + } catch (err) { + toasts.error(err); + } finally { + archivingId.value = null; + } +} diff --git a/packages/web/src/views/SettingsView.vue b/packages/web/src/views/SettingsView.vue index 6a9fabc..16ed7d4 100644 --- a/packages/web/src/views/SettingsView.vue +++ b/packages/web/src/views/SettingsView.vue @@ -175,6 +175,26 @@ + +
+

+ {{ t('settings.sessions') }} +

+

{{ t('settings.sessionsHint') }}

+ + + +
+ + {{ t('settings.save') }} + +
+
+

@@ -240,7 +260,7 @@