Files
arboretum/packages/shared/src/protocol.ts
Johan LEROY e8d10b7ec0 feat(p11): temps réel complet (watcher checkout principal + topic settings)
P11-A — branche du checkout principal modifiée hors Arboretum :
- fs-watcher: pin « permanent » non évinçable (pinRepo/unpinRepo, repoPins dans evictIfNeeded)
- worktree-manager: armMainCheckoutWatchers() + arm/désarm sur addRepo/découverte/removeRepo/hidden
- index.ts: armMainCheckoutWatchers() dans runDaemon → git checkout CLI sur le principal → worktree_update <500ms sans watch client

P11-B — réglages en temps réel :
- protocole additif: type SettingsBroadcast (source unique, réutilisé par SettingsResponse), topic 'settings', message settings_update (validés parseClientMessage + gateway)
- core/settings-bus.ts (EventEmitter) ; routes/settings émet après PATCH ; gateway relaie aux abonnés 'settings'
- web: ws-client subscribeSettings + routage ; store settings applySettings/startRealtime ; AppShell abonne globalement ; SettingsView re-sync des drafts scalaires sans écraser une saisie en cours
- tests: protocol (topic settings) + fs-watcher (pinRepo non évincé) ; acceptance-p11.mjs (checkout principal <500ms + settings_update)
2026-06-27 14:08:44 +02:00

