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,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');
});
});
});