Files
arboretum/packages/server/test/discovery-service.test.ts
Johan LEROY c177eeea07 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>
2026-06-12 18:20:21 +02:00

112 lines
4.2 KiB
TypeScript
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.
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']);
});
});