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

@@ -1,11 +1,16 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp, type AppBundle } from '../src/app.js';
import { openDb, type Db } from '../src/db/index.js';
import { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js';
import type { Config } from '../src/config.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
class FakePty {
@@ -49,6 +54,9 @@ function makeApp(name: string): TestApp {
dataDir: dir,
allowedOrigins: [ALLOWED_ORIGIN],
printToken: false,
// ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
};
const bundle = buildApp(config, db, '0.0.0-test');
const token = bundle.auth.ensureBootstrapToken();
@@ -268,3 +276,64 @@ describe('app e2e — rate limit du login', () => {
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
});
});
describe('app e2e — découverte, resume & fork (P2)', () => {
let t: TestApp;
let projectsDir: string;
let sessionsDir: string;
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
function writeDiscoveredJsonl(cwd: string, sid: string): void {
const d = join(projectsDir, munge(cwd));
mkdirSync(d, { recursive: true });
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
}
beforeAll(() => {
t = makeApp('resume');
projectsDir = join(dir, 'resume-claude', 'projects');
sessionsDir = join(dir, 'resume-claude', 'sessions');
mkdirSync(sessionsDir, { recursive: true });
});
it('resume / fork dun id inconnu → 404', async () => {
const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
expect(r.statusCode).toBe(404);
const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
expect(f.statusCode).toBe(404);
});
it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
await t.bundle.discovery.refresh();
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
(s) => s.id === 'dead-e2e',
);
expect(found).toMatchObject({ source: 'discovered', resumable: true });
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
expect(res.statusCode).toBe(201);
expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
});
it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
const sid = 'live-e2e';
writeDiscoveredJsonl(dir, sid);
const ps = readProcStart(process.pid) as string;
writeFileSync(
join(sessionsDir, `${process.pid}.json`),
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
);
await t.bundle.discovery.refresh();
const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
expect(resume.statusCode).toBe(409);
expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });
const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
expect(fork.statusCode).toBe(201);
});
});

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SessionSummary } from '@arboretum/shared';
import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.js';
import { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js';
import { PtyManager } from '../src/core/pty-manager.js';
import { openDb, type Db } from '../src/db/index.js';
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
const dir = join(projectsDir, munge(cwd));
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, `${sid}.jsonl`),
`${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`,
);
}
describe('DiscoveryService', () => {
let projectsDir: string;
let sessionsDir: string;
let db: Db;
let manager: PtyManager;
let svc: DiscoveryService;
beforeEach(() => {
projectsDir = mkdtempSync(join(tmpdir(), 'arb-proj-'));
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
db = openDb(':memory:');
manager = new PtyManager(db, sessionsDir);
svc = new DiscoveryService({ ptyManager: manager, projectsDir, sessionsDir });
});
afterEach(() => {
svc.stop();
rmSync(projectsDir, { recursive: true, force: true });
rmSync(sessionsDir, { recursive: true, force: true });
});
it('session morte (pas dentrée registre) → resumable, non attachable', async () => {
writeJsonl(projectsDir, '/home/u/dead', 'dead-sid');
await svc.refresh();
const s = svc.list().find((x) => x.id === 'dead-sid');
expect(s).toMatchObject({ source: 'discovered', live: false, resumable: true, attachable: false, cwd: '/home/u/dead' });
expect(svc.getDiscovered('dead-sid')?.cwd).toBe('/home/u/dead');
});
it('session vivante (registre + pid vivant) → live, non resumable, registryStatus exposé', async () => {
writeJsonl(projectsDir, '/home/u/live', 'live-sid');
const ps = readProcStart(process.pid) as string;
writeFileSync(
join(sessionsDir, `${process.pid}.json`),
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/home/u/live', status: 'waiting', waitingFor: 'permission prompt' }),
);
await svc.refresh();
const s = svc.list().find((x) => x.id === 'live-sid');
expect(s).toMatchObject({ live: true, resumable: false, attachable: false, registryStatus: 'waiting', pid: process.pid });
});
it('exclut une session déjà connue dArboretum (managée / historique)', async () => {
writeJsonl(projectsDir, '/home/u/managed', 'managed-sid');
db.prepare('INSERT INTO sessions (id, cwd, command, created_at, claude_session_id) VALUES (?, ?, ?, ?, ?)').run(
'uuid-1',
'/home/u/managed',
'claude',
new Date().toISOString(),
'managed-sid',
);
await svc.refresh();
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
});
it('émet discovery_update sur changement uniquement', async () => {
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
const seen: SessionSummary[] = [];
svc.on('discovery_update', (s) => seen.push(s));
await svc.refresh();
expect(seen.map((s) => s.id)).toContain('sid-x');
seen.length = 0;
await svc.refresh(); // rien n'a changé → aucune ré-émission
expect(seen).toHaveLength(0);
});
});
describe('mergeSessions', () => {
const mk = (id: string, claudeSessionId: string | null, source: 'managed' | 'discovered'): SessionSummary => ({
id,
cwd: '/x',
command: 'claude',
title: null,
status: 'exited',
live: false,
createdAt: '',
endedAt: null,
exitCode: null,
clients: 0,
source,
claudeSessionId,
pid: null,
resumable: false,
attachable: false,
registryStatus: null,
});
it('dédoublonne par claudeSessionId, managé prioritaire', () => {
const managed = [mk('uuid', 'sid-1', 'managed')];
const discovered = [mk('sid-1', 'sid-1', 'discovered'), mk('sid-2', 'sid-2', 'discovered')];
expect(mergeSessions(managed, discovered).map((s) => s.id)).toEqual(['uuid', 'sid-2']);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { munge, parseLines, scanProjects } from '../src/core/jsonl-discovery.js';
function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object[]): void {
const dir = join(projectsDir, munge(cwd));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}.jsonl`), `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`);
}
describe('munge', () => {
it('reproduit le nom de dossier ~/.claude/projects', () => {
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
expect(munge('/home/johan/WebstormProjects/arboretum')).toBe('-home-johan-WebstormProjects-arboretum');
});
});
describe('parseLines (tolérance)', () => {
it('ignore les lignes vides, coupées ou malformées sans throw', () => {
const text = '{"a":1}\n\n{not json}\n{"b":2}\n{"c":';
expect(parseLines(text)).toEqual([{ a: 1 }, { b: 2 }]);
});
it('shift/pop les lignes partielles en bord de chunk', () => {
const text = 'partial}\n{"ok":1}\n{also partial';
expect(parseLines(text, true, true)).toEqual([{ ok: 1 }]);
});
});
describe('scanProjects', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arb-jsonl-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('dossier absent → tableau vide', async () => {
expect(await scanProjects(join(dir, 'nope'))).toEqual([]);
});
it('extrait claudeSessionId + cwd + branche + version', async () => {
writeJsonl(dir, '/home/u/proj', 'sid-1', [
{ type: 'user', sessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170', message: { content: 'hello world' } },
]);
const res = await scanProjects(dir);
expect(res).toHaveLength(1);
expect(res[0]).toMatchObject({ claudeSessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170' });
});
it('écarte un fichier sans cwd (queue-operation only)', async () => {
writeJsonl(dir, '/x', 'sid-q', [{ type: 'queue-operation', sessionId: 'sid-q' }]);
expect(await scanProjects(dir)).toEqual([]);
});
it('survit à une ligne malformée et reste exploitable', async () => {
const d = join(dir, munge('/home/u/p2'));
mkdirSync(d, { recursive: true });
writeFileSync(
join(d, 'sid-2.jsonl'),
`{bad json\n${JSON.stringify({ type: 'user', sessionId: 'sid-2', cwd: '/home/u/p2', message: { content: 'ok' } })}\n`,
);
const res = await scanProjects(dir);
expect(res).toHaveLength(1);
expect(res[0].claudeSessionId).toBe('sid-2');
});
describe('priorité de titre (ai-title > summary > last-prompt > 1er user)', () => {
it('ai-title gagne sur tout', async () => {
writeJsonl(dir, '/t/1', 's', [
{ type: 'user', sessionId: 's', cwd: '/t/1', message: { content: 'premier prompt' } },
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
{ type: 'ai-title', sessionId: 's', aiTitle: 'Titre IA' },
]);
expect((await scanProjects(dir))[0].title).toBe('Titre IA');
});
it('summary gagne sur last-prompt et user', async () => {
writeJsonl(dir, '/t/2', 's', [
{ type: 'user', sessionId: 's', cwd: '/t/2', message: { content: 'premier prompt' } },
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
]);
expect((await scanProjects(dir))[0].title).toBe('un résumé');
});
it('repli ultime sur le premier prompt user', async () => {
writeJsonl(dir, '/t/3', 's', [{ type: 'user', sessionId: 's', cwd: '/t/3', message: { content: 'premier prompt' } }]);
expect((await scanProjects(dir))[0].title).toBe('premier prompt');
});
});
});

View File

@@ -1,9 +1,14 @@
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import { openDb, type Db } from '../src/db/index.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
interface FakePty {
pid: number;
file: string;
@@ -135,6 +140,68 @@ describe('PtyManager (pty mocké)', () => {
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
expect(row.n).toBe(0);
});
it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
const { summary } = spawnBash();
expect(summary).toMatchObject({
source: 'managed',
attachable: true,
resumable: false,
registryStatus: null,
claudeSessionId: null,
});
expect(summary.pid).toBeGreaterThan(0);
});
});
describe('resume / fork (P2)', () => {
it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
const p = lastPty();
expect(summary.command).toBe('claude');
expect(summary.source).toBe('managed');
expect(summary.attachable).toBe(true);
expect(p.file).toBe('/usr/bin/claude');
expect(p.args).toEqual(['--resume', 'abc-123']);
const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
resumed_from: string | null;
command: string;
};
expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
});
it('fork → --resume <id> --fork-session', () => {
manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
});
it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
// simule la capture du claudeSessionId (normalement résolue via le registre)
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
lastPty().emitExit(0);
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
});
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
vi.useFakeTimers();
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
try {
const m = new PtyManager(db, sessDir);
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
vi.advanceTimersByTime(400);
const found = m.findLiveByClaudeSessionId('captured-sid');
expect(found?.id).toBe(summary.id);
expect(found?.claudeSessionId).toBe('captured-sid');
} finally {
vi.useRealTimers();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('attach', () => {

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();
});
});