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:
159
packages/server/scripts/acceptance-p10.mjs
Normal file
159
packages/server/scripts/acceptance-p10.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { Db } from './db/index.js';
|
|||||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
import { DiscoveryService } from './core/discovery-service.js';
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
|
import { SessionArchiveService } from './core/session-archive.js';
|
||||||
import { WorktreeManager } from './core/worktree-manager.js';
|
import { WorktreeManager } from './core/worktree-manager.js';
|
||||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||||
import { GroupManager } from './core/group-manager.js';
|
import { GroupManager } from './core/group-manager.js';
|
||||||
@@ -73,6 +74,7 @@ export interface AppBundle {
|
|||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
discovery: DiscoveryService;
|
discovery: DiscoveryService;
|
||||||
|
sessionArchive: SessionArchiveService;
|
||||||
repoDiscovery: RepoDiscoveryService;
|
repoDiscovery: RepoDiscoveryService;
|
||||||
worktrees: WorktreeManager;
|
worktrees: WorktreeManager;
|
||||||
groups: GroupManager;
|
groups: GroupManager;
|
||||||
@@ -94,6 +96,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
projectsDir: config.claudeProjectsDir,
|
projectsDir: config.claudeProjectsDir,
|
||||||
sessionsDir: config.claudeSessionsDir,
|
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
|
// 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).
|
// WS watch/unwatch. closeAll() au drain (libération des descripteurs).
|
||||||
const fsWatcher = new FsWatcherService();
|
const fsWatcher = new FsWatcherService();
|
||||||
@@ -177,7 +181,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery, db);
|
registerSessionRoutes(app, manager, discovery, sessionArchive, db);
|
||||||
registerProjectRoutes(app, manager, db);
|
registerProjectRoutes(app, manager, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
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
|
// 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).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
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)
|
// 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
|||||||
const CLAUDE_ID_POLL_MS = 400;
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
|
||||||
|
* `type` (et non `interface`) pour que TS infère l'index signature implicite → cast direct depuis
|
||||||
|
* le `Record<string, SQLOutputValue>` de node:sqlite.
|
||||||
|
*/
|
||||||
|
type HistoricalRow = {
|
||||||
|
id: string;
|
||||||
|
cwd: string;
|
||||||
|
command: string;
|
||||||
|
title: string | null;
|
||||||
|
created_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
claude_session_id: string | null;
|
||||||
|
added_dirs: string | null;
|
||||||
|
group_id: string | null;
|
||||||
|
archived_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||||
function parseAddedDirs(raw: string | null): string[] {
|
function parseAddedDirs(raw: string | null): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -246,15 +265,22 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): SessionSummary[] {
|
list(opts?: { includeArchived?: boolean }): SessionSummary[] {
|
||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
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')
|
.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 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 }>;
|
.all() as HistoricalRow[];
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
.map((r) => {
|
// 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);
|
const addedDirs = parseAddedDirs(r.added_dirs);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -276,9 +302,22 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
registryStatus: null,
|
registryStatus: null,
|
||||||
...(addedDirs.length ? { addedDirs } : {}),
|
...(addedDirs.length ? { addedDirs } : {}),
|
||||||
groupId: r.group_id,
|
groupId: r.group_id,
|
||||||
|
archived: r.archived_at != null,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
return [...liveSummaries, ...historical];
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
get(id: string): SessionSummary | null {
|
||||||
|
|||||||
48
packages/server/src/core/retention-settings.ts
Normal file
48
packages/server/src/core/retention-settings.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
75
packages/server/src/core/session-archive.ts
Normal file
75
packages/server/src/core/session-archive.ts
Normal file
@@ -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<SessionArchiveEvents> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
export type Db = DatabaseSync;
|
||||||
@@ -218,3 +230,48 @@ export function hideSession(db: Db, claudeSessionId: string): void {
|
|||||||
export function unhideSession(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);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ function applyClaudeHomeOverride(config: Config, db: Db): void {
|
|||||||
export async function runDaemon(config: Config): Promise<void> {
|
export async function runDaemon(config: Config): Promise<void> {
|
||||||
const db = openDb(config.dbPath);
|
const db = openDb(config.dbPath);
|
||||||
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
|
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();
|
const bootstrapToken = auth.ensureBootstrapToken();
|
||||||
await app.listen({ port: config.port, host: config.bind });
|
await app.listen({ port: config.port, host: config.bind });
|
||||||
discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes
|
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
|
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}`;
|
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<void> {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
|
sessionArchive.stop();
|
||||||
repoDiscovery.stop();
|
repoDiscovery.stop();
|
||||||
void fsWatcher.closeAll(); // libère les descripteurs des watchers FS
|
void fsWatcher.closeAll(); // libère les descripteurs des watchers FS
|
||||||
manager.shutdown();
|
manager.shutdown();
|
||||||
|
|||||||
@@ -2,13 +2,24 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||||
import type { PtyManager } from '../core/pty-manager.js';
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.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';
|
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<SessionsListResponse> => {
|
app.get('/api/v1/sessions', async (req): Promise<SessionsListResponse> => {
|
||||||
const includeHidden = (req.query as { includeHidden?: string }).includeHidden === 'true';
|
const q = req.query as { includeHidden?: string; includeArchived?: string };
|
||||||
const all = mergeSessions(manager.list(), discovery.list());
|
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) };
|
return { sessions: includeHidden ? all : all.filter((s) => !s.hidden) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +146,40 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
return reply.send(res);
|
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) => {
|
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
if (!manager.kill(id)) {
|
if (!manager.kill(id)) {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import {
|
|||||||
readClaudeBinPath,
|
readClaudeBinPath,
|
||||||
readClaudeHome,
|
readClaudeHome,
|
||||||
} from '../core/claude-settings.js';
|
} 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.
|
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||||
export function registerSettingsRoutes(
|
export function registerSettingsRoutes(
|
||||||
@@ -51,6 +59,8 @@ export function registerSettingsRoutes(
|
|||||||
scanIntervalMin: readScanIntervalMin(db),
|
scanIntervalMin: readScanIntervalMin(db),
|
||||||
claudeBinPath: readClaudeBinPath(db),
|
claudeBinPath: readClaudeBinPath(db),
|
||||||
claudeHome: readClaudeHome(db),
|
claudeHome: readClaudeHome(db),
|
||||||
|
retentionDays: readRetentionDays(db),
|
||||||
|
purgeDays: readPurgeDays(db),
|
||||||
},
|
},
|
||||||
server: serverInfo(),
|
server: serverInfo(),
|
||||||
});
|
});
|
||||||
@@ -87,6 +97,20 @@ export function registerSettingsRoutes(
|
|||||||
}
|
}
|
||||||
setSetting(db, CLAUDE_HOME_KEY, value);
|
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, {
|
recordAudit(db, {
|
||||||
actor: req.authContext?.tokenId ?? 'unknown',
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
action: 'settings.update',
|
action: 'settings.update',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
import type { DiscoveryService } from '../core/discovery-service.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 { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
import type { GroupManager } from '../core/group-manager.js';
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function registerWsGateway(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
manager: PtyManager,
|
manager: PtyManager,
|
||||||
discovery: DiscoveryService,
|
discovery: DiscoveryService,
|
||||||
|
sessionArchive: SessionArchiveService,
|
||||||
worktrees: WorktreeManager,
|
worktrees: WorktreeManager,
|
||||||
groups: GroupManager,
|
groups: GroupManager,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
@@ -57,6 +59,10 @@ export function registerWsGateway(
|
|||||||
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
||||||
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
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.
|
// Les sessions découvertes (DiscoveryService) sont relayées sur le même flux que les managées.
|
||||||
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
||||||
if (subscribedSessions) send({ type: 'session_update', session });
|
if (subscribedSessions) send({ type: 'session_update', session });
|
||||||
@@ -85,6 +91,7 @@ export function registerWsGateway(
|
|||||||
};
|
};
|
||||||
manager.on('session_update', onSessionUpdate);
|
manager.on('session_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
|
sessionArchive.on('session_archived', onSessionArchived);
|
||||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||||
worktrees.on('repo_update', onRepoUpdate);
|
worktrees.on('repo_update', onRepoUpdate);
|
||||||
worktrees.on('repo_removed', onRepoRemoved);
|
worktrees.on('repo_removed', onRepoRemoved);
|
||||||
@@ -234,6 +241,7 @@ export function registerWsGateway(
|
|||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
manager.off('session_update', onSessionUpdate);
|
manager.off('session_update', onSessionUpdate);
|
||||||
manager.off('session_exit', onSessionExit);
|
manager.off('session_exit', onSessionExit);
|
||||||
|
sessionArchive.off('session_archived', onSessionArchived);
|
||||||
discovery.off('discovery_update', onDiscoveryUpdate);
|
discovery.off('discovery_update', onDiscoveryUpdate);
|
||||||
worktrees.off('repo_update', onRepoUpdate);
|
worktrees.off('repo_update', onRepoUpdate);
|
||||||
worktrees.off('repo_removed', onRepoRemoved);
|
worktrees.off('repo_removed', onRepoRemoved);
|
||||||
|
|||||||
63
packages/server/test/retention-settings.test.ts
Normal file
63
packages/server/test/retention-settings.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
packages/server/test/session-archive.test.ts
Normal file
69
packages/server/test/session-archive.test.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,9 @@ describe('GET /api/v1/settings', () => {
|
|||||||
expect(body.settings.claudeHome).toBeNull();
|
expect(body.settings.claudeHome).toBeNull();
|
||||||
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
|
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
|
||||||
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
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 () => {
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -417,6 +417,10 @@ export interface SettingsResponse {
|
|||||||
claudeBinPath: string | null;
|
claudeBinPath: string | null;
|
||||||
/** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */
|
/** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */
|
||||||
claudeHome: string | null;
|
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;
|
server: ServerInfo;
|
||||||
}
|
}
|
||||||
@@ -429,6 +433,10 @@ export interface UpdateSettingsRequest {
|
|||||||
claudeBinPath?: string;
|
claudeBinPath?: string;
|
||||||
/** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */
|
/** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */
|
||||||
claudeHome?: string;
|
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) ----
|
// ---- Journal d'audit (conformité entreprise) ----
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export interface SessionSummary {
|
|||||||
// ---- Masquage (additif) ----
|
// ---- Masquage (additif) ----
|
||||||
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
||||||
hidden?: boolean;
|
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) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
@@ -232,6 +235,9 @@ export type ServerMessage =
|
|||||||
| { type: 'control_changed'; channel: number; controlling: boolean }
|
| { type: 'control_changed'; channel: number; controlling: boolean }
|
||||||
| { type: 'session_update'; session: SessionSummary }
|
| { type: 'session_update'; session: SessionSummary }
|
||||||
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
| { 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_update'; repo: RepoSummary }
|
||||||
| { type: 'repo_removed'; repoId: string }
|
| { type: 'repo_removed'; repoId: string }
|
||||||
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export default {
|
|||||||
hideDiscovered: 'Hide external history',
|
hideDiscovered: 'Hide external history',
|
||||||
showHidden: 'Show hidden',
|
showHidden: 'Show hidden',
|
||||||
hideHidden: 'Hide hidden',
|
hideHidden: 'Hide hidden',
|
||||||
|
archive: 'Archive',
|
||||||
|
unarchive: 'Unarchive',
|
||||||
|
archivedBadge: 'archived',
|
||||||
|
showArchived: 'Show archived',
|
||||||
|
hideArchived: 'Hide archived',
|
||||||
clients: 'no client | 1 client | {n} clients',
|
clients: 'no client | 1 client | {n} clients',
|
||||||
exitCode: 'exit code {code}',
|
exitCode: 'exit code {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -342,6 +347,8 @@ export default {
|
|||||||
sessionHidden: 'Session hidden',
|
sessionHidden: 'Session hidden',
|
||||||
sessionUnhidden: 'Session shown',
|
sessionUnhidden: 'Session shown',
|
||||||
sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden',
|
sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden',
|
||||||
|
sessionArchived: 'Session archived',
|
||||||
|
sessionUnarchived: 'Session unarchived',
|
||||||
repoAdded: 'Repository added',
|
repoAdded: 'Repository added',
|
||||||
repoRemoved: 'Repository removed',
|
repoRemoved: 'Repository removed',
|
||||||
worktreeCreated: 'Worktree created',
|
worktreeCreated: 'Worktree created',
|
||||||
@@ -413,6 +420,11 @@ export default {
|
|||||||
removeRoot: 'Remove',
|
removeRoot: 'Remove',
|
||||||
scanInterval: 'Re-scan interval (minutes)',
|
scanInterval: 'Re-scan interval (minutes)',
|
||||||
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
|
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
|
// Claude CLI
|
||||||
claudeCli: '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.',
|
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',
|
hideDiscovered: 'Masquer l’historique externe',
|
||||||
showHidden: 'Afficher les masquées',
|
showHidden: 'Afficher les masquées',
|
||||||
hideHidden: 'Masquer 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',
|
clients: 'aucun client | 1 client | {n} clients',
|
||||||
exitCode: 'code de sortie {code}',
|
exitCode: 'code de sortie {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -345,6 +350,8 @@ const fr: typeof en = {
|
|||||||
sessionHidden: 'Session masquée',
|
sessionHidden: 'Session masquée',
|
||||||
sessionUnhidden: 'Session réaffichée',
|
sessionUnhidden: 'Session réaffichée',
|
||||||
sessionsHidden: 'aucune session masquée | 1 session masquée | {n} sessions masquées',
|
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é',
|
repoAdded: 'Dépôt ajouté',
|
||||||
repoRemoved: 'Dépôt retiré',
|
repoRemoved: 'Dépôt retiré',
|
||||||
worktreeCreated: 'Worktree créé',
|
worktreeCreated: 'Worktree créé',
|
||||||
@@ -416,6 +423,11 @@ const fr: typeof en = {
|
|||||||
removeRoot: 'Retirer',
|
removeRoot: 'Retirer',
|
||||||
scanInterval: 'Intervalle de re-scan (minutes)',
|
scanInterval: 'Intervalle de re-scan (minutes)',
|
||||||
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
|
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
|
// Claude CLI
|
||||||
claudeCli: 'CLI Claude',
|
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.',
|
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;
|
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 WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||||
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_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é ». */
|
/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
|
||||||
@@ -470,7 +470,8 @@ export class WsClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'session_update':
|
case 'session_update':
|
||||||
case 'session_exit': {
|
case 'session_exit':
|
||||||
|
case 'session_archived': {
|
||||||
for (const cb of this.sessionListeners) cb(msg);
|
for (const cb of this.sessionListeners) cb(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
const loadError = ref<string | null>(null);
|
const loadError = ref<string | null>(null);
|
||||||
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
||||||
const showHidden = ref(false);
|
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;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
// 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);
|
remove(session.id);
|
||||||
return;
|
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);
|
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
||||||
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
||||||
else sessions.value.push(session);
|
else sessions.value.push(session);
|
||||||
@@ -39,6 +47,12 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
upsert(e.session);
|
upsert(e.session);
|
||||||
return;
|
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);
|
const current = sessions.value.find((s) => s.id === e.sessionId);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
upsert({
|
upsert({
|
||||||
@@ -55,8 +69,11 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = null;
|
loadError.value = null;
|
||||||
try {
|
try {
|
||||||
const url = showHidden.value ? '/api/v1/sessions?includeHidden=true' : '/api/v1/sessions';
|
const params = new URLSearchParams();
|
||||||
const res = await api.get<SessionsListResponse>(url);
|
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);
|
sessions.value = sorted(res.sessions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadError.value = err instanceof Error ? err.message : String(err);
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
@@ -70,6 +87,24 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
await fetchSessions();
|
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).
|
// Masque une session découverte (retrait optimiste si on n'affiche pas les masquées).
|
||||||
async function hideSession(id: string): Promise<void> {
|
async function hideSession(id: string): Promise<void> {
|
||||||
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
||||||
@@ -136,8 +171,10 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
loading,
|
loading,
|
||||||
loadError,
|
loadError,
|
||||||
showHidden,
|
showHidden,
|
||||||
|
showArchived,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
setShowHidden,
|
setShowHidden,
|
||||||
|
setShowArchived,
|
||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -148,5 +185,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
hideSession,
|
hideSession,
|
||||||
unhideSession,
|
unhideSession,
|
||||||
hideDiscovered,
|
hideDiscovered,
|
||||||
|
archiveSession,
|
||||||
|
unarchiveSession,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const scanIntervalMin = ref(0);
|
const scanIntervalMin = ref(0);
|
||||||
const claudeBinPath = ref<string | null>(null);
|
const claudeBinPath = ref<string | null>(null);
|
||||||
const claudeHome = ref<string | null>(null);
|
const claudeHome = ref<string | null>(null);
|
||||||
|
const retentionDays = ref(0);
|
||||||
|
const purgeDays = ref(0);
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
scanIntervalMin.value = res.settings.scanIntervalMin;
|
scanIntervalMin.value = res.settings.scanIntervalMin;
|
||||||
claudeBinPath.value = res.settings.claudeBinPath;
|
claudeBinPath.value = res.settings.claudeBinPath;
|
||||||
claudeHome.value = res.settings.claudeHome;
|
claudeHome.value = res.settings.claudeHome;
|
||||||
|
retentionDays.value = res.settings.retentionDays;
|
||||||
|
purgeDays.value = res.settings.purgeDays;
|
||||||
loaded.value = true;
|
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">
|
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
|
||||||
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
|
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
|
||||||
</BaseButton>
|
</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()">
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
||||||
{{ t('common.refresh') }}
|
{{ t('common.refresh') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
<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.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||||
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</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" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +91,9 @@
|
|||||||
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
|
<!-- 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-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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -106,7 +113,7 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
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 { useSessionsStore } from '../stores/sessions';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
@@ -147,6 +154,7 @@ const killArmedId = ref<string | null>(null);
|
|||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
const actingId = ref<string | null>(null);
|
const actingId = ref<string | null>(null);
|
||||||
const hidingId = ref<string | null>(null);
|
const hidingId = ref<string | null>(null);
|
||||||
|
const archivingId = ref<string | null>(null);
|
||||||
const hidingAll = ref(false);
|
const hidingAll = ref(false);
|
||||||
// au moins une session externe visible → propose le masquage de masse
|
// au moins une session externe visible → propose le masquage de masse
|
||||||
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||||
@@ -261,4 +269,36 @@ async function onToggleHidden(): Promise<void> {
|
|||||||
toasts.error(err);
|
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>
|
</script>
|
||||||
|
|||||||
@@ -175,6 +175,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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é -->
|
<!-- Sécurité & conformité -->
|
||||||
<section class="card flex flex-col gap-3">
|
<section class="card flex flex-col gap-3">
|
||||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
||||||
@@ -240,7 +260,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
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 {
|
import type {
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
AuditLogsResponse,
|
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) ----
|
// ---- Sécurité & conformité (audit / RGPD) ----
|
||||||
const audit = ref<AuditLogEntry[]>([]);
|
const audit = ref<AuditLogEntry[]>([]);
|
||||||
const auditing = ref(false);
|
const auditing = ref(false);
|
||||||
@@ -438,6 +474,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
|
||||||
syncDiscoveryDraft();
|
syncDiscoveryDraft();
|
||||||
syncClaudeDraft();
|
syncClaudeDraft();
|
||||||
|
syncRetentionDraft();
|
||||||
void push.refresh();
|
void push.refresh();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user