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.
This commit is contained in:
146
packages/server/scripts/acceptance-p5.mjs
Normal file
146
packages/server/scripts/acceptance-p5.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P5 (sans navigateur, sans quota Claude) : groupes de travail.
|
||||
// Vrai daemon + vrai repo git tmp. Couvre : CRUD groupe via REST, broadcast WS group_update/
|
||||
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
||||
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const PORT = 7545;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||
const repo = join(tmp, 'demo-repo');
|
||||
execFileSync('mkdir', ['-p', repo]);
|
||||
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.email', 'test@arboretum.dev');
|
||||
git('config', 'user.name', 'Test');
|
||||
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'init');
|
||||
|
||||
const srv = spawn(
|
||||
'node',
|
||||
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')],
|
||||
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
|
||||
function wsClient(cookie) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
const state = { msgs: [] };
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) state.msgs.push(JSON.parse(String(data)));
|
||||
});
|
||||
const waitMsg = async (pred, timeout = 8000) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const m = state.msgs.find(pred);
|
||||
if (m) return m;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||
}
|
||||
|
||||
const j = (path, method, cookie, body) =>
|
||||
fetch(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
await sleep(1500);
|
||||
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||
check('boot + token bootstrap', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||
check('login → cookie', login.status === 200);
|
||||
|
||||
const c = wsClient(cookie);
|
||||
await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej)));
|
||||
c.send({ type: 'hello', protocol: 1 });
|
||||
await c.waitMsg((m) => m.type === 'hello_ok');
|
||||
c.send({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
|
||||
|
||||
// Enregistrement d'un repo (cible de la membership).
|
||||
const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||
const repoSummary = (await addRepo.json()).repo;
|
||||
check('POST /repos → 201', addRepo.status === 201 && repoSummary?.valid === true);
|
||||
|
||||
// Création d'un groupe vide via REST → broadcast WS group_update.
|
||||
const created = await j('/api/v1/groups', 'POST', cookie, { label: 'Sprint 42', color: '#4f46e5' });
|
||||
const group = (await created.json()).group;
|
||||
check('POST /groups → 201', created.status === 201 && group?.label === 'Sprint 42' && group?.repoIds.length === 0);
|
||||
const pushedCreate = await c.waitMsg((m) => m.type === 'group_update' && m.group?.id === group.id);
|
||||
check('broadcast WS group_update (création)', !!pushedCreate);
|
||||
|
||||
const list = await (await j('/api/v1/groups', 'GET', cookie)).json();
|
||||
check('GET /groups → groupe présent', list.groups?.some((g) => g.id === group.id));
|
||||
|
||||
// Ajout du repo au groupe → group_update avec repoIds peuplé.
|
||||
const added = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repoSummary.id });
|
||||
const addedBody = await added.json();
|
||||
check('POST /groups/:id/repos → repoIds', added.status === 200 && addedBody.group?.repoIds.includes(repoSummary.id));
|
||||
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||
|
||||
// Renommage via PATCH.
|
||||
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||
|
||||
// repoId inexistant → 404.
|
||||
const bad = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: 'does-not-exist' });
|
||||
check('POST repo inexistant → 404', bad.status === 404);
|
||||
|
||||
// Suppression du repo → CASCADE purge la membership.
|
||||
const delRepo = await j(`/api/v1/repos/${repoSummary.id}`, 'DELETE', cookie);
|
||||
check('DELETE /repos/:id → 200', delRepo.status === 200);
|
||||
await sleep(200);
|
||||
const afterCascade = await (await j(`/api/v1/groups/${group.id}`, 'GET', cookie)).json();
|
||||
check('CASCADE : membership purgée à la suppression du repo', afterCascade.group?.repoIds.length === 0);
|
||||
|
||||
// Suppression du groupe → broadcast WS group_removed.
|
||||
const delGroup = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||
check('DELETE /groups/:id → 200', delGroup.status === 200);
|
||||
const removed = await c.waitMsg((m) => m.type === 'group_removed' && m.groupId === group.id);
|
||||
check('broadcast WS group_removed', !!removed);
|
||||
const delAgain = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||
check('DELETE groupe inconnu → 404', delAgain.status === 404);
|
||||
|
||||
c.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P5: ALL GREEN' : `\nACCEPTANCE P5: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
86
packages/server/src/routes/groups.ts
Normal file
86
packages/server/src/routes/groups.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
100
packages/server/test/group-manager.test.ts
Normal file
100
packages/server/test/group-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||
import { GroupManager } from '../src/core/group-manager.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */
|
||||
function insertRepo(db: Db, id: string, path: string): void {
|
||||
db.prepare(
|
||||
"INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)",
|
||||
).run(id, path, id, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('GroupManager', () => {
|
||||
let db: Db;
|
||||
let gm: GroupManager;
|
||||
|
||||
beforeEach(() => {
|
||||
db = openDb(':memory:');
|
||||
insertRepo(db, 'repo-a', '/tmp/a');
|
||||
insertRepo(db, 'repo-b', '/tmp/b');
|
||||
gm = new GroupManager(db);
|
||||
});
|
||||
|
||||
it('createGroup : groupe vide, persisté, événement group_update émis', () => {
|
||||
const spy = vi.fn();
|
||||
gm.on('group_update', spy);
|
||||
const g = gm.createGroup({ label: 'Sprint 42' });
|
||||
expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] });
|
||||
expect(gm.listGroups()).toHaveLength(1);
|
||||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id }));
|
||||
});
|
||||
|
||||
it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => {
|
||||
const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] });
|
||||
expect(g.repoIds).toEqual(['repo-b', 'repo-a']);
|
||||
});
|
||||
|
||||
it('createGroup : repoId inexistant → 404', () => {
|
||||
expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow(
|
||||
expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }),
|
||||
);
|
||||
expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction
|
||||
});
|
||||
|
||||
it('createGroup : validations (label vide / trop long, couleur invalide)', () => {
|
||||
expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5');
|
||||
});
|
||||
|
||||
it('getGroup : id inconnu → 404', () => {
|
||||
expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('updateGroup : modifie label/description/color et bump updatedAt', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' });
|
||||
expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' });
|
||||
expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null
|
||||
});
|
||||
|
||||
it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const spy = vi.fn();
|
||||
gm.on('group_update', spy);
|
||||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']);
|
||||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon
|
||||
expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']);
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('addRepo : groupe ou repo inexistant → 404', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||
expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('removeRepo : idempotent (retrait d’un repo absent = no-op)', () => {
|
||||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] });
|
||||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]);
|
||||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès
|
||||
});
|
||||
|
||||
it('deleteGroup : true + group_removed ; id inconnu → false', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
const spy = vi.fn();
|
||||
gm.on('group_removed', spy);
|
||||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||
expect(spy).toHaveBeenCalledWith(g.id);
|
||||
expect(gm.deleteGroup(g.id)).toBe(false);
|
||||
expect(gm.listGroups()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => {
|
||||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] });
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']);
|
||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user