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:
201
packages/server/src/core/group-manager.ts
Normal file
201
packages/server/src/core/group-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user