feat: groupes de travail (P5)

Ajoute la notion de groupe — collection nommée de repos (membership légère
persistée, many-to-many) — pour piloter plusieurs repos en simultané :

- DB : migration #5 (tables groups + group_repos, FK ON DELETE CASCADE).
- GroupManager (synchrone, EventEmitter) + routes REST /api/v1/groups ;
  ne persiste que la membership, worktrees/sessions filtrés par repoId côté client.
- Protocole : GroupSummary, DTOs, topic WS « groups », events group_update/removed
  (additifs, PROTOCOL_VERSION inchangé).
- Web : store groups, GroupsListView, GroupView (réutilise RepoSection),
  grille multi-terminaux (TerminalGrid/TerminalCell), action « feature cross-repo »
  (orchestration client, tolérante aux échecs partiels), routes + i18n EN/FR.
- Tests : group-manager.test.ts + extension protocol.test.ts ; acceptance-p5.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-18 10:03:19 +02:00
parent 7ac933ca9a
commit 6a95c9510e
24 changed files with 1429 additions and 12 deletions

View File

@@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
import { PtyManager } from './core/pty-manager.js';
import { DiscoveryService } from './core/discovery-service.js';
import { WorktreeManager } from './core/worktree-manager.js';
import { GroupManager } from './core/group-manager.js';
import { PushService } from './core/push-service.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerRepoRoutes } from './routes/repos.js';
import { registerGroupRoutes } from './routes/groups.js';
import { registerWorktreeRoutes } from './routes/worktrees.js';
import { registerPushRoutes } from './routes/push.js';
import { registerFsRoutes } from './routes/fs.js';
@@ -35,6 +37,7 @@ export interface AppBundle {
manager: PtyManager;
discovery: DiscoveryService;
worktrees: WorktreeManager;
groups: GroupManager;
push: PushService;
}
@@ -50,6 +53,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
sessionsDir: config.claudeSessionsDir,
});
const worktrees = new WorktreeManager(db, manager, discovery);
const groups = new GroupManager(db);
void app.register(fastifyCookie);
void app.register(fastifyWebsocket, {
@@ -91,13 +95,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuthRoutes(app, auth, limiter, serverVersion);
registerSessionRoutes(app, manager, discovery);
registerRepoRoutes(app, worktrees);
registerGroupRoutes(app, groups);
registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push);
registerFsRoutes(app);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
void app.register(async (scoped) => {
registerWsGateway(scoped, manager, discovery, worktrees, serverVersion);
registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion);
});
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
@@ -112,5 +117,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
});
}
return { app, auth, manager, discovery, worktrees, push };
return { app, auth, manager, discovery, worktrees, groups, push };
}

View File

