fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule
Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde. 1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off, inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis une session Claude Code, il héritait donc de ses marqueurs d'exécution et les repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et coupait la sauvegarde de son transcript : plus d'historique, plus de --resume, claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui explique les sessions sans activité détectée). L'environnement des PTY est desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un `claude` tapé à la main en héritait aussi). La configuration légitime de l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte. Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement volontairement pollué, et le PTY n'en voit plus rien. 2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4 détectait le remplacement du binaire et proposait un dialogue « Restart now » : le travail restait à la charge de l'utilisateur. L'app redémarre maintenant d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne demande que s'il y a quelque chose à perdre : des sessions vivantes à interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later » reste définitif pour cette version : rien ne redémarre dans le dos de personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou tout un répertoire.
This commit is contained in:
@@ -18,7 +18,7 @@ import { registerClipboardBridge } from './clipboard';
|
||||
import { initUpdater } from './updater';
|
||||
import { resolveIconPath } from './paths';
|
||||
import { describeStartFailure } from './start-failure';
|
||||
import { installChanged, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
||||
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
||||
|
||||
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
||||
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
||||
@@ -44,6 +44,11 @@ let serverReady = false;
|
||||
const bootStamp = readInstallStamp(process.execPath);
|
||||
let dismissedStamp: InstallStamp | null = null;
|
||||
let restartPromptOpen = false;
|
||||
/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur
|
||||
* ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */
|
||||
let installPoll: { stop: () => void } | null = null;
|
||||
/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */
|
||||
const INSTALL_POLL_MS = 30_000;
|
||||
|
||||
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
@@ -103,6 +108,16 @@ async function bootstrap(): Promise<void> {
|
||||
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
|
||||
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
|
||||
initUpdater();
|
||||
// Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte
|
||||
// n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais.
|
||||
if (app.isPackaged && !installPoll) {
|
||||
installPoll = pollInstall({
|
||||
path: process.execPath,
|
||||
intervalMs: INSTALL_POLL_MS,
|
||||
boot: bootStamp,
|
||||
onChanged: () => void handleUpgradeInstalled(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
|
||||
@@ -205,33 +220,71 @@ app.on('activate', showWindow);
|
||||
|
||||
function showWindow(): void {
|
||||
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
|
||||
// l'app, donc le bon moment pour signaler une mise à jour installée entre-temps.
|
||||
void maybePromptRestartAfterUpgrade();
|
||||
// l'app, donc le bon moment pour traiter une mise à jour installée entre-temps.
|
||||
void handleUpgradeInstalled();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage
|
||||
* détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors.
|
||||
* Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas.
|
||||
*/
|
||||
async function countLiveSessions(): Promise<number | null> {
|
||||
const handle = daemon;
|
||||
if (!handle || !serverReady) return null;
|
||||
try {
|
||||
const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> };
|
||||
return (body.sessions ?? []).filter((s) => s.live).length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
|
||||
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
|
||||
*
|
||||
* Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on
|
||||
* redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon
|
||||
* ne répond pas. Un « Later » précédent est définitif pour cette version.
|
||||
*/
|
||||
async function maybePromptRestartAfterUpgrade(): Promise<void> {
|
||||
if (restartPromptOpen || isQuitting || !app.isPackaged) return;
|
||||
async function handleUpgradeInstalled(): Promise<void> {
|
||||
if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return;
|
||||
const current = readInstallStamp(process.execPath);
|
||||
if (!installChanged(bootStamp, current)) return;
|
||||
if (dismissedStamp && !installChanged(dismissedStamp, current)) return;
|
||||
if (!current || !installChanged(bootStamp, current)) return;
|
||||
|
||||
const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current);
|
||||
const liveSessions = dismissed ? 0 : await countLiveSessions();
|
||||
const action = decideUpgradeAction({ changed: true, liveSessions, dismissed });
|
||||
if (action === 'none') return;
|
||||
|
||||
if (action === 'restart') {
|
||||
// Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant.
|
||||
installPoll?.stop();
|
||||
installPoll = null;
|
||||
relaunchAfterQuit = true;
|
||||
quitApp();
|
||||
return;
|
||||
}
|
||||
|
||||
restartPromptOpen = true;
|
||||
try {
|
||||
const running = liveSessions ?? 0;
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Arboretum',
|
||||
message: 'A new version of Arboretum has been installed',
|
||||
detail:
|
||||
`This window still runs version ${app.getVersion()}, started before the update. ` +
|
||||
'Restart to load the installed version. Running sessions will be stopped.',
|
||||
(running > 0
|
||||
? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.`
|
||||
: 'Restart to load the installed version.'),
|
||||
buttons: ['Restart now', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
|
||||
@@ -26,3 +26,46 @@ export function installChanged(boot: InstallStamp | null, current: InstallStamp
|
||||
if (!boot || !current) return false;
|
||||
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qu'il faut faire d'une mise à jour installée à chaud.
|
||||
*
|
||||
* Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien,
|
||||
* on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par
|
||||
* le daemon (des agents en train de travailler) : là seulement, on demande.
|
||||
*
|
||||
* `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence.
|
||||
* Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne
|
||||
* redémarre jamais dans le dos de quelqu'un qui a dit non.
|
||||
*/
|
||||
export function decideUpgradeAction(input: {
|
||||
changed: boolean;
|
||||
liveSessions: number | null;
|
||||
dismissed: boolean;
|
||||
}): 'none' | 'restart' | 'ask' {
|
||||
if (!input.changed || input.dismissed) return 'none';
|
||||
return input.liveSessions === 0 ? 'restart' : 'ask';
|
||||
}
|
||||
|
||||
/**
|
||||
* Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire
|
||||
* (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de
|
||||
* fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en
|
||||
* coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller.
|
||||
*/
|
||||
export function pollInstall(opts: {
|
||||
path: string;
|
||||
intervalMs: number;
|
||||
boot: InstallStamp | null;
|
||||
onChanged: (current: InstallStamp) => void;
|
||||
}): { stop: () => void } {
|
||||
const timer = setInterval(() => {
|
||||
const current = readInstallStamp(opts.path);
|
||||
if (!current || !installChanged(opts.boot, current)) return;
|
||||
clearInterval(timer);
|
||||
opts.onChanged(current);
|
||||
}, opts.intervalMs);
|
||||
// Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter.
|
||||
timer.unref?.();
|
||||
return { stop: () => clearInterval(timer) };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installChanged, readInstallStamp } from '../src/main/upgrade-watch';
|
||||
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
|
||||
|
||||
describe('upgrade-watch', () => {
|
||||
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
|
||||
@@ -31,3 +31,70 @@ describe('upgrade-watch', () => {
|
||||
expect(installChanged(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
|
||||
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
|
||||
describe('decideUpgradeAction', () => {
|
||||
it('rien à faire si l’installation n’a pas changé', () => {
|
||||
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
|
||||
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
|
||||
});
|
||||
|
||||
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
|
||||
});
|
||||
|
||||
it('des sessions tournent : on demande avant de les interrompre', () => {
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
|
||||
});
|
||||
|
||||
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
|
||||
});
|
||||
|
||||
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
|
||||
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollInstall', () => {
|
||||
it('signale le premier changement, puis s’arrête de lui-même', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
|
||||
const file = join(dir, 'app');
|
||||
try {
|
||||
writeFileSync(file, 'v1');
|
||||
const boot = readInstallStamp(file);
|
||||
const seen: number[] = [];
|
||||
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(seen).toHaveLength(0); // rien n'a bougé
|
||||
|
||||
writeFileSync(file, 'v2-plus-long');
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
|
||||
writeFileSync(file, 'v3-encore-plus-long');
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(seen).toHaveLength(1);
|
||||
handle.stop();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('un chemin illisible ne déclenche rien', async () => {
|
||||
const seen: string[] = [];
|
||||
const handle = pollInstall({
|
||||
path: '/definitely/not/here',
|
||||
intervalMs: 10,
|
||||
boot: { ino: 1, mtimeMs: 1, size: 1 },
|
||||
onChanged: () => seen.push('x'),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(seen).toHaveLength(0);
|
||||
handle.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user