import { defineStore } from 'pinia'; import { ref } from 'vue'; import type { CreateGroupRequest, CreateGroupSessionRequest, GroupResponse, GroupSessionResponse, GroupsListResponse, GroupSummary, RepoSummary, SessionSummary, UpdateGroupRequest, WorktreeSummary, } from '@arboretum/shared'; import { api, ApiError } from '../lib/api'; import { wsClient, type GroupEvent } from '../lib/ws-client'; import { useWorktreesStore } from './worktrees'; import { useSessionsStore } from './sessions'; /** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */ export interface CrossRepoResult { repoId: string; status: 'ok' | 'error'; message?: string; worktreePath?: string; } /** Options de lancement d'une session de groupe (P6). */ export interface GroupFeatureOptions { command: 'claude' | 'bash'; /** présent → crée/réutilise le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */ branch?: string; /** branche de base d'une branche créée (défaut serveur : branche par défaut du dépôt). */ baseRef?: string; } /** Bilan d'un lancement de session de groupe. */ export interface GroupFeatureOutcome { /** résultats de création de worktree par repo ([] en mode checkouts principaux). */ worktreeResults: CrossRepoResult[]; session: SessionSummary | null; dirs: string[]; skipped: Array<{ repoId: string; reason: string }>; } export const useGroupsStore = defineStore('groups', () => { const groups = ref([]); const selectedGroupId = ref(null); const loading = ref(false); const loadError = ref(null); let unsubscribe: (() => void) | null = null; function upsert(group: GroupSummary): void { const idx = groups.value.findIndex((g) => g.id === group.id); if (idx >= 0) groups.value.splice(idx, 1, group); else groups.value.push(group); } function removeLocal(id: string): void { groups.value = groups.value.filter((g) => g.id !== id); if (selectedGroupId.value === id) selectedGroupId.value = null; } function onEvent(e: GroupEvent): void { if (e.type === 'group_update') upsert(e.group); else removeLocal(e.groupId); } function byId(id: string): GroupSummary | undefined { return groups.value.find((g) => g.id === id); } // ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ---- function reposInGroup(groupId: string): RepoSummary[] { const g = byId(groupId); if (!g) return []; const wt = useWorktreesStore(); return g.repoIds .map((id) => wt.repos.find((r) => r.id === id)) .filter((r): r is RepoSummary => r !== undefined); } function worktreesInGroup(groupId: string): WorktreeSummary[] { const g = byId(groupId); if (!g) return []; const ids = new Set(g.repoIds); return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId)); } function sessionsInGroup(groupId: string): SessionSummary[] { // Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) }, // dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois. const byId = new Map(); for (const w of worktreesInGroup(groupId)) for (const s of w.sessions) byId.set(s.id, s); for (const s of useSessionsStore().sessions) if (s.groupId === groupId) byId.set(s.id, s); return [...byId.values()]; } async function fetchGroups(): Promise { loading.value = true; loadError.value = null; try { const res = await api.get('/api/v1/groups'); groups.value = res.groups; } catch (err) { loadError.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } async function createGroup(req: CreateGroupRequest): Promise { const res = await api.post('/api/v1/groups', req); upsert(res.group); return res.group; } async function updateGroup(id: string, patch: UpdateGroupRequest): Promise { const res = await api.patch(`/api/v1/groups/${id}`, patch); upsert(res.group); return res.group; } async function deleteGroup(id: string): Promise { await api.delete<{ ok: true }>(`/api/v1/groups/${id}`); removeLocal(id); } async function addRepoToGroup(groupId: string, repoId: string): Promise { const res = await api.post(`/api/v1/groups/${groupId}/repos`, { repoId }); upsert(res.group); return res.group; } async function removeRepoFromGroup(groupId: string, repoId: string): Promise { const res = await api.delete(`/api/v1/groups/${groupId}/repos/${repoId}`); upsert(res.group); return res.group; } /** Lance UNE session Claude couvrant tous les repos du groupe (--add-dir côté serveur). */ async function createGroupSession(groupId: string, req: CreateGroupSessionRequest): Promise { // La session est upsertée dans le store sessions par le broadcast WS `session_update`. return api.post(`/api/v1/groups/${groupId}/session`, req); } /** * Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie), * crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante * aux échecs partiels, feedback par repo — PUIS une seule session couvrant ces worktrees. En mode * « checkouts principaux » (sans branch), la session couvre directement le worktree principal de chaque repo. */ async function createGroupFeature( groupId: string, repoIds: string[], opts: GroupFeatureOptions, onProgress?: (result: CrossRepoResult) => void, ): Promise { const wt = useWorktreesStore(); let worktreeResults: CrossRepoResult[] = []; if (opts.branch) { const branch = opts.branch; worktreeResults = await Promise.all( repoIds.map(async (repoId): Promise => { try { const res = await wt.createWorktree(repoId, { branch, mode: 'auto', // détecte créer / checkout / suivi remote selon l'existence par dépôt ...(opts.baseRef ? { baseRef: opts.baseRef } : {}), startSession: null, }); const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path }; onProgress?.(result); return result; } catch (err) { const result: CrossRepoResult = { repoId, status: 'error', message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err), }; onProgress?.(result); return result; } }), ); } // Mode feature : si AUCUN worktree n'a pu être créé/résolu, ne pas tenter la session (elle // échouerait en « no resolvable worktree » et masquerait les vraies erreurs par dépôt). if (opts.branch && worktreeResults.length > 0 && !worktreeResults.some((r) => r.status === 'ok')) { return { worktreeResults, session: null, dirs: [], skipped: [] }; } const res = await createGroupSession(groupId, { command: opts.command, ...(opts.branch ? { branch: opts.branch } : {}), }); return { worktreeResults, session: res.session, dirs: res.dirs, skipped: res.skipped }; } function startRealtime(): void { unsubscribe ??= wsClient.subscribeGroups(onEvent); } function stopRealtime(): void { unsubscribe?.(); unsubscribe = null; } return { groups, selectedGroupId, loading, loadError, byId, reposInGroup, worktreesInGroup, sessionsInGroup, fetchGroups, createGroup, updateGroup, deleteGroup, addRepoToGroup, removeRepoFromGroup, createGroupSession, createGroupFeature, startRealtime, stopRealtime, }; });