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:
@@ -1,9 +1,14 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||
|
||||
interface FakePty {
|
||||
pid: number;
|
||||
file: string;
|
||||
@@ -135,6 +140,68 @@ describe('PtyManager (pty mocké)', () => {
|
||||
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
|
||||
expect(row.n).toBe(0);
|
||||
});
|
||||
|
||||
it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
|
||||
const { summary } = spawnBash();
|
||||
expect(summary).toMatchObject({
|
||||
source: 'managed',
|
||||
attachable: true,
|
||||
resumable: false,
|
||||
registryStatus: null,
|
||||
claudeSessionId: null,
|
||||
});
|
||||
expect(summary.pid).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume / fork (P2)', () => {
|
||||
it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
|
||||
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
|
||||
const p = lastPty();
|
||||
expect(summary.command).toBe('claude');
|
||||
expect(summary.source).toBe('managed');
|
||||
expect(summary.attachable).toBe(true);
|
||||
expect(p.file).toBe('/usr/bin/claude');
|
||||
expect(p.args).toEqual(['--resume', 'abc-123']);
|
||||
const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
|
||||
resumed_from: string | null;
|
||||
command: string;
|
||||
};
|
||||
expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
|
||||
});
|
||||
|
||||
it('fork → --resume <id> --fork-session', () => {
|
||||
manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
|
||||
expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
|
||||
});
|
||||
|
||||
it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
|
||||
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
|
||||
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
|
||||
lastPty().emitExit(0);
|
||||
const listed = manager.list().find((s) => s.id === summary.id);
|
||||
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
|
||||
});
|
||||
|
||||
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
|
||||
vi.useFakeTimers();
|
||||
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||
try {
|
||||
const m = new PtyManager(db, sessDir);
|
||||
const summary = m.spawn({ cwd, command: 'claude' });
|
||||
const p = lastPty();
|
||||
writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
|
||||
expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
|
||||
vi.advanceTimersByTime(400);
|
||||
const found = m.findLiveByClaudeSessionId('captured-sid');
|
||||
expect(found?.id).toBe(summary.id);
|
||||
expect(found?.claudeSessionId).toBe('captured-sid');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
rmSync(sessDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach', () => {
|
||||
|
||||
Reference in New Issue
Block a user