Files
arboretum/packages/server/scripts/acceptance-p2.mjs
Johan LEROY fe2a3e66c7 feat: découverte automatique des dépôts git (scan + montrer/cacher)
Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations).
2026-06-18 14:45:12 +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, '--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));
// 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);
}