The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
After several hours of migration the archive is what one wanted anyway,
and the prompt asked for it with an empty default: pressing Enter — or
letting auto-run answer — walked past the only artifact of the whole run.
The three-way answer moved into a function of its own, because the third
way is the one that traps. « n » refuses, « y » or Enter takes the
timestamped name, and EVERYTHING ELSE is a filename. So « non.zip » is a
file, not a refusal: comparing the whole answer rather than its first
letter is what keeps it one.
--- FR ---
[FIX] migration : Entrée exporte la sauvegarde à la fin
Au bout de plusieurs heures de migration, l'archive est ce qu'on voulait
de toute façon, et l'invite la proposait avec un défaut vide : appuyer sur
Entrée — ou laisser l'auto-exécution répondre — passait à côté du seul
artefact de toute la course.
La réponse à trois sens est devenue une fonction à part, car c'est le
troisième qui piège. « n » refuse, « y » ou Entrée prend le nom horodaté,
et TOUT LE RESTE est un nom de fichier. « non.zip » est donc un fichier et
non un refus : comparer la réponse entière plutôt que sa première lettre
est ce qui le garantit.
Assisted-by: Claude Opus 5
The statistics screen answers « what was removed, and why ». The state
screen answers « where are we, and what broke ». Two neighbouring
questions asked at the same moment: separating them by a menu entry
rather than by two commands to remember is what makes them usable.
Same letter as the prompts. One that changes meaning from one screen to
the next is not learned, it is looked up — and nobody looks it up.
Adding it surfaced two ways that screen could not survive auto-run. The
interface question is the FIRST one asked after auto-run is switched on,
and it was a bare input(): an unattended migration stopped there before
it had begun. The statistics menu was one too. And the state screen
writes the progression before reading it — but the statistics screen runs
before the progression is even loaded, so that write failed on an
attribute that does not exist yet.
--- FR ---
[ADD] migration : atteindre l'état depuis les statistiques aussi
L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui
de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux
questions voisines, posées au même moment : les séparer par une entrée de
menu plutôt que par deux commandes à retenir est ce qui les rend
utilisables.
La même lettre que dans les invites. Une lettre qui change de sens d'un
écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la
cherche.
L'ajout a révélé deux façons dont cet écran ne survivait pas au mode
auto : la question d'interface, PREMIÈRE posée après l'activation, était
un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit
la progression avant de la lire, alors que les statistiques s'ouvrent
avant même qu'elle ne soit chargée.
Assisted-by: Claude Opus 5
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
The back-office pass was already there, and it works. What did not work
was the way it declined: one discreet line at the end of a long report,
saying « the database was not neutralized ». On six databases of a real
migration the test user is present up to the 15 bump and GONE at 17 and
18 — so the pass stopped silently exactly where a migration does the most
damage, and said something that was not even true.
The migration knows what it neutralized, so it now asks for the back
office by name. A missing test user on a database it neutralized is a
finding, printed loudly and counted as a failure. A missing tool is too:
returning None made the whole pass vanish without a word.
And it says UP FRONT which passes will run, on that database, by name.
--- FR ---
[FIX] migration : un back-office sauté ne doit pas se lire comme un sain
La passe back-office était déjà là et elle fonctionne. Ce qui ne
fonctionnait pas, c'est sa façon de renoncer : une ligne discrète en fin
d'un long rapport, disant « la base n'a pas été neutralisée ». Sur les six
bases d'une vraie migration, l'utilisateur test est présent jusqu'au
palier 15 et ABSENT en 17 et 18 — la passe s'arrêtait donc sans bruit là
où une migration fait le plus de dégâts, en disant quelque chose de faux.
La migration sait ce qu'elle a neutralisé : elle réclame désormais le
back-office. Un utilisateur test manquant sur une base qu'elle a
neutralisée est une trouvaille, affichée fort et comptée comme un échec.
Un outil absent aussi : rendre None faisait disparaître la passe entière.
Et elle annonce AVANT de lancer ce qui sera parcouru.
Assisted-by: Claude Opus 5
Six « press to continue » prompts on the module-code migration were bare
input() calls. Auto-run never reached them, so it stopped there and said
nothing — the question had been asked, after all. They now go through the
timed reader, where Enter and the countdown mean the same thing.
They were English too, on a path the rest of which speaks the system
language.
The guard that forbids a bare input() only looked at a handful of
methods, and internal_module_upgrade was not among them — which is
exactly how these six survived the first sweep. It is now covered, and
the module menu's own prompts are documented as deliberately blocking:
they ask for a module name, a path, a version, and « » after five
seconds would be a wrong answer, not a convenience.
--- FR ---
[FIX] migration : l'étape de migration de code n'attend plus indéfiniment
Six invites « appuyez pour continuer » de la migration de code étaient des
input() nus. L'auto-exécution ne les atteignait pas : elle s'arrêtait là
sans rien dire — la question avait bien été posée. Elles passent
désormais par le lecteur temporisé, où Entrée et le compte à rebours font
la même chose.
Elles étaient aussi en anglais, sur un chemin dont tout le reste parle la
langue du système.
Le garde-fou qui interdit les input() nus ne regardait qu'une poignée de
méthodes, et internal_module_upgrade n'en faisait pas partie — c'est
ainsi que ces six-là avaient survécu. Il la couvre maintenant, et les
invites du menu de modules sont documentées comme bloquantes à dessein :
elles demandent un nom, un chemin, une version.
Assisted-by: Claude Opus 5
The failure that stops an upgrade is almost always the same one — a COW
copy left behind on the previous version — and the repair is known. So
Enter now repairs, and a repair that actually changed something replays
the command by itself.
A default that acts must know when to stop, and here it must twice over.
Repairing when there is nothing left to repair, then offering it again,
loops without end: measured, « no COW copy has drifted » over and over.
And a repair that does not help would replay the command forever. Three
attempts, then the prompt says plainly that this one needs a developer.
The turn counter is deliberately redundant with that logic: both guard
the same failure, but the counter holds even if the logic is broken one
day by accident. An endless loop in an unattended migration costs a
night.
Also: the smoke tool forced `ask=input` on its own prompt, which
short-circuited auto-run — the question waited for a keystroke nobody
was there to give.
--- FR ---
[FIX] migration : réparer, rejouer, et savoir s'arrêter
L'échec qui arrête une migration est presque toujours le même — une copie
COW restée sur la version d'avant — et la réparation est connue. Entrée
répare donc, et une réparation qui a vraiment changé quelque chose rejoue
la commande d'elle-même.
Un défaut qui agit doit savoir s'arrêter, et ici deux fois plutôt qu'une.
Réparer quand il n'y a plus rien à réparer, puis le reproposer, boucle
sans fin : mesuré, « Aucune copie COW n'a dérivé », encore et encore. Et
une réparation qui ne suffit pas rejouerait indéfiniment. Trois
tentatives, puis l'invite dit qu'il faut un développeur.
Le compteur de tours double volontairement cette logique : il tient même
si elle est cassée un jour par mégarde.
Assisted-by: Claude Opus 5
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
The public smoke test opens what a visitor reaches. It says nothing about
the back office, which is where a migration does most of its damage: a
field dropped from a model but still named in a form, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit rien du
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
champ retiré du modèle mais toujours nommé dans un formulaire, un module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
A migration asks dozens of questions whose answer is almost always the
one offered, and someone has to stand there pressing Enter for hours.
Asked before the version choice — so it covers that choice too, now
that it has a default — and off by default: it decides in your place.
Every prompt of the migration goes through one reader, seventeen of
them. A prompt left as a bare input() would know nothing of auto mode
and would block the run with nothing to say why; a test reads the
function and rejects any that appears.
click.prompt had to go: it cannot hand back control after a delay, so
auto would have stopped at the first question. select() rather than a
thread or an alarm — a thread would leave a blocked input() behind it,
stealing the next keystroke.
--- FR ---
[ADD] migration : une auto-exécution qui prend le défaut après cinq secondes
Une migration pose des dizaines de questions dont la réponse est presque
toujours celle proposée, et il faut rester devant à taper Entrée pendant
des heures. Posée avant le choix de version — donc elle vaut aussi pour
lui, maintenant qu'il a un défaut — et éteinte par défaut : elle décide
à votre place.
Toutes les invites de la migration passent par une seule lecture,
dix-sept d'un coup. Une invite laissée en input() nu ne saurait rien du
mode auto et bloquerait la migration sans rien dire ; un test lit la
fonction et refuse toute apparition.
click.prompt devait partir : il ne sait pas rendre la main après un
délai, l'auto se serait arrêté à la première question. select() plutôt
qu'un fil ou une alarme — un fil laisserait un input() bloqué derrière
lui, qui volerait la frappe suivante.
Assisted-by: Claude Opus 5
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.
Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.
The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.
--- FR ---
[ADD] migration : parcourir les copies COW dérivées en plein écran
Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.
Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.
Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.
Assisted-by: Claude Opus 5
Without a starting point, a page that already answered 500 reads as
damage done by the migration, and the search goes the wrong way for
hours. Measured on the real one: two URLs were already failing before
anything had been migrated.
Asked in step 2, on the database before the bump — not on a bump
database that does not exist yet — and it says what it measures: a page
broken now will still be broken after, and that is not the migration.
--- FR ---
[ADD] migration : mesurer les URL publiques avant le premier palier aussi
Sans point de départ, une page qui rendait déjà 500 se lit comme un
dégât de la migration, et l'on cherche des heures du mauvais côté.
Mesuré sur la vraie : deux URL cassaient avant que quoi que ce soit
n'ait été migré.
Posée à l'étape 2, sur la base d'avant le palier — pas sur une base de
palier qui n'existe pas encore — et elle dit ce qu'elle mesure : une
page cassée maintenant le sera encore après, et ce ne sera pas la
migration.
Assisted-by: Claude Opus 5
Answering « all » reset one copy of two and reported success. Two
defects behind it, both mine.
A requested key matching no finding did nothing, silently: the tool
returned early on « nothing drifted » and never looked. Detection is
differential — it only sees a copy whose CHILD breaks — so a stale copy
without children escapes it. A key can now be reset outside detection,
an unknown one is named, and the exit code is 2.
And the culprit is not always the parent. On /contactus the parent was
identical to its module view; the CHILD held the stale arch. Both are
proposed now, parent first — it is the more common case.
Measured on the migration: 33 of 33 public URLs answer.
--- FR ---
[FIX] migration : réinitialiser la copie vraiment périmée, et signaler une clé sans objet
Répondre « toutes » réinitialisait une copie sur deux et annonçait un
succès. Deux défauts derrière, tous deux à moi.
Une clé demandée ne correspondant à aucun constat ne faisait rien, en
silence : l'outil sortait sur « rien n'a dérivé » sans jamais chercher.
La détection est différentielle — elle ne voit qu'une copie dont un
ENFANT casse — donc une copie périmée sans enfant lui échappe. Une clé
peut désormais être réinitialisée hors détection, une clé inconnue est
nommée, et le code de sortie vaut 2.
Et le coupable n'est pas toujours le parent. Sur /contactus, le parent
était identique à sa vue module ; c'est l'ENFANT qui portait l'arch
périmée. Les deux sont proposés, le parent d'abord — le cas le plus
fréquent.
Mesuré sur la migration : 33 URL publiques sur 33 répondent.
Assisted-by: Claude Opus 5
The theme uninstaller ends by asking what to do with the leftovers, and
the migration ran it through the capturing executor. Its stdout was a
pipe, Python buffers by blocks, so the prompt stayed invisible while the
process waited. It reads as a freeze: you press Enter blind, the first
keystroke answers unseen and the extra ones fall into the next question.
Two locks, because one is not enough. The migration runs the script on
the real terminal. And the three tools that ask now require BOTH ends:
something to read the answer from, and something to show the question
on. Guarding on stdin alone was the bug — measured, stdin tty=True with
stdout tty=False, and the question was asked into a pipe.
--- FR ---
[FIX] migration : ne jamais poser une question que le terminal ne montre pas
Le désinstalleur de thème finit par demander quoi faire des restes, et
la migration le lançait par l'exécuteur qui capture. Sa sortie partait
dans un tube, Python bufferise par blocs, et l'invite restait invisible
pendant l'attente. Cela se lit comme un blocage : on tape Entrée à
l'aveugle, la première frappe répond sans être vue et les suivantes
tombent dans la question d'après.
Deux verrous, car un seul ne suffit pas. La migration lance le script
sur le vrai terminal. Et les trois outils qui questionnent exigent
désormais les DEUX bouts : de quoi lire la réponse, et de quoi montrer
la question. Ne garder que stdin était le défaut — mesuré, stdin
tty=True et stdout tty=False, la question partait dans un tube.
Assisted-by: Claude Opus 5
click now receives the rank of the highest version, so it prints « [6] »
and returns « 6 » on Enter. The answer follows the normal path — the
special case for an empty line is gone, and with it a second behaviour
to keep in agreement with the first.
The rank is computed, never written down: 6 is where 18.0 sits today,
and one more version in the catalogue moves it. A test replays a
catalogue with 19.0 and expects [7].
--- FR ---
[UPD] migration : laisser click porter le défaut, calculé sur le menu
click reçoit désormais le rang de la version la plus haute : il affiche
« [6] » et rend « 6 » sur Entrée. La réponse suit le chemin normal — le
cas particulier de la ligne vide disparaît, et avec lui un second
comportement à tenir d'accord avec le premier.
Le rang est calculé, jamais écrit : 6 est la place de 18.0 aujourd'hui,
et une version de plus au catalogue la déplace. Un test rejoue un
catalogue avec 19.0 et attend [7].
Assisted-by: Claude Opus 5
(cherry picked from commit 5f0b0110d610187298e0129e45cef28e14bba092)
Migrating means going all the way, and that choice had to be typed
every time. Enter did nothing visible: click.prompt without a default
re-asks on an empty line without printing anything, so a default added
without `default=""` would never have been reached and nothing would
have said so.
The highest is computed, not read off the end of the list: display
order is not a guarantee, and « 9.0 » sorts after « 18.0 » as a string.
The prompt announces it — a default nobody sees is a default nobody
uses. No choice at all means no default rather than a crash.
--- FR ---
[ADD] migration : Entrée vise la version Odoo la plus élevée
Migrer, c'est aller au bout, et ce choix devait être tapé chaque fois.
Entrée ne faisait rien de visible : click.prompt sans défaut redemande
sur une ligne vide sans rien afficher, si bien qu'un défaut ajouté sans
`default=""` n'aurait jamais été atteint, sans que rien ne le signale.
La plus haute est calculée, pas lue au bout de la liste : l'ordre
d'affichage n'est pas une garantie, et « 9.0 » se trie après « 18.0 »
en chaînes. L'invite l'annonce — un défaut qu'on ne montre pas est un
défaut que personne n'utilise. Aucun choix possible ne donne aucun
défaut plutôt qu'une erreur.
Assisted-by: Claude Opus 5
(cherry picked from commit f0861363f496cdc3c0bb02216d30dd0aa8d3697b)
The smoke test stopped at « these URLs answer 500 ». Turning that into a
fix meant reading an id out of a traceback and translating it to a key
by hand, mid-migration — the copy where a character goes missing.
It now reads the error context Odoo logs — [view_id: N, parent_id: M],
the one line it does not translate — resolves the parent to its key,
offers the reset numbered with « all », and re-requests the failing URLs
afterwards. Applying without re-asking would be calling it fixed
without having seen it answer.
Two defects found while measuring, both mine. ./run.sh is a bash
wrapper: terminate() killed it and left odoo-bin holding the port, six
orphans in six runs, each later run silently querying the first one's
server. And the log was read before stopping, so only the 24 startup
lines existed — hence « no view in cause » on pages that named one.
--- FR ---
[ADD] migration : nommer les vues derrière une URL en échec, et corriger
Le test s'arrêtait à « ces URL répondent 500 ». En faire un correctif
demandait de relever un id dans une trace et de le traduire en clé à la
main, en pleine migration — la recopie où un caractère se perd.
Il lit désormais le contexte qu'Odoo journalise — [view_id: N,
parent_id: M], la seule ligne qu'il ne traduit pas —, résout le parent
en clé, propose la réinitialisation numérotée avec « toutes », et
redemande ensuite les URL en échec. Appliquer sans redemander, ce
serait déclarer réparé sans l'avoir vu répondre.
Deux défauts trouvés en mesurant, tous deux à moi. ./run.sh est une
enveloppe bash : terminate() la tuait et laissait odoo-bin tenir le
port, six orphelins en six essais, chaque essai suivant interrogeant
sans le savoir le serveur du premier. Et le journal était lu avant
l'arrêt, donc seules les 24 lignes de démarrage existaient — d'où
« aucune vue en cause » sur des pages qui en nommaient une.
Assisted-by: Claude Opus 5
(cherry picked from commit 30fbcd1aee20c4028ea6b24bd04eb518f0587ffe)
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.
The list is the sitemap, what Odoo publishes for search engines. It
cannot be read from odoo-bin shell: enumerate_pages() asks for
http.root.get_db_router(request.db) and raises « object unbound »
without a real request. So the server is started on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.
Offered before the Selenium prompt, default no: it boots a server and
can take minutes.
--- FR ---
[ADD] migration : interroger chaque URL publique avant de conclure
Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.
La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.
Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.
Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
« Command returned error code: 1 » followed a theme that was removed
correctly. 1 is this toolkit's « there are findings », but the report
was the uninstaller's last command, so its code became the script's;
and the COW check was still read through the capturing executor, which
announces any non-zero code as an error.
The leftovers can now be dealt with instead of only listed: keep by
default, or delete after their content is written to private/. Listing
fifteen attachments and stopping there meant composing an unlink() by
hand, mid-migration, from identifiers read off a screen.
--- FR ---
[FIX] addons : cesser d'appeler « erreur » un rapport de restes
« Command returned error code: 1 » suivait un thème correctement
retiré. 1 veut dire « il y a des constats » dans cet outillage, mais le
rapport était la dernière commande du désinstalleur, donc son code
devenait celui du script ; et la vérification COW passait encore par
l'exécuteur qui capture, lequel annonce tout code non nul comme une
erreur.
Les restes peuvent désormais être traités, pas seulement listés :
garder par défaut, ou effacer après écriture de leur contenu sous
private/. Lister quinze pièces jointes et s'arrêter là revenait à faire
composer un unlink() à la main, en pleine migration.
Assisted-by: Claude Opus 5
(cherry picked from commit 304ce32b2680e3d01e38c3224bb85c2798cd6f53)
The error prompt printed « --reset <key> --apply » and left the key to
be found in a thousand-line diff. Copying it by hand is where a
character goes missing, and a key matching no copy is not an error for
the tool: the command runs and does nothing, silently.
[3] now asks the tool for the keys, numbers them, and offers « all ».
An out-of-range or unreadable answer resets nothing — falling back to
« all » would act on what was never asked for.
--- FR ---
[ADD] migration : choisir la copie COW à réinitialiser dans une liste
L'invite d'erreur imprimait « --reset <key> --apply » et laissait
retrouver la clé dans un diff de mille lignes. La recopier à la main
est l'endroit où un caractère se perd, et une clé ne correspondant à
aucune copie n'est pas une erreur pour l'outil : la commande tourne et
ne fait rien, en silence.
[3] demande désormais les clés à l'outil, les numérote et offre
« toutes ». Une réponse hors liste ou illisible ne réinitialise rien —
retomber sur « toutes » agirait sur ce qui n'a pas été demandé.
Assisted-by: Claude Opus 5
(cherry picked from commit ae08e13fe5bba2b02b8661c67c4024fcd79f29bc)
The fix was offered while the checkout was still on the previous
version. Answering « a » ran reset_asset in an Odoo 12 shell, which has
no web_editor.assets: KeyError, nothing changed, and the migration went
on to break at the next bump — measured on a real run.
Predicting early is right; fixing early is not. The early call is now
--report-only, and a second call comes after the bump, on the upgraded
database, where the checkout can do it. The tool also refuses on its
own: it reads the checkout sources for reset_asset rather than trusting
a version number, and says when to come back.
--- FR ---
[FIX] migration : réinitialiser le SCSS périmé après le palier, pas avant
La correction était proposée alors que le checkout était encore sur la
version précédente. Répondre « a » lançait reset_asset dans un shell
Odoo 12, sans web_editor.assets : KeyError, rien de modifié, et la
migration continuait jusqu'à casser au palier suivant. Mesuré.
Prédire tôt est juste ; corriger tôt ne l'est pas. L'appel précoce est
désormais --report-only, et un second vient après le palier, sur la
base montée de version, là où le checkout sait le faire. L'outil refuse
aussi de lui-même : il cherche reset_asset dans les sources du checkout
plutôt que de se fier à un numéro, et dit quand revenir.
Assisted-by: Claude Opus 5
(cherry picked from commit 13d4d0a33c63efcbafb5fb29313a5377ba5f508e)
The report named the attachment and printed a command to paste. Deciding
« reset it » still meant accepting to lose one did not know what: the
diff against the module file is the only thing resetting gives up.
The tool now shows it and asks. --diff prints it, --tui browses it,
--apply resets without asking; with no flag and a terminal it asks, and
looking does not answer — the prompt comes back after each read.
--apply writes the copies under private/ first: reset_asset deletes the
attachment, so without that the customized lines would be nowhere.
The migration runs it on the real terminal, or none of this would be
reachable from there — the same pipe that was closing the TUI.
--- FR ---
[ADD] migration : lire le SCSS périmé, puis le corriger, sans sortir
Le rapport nommait la pièce jointe et imprimait une commande à coller.
Répondre « réinitialise » revenait encore à accepter de perdre on ne
sait quoi : l'écart avec le fichier du module est la seule chose que la
réinitialisation abandonne.
L'outil le montre et pose la question. --diff l'imprime, --tui le
parcourt, --apply réinitialise sans demander ; sans drapeau et devant
un terminal il demande, et regarder ne répond pas — l'invite revient
après chaque lecture. --apply écrit d'abord les copies sous private/ :
reset_asset supprime la pièce jointe, sans quoi les lignes
personnalisées ne seraient plus nulle part.
La migration le lance sur le vrai terminal, sinon rien de tout cela n'y
serait atteignable — le tube même qui fermait la TUI.
Assisted-by: Claude Opus 5
(cherry picked from commit 9e6c88622a74f7be408a8b1c523838eeadda1da3)
Customized SCSS lives in ir_attachment, frozen the day it is written,
still using the variables of that version. A bump can rename them:
website declared $o-theme-font-number in 12.0 and replaced the whole
mechanism in 13.0, so a 2020 customization stopped the frontend
bundle. Same shape as a stale COW view.
Reading the stored SCSS against the target sources answers before the
bump and names the attachment. Booting Odoo and opening a page answers
after, with « Style error » and no name — on a page a broken frontend
bundle is exactly what keeps you from reaching.
Measured: on the pre-bump database, targeting 13.0, it predicts the
failure; against its own 12.0 sources it reports nothing. That noise
floor is what makes it usable — the first version cried wolf on six
mixin parameters and named @include arguments.
--- FR ---
[ADD] migration : prédire quel SCSS personnalisé le palier va casser
Un SCSS personnalisé vit dans ir_attachment, figé le jour où il est
écrit, employant encore les variables de cette version-là. Un palier
peut les renommer : website déclarait $o-theme-font-number en 12.0 et a
remplacé le mécanisme en 13.0, arrêtant le bundle sur une
personnalisation de 2020. Même forme qu'une vue COW périmée.
Lire le SCSS stocké contre les sources cibles répond AVANT le palier et
nomme la pièce jointe. Démarrer Odoo et ouvrir une page répond après,
par « Style error » sans nom — et un bundle cassé est justement ce qui
empêche d'atteindre la page.
Mesuré : sur la base d'avant le palier, cible 13.0, il prédit la
panne ; contre ses propres sources 12.0 il ne signale rien. Ce bruit de
fond nul est ce qui le rend utilisable — la première version criait à
tort sur six paramètres de mixin et arguments nommés.
Assisted-by: Claude Opus 5
(cherry picked from commit c6611ee1431cfee94e321536d20052b8cca3ec10)
A theme carries view copies and SCSS through every version bump, and a
bump can rename what they rely on. Removing it first drops a whole
family of failures, and it can be put back afterwards.
Asked in step 1, before OpenUpgrade runs, and only when a theme is
actually installed. Default is no: removing a theme changes how a site
looks, and a migration does not decide that for its owner.
theme_default is not offered — it IS the absence of a theme.
--- FR ---
[ADD] migration : proposer de retirer les thèmes avant le premier palier
Un thème traîne des copies de vues et des SCSS à travers chaque palier
de version, et un palier peut renommer ce dont ils dépendent. Le
retirer d'abord enlève une famille entière de pannes, et se refait
ensuite.
Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un
thème installé. Par défaut non : retirer un thème change l'apparence
d'un site, et ce n'est pas à une migration de trancher cela à la place
de son propriétaire. theme_default n'est pas proposé — il EST
l'absence de thème.
Assisted-by: Claude Opus 5
(cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06)
There was no counterpart to install_addons_theme.sh, so themes were
removed with a plain --uninstall. That takes out the module, not the
theme: it skips _theme_remove(), whose first act is
_reset_default_config() — the call that writes font-number and its
three neighbours into user_values.scss.
Measured on a real 12 -> 13 migration: web.assets_frontend stopped on
« Undefined variable: $o-theme-font-number ». Odoo 12 defined it in
option_font_body_*, dropped in 13.0; only the theme still redefined
it, so removing the theme exposed a frozen 2020 customization.
theme_leftover.py then reports what unloading does not take — 15
attachments on the measured database. It deletes nothing: their
content may be the only trace of a customization.
--- FR ---
[ADD] addons : désinstaller un thème comme Odoo le retire
install_addons_theme.sh n'avait pas de symétrique : on retirait donc
les thèmes par un --uninstall nu. Cela enlève le module, pas le thème
— cela saute _theme_remove(), dont le premier geste est
_reset_default_config(), l'appel qui écrit font-number et ses trois
voisines dans user_values.scss.
Mesuré sur une vraie migration 12 → 13 : web.assets_frontend s'arrête
sur « Undefined variable: $o-theme-font-number ». Odoo 12 la
définissait dans option_font_body_*, supprimés en 13.0 ; seul le thème
la redéfinissait, et le retirer a mis à nu un SCSS figé en 2020.
theme_leftover.py signale ensuite ce que le déchargement ne prend pas
— 15 pièces jointes sur la base mesurée. Il ne supprime rien : leur
contenu peut être la seule trace d'une personnalisation.
Assisted-by: Claude Opus 5
(cherry picked from commit 16a7b1e1333752e2ef603d66f47374a22590d665)
Nine of the thirteen carried a shebang without the bit, so only four
could be run by their own path. Set to 755, not chmod +x: two were
664 under the 0002 umask and would have become 775 — executed by
others while a group member could still rewrite them. That pairing is
the only real risk here; the bit alone grants nothing, since reading
the file is enough to run python3 on it.
Being runnable opens a path without the venv: the shebang resolves to
the system python3, which has no Textual. --tui fell back to the text
report saying nothing. It now names the interpreter and the venv.
--- FR ---
[FIX] script : rendre exécutables les outils d'analyse et de migration
Neuf des treize portaient un shebang sans le bit ; quatre seulement
se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient
en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par
d'autres alors qu'un membre du groupe pouvait encore les réécrire.
C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le
fichier suffit déjà à lancer python3 dessus.
Devenir lançable ouvre un chemin sans le venv : le shebang résout le
python du système, sans Textual. --tui retombait sur le rapport texte
sans rien dire. Il nomme désormais l'interpréteur et le venv.
Assisted-by: Claude Opus 5
They run as subprocesses and had no i18n, so a French migration alternated
languages from one line to the next. The database upgrade itself had the
same gap.
The driver read English sentences out of their output to decide whether to
ask its question. Translating them would have made it mute -- no error, no
trace. The link is now the exit code, which no language touches: 0
nothing, 1 copies concerned, 2 the tool failed.
87 strings, five tools. A test rejects any displayed sentence that skips
t(), and proves itself on an untranslated one.
--- FR ---
Ils tournent en sous-processus et n'avaient aucun i18n : une migration en
français alternait les deux langues d'une ligne à l'autre. La mise à
niveau de la base elle-même souffrait du même manque.
Le pilote lisait des phrases anglaises dans leur sortie pour décider de
poser sa question. Les traduire l'aurait rendu muet — sans erreur, sans
trace. Le lien est désormais le code de sortie, qu'aucune langue ne
touche : 0 rien, 1 copies concernées, 2 l'outil a échoué.
87 chaînes, cinq outils. Un test refuse toute phrase affichée qui saute
t(), et fait sa preuve sur une chaîne non traduite.
Assisted-by: Claude Opus 5
Step 2 announced the copies the next bump will break, invited to
arbitrate, then moved on: the question came at the bump, tens of minutes
later. Yet looking writes nothing, and neutralizing here covers every bump
-- each bump database is a clone of this one.
The announcement is now a prompt: view what a copy holds, why it breaks,
full screen, or neutralize right away. Both prompts build their command in
one place, so they cannot drift apart.
Full screen means a real terminal: the viewer was started without one and
drew nothing, the pager exiting at once on a pipe.
--- FR ---
L'étape 2 annonçait les copies que le palier suivant casserait, invitait à
arbitrer, puis passait : la question venait au palier, des dizaines de
minutes plus tard. Or regarder n'écrit rien, et neutraliser ici vaut pour
tous les paliers — chaque base de palier est un clone de celle-ci.
L'annonce est désormais une invite : voir ce que porte une copie, pourquoi
elle casse, en plein écran, ou neutraliser tout de suite. Les deux invites
composent leur commande au même endroit, elles ne peuvent donc pas
diverger.
Le plein écran suppose un vrai terminal : la vue était lancée sans, et ne
dessinait rien, le pagineur sortant aussitôt sur un tube.
Assisted-by: Claude Opus 5
The screen could already replay from any step -- Enter on a row does it --
but that lived in a hint line under four named buttons. A capability you
have to guess is not offered, and it was reported as missing. It now has a
key, a button and a message, like the four others.
The b key added earlier went to the line-by-line prompts during the run,
not to this screen. Same letter on both, so going back is one key wherever
you are.
Two defects behind it. Replaying the chosen step actually SKIPPED it,
resuming at the next one. And a replayed step did not rewind the variables
it owns, so the second run started from the first one's leftovers.
Checked in a simulated terminal: b focuses the table, two arrows and Enter
return step 2, and the button focuses without choosing.
--- FR ---
L'écran savait déjà rejouer depuis n'importe quelle étape — Entrée sur une
rangée le fait — mais cela vivait dans une ligne d'indication sous quatre
boutons nommés. Une capacité qu'il faut deviner n'est pas offerte, et elle
a été signalée manquante. Elle a maintenant une touche, un bouton et un
message, comme les quatre autres.
La touche b ajoutée plus tôt s'adressait aux invites ligne à ligne pendant
l'exécution, pas à cet écran. Même lettre sur les deux : revenir en
arrière est une seule touche, où qu'on soit.
Deux défauts derrière cela. Rejouer l'étape choisie la SAUTAIT en fait,
reprenant à la suivante. Et une étape rejouée ne remettait pas à zéro les
variables qui lui appartiennent, si bien que la seconde exécution partait
des restes de la première.
Vérifié en terminal simulé : b donne le focus au tableau, deux flèches et
Entrée ramènent l'étape 2, et le bouton met le focus sans choisir.
Assisted-by: Claude Opus 5
Realising at a prompt that an earlier step deserved another answer had one
way out: Ctrl+C. That leaves the progression as it stands and makes you find
the resume screen again to rewind. « b » does it properly — it shows the
steps, rewinds the state, writes it, and says what to relaunch.
Cancelling that must not stop the migration, which is the trap: it returns to
the same prompt, exactly where you were. Removing that guard makes two tests
fail.
The COW warning printed « -d DB -t odooXX.0 » for commands meant to be pasted.
It knows both values, so it prints them, and offers --shape as well since that
is half the answer.
Checked on the four outcomes: a normal answer passes through, « b » then a
step rewinds and stops, cancelling and an unknown step both continue. 15 tests.
--- FR ---
S'apercevoir à une invite qu'une étape antérieure méritait un autre choix
n'avait qu'une issue : Ctrl+C. Cela laisse la progression telle quelle et
oblige à retrouver l'écran de reprise pour rembobiner. « b » le fait
proprement — il montre les étapes, rembobine l'état, l'écrit, et dit quoi
relancer.
Y renoncer ne doit pas arrêter la migration, et c'est le piège : on revient à
la même invite, exactement là où l'on était. Retirer ce garde-fou fait tomber
deux tests.
L'avertissement COW affichait « -d DB -t odooXX.0 » pour des commandes faites
pour être collées. Il connaît les deux valeurs, donc il les écrit, et propose
aussi --shape puisque c'est la moitié de la réponse.
Vérifié sur les quatre issues : une réponse normale passe, « b » puis une
étape rembobine et arrête, annuler et un choix inconnu continuent. 15 tests.
Assisted-by: Claude Opus 5
An early check warns, hours before the bump, that a copy will break it. It
then says « arbitrate BEFORE launching the migration » and hands over a raw
UPDATE — and no question follows. Read on a resumed run it looks like the
question already went by and was skipped, which is exactly how it was read.
It now says the migration asks at the bump itself, and shows what each copy
holds before the answer. The manual UPDATE stays, but after the two commands
that do it reversibly: telling someone to hand-write SQL when --restore exists
is offering the sharper tool first.
--- FR ---
Un contrôle précoce prévient, des heures avant le palier, qu'une copie va le
casser. Il dit ensuite « arbitrez AVANT de lancer la migration » et livre un
UPDATE brut — et aucune question ne suit. Lu au cours d'une reprise, on croit
que la question est passée et a été sautée, ce qui est exactement la lecture
qui en a été faite.
Il dit maintenant que la migration pose la question au palier lui-même, en
montrant ce que chaque copie contient avant qu'on réponde. L'UPDATE manuel
reste, mais après les deux commandes qui le font de façon réversible : dire à
quelqu'un d'écrire du SQL à la main quand --restore existe, c'est tendre
l'outil le plus coupant en premier.
Assisted-by: Claude Opus 5
A database coming from an old version is updated before the neutralization —
the tool offers it at the very start. That work IS step 2, done earlier, but
it was only recorded as state_1_update_all while the screen reads state_2_*.
So the step stayed « not started » right after running.
The costly half was invisible. Step 2 skipped the work through a local
variable, which is False again on a resume: with only the old flag written,
resuming a migration re-ran update_addons_all on an already-updated database.
Hours, for nothing. The check now reads the written trace, not the session.
The screen says when it happened rather than just « done », since the moment
is exactly what raised the doubt. Logs written before this fix read correctly
without being touched — the early flag alone is recognised.
Checked on the real log from the VM: step 2 goes from « not started » to
« done early, before the neutralization », the four other steps unchanged.
10 tests; removing the fix makes the resume one fail.
--- FR ---
Une base venant d'une vieille version se met à jour avant la neutralisation —
l'outil le propose au tout début. Ce travail EST l'étape 2, faite plus tôt,
mais il n'était enregistré que sous state_1_update_all quand l'écran lit
state_2_*. L'étape restait donc « non démarrée » juste après avoir tourné.
La moitié coûteuse était invisible. L'étape 2 sautait le travail grâce à une
variable locale, remise à False à la reprise : avec seulement l'ancien drapeau
écrit, reprendre une migration relançait update_addons_all sur une base déjà à
jour. Des heures, pour rien. Le contrôle lit maintenant la trace écrite, pas
la session.
L'écran dit quand cela a eu lieu plutôt que « terminée », le moment étant
justement la source du doute. Les journaux écrits avant ce correctif se lisent
correctement sans être modifiés — le drapeau précoce seul est reconnu.
Vérifié sur le vrai journal de la VM : l'étape 2 passe de « non démarrée » à
« faite plus tôt, avant la neutralisation », les quatre autres inchangées.
10 tests ; retirer le correctif fait tomber celui de la reprise.
Assisted-by: Claude Opus 5
The progression log lives at one path and the next migration writes over it.
Restarting therefore erased everything known about the run before — which
steps passed, which modules were missing, how long each took — and that is
exactly what someone looks for after having had to restart.
Both answers that discard the progression now copy it aside first, named after
the origin database and the moment of the copy, so two attempts on the same
database do not cover each other and a file moved out of its folder still says
what it is. Partial replays keep the log, so they archive nothing.
It lands under private/, not beside the original: reinstalling wipes
.venv.erplibre/ and would take the history with it.
Two copies in the same second overwrote each other in silence — undoing the
very loss this prevents. The second one is numbered now, and the test that
had skipped that case asserts it.
Checked on the real 51 KB log from the VM: 18 states copied unchanged, the
name carries the database and the timestamp, and a log with no state at all is
not archived. 12 tests.
--- FR ---
Le journal de progression vit à un seul endroit et la migration suivante écrit
par-dessus. Recommencer effaçait donc tout ce qu'on savait de la tentative
précédente — quels paliers étaient passés, quels modules manquaient, combien
de temps chacun avait pris — soit exactement ce qu'on cherche après avoir dû
recommencer.
Les deux réponses qui jettent la progression la copient désormais d'abord,
sous un nom portant la base d'origine et l'instant de la copie, pour que deux
tentatives sur la même base ne se recouvrent pas et qu'un fichier sorti de son
dossier se décrive encore. Une reprise partielle garde le journal, donc
n'archive rien.
La copie va sous private/, pas à côté de l'originale : une réinstallation
efface .venv.erplibre/ et emporterait l'historique avec elle.
Deux copies dans la même seconde s'écrasaient en silence — annulant la perte
même qu'on évite. La seconde est numérotée, et le test qui sautait ce cas
l'affirme.
Vérifié sur le vrai journal de 51 Ko de la VM : 18 états copiés à l'identique,
le nom porte la base et l'horodatage, et un journal sans aucun état n'est pas
archivé. 12 tests.
Assisted-by: Claude Opus 5
The prompt asked whether to neutralize copies without showing what they hold.
Answering meant giving up a customization sight unseen — often three lines, an
id and a container width, sometimes a whole page, and nothing told them apart.
« v » now shows both halves of the question. What the copy changed, diffed
against the module view it shadows: on the database at hand, 5 lines added and
3 removed, two CSS anchors and a container width. And why it breaks, by
showing the declaration in each version — portal.frontend_layout is a
standalone template in 12.0 and inheritance specs in 13.0, which is the whole
explanation. « w » is the same two, full screen, space to switch.
The current version comes from ir_module_module, not .odoo-version: the
checkout is switched to the target before this runs, so reading the file
compared the target with itself and printed the same declaration twice. That
is what it did until it was run against a real migration.
--list says which copies a past neutralization put aside. A renamed key is
invisible in the interface, so without it the only trace was remembering.
Checked on the VM mid-migration: both views on view 2670, the screen builds
headless and toggles, --list reports and reports nothing when there is
nothing. A missing database now exits 2 with a message instead of a traceback.
--- FR ---
L'invite demandait de neutraliser des copies sans montrer ce qu'elles
contiennent. Répondre revenait à renoncer à une personnalisation sans l'avoir
vue — souvent trois lignes, un id et une largeur de conteneur, parfois une
page entière, et rien ne les distinguait.
« v » montre désormais les deux moitiés de la question. Ce que la copie a
changé, comparé à la vue de module qu'elle masque : sur la base en cours, 5
lignes ajoutées et 3 retirées, deux ancres CSS et une largeur. Et pourquoi ça
casse, en affichant la déclaration dans chaque version —
portal.frontend_layout est un gabarit autonome en 12.0 et des consignes
d'héritage en 13.0, ce qui est toute l'explication. « w » donne les deux en
plein écran, espace pour basculer.
La version courante vient d'ir_module_module, pas de .odoo-version : le
checkout est basculé sur la cible avant cette étape, donc lire le fichier
comparait la cible avec elle-même et affichait deux fois la même déclaration.
C'est ce qu'il faisait jusqu'à l'essai sur une vraie migration.
--list dit quelles copies une neutralisation passée a mises de côté. Une clé
renommée est invisible dans l'interface ; sans cela, la seule trace était de
s'en souvenir.
Vérifié sur la VM en cours de migration : les deux vues sur la vue 2670,
l'écran se construit sans terminal et bascule, --list rapporte et ne rapporte
rien quand il n'y a rien. Une base absente sort en 2 avec un message plutôt
qu'une trace d'appel.
Assisted-by: Claude Opus 5
The gap between 4 and 8 GB was the first one worth filling -- it is the
usual size of an ERPLibre development VM -- but every neighbouring gap
had the same fault: the lists jumped precisely where development VMs
live.
RAM now offers every gigabyte up to 16, and vCPU every count up to 16.
Disk gains 30, 50, 100 and 160 G, the steps missing between those already
there. Above 16 nothing changes: those jumps are deliberate, a machine
that large not being sized by picking from a list.
A typed value was already accepted in both interfaces, so these are a
convenience, never a constraint.
--- FR ---
L'écart entre 4 et 8 Go était le premier à combler — c'est la taille
courante d'une VM de développement ERPLibre — mais tous les écarts
voisins avaient le même défaut : les listes sautaient précisément là où
vivent les VM de développement.
La RAM offre désormais chaque gigaoctet jusqu'à 16, et les vCPU chaque
valeur jusqu'à 16. Le disque gagne 30, 50, 100 et 160 G, les paliers qui
manquaient entre ceux déjà présents. Au-delà de 16, rien ne change : ces
sauts sont voulus, une machine de cette taille ne se dimensionne pas en
piochant dans une liste.
Une valeur tapée était déjà acceptée dans les deux interfaces : ce sont
donc des commodités, jamais une contrainte.
Assisted-by: Claude Opus 5
A per-VM setting appears not to be taken into account, and nothing on
screen tells which link failed: the gap may be between the widget and
the model, or between the model and the spec. A screenshot shows
neither.
F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt:
profile, shared values, overrides, locks, row generation, then VM by VM
what the list DISPLAYS against what the model HOLDS, and finally the
spec that would go to deployment.
The file can be pasted into a message, unlike an image, and it answers
the question on its own: if the list shows 16384 while the model says
1024, the defect is in the intake; if they agree and the created VM
differs, it is downstream, in deploy_qemu.
--- FR ---
Un réglage par VM ne semble pas pris en compte, et rien dans ce que
l'écran montre ne permet de trancher : l'écart peut être entre le widget
et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit
ni l'un ni l'autre.
F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt :
profil, valeurs communes, surcharges, verrous, génération des rangées,
puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT,
et enfin la spec qui partirait au déploiement.
Le fichier se recopie dans un message, contrairement à une image, et il
répond seul à la question : si la liste montre 16384 et que le modèle
dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et
que la VM créée diffère, il est en aval, dans deploy_qemu.
Assisted-by: Claude Opus 5
A "Remote desktop tunnel" entry under SSH configuration. It lists the
targets, resolves them, and composes the full command -- nothing to fill
in.
todo.py runs on the libvirt HOST; the tunnel starts from the workstation.
It cannot open it, but it alone knows the VM's private IP, its port and
the address through which it was reached. That last one comes from
SSH_CONNECTION, whose third field is exactly the server address used --
far safer than a "hostname" that may resolve to nothing outside.
When ~/.ssh/config already carries the entry, the tunnel takes it:
"ssh -N -L 5902:localhost:5901 <vm>". ProxyJump makes the route and
"localhost" means the VM's OWN loopback, so the tunnel survives an IP
change. That config is also the right source of targets, not the local
libvirt: a graphical VM is often nested, and virsh would only ever list
the orchestrator.
Two details: the hypervisor console needs VNC bound to the loopback, as
"listen=none" opens no socket at all; and two menu entries had no icon.
--- FR ---
Une entrée « Tunnel bureau distant » sous Configuration SSH. Elle liste
les cibles, les résout, et compose la commande complète — rien à remplir.
todo.py tourne sur l'HÔTE libvirt ; le tunnel, lui, part du poste de
travail. Il ne peut donc pas l'ouvrir, mais il est le seul à connaître
l'IP privée de la VM, son port et l'adresse par laquelle on l'a joint.
Cette dernière vient de SSH_CONNECTION, dont le troisième champ est
exactement l'adresse serveur utilisée — bien plus sûr qu'un « hostname »
qui peut ne rien résoudre depuis l'extérieur.
Quand ~/.ssh/config porte déjà l'entrée, le tunnel l'emprunte :
« ssh -N -L 5902:localhost:5901 <vm> ». Le ProxyJump fait la route et
« localhost » désigne le bouclage DE LA VM, si bien que le tunnel survit à
un changement d'IP. Ce fichier est aussi la bonne source de cibles, pas le
libvirt local : une VM graphique est souvent imbriquée, et virsh ne
listerait jamais que l'orchestrateur.
Deux détails : la console de l'hyperviseur exige un VNC sur la boucle
locale, « listen=none » n'ouvrant aucun socket ; et deux entrées de menu
n'avaient pas d'icône.
Assisted-by: Claude Opus 5
The VM announced "Remote desktop: RDP 3389" without saying how to get
there. Its address is on libvirt's private network, which is not routed
from outside its host: the port alone helps nobody.
It now prints the tunnel to set up, address already filled in --
"ssh -L 3390:<ip>:3389 <user>@<libvirt-host>", then an RDP client on
localhost:3390. Likewise in VNC for Arch, on 5902.
This is the only workable route when the libvirt host is itself a VM
with no graphical interface: the SPICE console is on "listen=none" and
assumes virt-viewer ON that host.
--- FR ---
La VM annonçait « Bureau distant : RDP 3389 » sans dire par où passer.
Son adresse est sur le réseau privé de libvirt, qui n'est pas routé
depuis l'extérieur de son hôte : le port seul n'aide personne.
Elle imprime maintenant le tunnel à monter, adresse déjà remplie —
« ssh -L 3390:<ip>:3389 <user>@<hote-libvirt> », puis un client RDP sur
localhost:3390. Idem en VNC pour Arch, sur 5902.
C'est le seul chemin praticable quand l'hôte libvirt est lui-même une VM
sans interface graphique : la console SPICE est en « listen=none » et
suppose virt-viewer SUR cet hôte.
Assisted-by: Claude Opus 5
The timezone was typed by hand. A misspelt IANA name is not rejected by
cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from
the timestamps, once deployed.
A list of twenty-five zones, Québec first, then the rest of Canada and
the places one actually meets. The host's zone goes to the top, without
duplication: a machine outside this list must still see its own at a
glance.
NAMES, not offsets: "UTC-5" says nothing about daylight saving and
cloud-init will not take it. A name carries its own switching rules.
"free value…" keeps the door open to the other six hundred zones in the
database. The choice is copied into the field, which stays the only
value the spec reads -- one place holds the answer.
--- FR ---
Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas
refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en
aperçoit qu'aux horodatages, une fois déployée.
Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du
Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte
passe en tête, sans doublon : une machine hors de cette liste doit voir
le sien en un coup d'œil.
Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et
cloud-init n'en veut pas. Un nom porte ses propres règles de bascule.
« libre… » garde la porte ouverte aux six cents autres fuseaux de la
base. Le choix est recopié dans le champ, qui reste la seule valeur lue
par la spec — un seul endroit porte la réponse.
Assisted-by: Claude Opus 5
A profile list per row, beside the branch: "ERPLibre + Odoo 18",
"Odoo 17", "ERPLibre alone". The form's profile stays the default; picking
another for a VM creates an override, going back clears it.
It reaches down to execution, like the branch and the type before it.
"final_cmd" now takes either a string for the whole fleet or a
{name: command} map, and the remote command is built per machine as soon
as profiles differ. A VM can install Odoo 18 while another validates
Odoo 17, in the same deployment.
Both lists are widened -- branch to 24 columns, profile to 28. Truncated,
"develop" and "ERPLibre + Odoo 18" no longer showed what had been picked,
and that is precisely what one wants to re-read before deploying.
Two more defects: the global branch and Odoo choices reached nothing at
all, and the summary announced the global profile for every VM, then named
a version nothing would install.
--- FR ---
Une liste de profils par rangée, à côté de la branche : « ERPLibre +
Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste
le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir
l'efface.
Il descend jusqu'à l'exécution, comme la branche et le type avant lui.
« final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte
{nom: commande}, et la commande distante est bâtie par machine dès que les
profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre
valide Odoo 17, dans le même déploiement.
Les deux listes sont élargies : la branche passe à 24 colonnes, le profil
à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus
voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire
avant de déployer.
Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient
rien, et le sommaire annonçait le profil global pour toutes les VM, puis
nommait une version que rien n'installait.
Assisted-by: Claude Opus 5
A branch list per row. The form's branch stays the default; picking
another for a VM creates an override, going back clears it -- the VM then
follows the form again, which is what one expects when restoring the
common choice.
It reaches all the way down to execution, like VM type before it.
"branch" now takes either a string for the whole fleet or a {name: branch}
map, and the remote command is built per machine as soon as branches
differ. The ground was ready: launch_installs already knows how to take
one command per VM.
A VM can therefore carry develop while another validates 1.6.0, in the
same deployment and the same monitoring table.
One trap: a row's branch fell back to develop on remount, the list being
rebuilt from the form rather than from the override it already held.
--- FR ---
Une liste de branches par rangée. La branche du formulaire reste le
défaut ; en choisir une autre pour une VM crée une surcharge, y revenir
l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus
attendu quand on remet le choix commun.
Elle descend jusqu'à l'exécution, comme le type de VM avant elle.
« branch » accepte désormais une chaîne pour tout le parc ou une carte
{nom: branche}, et la commande distante est bâtie par machine dès que les
branches diffèrent. Le terrain était prêt : launch_installs sait déjà
prendre une commande par VM.
Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le
même déploiement et dans le même tableau de suivi.
Un piège : la branche d'une rangée retombait sur develop au remontage, la
liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge
qu'elle portait déjà.
Assisted-by: Claude Opus 5
A "✎" per row opens an input prefilled with the current name. The given
name becomes an override like any other: it survives recomputation, and
F4 removes it along with the VM's other settings.
An explicit name makes automatic naming GIVE WAY. Switching the VM to
GNOME no longer appends "-gnome": adding a suffix would amount to
correcting the user. Clearing the field restores the automatic name,
copy and desktop suffixes included.
The name becomes the machine's HOSTNAME, so it is validated against
RFC 1123 before being accepted. One capital or one dot too many and
cloud-init silently ignores it -- the VM would stay "ubuntu", and you
would find out once deployed. Capitals are lowered, the rest is refused
with a message.
--- FR ---
Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom
donné devient une surcharge comme les autres : il survit au recalcul, et
F4 le retire avec le reste des réglages de la VM.
Un nom explicite fait CÉDER le nommage automatique. Passer la VM en
GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à
corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes
de copie et de bureau compris.
Le nom devient le NOM D'HÔTE de la machine : il est donc validé en
RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et
cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le
découvrirait une fois déployée. Les majuscules sont abaissées, le reste
est refusé avec un message.
Assisted-by: Claude Opus 5
A "+" per row adds a copy of the entry, a "−" removes it. The "−" only
appears on a copy: the original cannot be deleted by mistake, it is
unticked in the catalogue.
The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3.
The first keeps the catalogue name, so existing deployments keep theirs.
The real work is in identity. entry_key was (distro, version, arch): two
copies shared it, so setting the first set the second and their locks
fought each other. It now carries the instance number, and overrides as
well as locks follow the right machine. Each instance is a COPY of the
dictionary, never a shared reference.
Removing a copy forgets its settings: keeping them would resurrect old
values on the next copy, with nothing to explain it.
--- FR ---
Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le
« − » n'apparaît que sur une copie : l'original ne se supprime pas par
mégarde, il se décoche au catalogue.
Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le
premier garde le nom du catalogue, pour que les déploiements existants
gardent le leur.
Le vrai travail est dans l'identité. entry_key valait
(distro, version, arch) : deux copies la partageaient, donc régler la
première réglait la seconde et leurs verrous se marchaient dessus. Elle
porte maintenant le numéro d'exemplaire, et surcharges comme verrous
suivent la bonne machine. Chaque exemplaire est une COPIE du
dictionnaire, jamais une référence partagée.
Retirer une copie oublie ses réglages : les garder ferait resurgir
d'anciennes valeurs à la copie suivante, sans que rien ne l'explique.
Assisted-by: Claude Opus 5
A 🔒 box at the head of each row. Ticked, the VM's four current values --
vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile
no longer reaches it. Unticked, they are removed and the VM falls back
under the profile. The lock shows on the WHOLE ROW, not in a box lost at
the end: that is what lets you scan the plan and see at once what escapes
the profile.
It rests on the override mechanism already proven, indexed by catalog
identity, so it survives a remount. Locked state stays distinct from
overrides: a VM can be edited without being frozen, and the lock covers
all four fields at once.
Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving
the card an id broke the remount, the old one still being there. Lists
kept stale values across a remount, and a refresh took back the free
entry. A frozen VM changed version all the same. And switching profile
wiped the disk size that had been set.
--- FR ---
Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes
de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges :
le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM
retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une
case perdue au bout : c'est ce qui permet de balayer le plan et de savoir
d'un coup ce qui échappe au profil.
Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par
identité de catalogue : il survit donc à un remontage. L'état verrouillé
reste distinct des surcharges : une VM peut être modifiée sans être figée,
et le verrou couvre les quatre champs d'un coup.
Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE :
donner un id à la carte faisait échouer le remontage, l'ancienne étant
encore là. Les listes gardaient des valeurs périmées au remontage, et un
rafraîchissement reprenait la saisie libre. Une VM figée changeait quand
même de version. Et changer de profil effaçait la taille de disque réglée.
Assisted-by: Claude Opus 5
The architecture was missing, though it alone explains why an install
takes ten times longer: s390x and arm64 are EMULATED on an amd64 host.
Without it you look for the fault elsewhere. An "Arch" column, right after
the name.
The table was pinned at 74 columns: beyond that nothing was reachable and
no scrollbar showed, for want of reserved space. Automatic width capped at
60% of the screen, scrolling both ways, and VISIBLE bars rather than
guessable ones.
Columns can finally be adjusted: "+" widens the cursor's, "-" narrows it,
"0" returns them all to their original width -- the same keys as the mail
TUI. auto_width is turned off along the way, otherwise the setting did not
survive the table's first update, and the resize aimed at the "#" column
whatever the cursor was on.
--- FR ---
L'architecture manquait, alors qu'elle explique à elle seule pourquoi une
installation dure dix fois plus : s390x et arm64 sont ÉMULÉES sur un hôte
amd64. Sans elle, on cherche la faute ailleurs. Une colonne « Arch »,
juste après le nom.
Le tableau était figé à 74 colonnes : au-delà rien n'était atteignable et
aucune barre de défilement n'apparaissait, faute de place réservée.
Largeur automatique plafonnée à 60 % de l'écran, défilement dans les deux
sens, et barres VISIBLES plutôt que devinables.
Les colonnes s'ajustent enfin : « + » élargit celle du curseur, « - » la
rétrécit, « 0 » les ramène toutes à leur largeur d'origine — les mêmes
touches que la TUI courriel. auto_width est désactivé au passage, sans
quoi le réglage ne survivait pas à la première mise à jour du tableau, et
le redimensionnement visait la colonne « # » quel que soit le curseur.
Assisted-by: Claude Opus 5
The monitor could only watch. The "a" key now opens the selected VM's
actions: update, restart Odoo, delete.
The update is chosen by parts, and the order is not free: system packages,
then git repositories, then Python dependencies -- the latter compile
against the former. Nothing is chained with "&&": one failing part must
not take the others down. Deleting demands a second hand, a confirmation
screen naming the disk to be erased.
Two defects the dashboard revealed. Detached installs were stealing the
shell's keystrokes, the child inheriting a terminal it had no business
reading. And the elapsed time restarted from zero every time the dashboard
was reopened, since it was counted from the view rather than from the
run's own first write.
--- FR ---
Le suivi ne savait que regarder. La touche « a » ouvre désormais les
actions de la VM sélectionnée : mettre à jour, redémarrer Odoo, supprimer.
La mise à jour se choisit par parties, et l'ordre n'est pas libre :
paquets système, puis dépôts git, puis dépendances Python — ces dernières
compilent contre les premiers. Rien n'est enchaîné par « && » : une partie
en échec ne doit pas emporter les autres. Supprimer exige une seconde
main, un écran de confirmation nommant le disque à effacer.
Deux défauts que le tableau de bord a révélés. Les installations détachées
volaient les frappes du shell, l'enfant héritant d'un terminal qu'il
n'avait pas à lire. Et la durée repartait de zéro à chaque réouverture,
comptée depuis la vue plutôt que depuis la première écriture du run.
Assisted-by: Claude Opus 5