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:
2026-06-12 18:20:21 +02:00
parent 8bc48448c2
commit fb175bc7c5
27 changed files with 1363 additions and 41 deletions

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