Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7e78105
docs: le poste saura se mettre a jour depuis son ecran
lostmind84 Jul 29, 2026
8db40db
docs: le plan d'implementation de la mise a jour depuis l'ecran
lostmind84 Jul 29, 2026
301c986
docs(banc): le processus detache survit-il a l'arret du service
lostmind84 Jul 29, 2026
b206525
docs: le banc corrige le plan, et deux defauts connus repayent leur du
lostmind84 Jul 29, 2026
b4ecec9
feat(update): un numero de version se lit et s'ordonne
lostmind84 Jul 29, 2026
296564e
feat(update): le poste sait lire les versions publiees d'un depot
lostmind84 Jul 29, 2026
14cd107
feat(update): une archive se verifie avant d'etre posee sur le disque
lostmind84 Jul 29, 2026
3da2ab1
feat(config): une coop peut suivre son propre fork, et cela se voit
lostmind84 Jul 29, 2026
613a8a8
feat(station): le poste dit quand il ne peut pas tomber
lostmind84 Jul 29, 2026
28461f5
feat(update): l'etat de la bascule vit dans des fichiers, jamais en base
lostmind84 Jul 29, 2026
8d93ae5
Merge remote-tracking branch 'origin/main' into design/mise-a-jour-de…
lostmind84 Jul 29, 2026
4995964
fix(deploy): l'ecran client revient, et la bascule dit ce qu'elle a fait
lostmind84 Jul 29, 2026
8e1c8bf
feat(update): le poste ecrit ce qu'il tente avant de se laisser tuer
lostmind84 Jul 29, 2026
0ba640a
feat(web): trois routes pour lire, verifier et installer une version
lostmind84 Jul 29, 2026
0f80fab
feat(station): le poste demande une fois par jour s'il est perime
lostmind84 Jul 29, 2026
5bc6229
feat(admin): une page pour installer la version publiee
lostmind84 Jul 29, 2026
9807a80
feat(admin): le tableau de bord signale une version disponible
lostmind84 Jul 29, 2026
63132b6
docs: la mise a jour depuis l'ecran, et les trois defauts qu'elle a t…
lostmind84 Jul 29, 2026
32fde7c
Merge branch 'main' into design/mise-a-jour-depuis-admin
lostmind84 Jul 29, 2026
1d36494
fix(admin): la page ne compilait pas, et seul svelte-check le disait
lostmind84 Jul 29, 2026
5f068ba
style: gofmt, et un godoc que j'avais soude a un autre
lostmind84 Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,20 +332,45 @@ et les adresses de partage en sont retirés.

## Mettre à jour un poste

**Depuis l'écran, en un geste.** Bouton « Réglages » sur l'écran client → page
**« Mise à jour »**. Le poste vérifie une fois par jour s'il existe une version plus
récente, et le tableau de bord l'annonce quand c'est le cas. Le bouton la nomme.

Le poste s'arrête environ une minute, l'écran client s'éteint puis **revient tout seul**.
Ne débranchez rien pendant ce temps. Si la nouvelle version ne démarre pas, l'ancienne est
remise automatiquement et l'écran dit ce qui s'est passé — les quatre issues possibles sont
décrites dans `TROUBLESHOOTING.md`.

Le bouton refuse pendant une pesée, et tant qu'un catalogue qui vient d'arriver n'est pas
entré en service.

**À la main**, si l'écran d'administration est inaccessible, ou sur un poste Linux où le
bouton n'existe pas :

1. Copiez l'archive de la nouvelle version sur le poste et décompressez-la.
2. PowerShell **en administrateur**, dans le dossier de la nouvelle version :

```powershell
.\update.ps1
```

Le script arrête le service proprement, **sauvegarde l'ancienne version sous un nom
horodaté**, installe la nouvelle, redémarre, et **vérifie que le poste répond**. Si ça
échoue, il **remet l'ancienne version tout seul** et vous dit pourquoi.
C'est le même script que celui du bouton. Il arrête le service proprement, **sauvegarde
l'ancienne version sous un nom horodaté**, installe la nouvelle, redémarre, **vérifie que
le poste répond**, et **relance l'écran client**. Si ça échoue, il **remet l'ancienne
version tout seul** et vous dit pourquoi.

La configuration, le catalogue et le journal des pesées **ne sont pas touchés** : ils ne
vivent pas à côté du programme.

### Suivre un autre dépôt

