Files
arboretum/packages/server/scripts/acceptance-p2.mjs
Johan LEROY fb175bc7c5 P2: découverte & reprise des sessions Claude
Arboretum découvre désormais toutes les sessions Claude de la machine
(scan ~/.claude/projects + registre ~/.claude/sessions), distingue
vivantes/mortes par pid+procStart, et permet de reprendre une morte
(--resume dans son cwd d'origine) ou forker une vivante sans la corrompre.

- shared: SessionSummary enrichi (source, claudeSessionId, pid, resumable,
  attachable, registryStatus) — additif, PROTOCOL_VERSION inchangé ;
  types REST resume/fork.
- db: migration id:2 (claude_session_id, resumed_from).
- core: jsonl-discovery (parseur tolérant, scan asynchrone non bloquant),
  session-registry (vivacité pid+procStart), discovery-service (cache +
  refresh périodique + diff/broadcast), pty-manager (resume/fork + capture
  du claudeSessionId via le registre).
- routes: /sessions/:id/resume (garde-fou 409 anti-corruption sur session
  vivante) et /fork ; GET fusionné managées + découvertes ; relais WS.
- web: badges managed/discovered + busy/idle/waiting, actions conditionnelles
  (Open/Observe/Kill vs Fork/View vs Resume/Fork), vue read-only des sessions
  externes, i18n EN/FR.
- tests: jsonl-discovery, session-registry, discovery-service + resume/fork
  (130 verts) ; acceptation E2E acceptance-p2.mjs (sans quota) ALL GREEN.

Conforme aux verdicts S1 (resume dans cwd d'origine, vivacité pid+procStart)
et S4 (munge cwd, parseur tête+queue, priorité de titre).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:20:21 +02:00

165 lines
7.8 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 P2 (sans navigateur, sans quota Claude) : découverte & reprise de sessions.
// Faux ~/.claude jetable (--claude-home) + faux binaire `claude` (script bash) injecté dans le PATH.
// Couvre : découverte d'une session morte (resumable) et vivante (registre + pid réel),
// resume refusé sur une vivante (409), fork autorisé, resume d'une morte dans SON CWD d'origine,
// broadcast WS d'une session découverte au rafraîchissement périodique.
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, 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';
const require = createRequire(import.meta.url);
const WebSocket = require('ws');
const PORT = 7542;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const munge = (p) => p.replace(/[^A-Za-z0-9]/g, '-');
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-p2-'));
const claudeHome = join(tmp, 'claude');
const projects = join(claudeHome, 'projects');
const sessions = join(claudeHome, 'sessions');
const workDir = join(tmp, 'work'); // cwd d'origine des sessions découvertes
const fakeBin = join(tmp, 'bin');
mkdirSync(projects, { recursive: true });
mkdirSync(sessions, { recursive: true });
mkdirSync(workDir, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
// Faux binaire `claude` : imprime ses arguments + son cwd, puis reste vivant un instant.
writeFileSync(join(fakeBin, 'claude'), '#!/usr/bin/env bash\necho "FAKE-CLAUDE args=[$*] cwd=$(pwd)"\nsleep 30\n');
chmodSync(join(fakeBin, 'claude'), 0o755);
function writeJsonl(cwd, sid) {
const d = join(projects, munge(cwd));
mkdirSync(d, { recursive: true });
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
}
// Session découverte MORTE (aucune entrée registre) → resumable.
writeJsonl(workDir, 'sid-dead');
// Session découverte VIVANTE : un vrai process `sleep` fournit pid + procStart concordants.
const liveProc = spawn('sleep', ['300']);
const livePid = liveProc.pid;
const liveStat = readFileSync(`/proc/${livePid}/stat`, 'utf8');
const liveProcStart = liveStat.slice(liveStat.lastIndexOf(')') + 2).split(' ')[19];
writeJsonl(workDir, 'sid-live');
writeFileSync(
join(sessions, `${livePid}.json`),
JSON.stringify({ pid: livePid, procStart: liveProcStart, sessionId: 'sid-live', cwd: workDir, status: 'busy' }),
);
const srv = spawn(
'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome],
{ 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 } });
ws.binaryType = 'arraybuffer';
const state = { msgs: [], outputs: new Map() };
ws.on('message', (data, isBinary) => {
if (!isBinary) {
state.msgs.push(JSON.parse(String(data)));
return;
}
const buf = Buffer.from(data);
const type = buf.readUInt8(0);
const channel = buf.readUInt32LE(1);
const payload = buf.subarray(5);
const prev = type === 0x02 ? '' : (state.outputs.get(channel) ?? '');
state.outputs.set(channel, (prev + payload.toString('latin1')).slice(-100000));
});
const waitMsg = async (pred, timeout = 8000) => {
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 postJson = (path, cookie) =>
fetch(`${ORIGIN}${path}`, { method: 'POST', headers: { Origin: ORIGIN, Cookie: cookie } });
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 && cookie.startsWith('arb_session='));
// GET /sessions : la découverte (scan initial) doit exposer les deux sessions.
const listRes = await fetch(`${ORIGIN}/api/v1/sessions`, { headers: { Origin: ORIGIN, Cookie: cookie } });
const list = (await listRes.json()).sessions;
const dead = list.find((s) => s.id === 'sid-dead');
const live = list.find((s) => s.id === 'sid-live');
check('découverte session morte (resumable, discovered)', dead?.source === 'discovered' && dead?.resumable === true && dead?.live === false);
check('découverte session vivante (live, registryStatus)', live?.live === true && live?.attachable === false && live?.registryStatus === 'busy');
// Garde-fou : resume d'une session VIVANTE refusé (409), fork autorisé (201).
const resumeLive = await postJson('/api/v1/sessions/sid-live/resume', cookie);
check('resume dune session vivante → 409 SESSION_LIVE', resumeLive.status === 409 && (await resumeLive.json()).error.code === 'SESSION_LIVE');
const forkLive = await postJson('/api/v1/sessions/sid-live/fork', cookie);
check('fork dune session vivante → 201', forkLive.status === 201);
// Resume d'une session MORTE → nouveau PTY managé claude, DANS SON CWD D'ORIGINE.
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'] });
const resumeDead = await postJson('/api/v1/sessions/sid-dead/resume', cookie);
const resumed = (await resumeDead.json()).session;
check('resume dune session morte → 201 (PTY managé claude)', resumeDead.status === 201 && resumed?.command === 'claude' && resumed?.source === 'managed');
c.send({ type: 'attach', sessionId: resumed.id, mode: 'interactive', cols: 120, rows: 32 });
const att = await c.waitMsg((m) => m.type === 'attached');
await sleep(600);
const out = c.state.outputs.get(att?.channel) ?? '';
check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120));
// Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique.
writeJsonl(workDir, 'sid-new');
const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000);
check('broadcast WS dune session découverte (refresh périodique)', pushed?.session?.source === 'discovered');
c.ws.close();
} catch (err) {
check('exception', false, String(err));
} finally {
liveProc.kill('SIGKILL');
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 P2: ALL GREEN' : `\nACCEPTANCE P2: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}