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>
This commit is contained in:
Johan LEROY
2026-06-12 18:20:21 +02:00
parent 770f58a640
commit c177eeea07
27 changed files with 1363 additions and 41 deletions

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readProcStart, isLive, readRegistry, findByPid } from '../src/core/session-registry.js';
const DEAD_PID = 2 ** 31 - 1; // pid quasi impossible à attribuer
describe('readProcStart / isLive', () => {
it('lit le procStart du process courant et le valide', () => {
const ps = readProcStart(process.pid);
expect(ps).not.toBeNull();
expect(isLive(process.pid, ps as string)).toBe(true);
});
it('process absent → mort', () => {
expect(readProcStart(DEAD_PID)).toBeNull();
expect(isLive(DEAD_PID, '123')).toBe(false);
});
it('procStart discordant (pid recyclé) → stale → mort', () => {
expect(isLive(process.pid, 'definitely-not-the-real-starttime')).toBe(false);
});
it('procStart inconnu mais process vivant → incertain → vivant', () => {
expect(isLive(process.pid, null)).toBe(true);
});
});
describe('readRegistry', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arb-reg-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('dossier absent → vide', () => {
expect(readRegistry(join(dir, 'nope'))).toEqual([]);
});
it('calcule live par pid+procStart et ignore les fichiers malformés', () => {
const ps = readProcStart(process.pid) as string;
writeFileSync(
join(dir, `${process.pid}.json`),
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/x', status: 'busy' }),
);
writeFileSync(
join(dir, `${DEAD_PID}.json`),
JSON.stringify({ pid: DEAD_PID, procStart: '999', sessionId: 'dead-sid', cwd: '/y', status: 'idle' }),
);
writeFileSync(join(dir, 'broken.json'), '{ not json');
const entries = readRegistry(dir);
expect(entries).toHaveLength(2);
const live = entries.find((e) => e.pid === process.pid);
expect(live?.live).toBe(true);
expect(live?.status).toBe('busy');
expect(live?.claudeSessionId).toBe('live-sid');
const dead = entries.find((e) => e.pid === DEAD_PID);
expect(dead?.live).toBe(false);
expect(findByPid(dir, process.pid)?.claudeSessionId).toBe('live-sid');
});
it('normalise un status inconnu en null', () => {
writeFileSync(join(dir, `${DEAD_PID}.json`), JSON.stringify({ pid: DEAD_PID, status: 'weird' }));
expect(readRegistry(dir)[0].status).toBeNull();
});
});