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:
@@ -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<SessionsListResponse> => {
|
||||
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)) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user