Files
arboretum/packages/server/scripts/acceptance-p10.mjs
Johan LEROY c8dd539571 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)
2026-06-27 13:31:28 +02:00

160 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 larchivée par défaut', !def.some((s) => s.id === 'arch-old'));
const inc = await listSessions(cookie, '?includeArchived=true');
check('GET /sessions?includeArchived=true inclut larchivé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 dune 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 dun 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 narchive 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);
}