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).
This commit is contained in:
2026-06-12 18:20:21 +02:00
parent e9404cb567
commit 92c48f0978
27 changed files with 1363 additions and 41 deletions

View File

@@ -0,0 +1,137 @@
// Service de découverte : second producteur de SessionSummary (à côté du PtyManager).
// Scanne ~/.claude/projects (JSONL) + ~/.claude/sessions (registre), corrèle par claudeSessionId,
// calcule la vivacité, met en cache et notifie les changements. Lecture seule du disque.
import { EventEmitter } from 'node:events';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { SessionSummary } from '@arboretum/shared';
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
import { readRegistry, type RegistryEntry } from './session-registry.js';
import type { PtyManager } from './pty-manager.js';
const DEFAULT_REFRESH_MS = 10_000;
export interface DiscoveryServiceEvents {
/** session découverte nouvelle ou modifiée (relayée en session_update par la gateway). */
discovery_update: [SessionSummary];
}
export interface DiscoveryOptions {
ptyManager: PtyManager;
projectsDir?: string;
sessionsDir?: string;
refreshMs?: number;
}
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
private readonly projectsDir: string;
private readonly sessionsDir: string;
private readonly ptyManager: PtyManager;
private readonly refreshMs: number;
private timer: NodeJS.Timeout | null = null;
private cache: SessionSummary[] = [];
private byId = new Map<string, DiscoveredJsonl>();
private prevJson = new Map<string, string>();
constructor(opts: DiscoveryOptions) {
super();
this.ptyManager = opts.ptyManager;
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
this.refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
}
start(): void {
if (this.timer) return;
void this.refresh(); // premier scan asynchrone : ne bloque pas le boot
this.timer = setInterval(() => void this.refresh(), this.refreshMs);
this.timer.unref(); // ne maintient pas le process en vie
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Cache courant (jamais de scan synchrone dans le chemin chaud). */
list(): SessionSummary[] {
return this.cache;
}
/** Session découverte (avec son cwd d'origine lu sur disque) — pour resume/fork. null si absente. */
getDiscovered(claudeSessionId: string): DiscoveredJsonl | null {
return this.byId.get(claudeSessionId) ?? null;
}
/**
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) — garde-fou
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
*/
isClaudeSessionLive(claudeSessionId: string): boolean {
const entry = readRegistry(this.sessionsDir).find((r) => r.claudeSessionId === claudeSessionId);
return entry?.live ?? false;
}
/** Recalcule le cache depuis le disque. Tolérant : ne lève jamais. */
async refresh(): Promise<void> {
const regBySid = new Map<string, RegistryEntry>();
for (const r of readRegistry(this.sessionsDir)) {
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
}
const known = this.ptyManager.knownClaudeSessionIds();
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
const latest = new Map<string, DiscoveredJsonl>();
for (const d of await scanProjects(this.projectsDir)) {
const prev = latest.get(d.claudeSessionId);
if (!prev || d.mtimeMs > prev.mtimeMs) latest.set(d.claudeSessionId, d);
}
const summaries: SessionSummary[] = [];
const byId = new Map<string, DiscoveredJsonl>();
for (const d of latest.values()) {
if (known.has(d.claudeSessionId)) continue; // déjà gérée comme session managée
byId.set(d.claudeSessionId, d);
const r = regBySid.get(d.claudeSessionId);
const live = r?.live ?? false;
summaries.push({
id: d.claudeSessionId, // les découvertes s'identifient par leur claudeSessionId
cwd: d.cwd,
command: 'claude',
title: d.title,
status: live ? 'running' : 'exited',
live,
createdAt: new Date(d.mtimeMs).toISOString(),
endedAt: null,
exitCode: null,
clients: 0,
source: 'discovered',
claudeSessionId: d.claudeSessionId,
pid: live ? (r?.pid ?? null) : null,
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
registryStatus: r?.status ?? null,
});
}
this.cache = summaries;
this.byId = byId;
// Diff : n'émettre que les sessions nouvelles ou modifiées.
const nextJson = new Map<string, string>();
for (const s of summaries) {
const j = JSON.stringify(s);
nextJson.set(s.id, j);
if (this.prevJson.get(s.id) !== j) this.emit('discovery_update', s);
}
this.prevJson = nextJson;
}
}
/** Fusionne sessions managées + découvertes en dédoublonnant par claudeSessionId (managé prioritaire). */
export function mergeSessions(managed: SessionSummary[], discovered: SessionSummary[]): SessionSummary[] {
const managedSids = new Set(managed.map((s) => s.claudeSessionId).filter((x): x is string => x != null));
return [...managed, ...discovered.filter((s) => !(s.claudeSessionId && managedSids.has(s.claudeSessionId)))];
}