feat(groups): session Claude unique par groupe via --add-dir (P6)
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
Un groupe lance désormais UNE seule session Claude couvrant tous ses
repos/worktrees (--add-dir) au lieu d'une session par repo, pour
travailler en simultané dans une conversation partagée. Le transport WS,
le flow control et l'attach sont inchangés (toujours 1 session, 1 channel).
- shared: SessionSummary.addedDirs/groupId (champs additifs optionnels) ;
CreateGroupSessionRequest / GroupSessionResponse.
- server: --add-dir dans claude-launcher ; spawn({addDirs,groupId}) +
persistance (migration #8 : sessions.added_dirs/group_id) ;
POST /api/v1/groups/:id/session (résolution des dirs cote serveur) ;
resume re-relie les dirs ; deleteGroup nullifie group_id.
- web: GroupSessionModal (modes feature / checkouts principaux) ;
createGroupFeature/createGroupSession ; badge « groupe · N depots » ;
sessionsInGroup dedupliquee.
- site: textes EN/FR « une seule session Claude » + diagramme hub.
- tests: claude-launcher, pty-manager (groupe), group-manager ;
acceptation P5 etendue (session de groupe multi-repo). 272 tests verts.
This commit is contained in:
29
packages/server/test/claude-launcher.test.ts
Normal file
29
packages/server/test/claude-launcher.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildSpawnSpec } from '../src/core/claude-launcher.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' }));
|
||||
|
||||
describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
||||
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
|
||||
expect(spec.file).toBe('/usr/bin/claude');
|
||||
expect(spec.args).toEqual(['--add-dir', '/a', '--add-dir', '/b', '--add-dir', '/c']);
|
||||
});
|
||||
|
||||
it('combine --resume et --add-dir (reprise d’une session de groupe)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'claude', resume: { claudeSessionId: 'sid' }, addDirs: ['/x'] });
|
||||
expect(spec.args).toEqual(['--resume', 'sid', '--add-dir', '/x']);
|
||||
});
|
||||
|
||||
it('aucun --add-dir quand addDirs est vide/absent', () => {
|
||||
expect(buildSpawnSpec({ command: 'claude' }).args).toEqual([]);
|
||||
expect(buildSpawnSpec({ command: 'claude', addDirs: [] }).args).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignore addDirs pour bash (pas de --add-dir)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'bash', addDirs: ['/a', '/b'] });
|
||||
expect(spec.file).toBe('bash');
|
||||
expect(spec.args).toEqual(['--norc']);
|
||||
});
|
||||
});
|
||||
@@ -97,4 +97,15 @@ describe('GroupManager', () => {
|
||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||
});
|
||||
|
||||
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
|
||||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
|
||||
expect(row.group_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,4 +574,53 @@ describe('PtyManager (pty mocké)', () => {
|
||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('session de groupe multi-repo (P6)', () => {
|
||||
it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-'));
|
||||
const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' });
|
||||
expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]);
|
||||
expect(summary.addedDirs).toEqual([d1, d2]);
|
||||
expect(summary.groupId).toBe('grp1');
|
||||
const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as {
|
||||
added_dirs: string | null;
|
||||
group_id: string | null;
|
||||
};
|
||||
expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]);
|
||||
expect(row.group_id).toBe('grp1');
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
rmSync(d2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('dédoublonne et écarte le cwd primaire des addDirs', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] });
|
||||
expect(summary.addedDirs).toEqual([d1]);
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejette un addDir inexistant (400)', () => {
|
||||
expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow();
|
||||
});
|
||||
|
||||
it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' });
|
||||
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id);
|
||||
expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' });
|
||||
expect(manager.groupSessionContext('unknown')).toBeNull();
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user