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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<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. */
|
||||
function parseAddedDirs(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
@@ -246,41 +265,61 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
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;
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ function applyClaudeHomeOverride(config: Config, db: Db): void {
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user