P2: découverte & reprise des sessions Claude
Arboretum découvre désormais toutes les sessions Claude de la machine (scan ~/.claude/projects + registre ~/.claude/sessions), distingue vivantes/mortes par pid+procStart, et permet de reprendre une morte (--resume dans son cwd d'origine) ou forker une vivante sans la corrompre. - shared: SessionSummary enrichi (source, claudeSessionId, pid, resumable, attachable, registryStatus) — additif, PROTOCOL_VERSION inchangé ; types REST resume/fork. - db: migration id:2 (claude_session_id, resumed_from). - core: jsonl-discovery (parseur tolérant, scan asynchrone non bloquant), session-registry (vivacité pid+procStart), discovery-service (cache + refresh périodique + diff/broadcast), pty-manager (resume/fork + capture du claudeSessionId via le registre). - routes: /sessions/:id/resume (garde-fou 409 anti-corruption sur session vivante) et /fork ; GET fusionné managées + découvertes ; relais WS. - web: badges managed/discovered + busy/idle/waiting, actions conditionnelles (Open/Observe/Kill vs Fork/View vs Resume/Fork), vue read-only des sessions externes, i18n EN/FR. - tests: jsonl-discovery, session-registry, discovery-service + resume/fork (130 verts) ; acceptation E2E acceptance-p2.mjs (sans quota) ALL GREEN. Conforme aux verdicts S1 (resume dans cwd d'origine, vivacité pid+procStart) et S4 (munge cwd, parseur tête+queue, priorité de titre).
This commit is contained in:
79
CLAUDE.md
Normal file
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Arboretum : un daemon Node.js unique (`npx git-arboretum`) qui sert un dashboard web pour piloter les worktrees git et les sessions Claude Code qui tournent dessus, depuis n'importe quel appareil. Pré-MVP. L'étape **P1** (colonne vertébrale : monorepo, protocole WS, daemon, front, tests, CI) est terminée ; voir les `VERDICT.md` dans `spikes/` pour les décisions techniques actées avant implémentation.
|
||||||
|
|
||||||
|
Les commentaires du code et la doc interne sont en **français** ; le README et les messages utilisateur en anglais. Conserver cette convention.
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # build shared → server → web (l'ordre compte, voir ci-dessous)
|
||||||
|
npm run typecheck # tsc -b shared + server UNIQUEMENT (le web se typecheck via vue-tsc dans son build)
|
||||||
|
npm test # vitest run sur packages/*/test/**/*.test.ts
|
||||||
|
npx vitest run packages/server/test/ring-buffer.test.ts # un seul fichier
|
||||||
|
npx vitest run -t "flow control" # par nom de test
|
||||||
|
npm run dev:server # daemon en watch (tsc -b --watch + node --watch)
|
||||||
|
npm run dev:web # Vite ; proxifie /api et /ws vers le daemon sur :7317 (cf. note Origin)
|
||||||
|
npm run pack # build + npm pack du tarball git-arboretum
|
||||||
|
|
||||||
|
node packages/server/scripts/acceptance-p1.mjs # acceptation E2E P1 (daemon réel + client WS réel)
|
||||||
|
node packages/server/scripts/acceptance-p2.mjs # acceptation E2E P2 (découverte/reprise : faux ~/.claude + faux binaire claude)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'acceptation exige `npm run build` au préalable (elle lance `dist/index.js`) et utilise la commande `bash` plutôt que `claude` pour ne pas consommer de quota.
|
||||||
|
|
||||||
|
**Node >= 22.16 est requis** (pas seulement recommandé) : la persistance utilise `node:sqlite` (`DatabaseSync`), natif et stable seulement à partir de cette version. `.nvmrc` = 22.
|
||||||
|
|
||||||
|
## Monorepo (npm workspaces)
|
||||||
|
|
||||||
|
- **`packages/shared`** (`@arboretum/shared`) — source unique de vérité du protocole WS (`protocol.ts`) et des types REST (`api.ts`), importée par le serveur ET le front. C'est une **dépendance runtime non publiée** : la CI pack les deux tarballs (`@arboretum/shared` + `git-arboretum`) et les installe ensemble.
|
||||||
|
- **`packages/server`** (`git-arboretum`) — le daemon Fastify, **le seul paquet publié sur npm** (bin `arboretum`). Embarque la SPA buildée dans `public/` via le hook `prepack` (`scripts/copy-web.mjs`).
|
||||||
|
- **`packages/web`** (`@arboretum/web`) — SPA Vue 3 (Pinia, vue-router, xterm.js, Tailwind 4), privée.
|
||||||
|
|
||||||
|
Ordre de build imposé : `shared` se construit en premier (projets TS `composite` avec references) ; le front doit être buildé avant `prepack` du serveur pour être embarqué.
|
||||||
|
|
||||||
|
## Architecture serveur
|
||||||
|
|
||||||
|
`buildApp()` (`packages/server/src/app.ts`) câble tout : `AuthService`, `LoginRateLimiter`, `PtyManager`, les routes REST, la gateway WS, et le service statique de la SPA. `index.ts` est le bin (parse config, ouvre la db, écoute, gère le bootstrap token et le drain au SIGTERM/SIGINT).
|
||||||
|
|
||||||
|
### Modèle de sécurité (central — ne pas affaiblir)
|
||||||
|
Un terminal web est de l'exécution de code à distance par conception. Les garde-fous sont structurants :
|
||||||
|
- Bind `127.0.0.1` par défaut ; `config.ts` **refuse** tout bind non-loopback sans `--i-know-this-exposes-a-terminal`. Accès distant recommandé via Tailscale Serve.
|
||||||
|
- Un hook `preValidation` global authentifie **toute** requête `/api/**` et `/ws`, et applique un **check Origin strict** quand l'en-tête est présent (anti cross-site WS hijacking — le cookie `SameSite=Strict` ne couvre pas les upgrades). Les routes statiques sont publiques ; les routes publiques explicites portent `config: { public: true }` (ex. login).
|
||||||
|
- Tokens stockés **hashés** (sha256), comparaison en temps constant. Le bootstrap token n'est affiché qu'une fois au premier démarrage. Le cookie de session est un payload signé HMAC. Le rate-limit du login est **global** (pas par IP : derrière Tailscale tout arrive de 127.0.0.1) avec backoff exponentiel.
|
||||||
|
|
||||||
|
### Protocole WebSocket (`packages/shared/src/protocol.ts`)
|
||||||
|
Une **seule connexion multiplexée par client**, plusieurs canaux (channels) :
|
||||||
|
- Contrôle = frames **texte JSON** ; sortie terminal = frames **binaires** `[type u8][channel u32le][payload]`. Un chunk PTY peut couper un caractère UTF-8 en frontière de frame → le décodage est délégué à `xterm.write(Uint8Array)`, jamais au transport.
|
||||||
|
- **Flow control par watermarks** (`FLOW`). Invariant anti-deadlock à préserver absolument : `ACK_EVERY_BYTES <= LOW_WATERMARK`. Le PTY n'est mis en pause que quand **tous** les clients interactifs dépassent `HIGH` ; les observers ne freinent jamais le flux. Un client trop en retard (`LAGGING_BYTES`) est coupé puis resynchronisé.
|
||||||
|
- Handshake `hello`/`hello_ok` négocie `PROTOCOL_VERSION`. `parseClientMessage` valide/sanitise tout message entrant (bornes sur dims, longueurs) — toute nouvelle commande client doit y être validée.
|
||||||
|
|
||||||
|
### PtyManager (`packages/server/src/core/pty-manager.ts`)
|
||||||
|
Lance `claude` (ou `bash`) dans node-pty. Sessions vivantes en mémoire (`Map`) ; l'historique est persisté dans la table `sessions` (sqlite). Chaque session a un `RingBuffer` (2 Mo) ; à l'attach, le client reçoit un **resync** = reset terminal + replay de la queue du ring (`REPLAY_TAIL_BYTES`). Outil mono-utilisateur : tout client interactif peut écrire, les observers sont read-only. `kill` envoie SIGTERM puis SIGKILL après un délai de grâce.
|
||||||
|
|
||||||
|
### claude-launcher (`packages/server/src/core/claude-launcher.ts`)
|
||||||
|
Couture volontairement abstraite : Arboretum enveloppe le **CLI `claude` interactif** dans un PTY (pas l'Agent SDK, pas le mode headless). La commande `bash` sert aux tests sans quota. C'est ici que se brancherait un éventuel plan B.
|
||||||
|
|
||||||
|
### Détails fastify à connaître
|
||||||
|
- La route `/ws` est enregistrée **après** le plugin `@fastify/websocket` dans un contexte encapsulé (`app.register(async (scoped) => ...)`) ; sinon le handler reçoit la signature REST `(request, reply)` au lieu de `(socket, req)`.
|
||||||
|
- `notFoundHandler` renvoie l'`index.html` de la SPA pour tout ce qui n'est pas `/api/` ou `/ws` (routing client-side).
|
||||||
|
|
||||||
|
### Base de données (`packages/server/src/db/index.ts`)
|
||||||
|
`node:sqlite` en mode WAL. Migrations idempotentes versionnées dans le tableau `MIGRATIONS` + table `schema_migrations`. Tables : `settings`, `auth_tokens`, `sessions`.
|
||||||
|
|
||||||
|
## Architecture web
|
||||||
|
|
||||||
|
SPA Vue 3. Un **singleton `wsClient`** (`src/lib/ws-client.ts`) gère l'unique connexion : handshake, reconnexion avec backoff, ré-attache des terminaux ouverts, corrélation FIFO des `attached`, et le **flow control par ACK côté client** (compteur d'octets réellement traités par xterm, remis à zéro à chaque resync, avec un ACK traînant débouncé anti-deadlock). En dev, `vite.config.ts` proxifie `/api` et `/ws` vers le daemon en **réécrivant l'en-tête Origin** vers celle du daemon (sinon le check Origin strict rejette l'origine de Vite). Le garde de routeur vérifie la session via `GET /api/v1/auth/me`.
|
||||||
|
|
||||||
|
## Spikes & roadmap
|
||||||
|
|
||||||
|
Les `spikes/sN-*/VERDICT.md` documentent des décisions qui contraignent l'implémentation à venir, notamment :
|
||||||
|
- **`--resume` doit toujours s'exécuter dans le cwd d'origine** de la session (lu dans le JSONL).
|
||||||
|
- La **vivacité** d'une session se déduit de `pid` + `procStart`, **jamais** de la présence du fichier registre.
|
||||||
|
- La distinction d'états fins (busy / waiting / idle) du futur `claude-adapter` (P3) nécessite une lecture d'écran via `@xterm/headless` (le strip ANSI naïf mange les espaces) + le registre `~/.claude/sessions`.
|
||||||
|
|
||||||
|
Les états de session du protocole sont aujourd'hui un sous-ensemble P1 (`starting`/`running`/`exited`) à étendre en P3.
|
||||||
164
packages/server/scripts/acceptance-p2.mjs
Normal file
164
packages/server/scripts/acceptance-p2.mjs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P2 (sans navigateur, sans quota Claude) : découverte & reprise de sessions.
|
||||||
|
// Faux ~/.claude jetable (--claude-home) + faux binaire `claude` (script bash) injecté dans le PATH.
|
||||||
|
// Couvre : découverte d'une session morte (resumable) et vivante (registre + pid réel),
|
||||||
|
// resume refusé sur une vivante (409), fork autorisé, resume d'une morte dans SON CWD d'origine,
|
||||||
|
// broadcast WS d'une session découverte au rafraîchissement périodique.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } 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 = 7542;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const munge = (p) => p.replace(/[^A-Za-z0-9]/g, '-');
|
||||||
|
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-p2-'));
|
||||||
|
const claudeHome = join(tmp, 'claude');
|
||||||
|
const projects = join(claudeHome, 'projects');
|
||||||
|
const sessions = join(claudeHome, 'sessions');
|
||||||
|
const workDir = join(tmp, 'work'); // cwd d'origine des sessions découvertes
|
||||||
|
const fakeBin = join(tmp, 'bin');
|
||||||
|
mkdirSync(projects, { recursive: true });
|
||||||
|
mkdirSync(sessions, { recursive: true });
|
||||||
|
mkdirSync(workDir, { recursive: true });
|
||||||
|
mkdirSync(fakeBin, { recursive: true });
|
||||||
|
|
||||||
|
// Faux binaire `claude` : imprime ses arguments + son cwd, puis reste vivant un instant.
|
||||||
|
writeFileSync(join(fakeBin, 'claude'), '#!/usr/bin/env bash\necho "FAKE-CLAUDE args=[$*] cwd=$(pwd)"\nsleep 30\n');
|
||||||
|
chmodSync(join(fakeBin, 'claude'), 0o755);
|
||||||
|
|
||||||
|
function writeJsonl(cwd, sid) {
|
||||||
|
const d = join(projects, munge(cwd));
|
||||||
|
mkdirSync(d, { recursive: true });
|
||||||
|
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session découverte MORTE (aucune entrée registre) → resumable.
|
||||||
|
writeJsonl(workDir, 'sid-dead');
|
||||||
|
|
||||||
|
// Session découverte VIVANTE : un vrai process `sleep` fournit pid + procStart concordants.
|
||||||
|
const liveProc = spawn('sleep', ['300']);
|
||||||
|
const livePid = liveProc.pid;
|
||||||
|
const liveStat = readFileSync(`/proc/${livePid}/stat`, 'utf8');
|
||||||
|
const liveProcStart = liveStat.slice(liveStat.lastIndexOf(')') + 2).split(' ')[19];
|
||||||
|
writeJsonl(workDir, 'sid-live');
|
||||||
|
writeFileSync(
|
||||||
|
join(sessions, `${livePid}.json`),
|
||||||
|
JSON.stringify({ pid: livePid, procStart: liveProcStart, sessionId: 'sid-live', cwd: workDir, status: 'busy' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const srv = spawn(
|
||||||
|
'node',
|
||||||
|
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome],
|
||||||
|
{ env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, 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 } });
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
const state = { msgs: [], outputs: new Map() };
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
state.msgs.push(JSON.parse(String(data)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
const type = buf.readUInt8(0);
|
||||||
|
const channel = buf.readUInt32LE(1);
|
||||||
|
const payload = buf.subarray(5);
|
||||||
|
const prev = type === 0x02 ? '' : (state.outputs.get(channel) ?? '');
|
||||||
|
state.outputs.set(channel, (prev + payload.toString('latin1')).slice(-100000));
|
||||||
|
});
|
||||||
|
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 postJson = (path, cookie) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, { method: 'POST', headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
|
||||||
|
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 && cookie.startsWith('arb_session='));
|
||||||
|
|
||||||
|
// GET /sessions : la découverte (scan initial) doit exposer les deux sessions.
|
||||||
|
const listRes = await fetch(`${ORIGIN}/api/v1/sessions`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
const list = (await listRes.json()).sessions;
|
||||||
|
const dead = list.find((s) => s.id === 'sid-dead');
|
||||||
|
const live = list.find((s) => s.id === 'sid-live');
|
||||||
|
check('découverte session morte (resumable, discovered)', dead?.source === 'discovered' && dead?.resumable === true && dead?.live === false);
|
||||||
|
check('découverte session vivante (live, registryStatus)', live?.live === true && live?.attachable === false && live?.registryStatus === 'busy');
|
||||||
|
|
||||||
|
// Garde-fou : resume d'une session VIVANTE refusé (409), fork autorisé (201).
|
||||||
|
const resumeLive = await postJson('/api/v1/sessions/sid-live/resume', cookie);
|
||||||
|
check('resume d’une session vivante → 409 SESSION_LIVE', resumeLive.status === 409 && (await resumeLive.json()).error.code === 'SESSION_LIVE');
|
||||||
|
const forkLive = await postJson('/api/v1/sessions/sid-live/fork', cookie);
|
||||||
|
check('fork d’une session vivante → 201', forkLive.status === 201);
|
||||||
|
|
||||||
|
// Resume d'une session MORTE → nouveau PTY managé claude, DANS SON CWD D'ORIGINE.
|
||||||
|
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'] });
|
||||||
|
|
||||||
|
const resumeDead = await postJson('/api/v1/sessions/sid-dead/resume', cookie);
|
||||||
|
const resumed = (await resumeDead.json()).session;
|
||||||
|
check('resume d’une session morte → 201 (PTY managé claude)', resumeDead.status === 201 && resumed?.command === 'claude' && resumed?.source === 'managed');
|
||||||
|
|
||||||
|
c.send({ type: 'attach', sessionId: resumed.id, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att = await c.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(600);
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Broadcast : une nouvelle session découverte est poussée via session_update au rafraîchissement périodique.
|
||||||
|
writeJsonl(workDir, 'sid-new');
|
||||||
|
const pushed = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === 'sid-new', 13000);
|
||||||
|
check('broadcast WS d’une session découverte (refresh périodique)', pushed?.session?.source === 'discovered');
|
||||||
|
|
||||||
|
c.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
liveProc.kill('SIGKILL');
|
||||||
|
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 P2: ALL GREEN' : `\nACCEPTANCE P2: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { Config } from './config.js';
|
|||||||
import type { Db } from './db/index.js';
|
import type { Db } from './db/index.js';
|
||||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
@@ -26,13 +27,19 @@ export interface AppBundle {
|
|||||||
app: FastifyInstance;
|
app: FastifyInstance;
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
|
discovery: DiscoveryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||||
const auth = new AuthService(db);
|
const auth = new AuthService(db);
|
||||||
const limiter = new LoginRateLimiter();
|
const limiter = new LoginRateLimiter();
|
||||||
const manager = new PtyManager(db);
|
const manager = new PtyManager(db, config.claudeSessionsDir);
|
||||||
|
const discovery = new DiscoveryService({
|
||||||
|
ptyManager: manager,
|
||||||
|
projectsDir: config.claudeProjectsDir,
|
||||||
|
sessionsDir: config.claudeSessionsDir,
|
||||||
|
});
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
@@ -72,11 +79,11 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// 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).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
registerWsGateway(scoped, manager, serverVersion);
|
registerWsGateway(scoped, manager, discovery, serverVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||||
@@ -91,5 +98,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { app, auth, manager };
|
return { app, auth, manager, discovery };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface Config {
|
|||||||
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
||||||
allowedOrigins: string[];
|
allowedOrigins: string[];
|
||||||
printToken: boolean;
|
printToken: boolean;
|
||||||
|
/** ~/.claude/projects (transcripts JSONL) — surchargeable via --claude-home (tests). */
|
||||||
|
claudeProjectsDir: string;
|
||||||
|
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
||||||
|
claudeSessionsDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||||
@@ -23,6 +27,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
'allow-origin': { type: 'string', multiple: true },
|
'allow-origin': { type: 'string', multiple: true },
|
||||||
'print-token': { type: 'boolean', default: false },
|
'print-token': { type: 'boolean', default: false },
|
||||||
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
||||||
|
// racine de l'install Claude (~/.claude par défaut) — surchargée par les tests d'acceptation.
|
||||||
|
'claude-home': { type: 'string' },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
});
|
});
|
||||||
@@ -39,6 +45,7 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
|
|
||||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
||||||
return {
|
return {
|
||||||
port: Number(values.port),
|
port: Number(values.port),
|
||||||
bind,
|
bind,
|
||||||
@@ -46,5 +53,7 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
|||||||
dataDir,
|
dataDir,
|
||||||
allowedOrigins: values['allow-origin'] ?? [],
|
allowedOrigins: values['allow-origin'] ?? [],
|
||||||
printToken: values['print-token'] ?? false,
|
printToken: values['print-token'] ?? false,
|
||||||
|
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||||
|
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export interface SpawnSpec {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpawnOptions {
|
||||||
|
command: 'claude' | 'bash';
|
||||||
|
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
||||||
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
let cachedClaudeBin: string | null = null;
|
let cachedClaudeBin: string | null = null;
|
||||||
|
|
||||||
export function resolveClaudeBin(): string {
|
export function resolveClaudeBin(): string {
|
||||||
@@ -21,14 +27,20 @@ export function resolveClaudeBin(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(command: 'claude' | 'bash'): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
};
|
};
|
||||||
if (command === 'bash') {
|
if (opts.command === 'bash') {
|
||||||
return { file: 'bash', args: ['--norc'], env };
|
return { file: 'bash', args: ['--norc'], env };
|
||||||
}
|
}
|
||||||
return { file: resolveClaudeBin(), args: [], env };
|
const args: string[] = [];
|
||||||
|
if (opts.resume) {
|
||||||
|
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant — spike S1).
|
||||||
|
args.push('--resume', opts.resume.claudeSessionId);
|
||||||
|
if (opts.resume.fork) args.push('--fork-session');
|
||||||
|
}
|
||||||
|
return { file: resolveClaudeBin(), args, env };
|
||||||
}
|
}
|
||||||
|
|||||||
137
packages/server/src/core/discovery-service.ts
Normal file
137
packages/server/src/core/discovery-service.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Service de découverte : second producteur de SessionSummary (à côté du PtyManager).
|
||||||
|
// Scanne ~/.claude/projects (JSONL) + ~/.claude/sessions (registre), corrèle par claudeSessionId,
|
||||||
|
// calcule la vivacité, met en cache et notifie les changements. Lecture seule du disque.
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
||||||
|
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
||||||
|
import type { PtyManager } from './pty-manager.js';
|
||||||
|
|
||||||
|
const DEFAULT_REFRESH_MS = 10_000;
|
||||||
|
|
||||||
|
export interface DiscoveryServiceEvents {
|
||||||
|
/** session découverte nouvelle ou modifiée (relayée en session_update par la gateway). */
|
||||||
|
discovery_update: [SessionSummary];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryOptions {
|
||||||
|
ptyManager: PtyManager;
|
||||||
|
projectsDir?: string;
|
||||||
|
sessionsDir?: string;
|
||||||
|
refreshMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
||||||
|
private readonly projectsDir: string;
|
||||||
|
private readonly sessionsDir: string;
|
||||||
|
private readonly ptyManager: PtyManager;
|
||||||
|
private readonly refreshMs: number;
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private cache: SessionSummary[] = [];
|
||||||
|
private byId = new Map<string, DiscoveredJsonl>();
|
||||||
|
private prevJson = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(opts: DiscoveryOptions) {
|
||||||
|
super();
|
||||||
|
this.ptyManager = opts.ptyManager;
|
||||||
|
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
||||||
|
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
||||||
|
this.refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
void this.refresh(); // premier scan asynchrone : ne bloque pas le boot
|
||||||
|
this.timer = setInterval(() => void this.refresh(), this.refreshMs);
|
||||||
|
this.timer.unref(); // ne maintient pas le process en vie
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache courant (jamais de scan synchrone dans le chemin chaud). */
|
||||||
|
list(): SessionSummary[] {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session découverte (avec son cwd d'origine lu sur disque) — pour resume/fork. null si absente. */
|
||||||
|
getDiscovered(claudeSessionId: string): DiscoveredJsonl | null {
|
||||||
|
return this.byId.get(claudeSessionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) — garde-fou
|
||||||
|
* anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan.
|
||||||
|
*/
|
||||||
|
isClaudeSessionLive(claudeSessionId: string): boolean {
|
||||||
|
const entry = readRegistry(this.sessionsDir).find((r) => r.claudeSessionId === claudeSessionId);
|
||||||
|
return entry?.live ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalcule le cache depuis le disque. Tolérant : ne lève jamais. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const regBySid = new Map<string, RegistryEntry>();
|
||||||
|
for (const r of readRegistry(this.sessionsDir)) {
|
||||||
|
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
||||||
|
}
|
||||||
|
const known = this.ptyManager.knownClaudeSessionIds();
|
||||||
|
|
||||||
|
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
||||||
|
const latest = new Map<string, DiscoveredJsonl>();
|
||||||
|
for (const d of await scanProjects(this.projectsDir)) {
|
||||||
|
const prev = latest.get(d.claudeSessionId);
|
||||||
|
if (!prev || d.mtimeMs > prev.mtimeMs) latest.set(d.claudeSessionId, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries: SessionSummary[] = [];
|
||||||
|
const byId = new Map<string, DiscoveredJsonl>();
|
||||||
|
for (const d of latest.values()) {
|
||||||
|
if (known.has(d.claudeSessionId)) continue; // déjà gérée comme session managée
|
||||||
|
byId.set(d.claudeSessionId, d);
|
||||||
|
const r = regBySid.get(d.claudeSessionId);
|
||||||
|
const live = r?.live ?? false;
|
||||||
|
summaries.push({
|
||||||
|
id: d.claudeSessionId, // les découvertes s'identifient par leur claudeSessionId
|
||||||
|
cwd: d.cwd,
|
||||||
|
command: 'claude',
|
||||||
|
title: d.title,
|
||||||
|
status: live ? 'running' : 'exited',
|
||||||
|
live,
|
||||||
|
createdAt: new Date(d.mtimeMs).toISOString(),
|
||||||
|
endedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
clients: 0,
|
||||||
|
source: 'discovered',
|
||||||
|
claudeSessionId: d.claudeSessionId,
|
||||||
|
pid: live ? (r?.pid ?? null) : null,
|
||||||
|
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
||||||
|
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
||||||
|
registryStatus: r?.status ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache = summaries;
|
||||||
|
this.byId = byId;
|
||||||
|
|
||||||
|
// Diff : n'émettre que les sessions nouvelles ou modifiées.
|
||||||
|
const nextJson = new Map<string, string>();
|
||||||
|
for (const s of summaries) {
|
||||||
|
const j = JSON.stringify(s);
|
||||||
|
nextJson.set(s.id, j);
|
||||||
|
if (this.prevJson.get(s.id) !== j) this.emit('discovery_update', s);
|
||||||
|
}
|
||||||
|
this.prevJson = nextJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fusionne sessions managées + découvertes en dédoublonnant par claudeSessionId (managé prioritaire). */
|
||||||
|
export function mergeSessions(managed: SessionSummary[], discovered: SessionSummary[]): SessionSummary[] {
|
||||||
|
const managedSids = new Set(managed.map((s) => s.claudeSessionId).filter((x): x is string => x != null));
|
||||||
|
return [...managed, ...discovered.filter((s) => !(s.claudeSessionId && managedSids.has(s.claudeSessionId)))];
|
||||||
|
}
|
||||||
160
packages/server/src/core/jsonl-discovery.ts
Normal file
160
packages/server/src/core/jsonl-discovery.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// Découverte des sessions Claude sur disque (~/.claude/projects/**/*.jsonl).
|
||||||
|
// Transcription TS du spike S4 (scan.mjs) : parseur strictement tolérant, lecture partielle
|
||||||
|
// tête+queue (~1,4 s pour 1300+ fichiers), 99,8 % des transcripts exploitables.
|
||||||
|
// I/O ASYNCHRONES : le scan rend la main à la boucle d'événements entre fichiers, pour ne pas
|
||||||
|
// figer le streaming des terminaux (il tourne périodiquement dans le DiscoveryService).
|
||||||
|
import { readdir, stat, open } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const HEAD_BYTES = 256 * 1024;
|
||||||
|
const TAIL_BYTES = 64 * 1024;
|
||||||
|
const TITLE_MAX = 120;
|
||||||
|
|
||||||
|
/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % — spike S4). */
|
||||||
|
export function munge(cwd: string): string {
|
||||||
|
return cwd.replace(/[^A-Za-z0-9]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredJsonl {
|
||||||
|
claudeSessionId: string;
|
||||||
|
cwd: string;
|
||||||
|
title: string | null;
|
||||||
|
gitBranch: string | null;
|
||||||
|
version: string | null;
|
||||||
|
mtimeMs: number;
|
||||||
|
/** chemin absolu du .jsonl */
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChunk(path: string, position: number, length: number): Promise<string> {
|
||||||
|
const fh = await open(path, 'r');
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(length);
|
||||||
|
const { bytesRead } = await fh.read(buf, 0, length, position);
|
||||||
|
return buf.subarray(0, bytesRead).toString('utf8');
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse tolérant : chaque ligne en try/catch, lignes coupées/malformées ignorées (jamais de throw). */
|
||||||
|
export function parseLines(text: string, partialFirst = false, partialLast = false): Array<Record<string, unknown>> {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (partialFirst) lines.shift(); // 1re ligne potentiellement coupée (lecture de queue)
|
||||||
|
if (partialLast) lines.pop(); // dernière ligne potentiellement coupée (lecture de tête)
|
||||||
|
const out: Array<Record<string, unknown>> = [];
|
||||||
|
for (const l of lines) {
|
||||||
|
if (!l.trim()) continue;
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(l) as unknown;
|
||||||
|
if (o && typeof o === 'object') out.push(o as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
/* ligne coupée ou malformée : ignorée */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Meta {
|
||||||
|
sessionId?: string;
|
||||||
|
cwd?: string;
|
||||||
|
gitBranch?: string;
|
||||||
|
version?: string;
|
||||||
|
aiTitle?: string;
|
||||||
|
summary?: string;
|
||||||
|
lastPrompt?: string;
|
||||||
|
firstUserPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(v: unknown): string | undefined {
|
||||||
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
|
||||||
|
const setOnce = (key: 'sessionId' | 'cwd' | 'gitBranch' | 'version', v: unknown): void => {
|
||||||
|
if (meta[key] === undefined) {
|
||||||
|
const s = asString(v);
|
||||||
|
if (s) meta[key] = s;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const o of objs) {
|
||||||
|
setOnce('sessionId', o.sessionId);
|
||||||
|
setOnce('cwd', o.cwd);
|
||||||
|
setOnce('gitBranch', o.gitBranch);
|
||||||
|
setOnce('version', o.version);
|
||||||
|
// Titre : on retient la dernière valeur vue (la plus récente) — head puis tail → la queue gagne.
|
||||||
|
const ai = asString(o.aiTitle);
|
||||||
|
if (ai) meta.aiTitle = ai;
|
||||||
|
const sum = asString(o.summary);
|
||||||
|
if (sum) meta.summary = sum;
|
||||||
|
const lp = asString(o.lastPrompt);
|
||||||
|
if (lp) meta.lastPrompt = lp;
|
||||||
|
// Premier prompt utilisateur (depuis la tête) : repli ultime pour le titre.
|
||||||
|
if (meta.firstUserPrompt === undefined && o.type === 'user') {
|
||||||
|
const msg = o.message;
|
||||||
|
if (msg && typeof msg === 'object') {
|
||||||
|
const text = asString((msg as Record<string, unknown>).content);
|
||||||
|
if (text) meta.firstUserPrompt = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priorité de titre actée S4 : ai-title > summary > last-prompt > 1er prompt user. */
|
||||||
|
function pickTitle(meta: Meta): string | null {
|
||||||
|
const raw = meta.aiTitle ?? meta.summary ?? meta.lastPrompt ?? meta.firstUserPrompt ?? null;
|
||||||
|
if (!raw) return null;
|
||||||
|
const t = raw.replace(/\s+/g, ' ').trim();
|
||||||
|
return t.length > TITLE_MAX ? `${t.slice(0, TITLE_MAX - 1)}…` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne `projectsDir` en lecture partielle tête+queue. Tolérant : un dossier/fichier illisible ou
|
||||||
|
* malformé est ignoré (jamais de throw). Un fichier sans sessionId+cwd exploitables est écarté.
|
||||||
|
*/
|
||||||
|
export async function scanProjects(projectsDir: string): Promise<DiscoveredJsonl[]> {
|
||||||
|
let dirs: string[];
|
||||||
|
try {
|
||||||
|
dirs = await readdir(projectsDir);
|
||||||
|
} catch {
|
||||||
|
return []; // dossier absent (Claude jamais lancé) : pas une erreur
|
||||||
|
}
|
||||||
|
const out: DiscoveredJsonl[] = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const dirPath = join(projectsDir, dir);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
|
||||||
|
const fp = join(dirPath, e.name);
|
||||||
|
try {
|
||||||
|
const st = await stat(fp);
|
||||||
|
if (st.size === 0) continue;
|
||||||
|
const meta: Meta = {};
|
||||||
|
const head = await readChunk(fp, 0, Math.min(HEAD_BYTES, st.size));
|
||||||
|
extractMeta(parseLines(head, false, st.size > HEAD_BYTES), meta);
|
||||||
|
if (st.size > HEAD_BYTES + TAIL_BYTES) {
|
||||||
|
const tail = await readChunk(fp, st.size - TAIL_BYTES, TAIL_BYTES);
|
||||||
|
extractMeta(parseLines(tail, true, false), meta);
|
||||||
|
}
|
||||||
|
if (!meta.sessionId || !meta.cwd) continue; // pas une session exploitable
|
||||||
|
out.push({
|
||||||
|
claudeSessionId: meta.sessionId,
|
||||||
|
cwd: meta.cwd,
|
||||||
|
title: pickTitle(meta),
|
||||||
|
gitBranch: meta.gitBranch ?? null,
|
||||||
|
version: meta.version ?? null,
|
||||||
|
mtimeMs: st.mtimeMs,
|
||||||
|
file: fp,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* fichier illisible : ignoré */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync } from 'node:fs';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||||
import { RingBuffer } from './ring-buffer.js';
|
import { RingBuffer } from './ring-buffer.js';
|
||||||
import { buildSpawnSpec } from './claude-launcher.js';
|
import { buildSpawnSpec } from './claude-launcher.js';
|
||||||
|
import { findByPid } from './session-registry.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
|
|
||||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||||
const KILL_GRACE_MS = 5000;
|
const KILL_GRACE_MS = 5000;
|
||||||
|
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
||||||
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||||
export interface ClientBinding {
|
export interface ClientBinding {
|
||||||
@@ -36,6 +42,8 @@ interface ManagedSession {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
exited: { exitCode: number | null; signal: number | null } | null;
|
exited: { exitCode: number | null; signal: number | null } | null;
|
||||||
killTimer: NodeJS.Timeout | null;
|
killTimer: NodeJS.Timeout | null;
|
||||||
|
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||||
|
claudeSessionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PtyManagerEvents {
|
export interface PtyManagerEvents {
|
||||||
@@ -46,17 +54,21 @@ export interface PtyManagerEvents {
|
|||||||
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||||
private readonly live = new Map<string, ManagedSession>();
|
private readonly live = new Map<string, ManagedSession>();
|
||||||
|
|
||||||
constructor(private readonly db: Db) {
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash' }): SessionSummary {
|
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
|
||||||
const cwd = opts.cwd;
|
const cwd = opts.cwd;
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||||
}
|
}
|
||||||
const command = opts.command ?? 'claude';
|
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||||
const spec = buildSpawnSpec(command);
|
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||||
|
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const proc = pty.spawn(spec.file, spec.args, {
|
const proc = pty.spawn(spec.file, spec.args, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -77,26 +89,66 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
paused: false,
|
paused: false,
|
||||||
exited: null,
|
exited: null,
|
||||||
killTimer: null,
|
killTimer: null,
|
||||||
|
claudeSessionId: null,
|
||||||
};
|
};
|
||||||
this.live.set(id, session);
|
this.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at) VALUES (?, ?, ?, ?)')
|
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(id, cwd, command, session.createdAt);
|
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
|
||||||
|
|
||||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||||
|
if (command === 'claude') this.captureClaudeSessionId(session);
|
||||||
|
|
||||||
const summary = this.summarize(session);
|
const summary = this.summarize(session);
|
||||||
this.emit('session_update', summary);
|
this.emit('session_update', summary);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady — S1). */
|
||||||
|
private captureClaudeSessionId(s: ManagedSession): void {
|
||||||
|
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
|
||||||
|
const tick = (): void => {
|
||||||
|
if (s.exited || s.claudeSessionId) return;
|
||||||
|
const entry = findByPid(this.sessionsDir, s.proc.pid);
|
||||||
|
if (entry?.claudeSessionId) {
|
||||||
|
s.claudeSessionId = entry.claudeSessionId;
|
||||||
|
this.db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run(entry.claudeSessionId, s.id);
|
||||||
|
this.emit('session_update', this.summarize(s));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() < deadline) setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
||||||
|
};
|
||||||
|
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
||||||
|
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
||||||
|
for (const s of this.live.values()) {
|
||||||
|
if (s.claudeSessionId === claudeSessionId) return this.summarize(s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensemble des claudeSessionId déjà connus d'Arboretum (sessions managées vivantes + historique DB).
|
||||||
|
* Sert au DiscoveryService à exclure les doublons (une managée ne doit pas réapparaître en découverte).
|
||||||
|
*/
|
||||||
|
knownClaudeSessionIds(): Set<string> {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare('SELECT claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL')
|
||||||
|
.all() as Array<{ claude_session_id: string }>;
|
||||||
|
const set = new Set(rows.map((r) => r.claude_session_id));
|
||||||
|
for (const s of this.live.values()) if (s.claudeSessionId) set.add(s.claudeSessionId);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
list(): SessionSummary[] {
|
list(): SessionSummary[] {
|
||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code FROM sessions ORDER BY created_at DESC LIMIT 100')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null }>;
|
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
@@ -110,6 +162,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
endedAt: r.ended_at,
|
endedAt: r.ended_at,
|
||||||
exitCode: r.exit_code,
|
exitCode: r.exit_code,
|
||||||
clients: 0,
|
clients: 0,
|
||||||
|
source: 'managed',
|
||||||
|
claudeSessionId: r.claude_session_id,
|
||||||
|
pid: null,
|
||||||
|
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
||||||
|
resumable: r.command === 'claude' && r.claude_session_id != null,
|
||||||
|
attachable: false,
|
||||||
|
registryStatus: null,
|
||||||
}));
|
}));
|
||||||
return [...liveSummaries, ...historical];
|
return [...liveSummaries, ...historical];
|
||||||
}
|
}
|
||||||
@@ -265,6 +324,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
endedAt: null,
|
endedAt: null,
|
||||||
exitCode: s.exited?.exitCode ?? null,
|
exitCode: s.exited?.exitCode ?? null,
|
||||||
clients: s.clients.size,
|
clients: s.clients.size,
|
||||||
|
source: 'managed',
|
||||||
|
claudeSessionId: s.claudeSessionId,
|
||||||
|
pid: s.exited ? null : s.proc.pid,
|
||||||
|
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
|
||||||
|
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
||||||
|
attachable: !s.exited,
|
||||||
|
registryStatus: null, // P2 : statut fin des managées via claude-adapter (P3-B)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
packages/server/src/core/session-registry.ts
Normal file
92
packages/server/src/core/session-registry.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Lecture du registre ~/.claude/sessions (un fichier <pid>.json par session CLI vive).
|
||||||
|
// Module partagé : P2 l'utilise pour la vivacité + le lookup par pid ; P3-B (claude-adapter)
|
||||||
|
// s'en sert comme source primaire d'état fin (busy/idle/waiting + waitingFor).
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { SessionRegistryStatus } from '@arboretum/shared';
|
||||||
|
|
||||||
|
export interface RegistryEntry {
|
||||||
|
claudeSessionId: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
pid: number;
|
||||||
|
procStart: string | null;
|
||||||
|
status: SessionRegistryStatus | null;
|
||||||
|
waitingFor: string | null;
|
||||||
|
/** vivacité réelle = pid + procStart concordants (jamais la simple présence du fichier). */
|
||||||
|
live: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** starttime (champ 22 de /proc/<pid>/stat) ou null si le process n'existe pas. Linux-only. */
|
||||||
|
export function readProcStart(pid: number): string | null {
|
||||||
|
try {
|
||||||
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||||
|
// Le nom du process (champ 2) peut contenir espaces/parenthèses → parser après le dernier ')'.
|
||||||
|
const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||||
|
return after[19] ?? null; // champ 22 global = index 19 après le champ 3
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vivacité par pid + procStart (spike S1/S4) : jamais la présence d'un fichier registre (stale possible).
|
||||||
|
* "Incertain → vivant" : si le process existe mais que le registre n'a pas de procStart, on traite vivant.
|
||||||
|
*/
|
||||||
|
export function isLive(pid: number, procStart: string | number | null | undefined): boolean {
|
||||||
|
const actual = readProcStart(pid);
|
||||||
|
if (actual === null) return false; // process absent → mort
|
||||||
|
if (procStart === null || procStart === undefined) return true; // incertain → vivant
|
||||||
|
return String(procStart) === actual; // procStart discordant → pid recyclé → stale → mort
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(v: unknown): SessionRegistryStatus | null {
|
||||||
|
return v === 'busy' || v === 'idle' || v === 'waiting' ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEntry(r: Record<string, unknown>): RegistryEntry | null {
|
||||||
|
const pid = typeof r.pid === 'number' ? r.pid : Number(r.pid);
|
||||||
|
if (!Number.isInteger(pid)) return null;
|
||||||
|
const procStart = r.procStart === undefined || r.procStart === null ? null : String(r.procStart);
|
||||||
|
return {
|
||||||
|
claudeSessionId: typeof r.sessionId === 'string' ? r.sessionId : null,
|
||||||
|
cwd: typeof r.cwd === 'string' ? r.cwd : null,
|
||||||
|
pid,
|
||||||
|
procStart,
|
||||||
|
status: normalizeStatus(r.status),
|
||||||
|
waitingFor: typeof r.waitingFor === 'string' ? r.waitingFor : null,
|
||||||
|
live: isLive(pid, procStart),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit toutes les entrées de `sessionsDir`. Tolérant aux fichiers en cours d'écriture / JSON invalide
|
||||||
|
* (ignorés). Dossier absent → tableau vide (pas une erreur).
|
||||||
|
*/
|
||||||
|
export function readRegistry(sessionsDir: string): RegistryEntry[] {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(sessionsDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: RegistryEntry[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(readFileSync(join(sessionsDir, f), 'utf8')) as Record<string, unknown>;
|
||||||
|
const entry = toEntry(r);
|
||||||
|
if (entry) out.push(entry);
|
||||||
|
} catch {
|
||||||
|
/* fichier en cours d'écriture / JSON invalide : ignoré */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrée registre correspondant à un pid donné (le CLI nomme son fichier <pid>.json, mais on
|
||||||
|
* matche sur le champ `pid` pour ne pas dépendre du nommage). null si absente/illisible.
|
||||||
|
*/
|
||||||
|
export function findByPid(sessionsDir: string, pid: number): RegistryEntry | null {
|
||||||
|
return readRegistry(sessionsDir).find((e) => e.pid === pid) ?? null;
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
);
|
);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// P2 — découverte & reprise : corrélation avec les sessions Claude sur disque.
|
||||||
|
id: 2,
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;
|
||||||
|
ALTER TABLE sessions ADD COLUMN resumed_from TEXT;
|
||||||
|
CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ const pkg = JSON.parse(
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const db = openDb(config.dbPath);
|
const db = openDb(config.dbPath);
|
||||||
const { app, auth, manager } = buildApp(config, db, pkg.version);
|
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
||||||
|
|
||||||
const bootstrapToken = auth.ensureBootstrapToken();
|
const bootstrapToken = auth.ensureBootstrapToken();
|
||||||
await app.listen({ port: config.port, host: config.bind });
|
await app.listen({ port: config.port, host: config.bind });
|
||||||
|
discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes
|
||||||
|
|
||||||
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||||
@@ -36,6 +37,7 @@ async function main(): Promise<void> {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||||
|
discovery.stop();
|
||||||
manager.shutdown();
|
manager.shutdown();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void app.close().then(() => process.exit(0));
|
void app.close().then(() => process.exit(0));
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||||
import type { PtyManager } from '../core/pty-manager.js';
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
|
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
|
||||||
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager): void {
|
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService): void {
|
||||||
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
||||||
return { sessions: manager.list() };
|
return { sessions: mergeSessions(manager.list(), discovery.list()) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/v1/sessions', async (req, reply) => {
|
app.post('/api/v1/sessions', async (req, reply) => {
|
||||||
@@ -25,6 +26,45 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reprise d'une session morte : nouveau PTY managé `--resume <id>` DANS SON CWD D'ORIGINE (spike S1).
|
||||||
|
// Le cwd n'est jamais fourni par le client : il est lu sur disque via la découverte.
|
||||||
|
app.post('/api/v1/sessions/:id/resume', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const discovered = discovery.getDiscovered(id);
|
||||||
|
if (!discovered) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No resumable session with this id' } });
|
||||||
|
}
|
||||||
|
// Garde-fou anti-corruption : jamais de resume direct d'une session vivante (vérif FRAÎCHE).
|
||||||
|
if (discovery.isClaudeSessionLive(id) || manager.findLiveByClaudeSessionId(id)) {
|
||||||
|
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } });
|
||||||
|
const res: SessionResponse = { session };
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||||
|
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fork : duplique une session (vivante ou morte) sans la corrompre (`--resume <id> --fork-session`).
|
||||||
|
app.post('/api/v1/sessions/:id/fork', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const discovered = discovery.getDiscovered(id);
|
||||||
|
if (!discovered) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to fork' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id, fork: true } });
|
||||||
|
const res: SessionResponse = { session };
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||||
|
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
if (!manager.kill(id)) {
|
if (!manager.kill(id)) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
|
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
@@ -17,7 +18,12 @@ interface ChannelState {
|
|||||||
binding: ClientBinding;
|
binding: ClientBinding;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWsGateway(app: FastifyInstance, manager: PtyManager, serverVersion: string): void {
|
export function registerWsGateway(
|
||||||
|
app: FastifyInstance,
|
||||||
|
manager: PtyManager,
|
||||||
|
discovery: DiscoveryService,
|
||||||
|
serverVersion: string,
|
||||||
|
): void {
|
||||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||||
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
|
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
|
||||||
const channels = new Map<number, ChannelState>();
|
const channels = new Map<number, ChannelState>();
|
||||||
@@ -39,8 +45,13 @@ export function registerWsGateway(app: FastifyInstance, manager: PtyManager, ser
|
|||||||
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
||||||
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
||||||
};
|
};
|
||||||
|
// Les sessions découvertes (DiscoveryService) sont relayées sur le même flux que les managées.
|
||||||
|
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
||||||
|
if (subscribedSessions) send({ type: 'session_update', session });
|
||||||
|
};
|
||||||
manager.on('session_update', onSessionUpdate);
|
manager.on('session_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
|
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (!alive) {
|
if (!alive) {
|
||||||
@@ -148,6 +159,7 @@ export function registerWsGateway(app: FastifyInstance, manager: PtyManager, ser
|
|||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
manager.off('session_update', onSessionUpdate);
|
manager.off('session_update', onSessionUpdate);
|
||||||
manager.off('session_exit', onSessionExit);
|
manager.off('session_exit', onSessionExit);
|
||||||
|
discovery.off('discovery_update', onDiscoveryUpdate);
|
||||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||||
channels.clear();
|
channels.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { buildApp, type AppBundle } from '../src/app.js';
|
import { buildApp, type AppBundle } from '../src/app.js';
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
import { munge } from '../src/core/jsonl-discovery.js';
|
||||||
|
import { readProcStart } from '../src/core/session-registry.js';
|
||||||
import type { Config } from '../src/config.js';
|
import type { Config } from '../src/config.js';
|
||||||
|
|
||||||
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
|
||||||
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
class FakePty {
|
class FakePty {
|
||||||
@@ -49,6 +54,9 @@ function makeApp(name: string): TestApp {
|
|||||||
dataDir: dir,
|
dataDir: dir,
|
||||||
allowedOrigins: [ALLOWED_ORIGIN],
|
allowedOrigins: [ALLOWED_ORIGIN],
|
||||||
printToken: false,
|
printToken: false,
|
||||||
|
// ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
|
||||||
|
claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
|
||||||
|
claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
|
||||||
};
|
};
|
||||||
const bundle = buildApp(config, db, '0.0.0-test');
|
const bundle = buildApp(config, db, '0.0.0-test');
|
||||||
const token = bundle.auth.ensureBootstrapToken();
|
const token = bundle.auth.ensureBootstrapToken();
|
||||||
@@ -268,3 +276,64 @@ describe('app e2e — rate limit du login', () => {
|
|||||||
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app e2e — découverte, resume & fork (P2)', () => {
|
||||||
|
let t: TestApp;
|
||||||
|
let projectsDir: string;
|
||||||
|
let sessionsDir: string;
|
||||||
|
|
||||||
|
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
|
||||||
|
|
||||||
|
function writeDiscoveredJsonl(cwd: string, sid: string): void {
|
||||||
|
const d = join(projectsDir, munge(cwd));
|
||||||
|
mkdirSync(d, { recursive: true });
|
||||||
|
writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
t = makeApp('resume');
|
||||||
|
projectsDir = join(dir, 'resume-claude', 'projects');
|
||||||
|
sessionsDir = join(dir, 'resume-claude', 'sessions');
|
||||||
|
mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resume / fork d’un id inconnu → 404', async () => {
|
||||||
|
const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
|
||||||
|
expect(r.statusCode).toBe(404);
|
||||||
|
const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
|
||||||
|
expect(f.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
|
||||||
|
writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
|
||||||
|
await t.bundle.discovery.refresh();
|
||||||
|
|
||||||
|
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
|
||||||
|
const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
|
||||||
|
(s) => s.id === 'dead-e2e',
|
||||||
|
);
|
||||||
|
expect(found).toMatchObject({ source: 'discovered', resumable: true });
|
||||||
|
|
||||||
|
const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
|
||||||
|
const sid = 'live-e2e';
|
||||||
|
writeDiscoveredJsonl(dir, sid);
|
||||||
|
const ps = readProcStart(process.pid) as string;
|
||||||
|
writeFileSync(
|
||||||
|
join(sessionsDir, `${process.pid}.json`),
|
||||||
|
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
|
||||||
|
);
|
||||||
|
await t.bundle.discovery.refresh();
|
||||||
|
|
||||||
|
const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
|
||||||
|
expect(resume.statusCode).toBe(409);
|
||||||
|
expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });
|
||||||
|
|
||||||
|
const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
|
||||||
|
expect(fork.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
111
packages/server/test/discovery-service.test.ts
Normal file
111
packages/server/test/discovery-service.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.js';
|
||||||
|
import { munge } from '../src/core/jsonl-discovery.js';
|
||||||
|
import { readProcStart } from '../src/core/session-registry.js';
|
||||||
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
|
||||||
|
const dir = join(projectsDir, munge(cwd));
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, `${sid}.jsonl`),
|
||||||
|
`${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DiscoveryService', () => {
|
||||||
|
let projectsDir: string;
|
||||||
|
let sessionsDir: string;
|
||||||
|
let db: Db;
|
||||||
|
let manager: PtyManager;
|
||||||
|
let svc: DiscoveryService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
projectsDir = mkdtempSync(join(tmpdir(), 'arb-proj-'));
|
||||||
|
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||||
|
db = openDb(':memory:');
|
||||||
|
manager = new PtyManager(db, sessionsDir);
|
||||||
|
svc = new DiscoveryService({ ptyManager: manager, projectsDir, sessionsDir });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
svc.stop();
|
||||||
|
rmSync(projectsDir, { recursive: true, force: true });
|
||||||
|
rmSync(sessionsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session morte (pas d’entrée registre) → resumable, non attachable', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/dead', 'dead-sid');
|
||||||
|
await svc.refresh();
|
||||||
|
const s = svc.list().find((x) => x.id === 'dead-sid');
|
||||||
|
expect(s).toMatchObject({ source: 'discovered', live: false, resumable: true, attachable: false, cwd: '/home/u/dead' });
|
||||||
|
expect(svc.getDiscovered('dead-sid')?.cwd).toBe('/home/u/dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session vivante (registre + pid vivant) → live, non resumable, registryStatus exposé', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/live', 'live-sid');
|
||||||
|
const ps = readProcStart(process.pid) as string;
|
||||||
|
writeFileSync(
|
||||||
|
join(sessionsDir, `${process.pid}.json`),
|
||||||
|
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/home/u/live', status: 'waiting', waitingFor: 'permission prompt' }),
|
||||||
|
);
|
||||||
|
await svc.refresh();
|
||||||
|
const s = svc.list().find((x) => x.id === 'live-sid');
|
||||||
|
expect(s).toMatchObject({ live: true, resumable: false, attachable: false, registryStatus: 'waiting', pid: process.pid });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exclut une session déjà connue d’Arboretum (managée / historique)', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/managed', 'managed-sid');
|
||||||
|
db.prepare('INSERT INTO sessions (id, cwd, command, created_at, claude_session_id) VALUES (?, ?, ?, ?, ?)').run(
|
||||||
|
'uuid-1',
|
||||||
|
'/home/u/managed',
|
||||||
|
'claude',
|
||||||
|
new Date().toISOString(),
|
||||||
|
'managed-sid',
|
||||||
|
);
|
||||||
|
await svc.refresh();
|
||||||
|
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('émet discovery_update sur changement uniquement', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
|
||||||
|
const seen: SessionSummary[] = [];
|
||||||
|
svc.on('discovery_update', (s) => seen.push(s));
|
||||||
|
await svc.refresh();
|
||||||
|
expect(seen.map((s) => s.id)).toContain('sid-x');
|
||||||
|
seen.length = 0;
|
||||||
|
await svc.refresh(); // rien n'a changé → aucune ré-émission
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeSessions', () => {
|
||||||
|
const mk = (id: string, claudeSessionId: string | null, source: 'managed' | 'discovered'): SessionSummary => ({
|
||||||
|
id,
|
||||||
|
cwd: '/x',
|
||||||
|
command: 'claude',
|
||||||
|
title: null,
|
||||||
|
status: 'exited',
|
||||||
|
live: false,
|
||||||
|
createdAt: '',
|
||||||
|
endedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
clients: 0,
|
||||||
|
source,
|
||||||
|
claudeSessionId,
|
||||||
|
pid: null,
|
||||||
|
resumable: false,
|
||||||
|
attachable: false,
|
||||||
|
registryStatus: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dédoublonne par claudeSessionId, managé prioritaire', () => {
|
||||||
|
const managed = [mk('uuid', 'sid-1', 'managed')];
|
||||||
|
const discovered = [mk('sid-1', 'sid-1', 'discovered'), mk('sid-2', 'sid-2', 'discovered')];
|
||||||
|
expect(mergeSessions(managed, discovered).map((s) => s.id)).toEqual(['uuid', 'sid-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
packages/server/test/jsonl-discovery.test.ts
Normal file
93
packages/server/test/jsonl-discovery.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { munge, parseLines, scanProjects } from '../src/core/jsonl-discovery.js';
|
||||||
|
|
||||||
|
function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object[]): void {
|
||||||
|
const dir = join(projectsDir, munge(cwd));
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, `${sid}.jsonl`), `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('munge', () => {
|
||||||
|
it('reproduit le nom de dossier ~/.claude/projects', () => {
|
||||||
|
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-');
|
||||||
|
expect(munge('/home/johan/WebstormProjects/arboretum')).toBe('-home-johan-WebstormProjects-arboretum');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseLines (tolérance)', () => {
|
||||||
|
it('ignore les lignes vides, coupées ou malformées sans throw', () => {
|
||||||
|
const text = '{"a":1}\n\n{not json}\n{"b":2}\n{"c":';
|
||||||
|
expect(parseLines(text)).toEqual([{ a: 1 }, { b: 2 }]);
|
||||||
|
});
|
||||||
|
it('shift/pop les lignes partielles en bord de chunk', () => {
|
||||||
|
const text = 'partial}\n{"ok":1}\n{also partial';
|
||||||
|
expect(parseLines(text, true, true)).toEqual([{ ok: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scanProjects', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-jsonl-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dossier absent → tableau vide', async () => {
|
||||||
|
expect(await scanProjects(join(dir, 'nope'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extrait claudeSessionId + cwd + branche + version', async () => {
|
||||||
|
writeJsonl(dir, '/home/u/proj', 'sid-1', [
|
||||||
|
{ type: 'user', sessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170', message: { content: 'hello world' } },
|
||||||
|
]);
|
||||||
|
const res = await scanProjects(dir);
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0]).toMatchObject({ claudeSessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('écarte un fichier sans cwd (queue-operation only)', async () => {
|
||||||
|
writeJsonl(dir, '/x', 'sid-q', [{ type: 'queue-operation', sessionId: 'sid-q' }]);
|
||||||
|
expect(await scanProjects(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survit à une ligne malformée et reste exploitable', async () => {
|
||||||
|
const d = join(dir, munge('/home/u/p2'));
|
||||||
|
mkdirSync(d, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(d, 'sid-2.jsonl'),
|
||||||
|
`{bad json\n${JSON.stringify({ type: 'user', sessionId: 'sid-2', cwd: '/home/u/p2', message: { content: 'ok' } })}\n`,
|
||||||
|
);
|
||||||
|
const res = await scanProjects(dir);
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].claudeSessionId).toBe('sid-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('priorité de titre (ai-title > summary > last-prompt > 1er user)', () => {
|
||||||
|
it('ai-title gagne sur tout', async () => {
|
||||||
|
writeJsonl(dir, '/t/1', 's', [
|
||||||
|
{ type: 'user', sessionId: 's', cwd: '/t/1', message: { content: 'premier prompt' } },
|
||||||
|
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
|
||||||
|
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
|
||||||
|
{ type: 'ai-title', sessionId: 's', aiTitle: 'Titre IA' },
|
||||||
|
]);
|
||||||
|
expect((await scanProjects(dir))[0].title).toBe('Titre IA');
|
||||||
|
});
|
||||||
|
it('summary gagne sur last-prompt et user', async () => {
|
||||||
|
writeJsonl(dir, '/t/2', 's', [
|
||||||
|
{ type: 'user', sessionId: 's', cwd: '/t/2', message: { content: 'premier prompt' } },
|
||||||
|
{ type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' },
|
||||||
|
{ type: 'summary', sessionId: 's', summary: 'un résumé' },
|
||||||
|
]);
|
||||||
|
expect((await scanProjects(dir))[0].title).toBe('un résumé');
|
||||||
|
});
|
||||||
|
it('repli ultime sur le premier prompt user', async () => {
|
||||||
|
writeJsonl(dir, '/t/3', 's', [{ type: 'user', sessionId: 's', cwd: '/t/3', message: { content: 'premier prompt' } }]);
|
||||||
|
expect((await scanProjects(dir))[0].title).toBe('premier prompt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
interface FakePty {
|
interface FakePty {
|
||||||
pid: number;
|
pid: number;
|
||||||
file: string;
|
file: string;
|
||||||
@@ -135,6 +140,68 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
|
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
|
||||||
expect(row.n).toBe(0);
|
expect(row.n).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
|
||||||
|
const { summary } = spawnBash();
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
source: 'managed',
|
||||||
|
attachable: true,
|
||||||
|
resumable: false,
|
||||||
|
registryStatus: null,
|
||||||
|
claudeSessionId: null,
|
||||||
|
});
|
||||||
|
expect(summary.pid).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resume / fork (P2)', () => {
|
||||||
|
it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
|
||||||
|
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
|
||||||
|
const p = lastPty();
|
||||||
|
expect(summary.command).toBe('claude');
|
||||||
|
expect(summary.source).toBe('managed');
|
||||||
|
expect(summary.attachable).toBe(true);
|
||||||
|
expect(p.file).toBe('/usr/bin/claude');
|
||||||
|
expect(p.args).toEqual(['--resume', 'abc-123']);
|
||||||
|
const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
|
||||||
|
resumed_from: string | null;
|
||||||
|
command: string;
|
||||||
|
};
|
||||||
|
expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fork → --resume <id> --fork-session', () => {
|
||||||
|
manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
|
||||||
|
expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
|
||||||
|
const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
|
||||||
|
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||||
|
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
|
||||||
|
lastPty().emitExit(0);
|
||||||
|
const listed = manager.list().find((s) => s.id === summary.id);
|
||||||
|
expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||||
|
try {
|
||||||
|
const m = new PtyManager(db, sessDir);
|
||||||
|
const summary = m.spawn({ cwd, command: 'claude' });
|
||||||
|
const p = lastPty();
|
||||||
|
writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
|
||||||
|
expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
const found = m.findLiveByClaudeSessionId('captured-sid');
|
||||||
|
expect(found?.id).toBe(summary.id);
|
||||||
|
expect(found?.claudeSessionId).toBe('captured-sid');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
rmSync(sessDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('attach', () => {
|
describe('attach', () => {
|
||||||
|
|||||||
67
packages/server/test/session-registry.test.ts
Normal file
67
packages/server/test/session-registry.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readProcStart, isLive, readRegistry, findByPid } from '../src/core/session-registry.js';
|
||||||
|
|
||||||
|
const DEAD_PID = 2 ** 31 - 1; // pid quasi impossible à attribuer
|
||||||
|
|
||||||
|
describe('readProcStart / isLive', () => {
|
||||||
|
it('lit le procStart du process courant et le valide', () => {
|
||||||
|
const ps = readProcStart(process.pid);
|
||||||
|
expect(ps).not.toBeNull();
|
||||||
|
expect(isLive(process.pid, ps as string)).toBe(true);
|
||||||
|
});
|
||||||
|
it('process absent → mort', () => {
|
||||||
|
expect(readProcStart(DEAD_PID)).toBeNull();
|
||||||
|
expect(isLive(DEAD_PID, '123')).toBe(false);
|
||||||
|
});
|
||||||
|
it('procStart discordant (pid recyclé) → stale → mort', () => {
|
||||||
|
expect(isLive(process.pid, 'definitely-not-the-real-starttime')).toBe(false);
|
||||||
|
});
|
||||||
|
it('procStart inconnu mais process vivant → incertain → vivant', () => {
|
||||||
|
expect(isLive(process.pid, null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readRegistry', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-reg-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dossier absent → vide', () => {
|
||||||
|
expect(readRegistry(join(dir, 'nope'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calcule live par pid+procStart et ignore les fichiers malformés', () => {
|
||||||
|
const ps = readProcStart(process.pid) as string;
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, `${process.pid}.json`),
|
||||||
|
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/x', status: 'busy' }),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, `${DEAD_PID}.json`),
|
||||||
|
JSON.stringify({ pid: DEAD_PID, procStart: '999', sessionId: 'dead-sid', cwd: '/y', status: 'idle' }),
|
||||||
|
);
|
||||||
|
writeFileSync(join(dir, 'broken.json'), '{ not json');
|
||||||
|
|
||||||
|
const entries = readRegistry(dir);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
const live = entries.find((e) => e.pid === process.pid);
|
||||||
|
expect(live?.live).toBe(true);
|
||||||
|
expect(live?.status).toBe('busy');
|
||||||
|
expect(live?.claudeSessionId).toBe('live-sid');
|
||||||
|
const dead = entries.find((e) => e.pid === DEAD_PID);
|
||||||
|
expect(dead?.live).toBe(false);
|
||||||
|
expect(findByPid(dir, process.pid)?.claudeSessionId).toBe('live-sid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalise un status inconnu en null', () => {
|
||||||
|
writeFileSync(join(dir, `${DEAD_PID}.json`), JSON.stringify({ pid: DEAD_PID, status: 'weird' }));
|
||||||
|
expect(readRegistry(dir)[0].status).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -29,3 +29,8 @@ export interface SessionsListResponse {
|
|||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /sessions/:id/resume et /sessions/:id/fork (P2) : aucun corps — cwd et command
|
||||||
|
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
||||||
|
export type ResumeSessionRequest = Record<string, never>;
|
||||||
|
export type ForkSessionRequest = Record<string, never>;
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export type SessionRuntimeStatus =
|
|||||||
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
|
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
|
||||||
| 'exited';
|
| 'exited';
|
||||||
|
|
||||||
|
/** Origine d'une session listée par Arboretum. */
|
||||||
|
export type SessionSource =
|
||||||
|
| 'managed' // PTY lancé par Arboretum (vit dans le PtyManager)
|
||||||
|
| 'discovered'; // session Claude externe lue sur disque (~/.claude)
|
||||||
|
|
||||||
|
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
||||||
|
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -71,6 +79,19 @@ export interface SessionSummary {
|
|||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
clients: number;
|
clients: number;
|
||||||
|
// ---- P2 : découverte & reprise (additif) ----
|
||||||
|
/** 'managed' = PtyManager ; 'discovered' = lue sur disque. */
|
||||||
|
source: SessionSource;
|
||||||
|
/** ID interne du CLI claude (clé de corrélation managé↔découvert) ; null pour bash ou claude pas encore prêt. */
|
||||||
|
claudeSessionId: string | null;
|
||||||
|
/** pid du process claude (découvertes vivantes & managées vivantes) ; null sinon. */
|
||||||
|
pid: number | null;
|
||||||
|
/** true si un `--resume` direct est sûr (session morte) ; false pour une vivante (corruption). */
|
||||||
|
resumable: boolean;
|
||||||
|
/** true si Arboretum tient le PTY (managée vivante) ; jamais pour une découverte. */
|
||||||
|
attachable: boolean;
|
||||||
|
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
||||||
|
registryStatus: SessionRegistryStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -27,9 +27,14 @@ export default {
|
|||||||
creating: 'Creating…',
|
creating: 'Creating…',
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
observe: 'Observe',
|
observe: 'Observe',
|
||||||
|
view: 'View',
|
||||||
|
resume: 'Resume',
|
||||||
|
fork: 'Fork',
|
||||||
kill: 'Kill',
|
kill: 'Kill',
|
||||||
confirmKill: 'Confirm kill',
|
confirmKill: 'Confirm kill',
|
||||||
live: 'live',
|
live: 'live',
|
||||||
|
resumable: 'resumable',
|
||||||
|
sourceDiscovered: 'discovered',
|
||||||
clients: 'no client | 1 client | {n} clients',
|
clients: 'no client | 1 client | {n} clients',
|
||||||
exitCode: 'exit code {code}',
|
exitCode: 'exit code {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -37,11 +42,17 @@ export default {
|
|||||||
running: 'running',
|
running: 'running',
|
||||||
exited: 'exited',
|
exited: 'exited',
|
||||||
},
|
},
|
||||||
|
registryStatus: {
|
||||||
|
busy: 'busy',
|
||||||
|
idle: 'idle',
|
||||||
|
waiting: 'waiting',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
|
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
||||||
},
|
},
|
||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connection lost — reconnecting…',
|
reconnecting: 'Connection lost — reconnecting…',
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ const fr: typeof en = {
|
|||||||
creating: 'Création…',
|
creating: 'Création…',
|
||||||
open: 'Ouvrir',
|
open: 'Ouvrir',
|
||||||
observe: 'Observer',
|
observe: 'Observer',
|
||||||
|
view: 'Voir',
|
||||||
|
resume: 'Reprendre',
|
||||||
|
fork: 'Dupliquer',
|
||||||
kill: 'Tuer',
|
kill: 'Tuer',
|
||||||
confirmKill: 'Confirmer',
|
confirmKill: 'Confirmer',
|
||||||
live: 'en cours',
|
live: 'en cours',
|
||||||
|
resumable: 'reprenable',
|
||||||
|
sourceDiscovered: 'découverte',
|
||||||
clients: 'aucun client | 1 client | {n} clients',
|
clients: 'aucun client | 1 client | {n} clients',
|
||||||
exitCode: 'code de sortie {code}',
|
exitCode: 'code de sortie {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -39,11 +44,18 @@ const fr: typeof en = {
|
|||||||
running: 'en cours',
|
running: 'en cours',
|
||||||
exited: 'terminée',
|
exited: 'terminée',
|
||||||
},
|
},
|
||||||
|
registryStatus: {
|
||||||
|
busy: 'occupée',
|
||||||
|
idle: 'inactive',
|
||||||
|
waiting: 'en attente',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
sessionEnded: 'Session terminée',
|
sessionEnded: 'Session terminée',
|
||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
|
notAttachable:
|
||||||
|
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
||||||
},
|
},
|
||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connexion perdue — reconnexion…',
|
reconnecting: 'Connexion perdue — reconnexion…',
|
||||||
|
|||||||
@@ -72,5 +72,30 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sessions, loading, loadError, fetchSessions, startRealtime, stopRealtime, createSession, killSession };
|
// Reprise d'une session morte (nouveau PTY managé dans son cwd d'origine, côté serveur).
|
||||||
|
async function resumeSession(id: string): Promise<SessionSummary> {
|
||||||
|
const res = await api.post<SessionResponse>(`/api/v1/sessions/${id}/resume`);
|
||||||
|
upsert(res.session);
|
||||||
|
return res.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fork d'une session (vivante ou morte) — ne corrompt jamais l'originale.
|
||||||
|
async function forkSession(id: string): Promise<SessionSummary> {
|
||||||
|
const res = await api.post<SessionResponse>(`/api/v1/sessions/${id}/fork`);
|
||||||
|
upsert(res.session);
|
||||||
|
return res.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
fetchSessions,
|
||||||
|
startRealtime,
|
||||||
|
stopRealtime,
|
||||||
|
createSession,
|
||||||
|
killSession,
|
||||||
|
resumeSession,
|
||||||
|
forkSession,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<TerminalView :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||||
|
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
||||||
|
<div v-else class="flex flex-1 items-center justify-center p-6 text-center">
|
||||||
|
<p class="max-w-md text-sm text-zinc-400">{{ t('terminal.notAttachable') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,6 +35,8 @@ const store = useSessionsStore();
|
|||||||
const sessionId = computed(() => String(route.params.id));
|
const sessionId = computed(() => String(route.params.id));
|
||||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||||
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
|
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
|
||||||
|
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
|
||||||
|
const attachable = computed(() => session.value?.attachable ?? true);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande)
|
// navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
|
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
|
||||||
|
<p v-if="actionError" class="text-sm text-red-400">{{ actionError }}</p>
|
||||||
|
|
||||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
||||||
<p v-else-if="store.loading && store.sessions.length === 0" class="text-sm text-zinc-500">
|
<p v-else-if="store.loading && store.sessions.length === 0" class="text-sm text-zinc-500">
|
||||||
@@ -54,32 +55,49 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||||
|
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
||||||
|
{{ t('sessions.sourceDiscovered') }}
|
||||||
|
</span>
|
||||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
||||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||||
{{ t('sessions.live') }}
|
{{ s.registryStatus ? t(`sessions.registryStatus.${s.registryStatus}`) : t('sessions.live') }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="s.resumable" class="badge bg-amber-950 text-amber-400">{{ t('sessions.resumable') }}</span>
|
||||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate font-mono text-sm text-zinc-300" :title="s.cwd">{{ s.cwd }}</p>
|
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
||||||
|
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
<span>{{ t(`sessions.statusLabel.${s.status}`) }}</span>
|
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
|
||||||
<span v-if="s.live">· {{ t('sessions.clients', s.clients) }}</span>
|
|
||||||
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
|
||||||
<div v-if="s.live" class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">
|
<!-- session managée vivante : terminal complet -->
|
||||||
{{ t('sessions.open') }}
|
<template v-if="s.attachable">
|
||||||
</RouterLink>
|
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">{{ t('sessions.open') }}</RouterLink>
|
||||||
<RouterLink :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }" class="btn">
|
<RouterLink :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }" class="btn">
|
||||||
{{ t('sessions.observe') }}
|
{{ t('sessions.observe') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<template v-if="killArmedId === s.id">
|
<template v-if="killArmedId === s.id">
|
||||||
<button class="btn-danger" :disabled="killing" @click="onConfirmKill(s.id)">
|
<button class="btn-danger" :disabled="killing" @click="onConfirmKill(s.id)">
|
||||||
{{ t('sessions.confirmKill') }}
|
{{ t('sessions.confirmKill') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" @click="killArmedId = null">{{ t('common.cancel') }}</button>
|
<button class="btn" @click="killArmedId = null">{{ t('common.cancel') }}</button>
|
||||||
|
</template>
|
||||||
|
<button v-else class="btn-danger" @click="killArmedId = s.id">{{ t('sessions.kill') }}</button>
|
||||||
|
</template>
|
||||||
|
<!-- session externe vivante : pas d'attache (PTY non détenu) → vue read-only ou fork -->
|
||||||
|
<template v-else-if="s.live">
|
||||||
|
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">{{ t('sessions.view') }}</RouterLink>
|
||||||
|
<button class="btn" :disabled="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</button>
|
||||||
|
</template>
|
||||||
|
<!-- session morte reprenable : resume direct ou fork -->
|
||||||
|
<template v-else-if="s.resumable">
|
||||||
|
<button class="btn-primary" :disabled="actingId === s.id" @click="onResume(s.id)">
|
||||||
|
{{ t('sessions.resume') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" :disabled="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn-danger" @click="killArmedId = s.id">{{ t('sessions.kill') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -107,6 +125,8 @@ const creating = ref(false);
|
|||||||
const createError = ref<string | null>(null);
|
const createError = ref<string | null>(null);
|
||||||
const killArmedId = ref<string | null>(null);
|
const killArmedId = ref<string | null>(null);
|
||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
|
const actingId = ref<string | null>(null);
|
||||||
|
const actionError = ref<string | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void store.fetchSessions();
|
void store.fetchSessions();
|
||||||
@@ -131,6 +151,32 @@ async function onCreate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onResume(id: string): Promise<void> {
|
||||||
|
actingId.value = id;
|
||||||
|
actionError.value = null;
|
||||||
|
try {
|
||||||
|
const session = await store.resumeSession(id);
|
||||||
|
await router.push({ name: 'session', params: { id: session.id } });
|
||||||
|
} catch (err) {
|
||||||
|
actionError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
actingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFork(id: string): Promise<void> {
|
||||||
|
actingId.value = id;
|
||||||
|
actionError.value = null;
|
||||||
|
try {
|
||||||
|
const session = await store.forkSession(id);
|
||||||
|
await router.push({ name: 'session', params: { id: session.id } });
|
||||||
|
} catch (err) {
|
||||||
|
actionError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
actingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onConfirmKill(id: string): Promise<void> {
|
async function onConfirmKill(id: string): Promise<void> {
|
||||||
killing.value = true;
|
killing.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user