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.
160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
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,
|
|
wt: WorktreeManager,
|
|
manager: PtyManager,
|
|
): void {
|
|
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ 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<CreateGroupRequest> | 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<UpdateGroupRequest> | 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<AddRepoRequest> | 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<CreateGroupSessionRequest> | 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 } });
|
|
}
|
|
});
|
|
}
|