Files
arboretum/packages/server/scripts/acceptance-p2.mjs
Johan LEROY 4e3b1f5604 fix(sessions): reprendre/forker une session managée morte par UUID (résout le 404)
Le bouton « Reprendre » envoie l'UUID Arboretum, mais resume/fork ne
résolvaient l'id que via la découverte (indexée par claudeSessionId, qui
exclut justement les sessions managées) → 404 systématique après fermeture.
Ajoute PtyManager.resumeTargetById (cwd d'origine + claudeSessionId + groupe
lus en DB par UUID) et un résolveur unifié dans resume ET fork : session
managée morte par UUID, sinon session claude externe découverte par
claudeSessionId. Garde-fous anti-resume d'une session vivante conservés.
2026-06-22 14:41:06 +02:00

196 lines
9.6 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, '--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 } });
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));
// --- Régression « Reprendre » : reprise d'une session MANAGÉE morte par son UUID Arboretum ---
// (le bouton web envoie l'UUID managé, pas le claudeSessionId ; le serveur doit le résoudre en DB).
const createManaged = await fetch(`${ORIGIN}/api/v1/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie },
body: JSON.stringify({ cwd: workDir, command: 'claude' }),
});
const managed = (await createManaged.json()).session;
check('création session managée claude → 201 (pid)', createManaged.status === 201 && managed?.pid > 0);
// Simule la capture du claudeSessionId : entrée registre pour le pid du PTY managé (pollée toutes les 400ms).
writeFileSync(
join(sessions, `${managed.pid}.json`),
JSON.stringify({ pid: managed.pid, sessionId: 'sid-managed', cwd: workDir, status: 'idle' }),
);
const captured = await c.waitMsg(
(m) => m.type === 'session_update' && m.session?.id === managed.id && m.session?.claudeSessionId === 'sid-managed',
);
check('claudeSessionId capturé depuis le registre (session managée)', !!captured);
// Fermeture (kill) puis reprise par UUID managé → 201 dans le cwd d'origine (avant le fix : 404).
const killManaged = await fetch(`${ORIGIN}/api/v1/sessions/${managed.id}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } });
check('kill session managée → 200', killManaged.status === 200);
await sleep(400); // laisse handleExit persister ended_at
const resumeManaged = await postJson(`/api/v1/sessions/${managed.id}/resume`, cookie);
const resumedManaged = (await resumeManaged.json()).session;
check(
'resume dune session MANAGÉE morte par UUID → 201',
resumeManaged.status === 201 && resumedManaged?.command === 'claude' && resumedManaged?.source === 'managed' && resumedManaged?.cwd === workDir,
);
// 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);
}