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:
137
packages/server/src/core/discovery-service.ts
Normal file
137
packages/server/src/core/discovery-service.ts
Normal 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)))];
|
||||
}
|
||||
Reference in New Issue
Block a user