diff --git a/packages/server/scripts/acceptance-p5.mjs b/packages/server/scripts/acceptance-p5.mjs index cd01d81..318b3fa 100644 --- a/packages/server/scripts/acceptance-p5.mjs +++ b/packages/server/scripts/acceptance-p5.mjs @@ -24,15 +24,20 @@ const check = (name, ok, detail = '') => { }; const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-')); +function initRepo(path) { + execFileSync('mkdir', ['-p', path]); + const git = (...args) => execFileSync('git', args, { cwd: path, stdio: 'pipe' }); + git('init', '-b', 'main'); + git('config', 'user.email', 'test@arboretum.dev'); + git('config', 'user.name', 'Test'); + execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: path }); + git('add', '-A'); + git('commit', '-m', 'init'); +} const repo = join(tmp, 'demo-repo'); -execFileSync('mkdir', ['-p', repo]); -const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); -git('init', '-b', 'main'); -git('config', 'user.email', 'test@arboretum.dev'); -git('config', 'user.name', 'Test'); -execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo }); -git('add', '-A'); -git('commit', '-m', 'init'); +const repo2 = join(tmp, 'demo-repo-2'); +initRepo(repo); +initRepo(repo2); const srv = spawn( 'node', @@ -109,6 +114,41 @@ try { const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id)); check('broadcast WS group_update (ajout repo)', !!pushedAdd); + // ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ---- + // Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota). + const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 }); + const repo2Summary = (await addRepo2.json()).repo; + check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true); + await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id }); + + // Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo. + const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' }); + const gsBody = await gsRes.json(); + const gsession = gsBody.session; + check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession); + check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2); + check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1); + check('session de groupe : groupId posé', gsession?.groupId === group.id); + check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2); + + // La session apparaît dans la liste globale avec son groupId. + const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json(); + const listed = sessList.sessions?.find((s) => s.id === gsession.id); + check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id); + + // broadcast WS session_update reçu pour la session de groupe. + const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id); + check('broadcast WS session_update (session de groupe)', !!pushedSession); + + // groupe sans worktree résolu (branche inexistante) → 400. + const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' }); + check('POST /groups/:id/session branche absente → 400', gsBad.status === 400); + + // Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership). + const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie); + check('DELETE session de groupe → 200', killSession.status === 200); + await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie); + // Renommage via PATCH. const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' }); check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed'); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 26cf181..8fa292e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -170,7 +170,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund registerAuthRoutes(app, auth, limiter, serverVersion, db); registerSessionRoutes(app, manager, discovery); registerRepoRoutes(app, worktrees, db); - registerGroupRoutes(app, groups, db); + registerGroupRoutes(app, groups, db, worktrees, manager); registerWorktreeRoutes(app, worktrees); registerPushRoutes(app, push, db); registerSettingsRoutes(app, db, config, serverVersion, push); diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index 3e459a3..7faa82e 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -10,6 +10,8 @@ export interface SpawnOptions { command: 'claude' | 'bash'; /** reprise d'une session existante (P2) : `--resume `, `--fork-session` si fork. */ resume?: { claudeSessionId: string; fork?: boolean }; + /** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir ` répété. */ + addDirs?: string[]; } let cachedClaudeBin: string | null = null; @@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { args.push('--resume', opts.resume.claudeSessionId); if (opts.resume.fork) args.push('--fork-session'); } + // Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6). + for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir); return { file: resolveClaudeBin(), args, env }; } diff --git a/packages/server/src/core/group-manager.ts b/packages/server/src/core/group-manager.ts index b7d1f4b..c70a2de 100644 --- a/packages/server/src/core/group-manager.ts +++ b/packages/server/src/core/group-manager.ts @@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter { // CASCADE (PRAGMA foreign_keys = ON) purge group_repos. const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id); if (res.changes === 0) return false; + // Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement. + this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id); this.emit('group_removed', id); return true; } diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index 0d33583..b4daf80 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500; const CLAUDE_ID_POLL_MS = 400; const CLAUDE_ID_TIMEOUT_MS = 60_000; +/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */ +function parseAddedDirs(raw: string | null): string[] { + if (!raw) return []; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } +} + /** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */ export interface ClientBinding { channel: number; @@ -48,6 +59,10 @@ interface ManagedSession { killTimer: NodeJS.Timeout | null; /** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */ claudeSessionId: string | null; + /** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */ + addedDirs: string[]; + /** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */ + groupId: string | null; /** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */ tracker: SessionActivityTracker | null; /** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */ @@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter { super(); } - spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary { + spawn(opts: { + cwd: string; + command?: 'claude' | 'bash'; + resume?: { claudeSessionId: string; fork?: boolean }; + /** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */ + addDirs?: string[]; + /** groupe propriétaire (session de groupe, P6). */ + groupId?: string; + }): SessionSummary { const cwd = opts.cwd; if (!existsSync(cwd) || !statSync(cwd).isDirectory()) { throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 }); } + // Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd). + const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd); + for (const dir of addedDirs) { + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 }); + } + } // Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1). const command = opts.resume ? 'claude' : (opts.command ?? 'claude'); - const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) }); + const spec = buildSpawnSpec({ + command, + ...(opts.resume ? { resume: opts.resume } : {}), + ...(addedDirs.length ? { addDirs: addedDirs } : {}), + }); const id = randomUUID(); const proc = pty.spawn(spec.file, spec.args, { name: 'xterm-256color', @@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter { exited: null, killTimer: null, claudeSessionId: null, + addedDirs, + groupId: opts.groupId ?? null, tracker: null, prevActivity: null, notifyTimer: null, @@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter { } this.live.set(id, session); this.db - .prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)') - .run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null); + .prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run( + id, + cwd, + command, + session.createdAt, + opts.resume?.claudeSessionId ?? null, + addedDirs.length ? JSON.stringify(addedDirs) : null, + session.groupId, + ); proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8'))); proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null)); @@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter { setTimeout(tick, CLAUDE_ID_POLL_MS).unref(); } + /** + * Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id` + * persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires. + */ + groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null { + const row = this.db + .prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1') + .get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined; + if (!row) return null; + return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id }; + } + /** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */ findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null { for (const s of this.live.values()) { @@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter { const liveSummaries = [...this.live.values()].map((s) => this.summarize(s)); const liveIds = new Set(this.live.keys()); const rows = this.db - .prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100') - .all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>; + .prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100') + .all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>; const historical: SessionSummary[] = rows .filter((r) => !liveIds.has(r.id)) - .map((r) => ({ - id: r.id, - cwd: r.cwd, - command: r.command, - title: r.title, - status: 'exited', - live: false, - createdAt: r.created_at, - endedAt: r.ended_at, - exitCode: r.exit_code, - clients: 0, - source: 'managed', - claudeSessionId: r.claude_session_id, - pid: null, - // une session claude morte avec un claudeSessionId connu est reprenable (--resume direct). - resumable: r.command === 'claude' && r.claude_session_id != null, - attachable: false, - registryStatus: null, - })); + .map((r) => { + const addedDirs = parseAddedDirs(r.added_dirs); + return { + id: r.id, + cwd: r.cwd, + command: r.command, + title: r.title, + status: 'exited' as const, + live: false, + createdAt: r.created_at, + endedAt: r.ended_at, + exitCode: r.exit_code, + clients: 0, + source: 'managed' as const, + claudeSessionId: r.claude_session_id, + pid: null, + // une session claude morte avec un claudeSessionId connu est reprenable (--resume direct). + resumable: r.command === 'claude' && r.claude_session_id != null, + attachable: false, + registryStatus: null, + ...(addedDirs.length ? { addedDirs } : {}), + groupId: r.group_id, + }; + }); return [...liveSummaries, ...historical]; } @@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter { activity: act?.activity ?? null, waitingFor: act?.waitingFor ?? null, dialog: act?.dialog ?? null, + ...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}), + groupId: s.groupId, }; } } diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 8bd4bd3..656ceec 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -118,6 +118,17 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [ CREATE INDEX idx_audit_ts ON audit_logs(ts); `, }, + { + // P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir) + // et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo). + // `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe. + id: 8, + sql: ` + ALTER TABLE sessions ADD COLUMN added_dirs TEXT; + ALTER TABLE sessions ADD COLUMN group_id TEXT; + CREATE INDEX idx_sessions_group_id ON sessions(group_id); + `, + }, ]; export type Db = DatabaseSync; diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts index 25342d5..28126f3 100644 --- a/packages/server/src/routes/groups.ts +++ b/packages/server/src/routes/groups.ts @@ -2,16 +2,26 @@ import type { FastifyInstance } from 'fastify'; import type { AddRepoRequest, CreateGroupRequest, + CreateGroupSessionRequest, GroupResponse, + GroupSessionResponse, GroupsListResponse, UpdateGroupRequest, } from '@arboretum/shared'; import type { GroupManager } from '../core/group-manager.js'; +import type { WorktreeManager } from '../core/worktree-manager.js'; +import type { PtyManager } from '../core/pty-manager.js'; import type { Db } from '../db/index.js'; import { recordAudit } from '../core/audit-log.js'; import { sendManagerError } from './repos.js'; -export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void { +export function registerGroupRoutes( + app: FastifyInstance, + gm: GroupManager, + db: Db, + wt: WorktreeManager, + manager: PtyManager, +): void { app.get('/api/v1/groups', async (): Promise => ({ groups: gm.listGroups() })); app.get('/api/v1/groups/:id', async (req, reply) => { @@ -88,4 +98,62 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: return sendManagerError(reply, err); } }); + + // Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir). + // Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe — le client + // ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ; + // absent → le worktree principal de chaque repo. + app.post('/api/v1/groups/:id/session', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = (req.body as Partial | null) ?? {}; + const command = body.command ?? 'claude'; + if (command !== 'claude' && command !== 'bash') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } }); + } + const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null; + + let group; + try { + group = gm.getGroup(id); + } catch (err) { + return sendManagerError(reply, err); + } + + const dirs: string[] = []; + const skipped: Array<{ repoId: string; reason: string }> = []; + for (const repoId of group.repoIds) { + let worktrees; + try { + worktrees = await wt.listRepoWorktrees(repoId); + } catch (err) { + skipped.push({ repoId, reason: err instanceof Error ? err.message : String(err) }); + continue; + } + const match = branch ? worktrees.find((w) => w.branch === branch) : worktrees.find((w) => w.isMain); + if (!match) { + skipped.push({ repoId, reason: branch ? `no worktree on branch ${branch}` : 'no main worktree' }); + continue; + } + if (!dirs.includes(match.path)) dirs.push(match.path); + } + + const [primary, ...rest] = dirs; + if (primary === undefined) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } }); + } + + try { + const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id }); + recordAudit(db, { + actor: req.authContext?.tokenId ?? 'unknown', + action: 'group.session.create', + resourceId: id, + details: { command, dirs: dirs.length, ...(branch ? { branch } : {}) }, + }); + return reply.status(201).send({ session, dirs, skipped } satisfies GroupSessionResponse); + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode ?? 500; + return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } }); + } + }); } diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 387a05e..0d440ae 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -39,7 +39,14 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } }); } try { - const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } }); + // Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume. + const ctx = manager.groupSessionContext(id); + const session = manager.spawn({ + cwd: discovered.cwd, + resume: { claudeSessionId: id }, + ...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}), + ...(ctx?.groupId ? { groupId: ctx.groupId } : {}), + }); const res: SessionResponse = { session }; return reply.status(201).send(res); } catch (err) { diff --git a/packages/server/test/claude-launcher.test.ts b/packages/server/test/claude-launcher.test.ts new file mode 100644 index 0000000..797aeb5 --- /dev/null +++ b/packages/server/test/claude-launcher.test.ts @@ -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']); + }); +}); diff --git a/packages/server/test/group-manager.test.ts b/packages/server/test/group-manager.test.ts index 66ed433..ac80fe0 100644 --- a/packages/server/test/group-manager.test.ts +++ b/packages/server/test/group-manager.test.ts @@ -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(); + }); }); diff --git a/packages/server/test/pty-manager.test.ts b/packages/server/test/pty-manager.test.ts index f9148fd..d1e1211 100644 --- a/packages/server/test/pty-manager.test.ts +++ b/packages/server/test/pty-manager.test.ts @@ -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 }); + } + }); + }); }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index d835100..1b402a3 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -152,6 +152,22 @@ export interface AddRepoRequest { repoId: string; } +// ---- Session de groupe multi-repo (P6) ---- +/** Lance UNE session Claude couvrant tous les repos du groupe (via `--add-dir`). */ +export interface CreateGroupSessionRequest { + /** défaut : claude. */ + command?: 'claude' | 'bash'; + /** présent → couvre le worktree de cette branche dans chaque repo ; absent → les checkouts principaux. */ + branch?: string; +} +export interface GroupSessionResponse { + session: SessionSummary; + /** répertoires effectivement couverts par la session (cwd primaire en tête). */ + dirs: string[]; + /** repos du groupe pour lesquels aucun worktree n'a pu être résolu. */ + skipped: Array<{ repoId: string; reason: string }>; +} + // ---- Navigateur de répertoires (sélecteur de dossier côté web) ---- export interface FsEntry { name: string; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index f00fb3d..e02639e 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -113,6 +113,11 @@ export interface SessionSummary { waitingFor?: string | null; /** dialogue typé en cours (présent quand activity === 'waiting'). */ dialog?: SessionDialog | null; + // ---- P6 : session de groupe multi-repo (additif) ---- + /** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */ + addedDirs?: string[]; + /** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */ + groupId?: string | null; } // ---- Worktrees & repos (P3) ---- diff --git a/packages/site/src/components/WorkGroupsSection.vue b/packages/site/src/components/WorkGroupsSection.vue index 05ba752..3bff348 100644 --- a/packages/site/src/components/WorkGroupsSection.vue +++ b/packages/site/src/components/WorkGroupsSection.vue @@ -25,7 +25,7 @@ const { t } = useI18n(); > - payments + {{ t('grpHub') }}
-
-
-

{{ t('crossRepo.title') }}

- -
-

{{ t('crossRepo.intro') }}

- -
- -
- - - -
- -
- {{ t('crossRepo.reposLabel') }} -
- -
-
- -
- - -
-
-
-
- - - diff --git a/packages/web/src/components/GroupSessionModal.vue b/packages/web/src/components/GroupSessionModal.vue new file mode 100644 index 0000000..678c743 --- /dev/null +++ b/packages/web/src/components/GroupSessionModal.vue @@ -0,0 +1,148 @@ + + + diff --git a/packages/web/src/components/TerminalCell.vue b/packages/web/src/components/TerminalCell.vue index f4654e5..320c245 100644 --- a/packages/web/src/components/TerminalCell.vue +++ b/packages/web/src/components/TerminalCell.vue @@ -4,6 +4,13 @@
{{ title }} + + {{ t('groups.spanBadge', spannedCount) }} + {{ session.command }}
@@ -15,6 +22,7 @@