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:
92
packages/server/src/core/session-registry.ts
Normal file
92
packages/server/src/core/session-registry.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Lecture du registre ~/.claude/sessions (un fichier <pid>.json par session CLI vive).
|
||||
// Module partagé : P2 l'utilise pour la vivacité + le lookup par pid ; P3-B (claude-adapter)
|
||||
// s'en sert comme source primaire d'état fin (busy/idle/waiting + waitingFor).
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SessionRegistryStatus } from '@arboretum/shared';
|
||||
|
||||
export interface RegistryEntry {
|
||||
claudeSessionId: string | null;
|
||||
cwd: string | null;
|
||||
pid: number;
|
||||
procStart: string | null;
|
||||
status: SessionRegistryStatus | null;
|
||||
waitingFor: string | null;
|
||||
/** vivacité réelle = pid + procStart concordants (jamais la simple présence du fichier). */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/** starttime (champ 22 de /proc/<pid>/stat) ou null si le process n'existe pas. Linux-only. */
|
||||
export function readProcStart(pid: number): string | null {
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
// Le nom du process (champ 2) peut contenir espaces/parenthèses → parser après le dernier ')'.
|
||||
const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||
return after[19] ?? null; // champ 22 global = index 19 après le champ 3
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vivacité par pid + procStart (spike S1/S4) : jamais la présence d'un fichier registre (stale possible).
|
||||
* "Incertain → vivant" : si le process existe mais que le registre n'a pas de procStart, on traite vivant.
|
||||
*/
|
||||
export function isLive(pid: number, procStart: string | number | null | undefined): boolean {
|
||||
const actual = readProcStart(pid);
|
||||
if (actual === null) return false; // process absent → mort
|
||||
if (procStart === null || procStart === undefined) return true; // incertain → vivant
|
||||
return String(procStart) === actual; // procStart discordant → pid recyclé → stale → mort
|
||||
}
|
||||
|
||||
function normalizeStatus(v: unknown): SessionRegistryStatus | null {
|
||||
return v === 'busy' || v === 'idle' || v === 'waiting' ? v : null;
|
||||
}
|
||||
|
||||
function toEntry(r: Record<string, unknown>): RegistryEntry | null {
|
||||
const pid = typeof r.pid === 'number' ? r.pid : Number(r.pid);
|
||||
if (!Number.isInteger(pid)) return null;
|
||||
const procStart = r.procStart === undefined || r.procStart === null ? null : String(r.procStart);
|
||||
return {
|
||||
claudeSessionId: typeof r.sessionId === 'string' ? r.sessionId : null,
|
||||
cwd: typeof r.cwd === 'string' ? r.cwd : null,
|
||||
pid,
|
||||
procStart,
|
||||
status: normalizeStatus(r.status),
|
||||
waitingFor: typeof r.waitingFor === 'string' ? r.waitingFor : null,
|
||||
live: isLive(pid, procStart),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit toutes les entrées de `sessionsDir`. Tolérant aux fichiers en cours d'écriture / JSON invalide
|
||||
* (ignorés). Dossier absent → tableau vide (pas une erreur).
|
||||
*/
|
||||
export function readRegistry(sessionsDir: string): RegistryEntry[] {
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(sessionsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: RegistryEntry[] = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
try {
|
||||
const r = JSON.parse(readFileSync(join(sessionsDir, f), 'utf8')) as Record<string, unknown>;
|
||||
const entry = toEntry(r);
|
||||
if (entry) out.push(entry);
|
||||
} catch {
|
||||
/* fichier en cours d'écriture / JSON invalide : ignoré */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrée registre correspondant à un pid donné (le CLI nomme son fichier <pid>.json, mais on
|
||||
* matche sur le champ `pid` pour ne pas dépendre du nommage). null si absente/illisible.
|
||||
*/
|
||||
export function findByPid(sessionsDir: string, pid: number): RegistryEntry | null {
|
||||
return readRegistry(sessionsDir).find((e) => e.pid === pid) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user