346 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Protocole WebSocket Arboretum — une connexion multiplexée par client.
// Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES
// (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le
// décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport).
export const PROTOCOL_VERSION = 1;
// ---- Frames binaires ----
// Layout : [type u8][channel u32le][payload...]
export const BINARY_FRAME = {
HEADER_BYTES: 5,
/** serveur → client : sortie terminal */
OUTPUT: 0x01,
/** serveur → client : resync — le client doit reset son terminal avant d'écrire le payload */
RESYNC: 0x02,
} as const;
export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array {
const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength);
const view = new DataView(frame.buffer);
view.setUint8(0, type);
view.setUint32(1, channel, true);
frame.set(payload, BINARY_FRAME.HEADER_BYTES);
return frame;
}
export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
return {
type: view.getUint8(0),
channel: view.getUint32(1, true),
payload: data.subarray(BINARY_FRAME.HEADER_BYTES),
};
}
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
// INVARIANT anti-deadlock : ACK_EVERY_BYTES <= LOW_WATERMARK. Le client n'ACK
// qu'à réception de données ; si le serveur pouvait se mettre en pause avec un
// reliquat non-ACKé > LOW mais < pas d'ACK, plus aucun des deux ne progresserait.
// Avec ACK_EVERY <= LOW, une fois tout le flux en vol traité, le reliquat est
// < LOW et le serveur reprend toujours. (Complété côté client par un ACK
// traînant débouncé.)
export const FLOW = {
/** le client ACK tous les N octets réellement traités par xterm.write */
ACK_EVERY_BYTES: 64 * 1024,
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
HIGH_WATERMARK: 384 * 1024,
/** reprise quand le min repasse sous LOW */
LOW_WATERMARK: 128 * 1024,
/** au-delà : client marqué lagging, flux coupé, resync au rattrapage */
LAGGING_BYTES: 2 * 1024 * 1024,
} as const;
/**
* Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu).
* 1 Mo (≈ 1015k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ;
* reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY.
*/
export const REPLAY_TAIL_BYTES = 1024 * 1024;
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
export type SessionRuntimeStatus =
| 'starting'
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
| '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';
// ---- États fins de session (P3-B, claude-adapter) ----
/** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */
export type SessionActivity = 'busy' | 'waiting' | 'idle';
export type DialogKind = 'trust' | 'permission' | 'question' | 'plan';
export interface DialogOption {
n: number;
label: string;
selected: boolean;
}
export interface SessionDialog {
kind: DialogKind;
waitingFor: string | null;
options: DialogOption[];
}
export interface SessionSummary {
id: string;
cwd: string;
command: string;
title: string | null;
status: SessionRuntimeStatus;
live: boolean;
createdAt: string;
endedAt: string | null;
exitCode: number | null;
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;
// ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ----
/** absent/null = inconnu (bash, démarrage, historique). */
activity?: SessionActivity | null;
waitingFor?: string | null;
/** dialogue typé en cours (présent quand activity === 'waiting'). */
dialog?: SessionDialog | null;
// ---- P6 : session de groupe multi-repo (additif) ----
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
addedDirs?: string[];
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
groupId?: string | null;
// ---- Masquage (additif) ----
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
hidden?: boolean;
// ---- Archivage auto (P10, additif) ----
/** true = session managée auto-archivée par ancienneté (exclue de la liste sauf includeArchived). */
archived?: boolean;
}
// ---- Worktrees & repos (P3) ----
/** Hook lancé après création d'un worktree (commande shell exécutée dans le nouveau worktree). */
export interface PostCreateHook {
id: string;
label: string;
run: string;
enabled: boolean;
}
export interface RepoSummary {
id: string;
/** chemin absolu de la racine du repo (main worktree). */
path: string;
label: string;
defaultBranch: string | null;
postCreateHooks: PostCreateHook[];
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
preTrust: boolean;
createdAt: string;
/** false si le chemin n'est plus un repo git accessible. */
valid: boolean;
/** true = masqué du dashboard (conservé en DB → non ré-ajouté au re-scan). */
hidden: boolean;
}
export interface WorktreeGitStatus {
ahead: number;
behind: number;
dirtyCount: number;
upstream: string | null;
// ---- Champs additifs P7 (optionnels, rétrocompat) — compteurs bon marché calculés dans le
// même passage `status --porcelain=v2` que dirtyCount. La liste détaillée des changements
// (FileChange[]) n'est JAMAIS diffusée ici : elle est servie à la demande (REST) et poussée
// uniquement aux clients qui « regardent » le worktree (voir `worktree_changes`).
/** fichiers avec des modifications indexées (staged). */
stagedCount?: number;
/** fichiers avec des modifications non indexées (unstaged, untracked inclus). */
unstagedCount?: number;
/** fichiers en conflit de merge. */
conflictCount?: number;
/** hash court du dernier commit (HEAD), null si le dépôt n'a aucun commit. */
lastCommitHash?: string | null;
/** sujet (première ligne) du dernier commit. */
lastCommitSubject?: string | null;
}
export interface WorktreeSummary {
repoId: string;
/** chemin absolu du worktree (clé de corrélation avec le cwd des sessions). */
path: string;
branch: string | null;
head: string;
detached: boolean;
locked: boolean;
prunable: boolean;
isMain: boolean;
git: WorktreeGitStatus;
/** sessions corrélées par cwd (managées + découvertes) ; leur `activity` est remplie en P3-B. */
sessions: SessionSummary[];
}
// ---- Groupes de travail (P5) ----
// Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur
// plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ;
// les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client.
export interface GroupSummary {
id: string;
label: string;
description: string | null;
/** couleur d'accent UI (hex `#rrggbb`) ou null. */
color: string | null;
/** ids de repos membres, ordonnés par leur position dans le groupe. */
repoIds: string[];
createdAt: string;
updatedAt: string;
}
// ---- Réglages diffusables (P11) ----
// Sous-ensemble NON sensible des réglages, transporté tel quel par `settings_update` et par la
// réponse REST GET /settings (source de vérité unique du type). JAMAIS de secret ici.
export interface SettingsBroadcast {
/** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */
scanRoots: string[];
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
scanIntervalMin: number;
/** chemin explicite du binaire `claude` ; null = auto-détection via PATH. */
claudeBinPath: string | null;
/** override de la racine ~/.claude ; null = défaut. */
claudeHome: string | null;
/** rétention des sessions terminées : auto-archivage après N jours ; 0 = jamais (P10). */
retentionDays: number;
/** purge définitive après N jours ; 0 = désactivée. */
purgeDays: number;
}
// ---- Messages client → serveur ----
export type ClientMessage =
| { type: 'hello'; protocol: number }
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
| { type: 'detach'; channel: number }
| { type: 'stdin'; channel: number; data: string }
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
// en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny')
// et valide l'option contre le dialogue courant (anti-frappe fantôme mobile).
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
| { type: 'resize'; channel: number; cols: number; rows: number }
| { type: 'ack'; channel: number; bytes: number }
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups' | 'settings'> }
// P7 — abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail
// qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du
// topic global 'worktrees' (qui ne transporte que les compteurs légers).
| { type: 'watch'; repoId: string; path: string }
| { type: 'unwatch'; repoId: string; path: string }
| { type: 'ping' };
// ---- Messages serveur → client ----
export type ServerMessage =
| { type: 'hello_ok'; protocol: number; serverVersion: string }
| { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean }
| { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' }
| { type: 'control_changed'; channel: number; controlling: boolean }
| { type: 'session_update'; session: SessionSummary }
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
// P10 — une session managée vient d'être archivée (auto par ancienneté ou manuellement). Signal
// léger : le client met à jour son row (badge archived) et le retire si « Show archived » est off.
| { type: 'session_archived'; sessionId: string }
// P11 — un réglage a changé (PATCH /settings) : on diffuse le snapshot non sensible aux abonnés
// du topic 'settings' pour que tous les clients (et onglets) se synchronisent sans polling.
| { type: 'settings_update'; settings: SettingsBroadcast }
| { type: 'repo_update'; repo: RepoSummary }
| { type: 'repo_removed'; repoId: string }
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
| { type: 'worktree_removed'; repoId: string; path: string }
// P7 — signal léger « le détail (diff/fichiers modifiés) de ce worktree a changé, refais un
// GET /changes ou /diff ». Envoyé UNIQUEMENT aux connexions ayant `watch`é cette clé. On ne
// pousse pas le diff complet dans la frame (taille/flow control) : le client re-fetch en REST.
| { type: 'worktree_changes'; repoId: string; path: string }
| { type: 'group_update'; group: GroupSummary }
| { type: 'group_removed'; groupId: string }
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
| { type: 'pong' };
export type ErrorCode =
| 'BAD_PROTOCOL'
| 'BAD_MESSAGE'
| 'NOT_FOUND'
| 'NOT_ATTACHED'
| 'NOT_CONTROLLING'
| 'SESSION_EXITED'
| 'INVALID_ANSWER'
| 'INTERNAL';
export function parseClientMessage(raw: string): ClientMessage | null {
let obj: unknown;
try {
obj = JSON.parse(raw);
} catch {
return null;
}
if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null;
const m = obj as Record<string, unknown>;
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 0xffffffff;
const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 2 && v <= 1000;
switch (m.type) {
case 'hello':
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
case 'attach':
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
: null;
case 'detach':
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
case 'stdin':
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
? { type: 'stdin', channel: m.channel, data: m.data }
: null;
case 'answer': {
if (!isU32(m.channel)) return null;
if (m.action === 'select')
return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99
? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number }
: null;
return m.action === 'confirm' || m.action === 'deny'
? { type: 'answer', channel: m.channel, action: m.action }
: null;
}
case 'resize':
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
: null;
case 'ack':
return isU32(m.channel) && isCount(m.bytes)
? { type: 'ack', channel: m.channel, bytes: m.bytes }
: null;
case 'sub':
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings')
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups' | 'settings'> }
: null;
case 'watch':
case 'unwatch':
// repoId/path bornés ici (typage + longueur) ; l'existence repo/worktree et la sûreté du
// chemin sont re-validées côté serveur (isSafeAbsolutePath + findWorktree) avant d'armer.
return typeof m.repoId === 'string' && m.repoId.length > 0 && m.repoId.length <= 128 &&
typeof m.path === 'string' && m.path.length > 0 && m.path.length <= 4096
? { type: m.type, repoId: m.repoId, path: m.path }
: null;
case 'ping':
return { type: 'ping' };
default:
return null;
}
}