Compare commits
5 Commits
afe39ac072
...
bf9c6ba8f3
| Author | SHA1 | Date | |
|---|---|---|---|
| bf9c6ba8f3 | |||
| d02c1dabd4 | |||
| 86cdc1f597 | |||
| 1b57c3801c | |||
| 4e3b1f5604 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -144,6 +144,37 @@ try {
|
|||||||
const out = c.state.outputs.get(att?.channel) ?? '';
|
const out = c.state.outputs.get(att?.channel) ?? '';
|
||||||
check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120));
|
check('resume lance `--resume sid-dead` dans le bon cwd', out.includes('args=[--resume sid-dead]') && out.includes(`cwd=${workDir}`), out.replace(/\s+/g, ' ').slice(0, 120));
|
||||||
|
|
||||||
|
// --- Régression « Reprendre » : reprise d'une session MANAGÉE morte par son UUID Arboretum ---
|
||||||
|
// (le bouton web envoie l'UUID managé, pas le claudeSessionId ; le serveur doit le résoudre en DB).
|
||||||
|
const createManaged = await fetch(`${ORIGIN}/api/v1/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie },
|
||||||
|
body: JSON.stringify({ cwd: workDir, command: 'claude' }),
|
||||||
|
});
|
||||||
|
const managed = (await createManaged.json()).session;
|
||||||
|
check('création session managée claude → 201 (pid)', createManaged.status === 201 && managed?.pid > 0);
|
||||||
|
|
||||||
|
// Simule la capture du claudeSessionId : entrée registre pour le pid du PTY managé (pollée toutes les 400ms).
|
||||||
|
writeFileSync(
|
||||||
|
join(sessions, `${managed.pid}.json`),
|
||||||
|
JSON.stringify({ pid: managed.pid, sessionId: 'sid-managed', cwd: workDir, status: 'idle' }),
|
||||||
|
);
|
||||||
|
const captured = await c.waitMsg(
|
||||||
|
(m) => m.type === 'session_update' && m.session?.id === managed.id && m.session?.claudeSessionId === 'sid-managed',
|
||||||
|
);
|
||||||
|
check('claudeSessionId capturé depuis le registre (session managée)', !!captured);
|
||||||
|
|
||||||
|
// Fermeture (kill) puis reprise par UUID managé → 201 dans le cwd d'origine (avant le fix : 404).
|
||||||
|
const killManaged = await fetch(`${ORIGIN}/api/v1/sessions/${managed.id}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
check('kill session managée → 200', killManaged.status === 200);
|
||||||
|
await sleep(400); // laisse handleExit persister ended_at
|
||||||
|
const resumeManaged = await postJson(`/api/v1/sessions/${managed.id}/resume`, cookie);
|
||||||
|
const resumedManaged = (await resumeManaged.json()).session;
|
||||||
|
check(
|
||||||
|
'resume d’une session MANAGÉE morte par UUID → 201',
|
||||||
|
resumeManaged.status === 201 && resumedManaged?.command === 'claude' && resumedManaged?.source === 'managed' && resumedManaged?.cwd === workDir,
|
||||||
|
);
|
||||||
|
|
||||||
// Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique.
|
// Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique.
|
||||||
writeJsonl(workDir, 'sid-new');
|
writeJsonl(workDir, 'sid-new');
|
||||||
const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000);
|
const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000);
|
||||||
|
|||||||
@@ -127,9 +127,11 @@ try {
|
|||||||
const gsession = gsBody.session;
|
const gsession = gsBody.session;
|
||||||
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
||||||
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
||||||
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
|
// cwd = parent commun des repos (P6), chaque repo relié en --add-dir → 2 addedDirs.
|
||||||
|
check('session de groupe : cwd = parent commun des repos', gsession?.cwd === tmp);
|
||||||
|
check('session de groupe : addedDirs = les 2 repos', (gsession?.addedDirs?.length ?? 0) === 2);
|
||||||
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
||||||
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
|
check('session de groupe : cwd parent + 2 repos addedDirs = 3 chemins distincts', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 3);
|
||||||
|
|
||||||
// La session apparaît dans la liste globale avec son groupId.
|
// La session apparaît dans la liste globale avec son groupId.
|
||||||
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||||
|
|||||||
37
packages/server/src/core/group-session.ts
Normal file
37
packages/server/src/core/group-session.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { parse, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
|
||||||
|
* de chaîne — `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
|
||||||
|
*/
|
||||||
|
export function commonAncestorDir(paths: string[]): string {
|
||||||
|
const first = paths[0];
|
||||||
|
if (first === undefined) throw new Error('commonAncestorDir: empty input');
|
||||||
|
const firstSegs = resolve(first).split(sep);
|
||||||
|
let common = firstSegs.length;
|
||||||
|
for (let i = 1; i < paths.length; i++) {
|
||||||
|
const segs = resolve(paths[i]!).split(sep);
|
||||||
|
let k = 0;
|
||||||
|
while (k < common && k < segs.length && segs[k] === firstSegs[k]) k++;
|
||||||
|
common = k;
|
||||||
|
}
|
||||||
|
const joined = firstSegs.slice(0, common).join(sep);
|
||||||
|
// `['', 'a', 'b'].slice(0,1).join('/')` === '' → racine POSIX ; rétablir le séparateur racine.
|
||||||
|
return joined === '' ? parse(resolve(first)).root : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Politique de répertoire de travail d'une session de groupe (P6) : le `cwd` est le PARENT COMMUN
|
||||||
|
* des répertoires couverts (racine neutre, chemins relatifs cross-repo naturels), chaque répertoire
|
||||||
|
* étant ensuite relié via `--add-dir` (le dédoublonnage + filtrage du cwd est fait par `PtyManager`).
|
||||||
|
* Garde-fou : si le parent commun est la racine du FS (repos éparpillés sur des racines différentes),
|
||||||
|
* on retombe sur le premier répertoire pour ne pas accorder à Claude la permission sur tout le disque.
|
||||||
|
*/
|
||||||
|
export function resolveGroupCwd(dirs: string[]): { cwd: string; addDirs: string[] } {
|
||||||
|
const first = dirs[0];
|
||||||
|
if (first === undefined) throw new Error('resolveGroupCwd: empty input');
|
||||||
|
const ancestor = commonAncestorDir(dirs);
|
||||||
|
const root = parse(resolve(first)).root;
|
||||||
|
const cwd = ancestor === root ? first : ancestor;
|
||||||
|
return { cwd, addDirs: dirs };
|
||||||
|
}
|
||||||
@@ -201,6 +201,24 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cible de reprise d'une session managée MORTE (P2/P6), résolue par UUID Arboretum : son cwd
|
||||||
|
* d'origine, son claudeSessionId et son contexte de groupe, lus en DB. null si l'id ne correspond
|
||||||
|
* pas à une session managée morte, reprenable (claude + claudeSessionId connu).
|
||||||
|
* Complète `DiscoveryService.getDiscovered`, qui ne couvre QUE les sessions claude EXTERNES
|
||||||
|
* (une managée connue est justement exclue de la découverte).
|
||||||
|
*/
|
||||||
|
resumeTargetById(id: string): { cwd: string; claudeSessionId: string; addedDirs: string[]; groupId: string | null } | null {
|
||||||
|
if (this.live.has(id)) return null; // vivante : pas de resume direct (fork via le même chemin)
|
||||||
|
const row = this.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT cwd, claude_session_id, added_dirs, group_id FROM sessions WHERE id = ? AND ended_at IS NOT NULL AND claude_session_id IS NOT NULL AND command = 'claude'",
|
||||||
|
)
|
||||||
|
.get(id) as { cwd: string; claude_session_id: string; added_dirs: string | null; group_id: string | null } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { cwd: row.cwd, claudeSessionId: row.claude_session_id, addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
||||||
|
}
|
||||||
|
|
||||||
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
||||||
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
||||||
for (const s of this.live.values()) {
|
for (const s of this.live.values()) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { WorktreeManager } from '../core/worktree-manager.js';
|
|||||||
import type { PtyManager } from '../core/pty-manager.js';
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
import { resolveGroupCwd } from '../core/group-session.js';
|
||||||
import { sendManagerError } from './repos.js';
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
export function registerGroupRoutes(
|
export function registerGroupRoutes(
|
||||||
@@ -137,13 +138,14 @@ export function registerGroupRoutes(
|
|||||||
if (!dirs.includes(match.path)) dirs.push(match.path);
|
if (!dirs.includes(match.path)) dirs.push(match.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [primary, ...rest] = dirs;
|
if (dirs.length === 0) {
|
||||||
if (primary === undefined) {
|
|
||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
||||||
}
|
}
|
||||||
|
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
|
||||||
|
const { cwd, addDirs } = resolveGroupCwd(dirs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
|
const session = manager.spawn({ cwd, addDirs, command, groupId: id });
|
||||||
recordAudit(db, {
|
recordAudit(db, {
|
||||||
actor: req.authContext?.tokenId ?? 'unknown',
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
action: 'group.session.create',
|
action: 'group.session.create',
|
||||||
|
|||||||
@@ -26,24 +26,29 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reprise d'une session morte : nouveau PTY managé `--resume <id>` DANS SON CWD D'ORIGINE (spike S1).
|
// Reprise d'une session morte : nouveau PTY managé `--resume <claudeSessionId>` DANS SON CWD D'ORIGINE
|
||||||
// Le cwd n'est jamais fourni par le client : il est lu sur disque via la découverte.
|
// (spike S1). Le cwd n'est JAMAIS fourni par le client. Deux origines d'id possibles :
|
||||||
|
// - session managée morte (id = UUID Arboretum) → cwd + claudeSessionId + groupe lus en DB ;
|
||||||
|
// - session claude EXTERNE découverte (id = claudeSessionId) → cwd lu sur disque.
|
||||||
app.post('/api/v1/sessions/:id/resume', async (req, reply) => {
|
app.post('/api/v1/sessions/:id/resume', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const discovered = discovery.getDiscovered(id);
|
const managed = manager.resumeTargetById(id);
|
||||||
if (!discovered) {
|
const discovered = managed ? null : discovery.getDiscovered(id);
|
||||||
|
if (!managed && !discovered) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } });
|
||||||
}
|
}
|
||||||
|
const cwd = managed ? managed.cwd : discovered!.cwd;
|
||||||
|
const claudeSessionId = managed ? managed.claudeSessionId : id;
|
||||||
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
|
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
|
||||||
if (discovery.isClaudeSessionLive(id) || manager.findLiveByClaudeSessionId(id)) {
|
if (discovery.isClaudeSessionLive(claudeSessionId) || manager.findLiveByClaudeSessionId(claudeSessionId)) {
|
||||||
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
|
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
|
||||||
const ctx = manager.groupSessionContext(id);
|
const ctx = managed ? { addedDirs: managed.addedDirs, groupId: managed.groupId } : manager.groupSessionContext(claudeSessionId);
|
||||||
const session = manager.spawn({
|
const session = manager.spawn({
|
||||||
cwd: discovered.cwd,
|
cwd,
|
||||||
resume: { claudeSessionId: id },
|
resume: { claudeSessionId },
|
||||||
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
|
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
|
||||||
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
|
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
|
||||||
});
|
});
|
||||||
@@ -56,14 +61,18 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume <id> --fork-session`).
|
// Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume <id> --fork-session`).
|
||||||
|
// Même résolution d'id que /resume (managée morte par UUID, sinon externe découverte par claudeSessionId).
|
||||||
app.post('/api/v1/sessions/:id/fork', async (req, reply) => {
|
app.post('/api/v1/sessions/:id/fork', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const discovered = discovery.getDiscovered(id);
|
const managed = manager.resumeTargetById(id);
|
||||||
if (!discovered) {
|
const discovered = managed ? null : discovery.getDiscovered(id);
|
||||||
|
if (!managed && !discovered) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } });
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } });
|
||||||
}
|
}
|
||||||
|
const cwd = managed ? managed.cwd : discovered!.cwd;
|
||||||
|
const claudeSessionId = managed ? managed.claudeSessionId : id;
|
||||||
try {
|
try {
|
||||||
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id, fork: true } });
|
const session = manager.spawn({ cwd, resume: { claudeSessionId, fork: true } });
|
||||||
const res: SessionResponse = { session };
|
const res: SessionResponse = { session };
|
||||||
return reply.status(201).send(res);
|
return reply.status(201).send(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
45
packages/server/test/group-cwd.test.ts
Normal file
45
packages/server/test/group-cwd.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { commonAncestorDir, resolveGroupCwd } from '../src/core/group-session.js';
|
||||||
|
|
||||||
|
describe('commonAncestorDir', () => {
|
||||||
|
it('renvoie le chemin lui-même pour un seul répertoire', () => {
|
||||||
|
expect(commonAncestorDir(['/home/johan/WebstormProjects/arboretum'])).toBe('/home/johan/WebstormProjects/arboretum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renvoie le parent commun de deux siblings', () => {
|
||||||
|
expect(commonAncestorDir(['/p/a', '/p/b'])).toBe('/p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère un répertoire ancêtre d’un autre', () => {
|
||||||
|
expect(commonAncestorDir(['/p/a', '/p/a/b'])).toBe('/p/a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compare par segments, pas par préfixe de chaîne', () => {
|
||||||
|
// /a/bc n'est PAS un ancêtre de /a/bcd : ancêtre commun = /a
|
||||||
|
expect(commonAncestorDir(['/a/bc', '/a/bcd'])).toBe('/a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renvoie la racine quand aucun segment n’est commun', () => {
|
||||||
|
expect(commonAncestorDir(['/x/a', '/y/b'])).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveGroupCwd', () => {
|
||||||
|
it('mono-repo : cwd = le repo, addDirs sera filtré à vide par PtyManager', () => {
|
||||||
|
const { cwd, addDirs } = resolveGroupCwd(['/p/a']);
|
||||||
|
expect(cwd).toBe('/p/a');
|
||||||
|
expect(addDirs).toEqual(['/p/a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-repos sous un parent commun : cwd = parent, addDirs = tous les repos', () => {
|
||||||
|
const dirs = ['/home/johan/WebstormProjects/beehelp_lambdas', '/home/johan/WebstormProjects/beehelp_api'];
|
||||||
|
const { cwd, addDirs } = resolveGroupCwd(dirs);
|
||||||
|
expect(cwd).toBe('/home/johan/WebstormProjects');
|
||||||
|
expect(addDirs).toEqual(dirs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('garde-fou : repos sur des racines différentes → retombe sur le premier répertoire', () => {
|
||||||
|
const { cwd } = resolveGroupCwd(['/x/a', '/y/b']);
|
||||||
|
expect(cwd).toBe('/x/a');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -187,6 +187,34 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
|
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resumeTargetById : session claude morte → cwd + claudeSessionId + groupe ; null sinon', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-rt-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpR' });
|
||||||
|
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-resume', summary.id);
|
||||||
|
// vivante → null (resume direct interdit ; fork passe par le même chemin)
|
||||||
|
expect(manager.resumeTargetById(summary.id)).toBeNull();
|
||||||
|
lastPty().emitExit(0);
|
||||||
|
// morte avec claudeSessionId connu → cible complète
|
||||||
|
expect(manager.resumeTargetById(summary.id)).toEqual({
|
||||||
|
cwd,
|
||||||
|
claudeSessionId: 'cs-resume',
|
||||||
|
addedDirs: [d1],
|
||||||
|
groupId: 'grpR',
|
||||||
|
});
|
||||||
|
// id inconnu → null
|
||||||
|
expect(manager.resumeTargetById('nope')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumeTargetById : session bash morte (sans claudeSessionId) → null', () => {
|
||||||
|
const { summary, pty } = spawnBash();
|
||||||
|
pty.emitExit(0);
|
||||||
|
expect(manager.resumeTargetById(summary.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
|
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default {
|
|||||||
sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.',
|
sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.',
|
||||||
sec4Title: 'Distant via Tailscale',
|
sec4Title: 'Distant via Tailscale',
|
||||||
sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.',
|
sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.',
|
||||||
faqTitle: 'Vos questions, nos réponses',
|
faqTitle: 'Vos questions, les réponses',
|
||||||
ctaTitle: 'Prenez le commandement de vos agents',
|
ctaTitle: 'Prenez le commandement de vos agents',
|
||||||
ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.',
|
ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.',
|
||||||
ctaSource: 'Voir le code sur Gitea',
|
ctaSource: 'Voir le code sur Gitea',
|
||||||
|
|||||||
@@ -79,15 +79,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { RepoSummary } from '@arboretum/shared';
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
|
||||||
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
const mode = ref<'feature' | 'main'>('feature');
|
const mode = ref<'feature' | 'main'>('feature');
|
||||||
const branch = ref('');
|
const branch = ref('');
|
||||||
@@ -139,6 +143,14 @@ async function onSubmit(): Promise<void> {
|
|||||||
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
|
||||||
|
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
||||||
|
if (outcome.value.session) {
|
||||||
|
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
||||||
|
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
||||||
|
else toasts.success(t('toast.groupSessionLaunched'));
|
||||||
|
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,9 +6,16 @@
|
|||||||
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||||
</p>
|
</p>
|
||||||
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||||
Sur mobile la grille retombe à une colonne (empilement). -->
|
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
|
||||||
|
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
|
||||||
|
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
<TerminalCell v-for="s in shown" :key="s.id" :session="s" class="h-80" />
|
<TerminalCell
|
||||||
|
v-for="s in shown"
|
||||||
|
:key="s.id"
|
||||||
|
:session="s"
|
||||||
|
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ export default {
|
|||||||
groupCreated: 'Group created',
|
groupCreated: 'Group created',
|
||||||
groupUpdated: 'Group updated',
|
groupUpdated: 'Group updated',
|
||||||
groupDeleted: 'Group deleted',
|
groupDeleted: 'Group deleted',
|
||||||
|
groupSessionLaunched: 'Group session started',
|
||||||
|
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
|
||||||
genericError: 'Something went wrong',
|
genericError: 'Something went wrong',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ const fr: typeof en = {
|
|||||||
groupCreated: 'Groupe créé',
|
groupCreated: 'Groupe créé',
|
||||||
groupUpdated: 'Groupe mis à jour',
|
groupUpdated: 'Groupe mis à jour',
|
||||||
groupDeleted: 'Groupe supprimé',
|
groupDeleted: 'Groupe supprimé',
|
||||||
|
groupSessionLaunched: 'Session de groupe démarrée',
|
||||||
|
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
|
||||||
genericError: 'Une erreur est survenue',
|
genericError: 'Une erreur est survenue',
|
||||||
dismiss: 'Fermer',
|
dismiss: 'Fermer',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const en: HelpSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Cross-repo feature',
|
title: 'Cross-repo feature',
|
||||||
body: 'Create the same worktree (and an optional session) across every repo of the group in one action; partial failures can be retried per repo.',
|
body: 'Create the same branch worktree across every repo of the group, then launch one Claude session that spans them all — one conversation, one shared context. Partial worktree failures can be retried per repo, and the new session opens straight into its terminal.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -290,7 +290,7 @@ const fr: HelpSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Feature cross-repo',
|
title: 'Feature cross-repo',
|
||||||
body: 'Créez le même worktree (et une session optionnelle) dans chaque dépôt du groupe en une seule action ; les échecs partiels sont rejouables dépôt par dépôt.',
|
body: 'Créez le même worktree de branche dans chaque dépôt du groupe, puis lancez une seule session Claude qui les couvre tous — une conversation, un contexte partagé. Les échecs partiels de worktree sont rejouables dépôt par dépôt, et la session s’ouvre directement sur son terminal.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user