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:
@@ -1,11 +1,16 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp, type AppBundle } from '../src/app.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
import { munge } from '../src/core/jsonl-discovery.js';
|
||||
import { readProcStart } from '../src/core/session-registry.js';
|
||||
import type { Config } from '../src/config.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' }));
|
||||
|
||||
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||
class FakePty {
|
||||
@@ -49,6 +54,9 @@ function makeApp(name: string): TestApp {
|
||||
dataDir: dir,
|
||||
allowedOrigins: [ALLOWED_ORIGIN],
|
||||
printToken: false,
|
||||
// ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
|
||||
claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
|
||||
claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
|
||||
};
|
||||
const bundle = buildApp(config, db, '0.0.0-test');
|
||||
const token = bundle.auth.ensureBootstrapToken();
|
||||
@@ -268,3 +276,64 @@ describe('app e2e — rate limit du login', () => {
|
||||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('app e2e — découverte, resume & fork (P2)', () => {
|
||||
let t: TestApp;
|
||||
let projectsDir: string;
|
||||
let sessionsDir: string;
|
||||
|
||||
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
|
||||
|
||||
function writeDiscoveredJsonl(cwd: string, sid: string): void {
|
||||
const d = join(projectsDir, munge(cwd));
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
t = makeApp('resume');
|
||||
projectsDir = join(dir, 'resume-claude', 'projects');
|
||||
sessionsDir = join(dir, 'resume-claude', 'sessions');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('resume / fork d’un id inconnu → 404', async () => {
|
||||
const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
|
||||
expect(r.statusCode).toBe(404);
|
||||
const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
|
||||
expect(f.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
|
||||
writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
|
||||
await t.bundle.discovery.refresh();
|
||||
|
||||
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
|
||||
const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
|
||||
(s) => s.id === 'dead-e2e',
|
||||
);
|
||||
expect(found).toMatchObject({ source: 'discovered', resumable: true });
|
||||
|
||||
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
|
||||
});
|
||||
|
||||
it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
|
||||
const sid = 'live-e2e';
|
||||
writeDiscoveredJsonl(dir, sid);
|
||||
const ps = readProcStart(process.pid) as string;
|
||||
writeFileSync(
|
||||
join(sessionsDir, `${process.pid}.json`),
|
||||
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
|
||||
);
|
||||
await t.bundle.discovery.refresh();
|
||||
|
||||
const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
|
||||
expect(resume.statusCode).toBe(409);
|
||||
expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });
|
||||
|
||||
const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
|
||||
expect(fork.statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user