Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
163 lines
6.6 KiB
TypeScript
163 lines
6.6 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 { 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<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);
|
|
}
|
|
|
|
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 } });
|
|
}
|
|
});
|
|
}
|