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 { resolveGroupCwd } from '../core/group-session.js'; import { sendManagerError } from './repos.js'; 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) => { const { id } = req.params as { id: string }; try { return reply.send({ group: gm.getGroup(id) } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); } }); app.post('/api/v1/groups', async (req, reply) => { const body = req.body as Partial | null; if (!body || typeof body.label !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label is required' } }); } try { const group = gm.createGroup({ label: body.label, ...(body.description !== undefined ? { description: body.description } : {}), ...(body.color !== undefined ? { color: body.color } : {}), ...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}), }); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.create', resourceId: group.id, details: { label: group.label } }); return reply.status(201).send({ group } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); } }); app.patch('/api/v1/groups/:id', async (req, reply) => { const { id } = req.params as { id: string }; const body = (req.body as Partial | null) ?? {}; try { const group = gm.updateGroup(id, { ...(body.label !== undefined ? { label: body.label } : {}), ...(body.description !== undefined ? { description: body.description } : {}), ...(body.color !== undefined ? { color: body.color } : {}), }); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id }); return reply.send({ group } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); } }); app.delete('/api/v1/groups/:id', async (req, reply) => { const { id } = req.params as { id: string }; if (!gm.deleteGroup(id)) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } }); } recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.delete', resourceId: id }); return reply.send({ ok: true }); }); app.post('/api/v1/groups/:id/repos', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.repoId !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'repoId is required' } }); } try { return reply.send({ group: gm.addRepo(id, body.repoId) } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); } }); app.delete('/api/v1/groups/:id/repos/:repoId', async (req, reply) => { const { id, repoId } = req.params as { id: string; repoId: string }; try { return reply.send({ group: gm.removeRepo(id, repoId) } satisfies GroupResponse); } catch (err) { 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); } if (dirs.length === 0) { const detail = branch ? `no repo has a worktree on branch "${branch}" (create it first)` : 'no repo has a resolvable main checkout'; return reply.status(400).send({ error: { code: 'NO_RESOLVABLE_WORKTREE', message: `Cannot start group session: ${detail}` } }); } // cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd. const { cwd, addDirs } = resolveGroupCwd(dirs); try { const session = manager.spawn({ cwd, addDirs, 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 } }); } }); }