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);
|
||||
}
|
||||
Reference in New Issue
Block a user