@@ -0,0 +1,201 @@
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici —
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import type { GroupSummary } from '@arboretum/shared';
import type { Db } from '../db/index.js';
const LABEL_MAX = 100;
const DESCRIPTION_MAX = 2000;
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
interface GroupRow {
id: string;
label: string;
description: string | null;
color: string | null;
position: number;
created_at: string;
updated_at: string;
}
export interface GroupManagerEvents {
group_update: [GroupSummary];
group_removed: [string];
}
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes (cf. sendManagerError). */
function httpError(statusCode: number, code: string, message: string): Error {
return Object.assign(new Error(message), { statusCode, code });
}
/** Valide un label : non vide après trim, borné. */
function normLabel(label: unknown): string {
if (typeof label !== 'string') throw httpError(400, 'BAD_REQUEST', 'label is required');
const v = label.trim();
if (v === '') throw httpError(400, 'BAD_REQUEST', 'label must not be empty');
if (v.length > LABEL_MAX) throw httpError(400, 'BAD_REQUEST', `label must be at most ${LABEL_MAX} characters`);
return v;
}
/** Normalise une description : vide/absente → null, bornée sinon. */
function normDescription(description: string | null | undefined): string | null {
if (description === null || description === undefined) return null;
const v = description.trim();
if (v === '') return null;
if (v.length > DESCRIPTION_MAX) throw httpError(400, 'BAD_REQUEST', `description must be at most ${DESCRIPTION_MAX} characters`);
return v;
}
/** Normalise une couleur : vide/absente → null, doit être un hex `#rrggbb` sinon. */
function normColor(color: string | null | undefined): string | null {
if (color === null || color === undefined) return null;
const v = color.trim();
if (v === '') return null;
if (!COLOR_RE.test(v)) throw httpError(400, 'BAD_REQUEST', 'color must be a hex string like #4f46e5');
return v;
}
export class GroupManager extends EventEmitter<GroupManagerEvents> {
constructor(private readonly db: Db) {
super();
}
private getGroupRow(id: string): GroupRow | null {
return (this.db.prepare('SELECT * FROM groups WHERE id = ?').get(id) as unknown as GroupRow | undefined) ?? null;
}
private repoIdsFor(groupId: string): string[] {
const rows = this.db
.prepare('SELECT repo_id FROM group_repos WHERE group_id = ? ORDER BY position ASC, created_at ASC')
.all(groupId) as Array<{ repo_id: string }>;
return rows.map((r) => r.repo_id);
}
private rowToSummary(row: GroupRow): GroupSummary {
return {
id: row.id,
label: row.label,
description: row.description,
color: row.color,
repoIds: this.repoIdsFor(row.id),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/** Garde-fou : le repo doit exister (meilleur message que de laisser la FK lever une contrainte opaque). */
private assertRepoExists(repoId: string): void {
if (typeof repoId !== 'string' || repoId === '') throw httpError(400, 'BAD_REQUEST', 'repoId is required');
const exists = this.db.prepare('SELECT 1 FROM repos WHERE id = ?').get(repoId);
if (!exists) throw httpError(404, 'REPO_NOT_FOUND', 'No repo with this id');
}
/** Position d'insertion suivante dans un groupe (append en queue). */
private nextPosition(groupId: string): number {
const row = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM group_repos WHERE group_id = ?').get(groupId) as {
pos: number;
};
return row.pos;
}
listGroups(): GroupSummary[] {
const rows = this.db.prepare('SELECT * FROM groups ORDER BY position ASC, created_at ASC').all() as unknown as GroupRow[];
return rows.map((r) => this.rowToSummary(r));
}
getGroup(id: string): GroupSummary {
const row = this.getGroupRow(id);
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
return this.rowToSummary(row);
}
createGroup(opts: { label: string; description?: string; color?: string; repoIds?: string[] }): GroupSummary {
const label = normLabel(opts.label);
const description = normDescription(opts.description);
const color = normColor(opts.color);
const repoIds = opts.repoIds ?? [];
if (!Array.isArray(repoIds)) throw httpError(400, 'BAD_REQUEST', 'repoIds must be an array');
for (const repoId of repoIds) this.assertRepoExists(repoId);
const now = new Date().toISOString();
const id = randomUUID();
const position = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM groups').get() as { pos: number };
this.db.exec('BEGIN');
try {
this.db
.prepare('INSERT INTO groups (id, label, description, color, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, label, description, color, position.pos, now, now);
const insertRepo = this.db.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)');
let pos = 0;
const seen = new Set<string>();
for (const repoId of repoIds) {
if (seen.has(repoId)) continue;
seen.add(repoId);
insertRepo.run(id, repoId, pos++, now);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
const summary = this.getGroup(id);
this.emit('group_update', summary);
return summary;
}
updateGroup(id: string, patch: { label?: string; description?: string | null; color?: string | null }): GroupSummary {
const row = this.getGroupRow(id);
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
if (patch.label !== undefined) row.label = normLabel(patch.label);
if (patch.description !== undefined) row.description = normDescription(patch.description);
if (patch.color !== undefined) row.color = normColor(patch.color);
const now = new Date().toISOString();
this.db
.prepare('UPDATE groups SET label = ?, description = ?, color = ?, updated_at = ? WHERE id = ?')
.run(row.label, row.description, row.color, now, id);
const summary = this.getGroup(id);
this.emit('group_update', summary);
return summary;
}
deleteGroup(id: string): boolean {
// 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;
this.emit('group_removed', id);
return true;
}
addRepo(groupId: string, repoId: string): GroupSummary {
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
this.assertRepoExists(repoId);
const now = new Date().toISOString();
// INSERT OR IGNORE → idempotent (PRIMARY KEY (group_id, repo_id)).
this.db
.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)')
.run(groupId, repoId, this.nextPosition(groupId), now);
this.touch(groupId, now);
const summary = this.getGroup(groupId);
this.emit('group_update', summary);
return summary;
}
removeRepo(groupId: string, repoId: string): GroupSummary {
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
// DELETE no-op si absent → idempotent.
this.db.prepare('DELETE FROM group_repos WHERE group_id = ? AND repo_id = ?').run(groupId, repoId);
this.touch(groupId, new Date().toISOString());
const summary = this.getGroup(groupId);
this.emit('group_update', summary);
return summary;
}
private touch(groupId: string, now: string): void {
this.db.prepare('UPDATE groups SET updated_at = ? WHERE id = ?').run(now, groupId);
}
}

View File

@@ -68,6 +68,31 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
`,
},
{
// P5 — groupes de travail. Un groupe = collection de repos (many-to-many).
// Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager
// et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb).
id: 5,
sql: `
CREATE TABLE groups (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
description TEXT,
color TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE group_repos (
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
PRIMARY KEY (group_id, repo_id)
);
CREATE INDEX idx_group_repos_repo ON group_repos(repo_id);
`,
},
];
export type Db = DatabaseSync;

View File

@@ -0,0 +1,86 @@
import type { FastifyInstance } from 'fastify';
import type {
AddRepoRequest,
CreateGroupRequest,
GroupResponse,
GroupsListResponse,
UpdateGroupRequest,
} from '@arboretum/shared';
import type { GroupManager } from '../core/group-manager.js';
import { sendManagerError } from './repos.js';
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): 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 } : {}),
});
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 } : {}),
});
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' } });
}
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);
}
});
}

View File

@@ -5,6 +5,7 @@ import {
PROTOCOL_VERSION,
encodeBinaryFrame,
parseClientMessage,
type GroupSummary,
type RepoSummary,
type ServerMessage,
type SessionSummary,
@@ -13,6 +14,7 @@ import {
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
import type { DiscoveryService } from '../core/discovery-service.js';
import type { WorktreeManager } from '../core/worktree-manager.js';
import type { GroupManager } from '../core/group-manager.js';
const HEARTBEAT_MS = 30_000;
@@ -26,6 +28,7 @@ export function registerWsGateway(
manager: PtyManager,
discovery: DiscoveryService,
worktrees: WorktreeManager,
groups: GroupManager,
serverVersion: string,
): void {
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
@@ -35,6 +38,7 @@ export function registerWsGateway(
let helloDone = false;
let subscribedSessions = false;
let subscribedWorktrees = false;
let subscribedGroups = false;
let alive = true;
const send = (msg: ServerMessage): void => {
@@ -66,6 +70,12 @@ export function registerWsGateway(
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
};
const onGroupUpdate = (group: GroupSummary): void => {
if (subscribedGroups) send({ type: 'group_update', group });
};
const onGroupRemoved = (groupId: string): void => {
if (subscribedGroups) send({ type: 'group_removed', groupId });
};
manager.on('session_update', onSessionUpdate);
manager.on('session_exit', onSessionExit);
discovery.on('discovery_update', onDiscoveryUpdate);
@@ -73,6 +83,8 @@ export function registerWsGateway(
worktrees.on('repo_removed', onRepoRemoved);
worktrees.on('worktree_update', onWorktreeUpdate);
worktrees.on('worktree_removed', onWorktreeRemoved);
groups.on('group_update', onGroupUpdate);
groups.on('group_removed', onGroupRemoved);
const heartbeat = setInterval(() => {
if (!alive) {
@@ -111,6 +123,7 @@ export function registerWsGateway(
case 'sub': {
subscribedSessions = msg.topics.includes('sessions');
subscribedWorktrees = msg.topics.includes('worktrees');
subscribedGroups = msg.topics.includes('groups');
return;
}
case 'attach': {
@@ -202,6 +215,8 @@ export function registerWsGateway(
worktrees.off('repo_removed', onRepoRemoved);
worktrees.off('worktree_update', onWorktreeUpdate);
worktrees.off('worktree_removed', onWorktreeRemoved);
groups.off('group_update', onGroupUpdate);
groups.off('group_removed', onGroupRemoved);
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
channels.clear();
});