Le code est libre. Une coopérative qui fait tourner sa propre version peut la faire suivre
à ses postes : page **« Mise à jour »**, champ *Dépôt suivi*, sous la forme
`propriétaire/projet` — jamais une adresse web. C'est un réglage protégé par le mot de
passe, et il doit être **le même sur les quatre postes** : l'empreinte de configuration
affichée au tableau de bord en tient compte, et deux postes qui suivent deux dépôts
différents n'affichent pas la même.

## Désinstaller un poste

```powershell
Expand Down
42 changes: 42 additions & 0 deletions SUIVI.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,45 @@
> Tableau de bord. À mettre à jour au fil de l'eau — c'est le premier fichier à lire
> pour savoir où on en est.

**La mise à jour se déclenche depuis l'écran, et elle est livrée (29/07/2026).** ADR-040 :
le poste sonde une fois par jour l'API des publications du dépôt suivi, porte une pastille
au tableau de bord, télécharge l'archive au clic, **vérifie son empreinte SHA-256**,
l'extrait, écrit `pending.json` puis lance `update.ps1` détaché. Le service tourne en
`LocalSystem` : **il n'y avait aucune élévation à obtenir**, ce qui a rendu tout le reste
possible. Treize tâches, 13 commits, suite Go complète verte en `-race`, **686 tests front**,
budget de l'écran client inchangé à 70,3 % — cette page est dans le paquet
d'administration, que la grille ne charge jamais.

**Trois défauts existants ont été payés par ce chantier.** (1) **L'écran client restait
noir** après toute mise à jour : `Stop-OpenScaleBinaryHolders` termine la tâche du kiosque,
`openscale-kiosk.xml` n'a qu'un `LogonTrigger`, et personne ne la relançait — ni
`install.ps1`, ni `update.ps1`. Le défaut a survécu parce qu'un humain qui met à jour finit
par redémarrer le poste ; un bénévole qui touche un bouton, non. (2) `update.ps1`
**confondait ses échecs** sous un seul `exit 1`, alors que « restauré, le poste marche » et
« restauré, le poste est mort » ne demandent pas la même chose. (3) Le **nil typé** : sur un
binaire `dev`, aucun service de mise à jour n'est construit, et affecter ce `nil` à une
interface produit une interface **qui n'est pas nulle** — la garde répond faux, la méthode
est appelée sur un récepteur nul, et le sondage du tableau de bord faisait tomber la
connexion HTTP toutes les trois secondes. C'était le poste de chaque développeur.

**Quatre décisions ont été prises par des tests, contre le plan.** La table de `tokens.test`
n'inventorie que `--ink` et `--ink-muted` comme texte : la pastille est **soulignée** et non
colorée, les deux fonds pleins n'ayant été mesurés que comme fonds. `admin-two-levels` tient
la page bénévole à **une seule route** : la disponibilité voyage donc dans
`/admin/api/health` plutôt que dans un second appel qui aurait élargi, pour une courtoisie,
ce que fait un écran ouvert sans mot de passe. Le jugement des chemins d'archive se fait en
séparateurs `/` **avant** toute conversion — `filepath.Clean` transforme sur Windows
`/etc/cron.d/evil` en `\etc\cron.d\evil`, que `filepath.IsAbs` déclare **relatif**, et la
première écriture du contrôle laissait donc passer un chemin absolu à la Unix. Et le worker
de sondage enregistre ses minuteries **dans la goroutine appelante**, comme le Hub et le
superviseur : les enregistrer dans la sienne faisait dépendre le premier sondage de
l'ordonnanceur. Trente jours passent maintenant en 0,28 s.

**Deux gardes contre le murage d'un poste.** Une bascule dont le budget de quinze minutes
est dépassé est **effacée** au lieu d'opposer `ErrAlreadyRunning` pour toujours — c'est la
conséquence directe de ce que le banc a mesuré, un `Start()` qui rend `nil` sans rien
lancer. Et un lancement qui **échoue** efface son `pending.json` : le processus est encore
vivant pour le faire.
**Installer la v0.5 comme un bénévole a buté six fois (29/07/2026).** L'archive publiée a
été posée sur `PC-RECEPTION` par `install.ps1` sans option, puis conduite étape par étape
comme `INSTALLATION.md` la décrit. Le poste tourne — service automatique, balance GRAM sur
Expand Down Expand Up @@ -555,6 +594,7 @@ comptage A/B au scanner de caisse est ce qui tranchera le tracé géométrique d
| **L7** | Catalogue — sources, import CSV, images | 2,5 sem. | ✅ **26/07/2026** |
| **L8** | Admin et exploitation — écrans, diagnostic, installeurs | 4 sem. | ✅ **26/07/2026** |
| **L9** | Recette et mise en service — poste pilote 2 semaines | 3 sem. | ⬜ |
| **hors lot** | Mise à jour depuis l'écran (ADR-040) — paquet `internal/update`, page « Mise à jour », contrat `update.ps1` | 1 j·h | ✅ **29/07/2026** |

**Ce qui reste, et il n'y a que ça.** L0 approvisionne le banc (SATO WS408, GRAM XFOC,
rouleau, lecteur) ; L9 est la recette sur site. **Aucun des deux ne demande d'écrire du
Expand Down Expand Up @@ -716,6 +756,7 @@ son périmètre. Aucune ne bloque : ce sont des endroits où la documentation d
|---|---|---|
| 1 | §16.4, l'extrait du `Makefile` | Montre `bin/balance`, `./tools/boundary/check.sh` et `test: front` là où le `Makefile` réel dit `bin/openscale`, `go run ./tools/boundary` et `test: vet` |
| 2 | §16.4, l'énumération du pipeline CI | Nomme `staticcheck`, qu'aucune étape de `ci.yml` ne lance ; et place `make boundary` / `make deps` **avant** `go test -race`, alors que la CI les lance après |
| 3 | §11.1 et §13.2, les chemins de `ProgramData` | Trois occurrences disent encore `C:\ProgramData\Balance` et `balance.db` là où le poste écrit `C:\ProgramData\OpenScale` et `openscale.db`. Relevé en écrivant §15.5, qui portait la même faute et a été corrigé ; les trois autres sont hors du périmètre d'ADR-040 |

C'est exactement la classe de défaut qu'ADR-039 et `make deps` suppriment pour les
**dépendances**. Ces deux-là portent sur les **outils**, et rien ne les vérifie encore.
Expand Down Expand Up @@ -766,6 +807,7 @@ de référence produit, pas une correction cosmétique.

| Date | Événement |
|---|---|
| 29/07/2026 | **Mise à jour depuis l'écran livrée** (ADR-040) : sondage quotidien, empreinte SHA-256 vérifiée, `update.ps1` devenu un contrat à quatre issues, page « Mise à jour », contrôle 48 sur le dépôt suivi. Trois défauts existants payés au passage — l'écran client qui restait noir, les échecs indistincts du script, et le nil typé qui faisait paniquer le tableau de bord de tout binaire `dev` |
| 29/07/2026 | **La v0.5 installée comme un bénévole sur un poste neuf** : le poste tourne de bout en bout — balance, étiquette, catalogue, redémarrage recette — mais **six défauts** rendent les étapes 4 à 7 infaisables sans ligne de commande. Deux corrigés (la veille du catalogue, dans les deux cas où elle ne quittait pas sa source), quatre ouverts, dont le retour arrière qui écrit le profil d'usine par-dessus les tarifs de la coopérative |
| 29/07/2026 | **Tâche 0 du plan de mise à jour depuis l'écran mesurée sur le banc** : le processus détaché survit à l'arrêt du service (113 lignes après), mais `DETACHED_PROCESS` empêche `powershell.exe` de démarrer — le plan passe à `CREATE_NO_WINDOW`. Deux trouvailles incidentes : `-InstallDir`/`-DataRoot` morts sur `install.ps1`, et le `--listen` ignoré de L8 repayé une seconde fois |
| 28/07/2026 | Écran client repris en « Grand Format » (ADR-035, ADR-036) : grille continue — `ui.tile_size` retiré, ce qui **annule le réglage à trois valeurs livré la veille** —, double tarif affiché par tuile, recherche au clavier physique (le poste n'est pas tactile), CategoryBar/StatusBar remplacent FilterBar/ReprintBar. **438 tests front** (23 fichiers), tous verts, mesurés sur ce poste |
Expand Down
44 changes: 38 additions & 6 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,17 +291,41 @@ un accès administrateur au PC pour réinitialiser la configuration :
Le poste redemande alors le parcours « premier accès » et **impose** un nouveau mot de
passe. **Refaites une fiche d'installation** dans la foulée.

## Mettre le poste à jour

**Réglages → Mise à jour.** Quand une version plus récente existe, le tableau de bord
l'annonce et cette page porte un bouton rouge qui la nomme. Le poste s'arrête environ une
minute, l'écran client s'éteint puis revient tout seul. **Ne débranchez rien pendant ce
temps.**

Le bouton refuse pendant une pesée, et tant qu'un catalogue qui vient d'arriver n'est pas
entré en service : réessayez dans un instant. Il accepte, en revanche, sur un poste en
configuration d'usine — c'est justement le cas où une version neuve peut aider.

---

## Une mise à jour a échoué

`update.ps1` **remet l'ancienne version tout seul** et affiche pourquoi. Le poste doit
donc fonctionner comme avant. Vérifiez l'écran client, puis prévenez un responsable avec
le message affiché.
**Le poste remet l'ancienne version tout seul**, et la page Mise à jour dit ce qui s'est
passé. Quatre phrases, et elles ne demandent pas la même chose :

| Ce que dit l'écran | Ce que vous faites |
|---|---|
| « La dernière mise à jour a réussi. » | Rien. Vérifiez l'écran client. |
| « …a échoué. La version précédente a été remise et le poste fonctionne. » | **Personne à appeler.** Signalez-le à un responsable quand vous en aurez l'occasion. |
| « …a échoué et le poste n'a pas redémarré. Appelez le support. » | Tout de suite. Envoyez le fichier de diagnostic (voir plus bas). |
| « …n'a pas démarré : rien n'a été remplacé. » | **Rien n'a bougé.** Vous pouvez réessayer. |

**Si le message parle du schéma de la base** (`ERR-DB-02`, « base créée par une version
plus récente »), le retour arrière est en **trois** gestes et le troisième vous appartient :
le script vous **nomme le fichier de sauvegarde de la base** à remettre en place.
Attention, les pesées enregistrées depuis la mise à jour seront perdues : exportez le
journal depuis l'écran d'administration avant de le faire.
le journal de la mise à jour vous **nomme le fichier de sauvegarde de la base** à remettre
en place. Attention, les pesées enregistrées depuis la mise à jour seront perdues :
exportez le journal depuis l'écran d'administration avant de le faire.

**Si l'écran d'administration est inaccessible**, la procédure à la main existe toujours :
décompressez l'archive de la version, puis, dans une console **en administrateur**,
`powershell -ExecutionPolicy Bypass -File .\update.ps1`. C'est le seul chemin sur un poste
Linux, où le bouton n'existe pas et où l'écran le dit.

---

Expand Down Expand Up @@ -341,3 +365,11 @@ Ils ne servent **pas** à chercher : ils servent à confirmer qu'on parle de la
| `ERR-SYS-08` | **le redémarrage sans intervention n'est pas configuré** |
| `ERR-KSK-02` | l'affichage n'arrive pas à rester ouvert |
| `ERR-UI-01` | l'écran client a rencontré une erreur d'affichage |
| `ERR-UPD-01` | le serveur des versions est injoignable — la connexion du magasin, le plus souvent |
| `ERR-UPD-02` | le fichier téléchargé est abîmé ; **rien n'a été installé** |
| `ERR-UPD-03` | le poste est occupé : une pesée, ou un catalogue qui entre en service |
| `ERR-UPD-04` | une mise à jour est déjà en cours |
| `ERR-UPD-05` | la mise à jour depuis l'écran n'existe pas sur ce poste (Linux) |
| `ERR-UPD-06` · `ERR-UPD-07` | la bascule a échoué, version précédente remise · **et le poste ne répond pas** |
| `ERR-UPD-08` | cette version ne contient pas de fichier pour ce poste |
| `ERR-UPD-09` | une autre version est parue depuis l'affichage : rechargez la page |
12 changes: 11 additions & 1 deletion cmd/openscale/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,15 @@ func serve(ctx context.Context, o serveOptions, out io.Writer) error {
// §13.4 lives.
httpHolder := &heldServer{}

st, err := station.New(station.Options{
// The update service needs the Hub to ask « may the station be taken down? »,
// and the station needs the service to run its daily poll: the same knot as
// the one above, untied the same way. The closure reads `st` when a volunteer
// touches a button, which is long after the line that assigns it.
var st *station.Station
updateService := newUpdateService(clock,
guardFunc(func() (bool, string) { return st.Hub().UpdateGuard() }), o.dataDir)

st, err = station.New(station.Options{
Clock: clock,
Config: cfg,
Catalog: catalog,
Expand All @@ -339,6 +347,7 @@ func serve(ctx context.Context, o serveOptions, out io.Writer) error {
// stops being: the answer must not depend on which of the two moments asked.
Registries: registries,
Templates: templates,
Poller: newUpdatePoller(updateService),
Scale: weigher,
Printer: printer,
Journal: db,
Expand Down Expand Up @@ -450,6 +459,7 @@ func serve(ctx context.Context, o serveOptions, out io.Writer) error {
printer: live, catalog: liveSource,
machine: diag.NewMachine(clock), dataDir: o.dataDir,
},
Update: updaterFor(updateService),
})
if err != nil {
_ = binder.Close()
Expand Down
115 changes: 115 additions & 0 deletions cmd/openscale/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"context"
"os"
"path/filepath"
"runtime"

"openscale/internal/platform"
"openscale/internal/station"
"openscale/internal/station/ports"
"openscale/internal/update"
"openscale/internal/web"
)

// guardFunc lets a plain function answer update.Guard.
//
// It exists so that the service can be built BEFORE the station whose Hub answers
// the question: the two need each other, and the closure resolves the Hub when a
// volunteer touches a button rather than when the wiring is laid out.
type guardFunc func() (bool, string)

// UpdateGuard answers by calling the function.
func (f guardFunc) UpdateGuard() (bool, string) { return f() }

// newUpdateService wires the update service over the running station.
//
// It returns nil -- and the routes then answer honestly that this binary cannot
// update itself -- in the two cases where offering the button would be a lie:
//
// - a development build, whose version is « dev » and not a number. Comparing
// that to a release is meaningless, and a station running it would be told it
// is out of date by an arithmetic nobody can defend;
// - a platform with no swap. platform.ApplyUpdate answers ErrUpdateUnsupported
// off Windows, and a screen that discovered that only at the last click would
// have let a volunteer confirm an irreversible-looking act for nothing.
func newUpdateService(clock ports.Clock, guard update.Guard, dataDir string) *update.Service {
running, err := update.ParseVersion(version)
if err != nil {
return nil
}
if runtime.GOOS != "windows" {
return nil
}
// THE PATHS COME FROM THE RUNNING PROCESS, never from the script's own
// defaults -- which point at Program Files. A station installed anywhere else
// would otherwise be updated somewhere it does not live.
binary, err := os.Executable()
if err != nil {
return nil
}
updatesDir := filepath.Join(dataDir, "updates")
return &update.Service{
Clock: clock,
State: update.State{Dir: updatesDir},
Stager: update.Stager{Dir: updatesDir, Platform: releasePlatform()},
Guard: guard,
Running: running,
Supported: true,
Paths: update.Paths{
InstallDir: filepath.Dir(binary),
DataRoot: dataDir,
UpdatesDir: updatesDir,
},
Applier: platform.ApplyUpdate,
}
}

// releasePlatform is the suffix release.yml gives the archives.
func releasePlatform() string { return runtime.GOOS + "-" + runtime.GOARCH }

// updaterFor returns what the HTTP layer should be given for the update routes.
//
// ★ IT RETURNS A NIL INTERFACE, NEVER A TYPED NIL, and that distinction is the
// whole reason this function exists rather than a direct assignment. Putting a nil
// *update.Service into an interface produces an interface that IS NOT nil: the
// `s.updater == nil` guard of every handler answers false, the method is then
// called on a nil receiver, and the first field it reads panics.
//
// Measured, not imagined: a binary whose version is « dev » builds no service, and
// every three-second poll of the dashboard took the whole HTTP connection down
// with it. That is every developer's station, and it would have been every
// station's until the first tagged build.
func updaterFor(service *update.Service) web.Updater {
if service == nil {
return nil
}
return service
}

// updatePoller adapts an update.Service to what the station's daily worker asks.
//
// It exists so that internal/station names no type of internal/update: the station
// asks « is there something newer for this repository? » and gets a version string
// back. Knowing what a release is remains the business of one package, and the
// composition root is where the two are introduced.
type updatePoller struct{ service *update.Service }

// Poll asks once, and records what came back.
func (p updatePoller) Poll(ctx context.Context, repository string) (string, error) {
check, err := p.service.Check(ctx, repository)
if err != nil {
return "", err
}
return check.Version, nil
}

// newUpdatePoller returns the daily poll, or nil when this binary cannot update
// itself -- in which case the station starts no worker at all.
func newUpdatePoller(service *update.Service) station.Poller {
if service == nil {
return nil
}
return updatePoller{service: service}
}
Loading