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:
160
packages/server/src/core/jsonl-discovery.ts
Normal file
160
packages/server/src/core/jsonl-discovery.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Découverte des sessions Claude sur disque (~/.claude/projects/**/*.jsonl).
|
||||
// Transcription TS du spike S4 (scan.mjs) : parseur strictement tolérant, lecture partielle
|
||||
// tête+queue (~1,4 s pour 1300+ fichiers), 99,8 % des transcripts exploitables.
|
||||
// I/O ASYNCHRONES : le scan rend la main à la boucle d'événements entre fichiers, pour ne pas
|
||||
// figer le streaming des terminaux (il tourne périodiquement dans le DiscoveryService).
|
||||
import { readdir, stat, open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HEAD_BYTES = 256 * 1024;
|
||||
const TAIL_BYTES = 64 * 1024;
|
||||
const TITLE_MAX = 120;
|
||||
|
||||
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % — spike S4). */
|
||||
export function munge(cwd: string): string {
|
||||
return cwd.replace(/[^A-Za-z0-9]/g, '-');
|
||||
}
|
||||
|
||||
export interface DiscoveredJsonl {
|
||||
claudeSessionId: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
gitBranch: string | null;
|
||||
version: string | null;
|
||||
mtimeMs: number;
|
||||
/** chemin absolu du .jsonl */
|
||||
file: string;
|
||||
}
|
||||
|
||||
async function readChunk(path: string, position: number, length: number): Promise<string> {
|
||||
const fh = await open(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(length);
|
||||
const { bytesRead } = await fh.read(buf, 0, length, position);
|
||||
return buf.subarray(0, bytesRead).toString('utf8');
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse tolérant : chaque ligne en try/catch, lignes coupées/malformées ignorées (jamais de throw). */
|
||||
export function parseLines(text: string, partialFirst = false, partialLast = false): Array<Record<string, unknown>> {
|
||||
const lines = text.split('\n');
|
||||
if (partialFirst) lines.shift(); // 1re ligne potentiellement coupée (lecture de queue)
|
||||
if (partialLast) lines.pop(); // dernière ligne potentiellement coupée (lecture de tête)
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const l of lines) {
|
||||
if (!l.trim()) continue;
|
||||
try {
|
||||
const o = JSON.parse(l) as unknown;
|
||||
if (o && typeof o === 'object') out.push(o as Record<string, unknown>);
|
||||
} catch {
|
||||
/* ligne coupée ou malformée : ignorée */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
gitBranch?: string;
|
||||
version?: string;
|
||||
aiTitle?: string;
|
||||
summary?: string;
|
||||
lastPrompt?: string;
|
||||
firstUserPrompt?: string;
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
|
||||
const setOnce = (key: 'sessionId' | 'cwd' | 'gitBranch' | 'version', v: unknown): void => {
|
||||
if (meta[key] === undefined) {
|
||||
const s = asString(v);
|
||||
if (s) meta[key] = s;
|
||||
}
|
||||
};
|
||||
for (const o of objs) {
|
||||
setOnce('sessionId', o.sessionId);
|
||||
setOnce('cwd', o.cwd);
|
||||
setOnce('gitBranch', o.gitBranch);
|
||||
setOnce('version', o.version);
|
||||
// Titre : on retient la dernière valeur vue (la plus récente) — head puis tail → la queue gagne.
|
||||
const ai = asString(o.aiTitle);
|
||||
if (ai) meta.aiTitle = ai;
|
||||
const sum = asString(o.summary);
|
||||
if (sum) meta.summary = sum;
|
||||
const lp = asString(o.lastPrompt);
|
||||
if (lp) meta.lastPrompt = lp;
|
||||
// Premier prompt utilisateur (depuis la tête) : repli ultime pour le titre.
|
||||
if (meta.firstUserPrompt === undefined && o.type === 'user') {
|
||||
const msg = o.message;
|
||||
if (msg && typeof msg === 'object') {
|
||||
const text = asString((msg as Record<string, unknown>).content);
|
||||
if (text) meta.firstUserPrompt = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Priorité de titre actée S4 : ai-title > summary > last-prompt > 1er prompt user. */
|
||||
function pickTitle(meta: Meta): string | null {
|
||||
const raw = meta.aiTitle ?? meta.summary ?? meta.lastPrompt ?? meta.firstUserPrompt ?? null;
|
||||
if (!raw) return null;
|
||||
const t = raw.replace(/\s+/g, ' ').trim();
|
||||
return t.length > TITLE_MAX ? `${t.slice(0, TITLE_MAX - 1)}…` : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanne `projectsDir` en lecture partielle tête+queue. Tolérant : un dossier/fichier illisible ou
|
||||
* malformé est ignoré (jamais de throw). Un fichier sans sessionId+cwd exploitables est écarté.
|
||||
*/
|
||||
export async function scanProjects(projectsDir: string): Promise<DiscoveredJsonl[]> {
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return []; // dossier absent (Claude jamais lancé) : pas une erreur
|
||||
}
|
||||
const out: DiscoveredJsonl[] = [];
|
||||
for (const dir of dirs) {
|
||||
const dirPath = join(projectsDir, dir);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
|
||||
const fp = join(dirPath, e.name);
|
||||
try {
|
||||
const st = await stat(fp);
|
||||
if (st.size === 0) continue;
|
||||
const meta: Meta = {};
|
||||
const head = await readChunk(fp, 0, Math.min(HEAD_BYTES, st.size));
|
||||
extractMeta(parseLines(head, false, st.size > HEAD_BYTES), meta);
|
||||
if (st.size > HEAD_BYTES + TAIL_BYTES) {
|
||||
const tail = await readChunk(fp, st.size - TAIL_BYTES, TAIL_BYTES);
|
||||
extractMeta(parseLines(tail, true, false), meta);
|
||||
}
|
||||
if (!meta.sessionId || !meta.cwd) continue; // pas une session exploitable
|
||||
out.push({
|
||||
claudeSessionId: meta.sessionId,
|
||||
cwd: meta.cwd,
|
||||
title: pickTitle(meta),
|
||||
gitBranch: meta.gitBranch ?? null,
|
||||
version: meta.version ?? null,
|
||||
mtimeMs: st.mtimeMs,
|
||||
file: fp,
|
||||
});
|
||||
} catch {
|
||||
/* fichier illisible : ignoré */
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user