Dotfiles_ArchAP/docs/session-restore-v1.md

697 lines
26 KiB
Markdown

# Restore Plan et Executor V1 — STEP19A/STEP19B/STEP19C
## Statut et architecture
**Le planner STEP19A ne restaure jamais.** L'exécution contrôlée appartient
exclusivement au module séparé STEP19B décrit à la fin de ce document.
STEP19A charge un Session Snapshot V1 persistant, observe la session Sway
actuelle avec les quatre lectures STEP18, puis produit un plan JSON. Il ne
lance ni ne tue aucun processus, ne change aucun workspace, ne déplace aucune
fenêtre et n'envoie aucune commande Sway mutatrice. L'exécuteur appartient à
STEP19B ; le Spatial Canvas appartient à STEP20.
```text
snapshot persistant validé + Session Snapshot V1 live validé
↓
session_restore.py (fonction pure)
↓
labfy.sway.restore-plan / version 1
```
Le point d'entrée reste :
```bash
session_snapshot.py plan <name>
```
`session_snapshot.py` possède le chargement sûr, le verrou partagé, la
validation persistante et la collecte IPC. `session_restore.py` ne possède ni
I/O fichier, ni subprocess, ni API Sway. Il reçoit deux documents normalisés
et réutilise donc sans duplication la classification, la résolution
DesktopEntry et l'inventaire live de STEP18.
## Enveloppe Restore Plan V1
```json
{
"schema": "labfy.sway.restore-plan",
"version": 1,
"read_only": true,
"execution_supported": false,
"source_session": "dev",
"source": {},
"live": {},
"policy": {},
"matches": [],
"unmatched_source_windows": [],
"actions": [],
"diagnostics": {}
}
```
Le planner n'ajoute pas d'heure courante. Les seuls timestamps viennent de ses
entrées. Un même couple snapshot/live produit donc le même plan.
Chaque action contient au minimum :
```text
action_id
action
phase
source_window
snapshot_identity
depends_on[]
```
`depends_on` exprime les chaînes futures, par exemple lancement, placement,
arbre puis focus. Il n'exécute pas ces opérations.
## Inventaire live et identité runtime
`live.windows[]` conserve :
```text
runtime.con_id, runtime.pid, workspace, output
app_id, class, instance, desktop_entry, executable_basename
classification.category, classification.managed_by
floating, fullscreen_mode, scratchpad, focused, title_hint
```
Les PID et `con_id` sont exposés comme observations pour un futur exécuteur
dans la session courante, mais `policy.runtime_identity_fields` est vide. Ils
ne participent jamais au matching persistant : ils changent, et peuvent même
être réutilisés numériquement, après logout ou reboot.
## Matching one-to-one
Une arête snapshot→live n'existe que si une preuve d'identité applicative
existe. Les preuves, par poids décroissant, sont :
1. même DesktopEntry résolue ;
2. même `app_id` ;
3. même classe XWayland/`StartupWMClass` ;
4. même instance ;
5. même basename exécutable compatible.
Deux `app_id` explicites et différentes interdisent un match fondé seulement
sur un basename. La résolution DesktopEntry n'est pas réimplémentée : elle
provient de `resolve_desktop_entry()` STEP18 pour le snapshot comme pour le
live.
Le workspace identique, le même état floating et un `title_hint` identique
servent seulement de départage. Un titre ne crée jamais un candidat et n'est
jamais une identité obligatoire.
Le planner trie globalement toutes les arêtes par preuve, puis applique un
matching one-to-one. Une fenêtre live ne peut satisfaire qu'une fenêtre
sauvegardée. Pour deux Firefox sauvegardées et une live, une seule est
réutilisée ; l'autre reste absente. L'ordre snapshot ne sert qu'au dernier
départage.
## Confiance
```text
exact DesktopEntry identique + app_id ou classe identique
high DesktopEntry, app_id ou classe exacte sans preuve combinée
medium instance ou basename compatible
ambiguous plusieurs arêtes de meilleur score subsistent
unresolved aucune preuve suffisante
```
`exact` n'est jamais déduit du PID, du `con_id`, du titre seul ou de la seule
position dans l'arbre.
## Classification et lancement futur
### Applications utilisateur
Une application absente n'est proposée au lancement que si sa DesktopEntry a
été résolue avec confiance STEP18 `exact`. Une identité heuristique ou
absente produit `manual-required` ; aucune ligne de commande n'est inventée.
Le futur backend STEP19B est figé conceptuellement comme :
```json
{
"backend": "uwsm-app-desktop-entry",
"argv": ["uwsm", "app", "--", "firefox.desktop"],
"execute_in_step19a": false
}
```
L'audit de `uwsm 0.27.0` confirme que `uwsm app` accepte un exécutable, un
identifiant DesktopEntry ou son chemin, et lance l'application comme scope ou
service dans une slice graphique. Sa documentation recommande ce mécanisme
pour ne pas accumuler les applications dans l'unité du compositeur. STEP19B
devra donc préférer l'identifiant DesktopEntry via `uwsm app` à `gtk-launch`,
`gio launch` ou un enfant direct de Sway. STEP19A ne l'exécute jamais.
Le lancement ne promet pas de restaurer l'état interne d'un terminal, les
onglets d'un navigateur ou le contenu propre à une application.
### Applications gérées par autostart
Une vue présente est réutilisée normalement. Une vue absente produit
`skip-autostart-managed`, jamais `launch-application`, car `sway-autostart`
possède son lancement. C'est notamment le contrat de `limusic-app` issu de
`exec limusic-app`. STEP19B devra attendre ou demander une intervention au
lieu de créer une double instance.
### Infrastructure et daemons
`session-infrastructure` et `daemon` ne produisent jamais de lancement.
QuickShell, Polkit, autotiling, wlsunset, les services systemd utilisateur et
le listener de transparence restent possédés par leur sous-système. Une
absence inattendue produit `manual-required`.
## Workspaces et outputs
Chaque action conserve `desired_workspace` et `desired_output`. Une fenêtre
live au mauvais endroit produit `reuse-window`, puis `move-to-workspace`, sans
lancement supplémentaire.
Les outputs sont appariés par nom de connecteur. La politique V1 est :
```text
connector name preferred
missing output = deferred-no-fallback
```
Si le connecteur sauvegardé est absent, le planner produit
`manual-required(reason=missing-output)` et n'invente ni écran ni fallback. Le
lancement d'une application absente est lui aussi différé dans ce cas.
## Arbre Sway natif
Le planner compare une signature sémantique par vue : layout/orientation du
workspace, chaîne des containers parents, layout/orientation/percent des
parents, branche, index et percent de la fenêtre. Les `container_id` runtime
sont exclus.
Une différence produit `restore-tree-position` avec
`capability=partially-supported`. Sway natif sait déplacer, splitter, choisir
un layout et ajuster des proportions, mais V1 ne prétend pas reconstruire
atomiquement tout arbre arbitraire. STEP19B devra valider ses primitives et
replanifier après l'apparition des fenêtres.
Seul `layout_engine=sway-native` est accepté. `spatial-canvas-v1` reste hors
périmètre jusqu'à STEP20.
## Floating, fullscreen et scratchpad
Une différence live/source produit une intention distincte :
```text
restore-floating
restore-fullscreen
restore-scratchpad-hidden
restore-scratchpad-visible
```
Une fenêtre live présente dans le scratchpad alors que le snapshot ne le
demande pas produit `manual-required`, plutôt qu'une hypothèse destructrice.
## Ordre et focus
Le focus est toujours la dernière phase. Il n'est ajouté que si le focus live
diffère ou si une intention antérieure pourrait le perturber.
```text
1. match-managed : réutiliser/différer/bloquer
2. launch : intentions de lancement exactes
3. wait-match : réservé au rematching STEP19B
4. workspace : placement workspace/output
5. tree-layout : arbre Sway natif
6. floating
7. fullscreen
8. scratchpad
9. focus
```
Le validateur refuse une dépendance future, un recul de phase ou une action
après le focus.
## Actions V1 et refus explicites
```text
reuse-window
launch-application
skip-autostart-managed
move-to-workspace
restore-tree-position
restore-floating
restore-fullscreen
restore-scratchpad-hidden
restore-scratchpad-visible
restore-focus
manual-required
```
`manual-required` porte notamment les raisons suivantes :
```text
no-exact-launch-identity
missing-output
non-restorable-category
live-window-unexpected-scratchpad
```
Dire que l'automatisation ne sait pas est préférable à une commande inventée.
## Garanties et limites STEP19A
Le module planner n'importe pas `subprocess`, ne contient pas `swaymsg`, et ne
possède aucune fonction de lancement, d'écriture ou de suppression. Le CLI
n'appelle que :
```text
show_session(name) # lecture et validation persistante
collect(autostart) # quatre IPC read-only STEP18
build_restore_plan() # fonction pure
```
Le plan expose :
```text
read_only = true
execution_supported = false
mutating_commands_executed = 0
applications_launched = 0
snapshots_written = 0
```
Le planner n'effectue aucune attente de fenêtre, aucune commande Sway
mutatrice, aucune restauration d'état interne, aucun fallback multi-output,
aucune UI QML et aucun comportement Spatial Canvas.
## Executor V1 — STEP19B
### Frontière d'effets et CLI
`session_restore.py` reste le moteur pur. `session_executor.py` est la seule
frontière autorisée à ouvrir le lock d'exécution et à lancer des argv directs.
```text
Session Snapshot V1 validé
↓
Restore Planner pur
↓
Restore Plan V1 frais
↓
Session Executor V1
↓
uwsm app + Sway IPC ciblé
```
La mutation nécessite une intention explicite difficile à déclencher par
accident :
```bash
session_snapshot.py apply <name> --execute
```
`apply <name>` sans `--execute` retourne
`RESTORE_EXECUTION_REQUIRES_EXPLICIT_EXECUTE` sans charger l'exécuteur. Le CLI
n'accepte jamais un fichier Restore Plan comme autorité. Chaque apply exécute
obligatoirement :
```text
load snapshot → collect live → build fresh plan → validate → preflight → execute
```
Le snapshot est hashé avant et après l'opération. Une différence produit un
échec explicite ; l'exécuteur ne possède aucune API d'écriture de snapshot.
### Capability gate intégrale
Avant le premier lancement ou la première commande Sway, l'exécuteur examine
toutes les actions. Le sous-ensemble STEP19B est :
```text
reuse-window no-op revalidé
launch-application uwsm app + attente/rematching
skip-autostart-managed no-op explicite, propriétaire inchangé
move-to-workspace workspace numérique seulement
restore-floating enable/disable déterministe
restore-fullscreen modes 0 et 1 seulement
restore-focus toujours dernière mutation
```
Ces actions bloquent l'ensemble du plan avec
`RESTORE_BLOCKED_UNSUPPORTED_ACTION` et une liste précise, sans aucune mutation :
```text
restore-tree-position
restore-scratchpad-hidden
restore-scratchpad-visible
manual-required
toute action inconnue
```
Un matching runtime `ambiguous`, un workspace non numérique et un mode
fullscreen non prouvé bloquent eux aussi le plan. Une fenêtre absente n'a pas
encore de position d'arbre observable ; le planner n'émet donc une différence
`restore-tree-position` que pour une fenêtre déjà matchée. STEP19C pourra
replanifier l'arbre après apparition.
### Lock privé
Un apply détient un `flock(LOCK_EX|LOCK_NB)` sur `.restore.lock` pendant toute
la séquence load/plan/execute/rehash. Le fichier est régulier, ouvert sans
suivre les symlinks et forcé à `0600` dans le répertoire sessions `0700`. Le
fichier peut persister ; la fermeture du FD libère le lock. Aucun daemon ou
scheduler supplémentaire n'est créé.
### DesktopEntry et lancement UWSM
`launch.argv` dans le plan reste purement descriptif et n'est jamais exécuté.
Pour chaque lancement, l'exécuteur vérifie :
```text
action = launch-application
backend = uwsm-app-desktop-entry
classification = user-application
restore_identity.confidence = exact
DesktopEntry ID syntaxiquement bornée
résolution exacte identique dans les sources XDG courantes
```
Il reconstruit ensuite lui-même exactement :
```python
["uwsm", "app", "--", desktop_entry]
```
Il n'utilise ni shell, ni `Sway exec`, ni `gtk-launch`, ni `gio launch`. Comme
`uwsm app` peut rester attaché pendant toute la vie de l'application, le
processus est lancé avec un argv direct, sorties vers `DEVNULL` et une nouvelle
session de processus. Son PID et son exit status ne prouvent jamais le succès.
### Apparition et matching post-launch
L'exécuteur conserve l'inventaire des `con_id` avant lancement, puis poll
`get_tree` via la collecte Session Snapshot V1 pendant au plus 10 secondes.
Seules les nouvelles fenêtres sont candidates. Il réutilise directement
`session_restore.match_windows()` ; il ne possède aucun second matcher.
Zéro candidate compatible conduit à `RESTORE_LAUNCH_TIMEOUT`. Plusieurs
candidates indiscernables conduisent à `RESTORE_LAUNCH_AMBIGUOUS`. Le PID du
processus lancé n'intervient pas, ce qui couvre aussi une application qui
demanderait à une instance existante de créer une nouvelle fenêtre.
### Revalidation runtime et commandes ciblées
Avant chaque mutation, une nouvelle collecte doit retrouver le même `con_id`,
une identité application compatible et un workspace connu. Cela interdit de
cibler silencieusement un `con_id` disparu ou recyclé. Après la commande, une
attente bornée à une seconde relit l'arbre jusqu'à observer la postcondition,
en revalidant l'identité à chaque lecture. Elle absorbe uniquement le délai de
visibilité IPC ; son expiration reste un échec critique.
Les workspaces exécutables sont des chaînes numériques canoniques positives.
Les noms libres restent hors périmètre afin qu'aucun quoting Sway non prouvé
ne devienne une surface d'injection. Un déplacement reconstruit uniquement :
```text
[con_id=<entier validé>] move container to workspace number <entier validé>
```
Floating utilise exclusivement `floating enable` ou `floating disable`.
L'audit de `sway(5)` 1.12.0 prouve `fullscreen enable|disable [global]` : V1
supporte `fullscreen_mode=0` avec `fullscreen disable` et le mode workspace
`fullscreen_mode=1` avec `fullscreen enable`. Le mode global et toute autre
valeur sont bloqués. Aucun état ne recourt à `toggle`.
Le focus utilise `[con_id=<id>] focus`, après la même revalidation, et reste la
dernière mutation. La disparition de la cible ne focalise jamais une autre
fenêtre par défaut.
### Rapport, arrêt et absence de rollback magique
Chaque action du rapport possède `pending`, `success`, `skipped`, `failed` ou
`blocked`, avec un `reason`. L'exécution s'arrête à la première divergence
critique et laisse les actions suivantes `pending`.
Les lancements et mutations Sway ne sont pas transactionnels. Executor V1 ne
promet aucun rollback global : sa politique est un preflight total strict,
des commandes ciblées, une vérification après chaque commande, puis un arrêt
immédiat au premier écart.
### Validation contrôlée STEP19B
La validation réelle utilise exclusivement une Kitty créée pour le test sur
un workspace numérique libre. Le scénario A réutilise la fenêtre existante et
la replace par `con_id`, sans nouveau lancement. Le scénario B ferme seulement
cette fenêtre, lance `kitty.desktop` via UWSM, observe une nouvelle fenêtre,
la rematche et la replace. Le cleanup ferme uniquement le nouveau `con_id`,
supprime uniquement le snapshot de validation et restaure le focus utilisateur
initial lorsqu'il existe encore.
Les fenêtres non marquées `TEST_ONLY` sont comparées avant/après sur
`con_id`, workspace, floating et fullscreen. L'arbre complet, le scratchpad
avancé, le Spatial Canvas et toute UI QML restent hors STEP19B.
## Tree Restore Compiler et scratchpad — STEP19C
### Audit local Sway 1.12 / SwayFX 0.6
L'implémentation STEP19C est fondée sur la documentation réellement installée
et sur des expériences `get_tree` avec des Kitty `TEST_ONLY`, pas sur une
supposition i3. La version validée est :
```text
swayfx version 0.6 (based on sway 1.12.0)
swaymsg version 0.6 (based on sway 1.12.0)
```
`sway(5)` confirme localement :
```text
layout default|splith|splitv|stacking|tabbed
split vertical|horizontal|none|toggle
focus parent|child
move left|right|up|down
move container to mark <mark>
mark --add|--replace [--toggle] <identifier>
unmark [<identifier>]
resize ... px|ppt
move container to scratchpad
scratchpad show
```
L'IPC expose le layout `stacked`, tandis que la commande correspondante est
`layout stacking`. Cette conversion est une table locale fermée. `scratchpad
show` sans critère est cyclique et n'est jamais utilisé ; STEP19C reconstruit
toujours `[con_id=<id>] scratchpad show`.
### Matrice de capacités
| Élément | Niveau STEP19C | Contrat |
|---|---|---|
| Racine `splith` | `supported-subset` | Container racine homogène, fenêtres toutes matchées |
| Racine `splitv` | `supported-subset` | Même contrat |
| Racine `tabbed` | `supported-subset` | Même contrat, layout final relu |
| Racine `stacked` | `supported-subset` | Commande locale `stacking`, layout IPC `stacked` |
| Ordre des enfants | `supported-subset` | Insertion ciblée après une fenêtre de référence |
| `H[A,V[B,C]]` et symétrique | `supported-subset` | Un seul container imbriqué, deux feuilles, splits orthogonaux |
| Arbre imbriqué arbitraire | `unsupported` | Bloqué avant mutation, jamais aplati silencieusement |
| Proportions `percent` | `unsupported` | Observées mais aucune promesse de restitution V1 |
| Branche floating | `supported-subset` | Préservée, jamais compilée comme enfant tiling |
| Scratchpad caché | `supported-subset` | `move scratchpad` ciblé + relecture V1 |
| Scratchpad visible | `supported-subset` | Workspace cible numérique déjà focalisé + show ciblé |
| Spatial Canvas | `unsupported/out-of-scope` | Aucun comportement `spatial-canvas-v1` |
Cette matrice ne signifie pas « arbitrary Sway tree restore ». Un changement
de style entre des fenêtres directement sous le workspace et un container
racine explicite, plusieurs containers imbriqués, un nesting en tête, des
splits imbriqués redondants ou un arbre live déjà imbriqué hors forme cible
sont refusés.
### Architecture et représentation normalisée
```text
Session Snapshot V1 target
+
Session Snapshot V1 live frais
+
matching source_window → runtime con_id
↓
session_tree_restore.py (pur)
↓
Tree Restore compilation
↓
operations[] typées
↓
session_executor.py (effets externes)
```
`TreeNode` ne conserve que `kind`, identité de fenêtre sauvegardée, layout,
percent observé et enfants. Il ne prend jamais `container_id`, `con_id` ou PID
sauvegardé comme identité persistante. Le lien live est reconstruit uniquement
depuis le matching frais du planner et le `con_id` observé dans cette session.
Le compilateur valide d'abord toutes les références V1, l'unicité des feuilles,
la branche tiling/floating, les layouts et la forme. Il compare ensuite les
arbres normalisés. Un arbre identique produit zéro opération. Pour le
sous-ensemble supporté, il produit seulement :
```text
set-container-layout
set-split-orientation
move-relative(relation=after)
```
Les opérations scratchpad sont portées par les actions du plan :
```text
move-to-scratchpad
show-scratchpad-window
```
Elles ne sont jamais des chaînes Sway provenant du snapshot. Les enums sont
validés puis traduits par l'exécuteur en argv directs, sans shell.
### Ordre et marks temporaires
`move left/right` n'est pas une primitive d'ordre universelle : sur la version
locale, elle peut extraire une vue d'un container `tabbed` ou `stacked`.
STEP19C utilise donc un mark temporaire uniquement pour `move-relative` :
```text
[reference] mark --add __labfy_restore_<token_hex_aléatoire>
[source] move container to mark __labfy_restore_<token_hex_aléatoire>
[reference] unmark __labfy_restore_<token_hex_aléatoire>
```
Le préfixe est réservé, le suffixe contient 64 bits aléatoires encodés en hex,
et jusqu'à huit candidats sont comparés à tous les marks live avant création.
Le mark est ajouté uniquement à une fenêtre cible déjà revalidée, n'utilise
jamais `--replace`, et est supprimé dans un `finally`. Son apparition et sa
disparition sont relues. Une fuite produit `RESTORE_TEMPORARY_MARK_LEAK`.
L'ordre cible est construit en gardant le premier enfant comme ancre, puis en
insérant chaque enfant suivant après son prédécesseur cible. Cette règle est
déterministe et fonctionne indépendamment de la direction géométrique du
layout.
### Nesting supporté
Le nesting V1 supporté contient exactement un container interne de deux
fenêtres, précédé d'au moins une feuille, avec orientations orthogonales :
```text
H[A,V[B,C]]
V[A,H[B,C]]
```
Le compilateur remet d'abord l'ordre plat, applique le layout racine, crée le
split interne sur B, puis insère C après B via le mark temporaire. `get_tree`
doit ensuite être exactement égal à la topologie cible normalisée. Un arbre
plus profond ou une transition depuis un arbre live imbriqué non reconnu est
`unsupported-live-nested-tree` et bloque le plan entier.
### Proportions
Sway 1.12 expose `resize ... ppt`, mais les ajustements sont relatifs aux
frères, arrondis par la géométrie disponible et dépendants de l'ordre des
redimensionnements. Les expériences STEP19C ne justifient donc pas une
restitution générale déterministe des `percent`. Le compilateur conserve les
valeurs pour le diagnostic mais publie :
```text
percent_capability = unsupported
```
Aucune commande `resize` n'est émise et le rapport ne prétend pas restaurer
les proportions exactes ou approchées.
### Preflight global et dérive
Avant la première mutation, le capability gate vérifie toutes les actions,
les identités de lancement, les workspaces/outputs, les modes fullscreen, les
cibles scratchpad et la compilation complète d'arbre. Pour chaque workspace
compilé :
```text
toutes les fenêtres target sont matchées
aucune fenêtre tiling live inattendue n'existe
aucune fenêtre floating ne devient tiling
layout et forme sont dans le sous-ensemble
toutes les opérations sont typées et validées
aucune action manual-required ne subsiste
```
Une seule erreur rend le plan entier `RESTORE_BLOCKED_UNSUPPORTED_ACTION`
avant lancement ou mutation.
Entre deux mutations d'arbre, l'exécuteur recalcule une empreinte contenant le
layout du workspace, les parents, les layouts et l'ordre des fenêtres ciblées.
Elle doit être égale à la postcondition de l'opération précédente. Une action
utilisateur concurrente produit `RESTORE_TREE_DRIFTED` et la commande suivante
n'est pas lancée. Après chaque opération, une nouvelle empreinte doit devenir
visible ; enfin le compilateur est relancé sur le live final et doit produire
zéro opération avec une topologie target/live égale.
### Scratchpad ciblé et isolation utilisateur
Le snapshot V1 distingue :
```text
member=true, visibility=hidden, workspace=null, branch=scratchpad
member=true, visibility=visible, workspace=<numérique>, branch=floating
```
Pour une cible cachée, STEP19C exécute `move scratchpad` si la fenêtre est
hors scratchpad ou visible, puis exige la représentation cachée ci-dessus.
Pour une cible visible, une fenêtre hors scratchpad est d'abord admise, puis
`[con_id=<id>] scratchpad show` est envoyé uniquement à cette fenêtre. Le
workspace numérique cible doit déjà être focalisé au preflight ; sinon le
placement de la vue visible serait ambigu et tout le plan est bloqué.
Les autres fenêtres scratchpad, y compris celles de l'utilisateur, ne sont ni
cyclées ni marquées. Une cible disparue, une identité changée ou un matching
multiple ambigu arrête l'exécution. Cette stratégie reste sûre même avec
plusieurs membres scratchpad, car aucune commande `scratchpad show` globale
n'existe dans l'exécuteur.
### Limite non transactionnelle
Les commandes Sway restent non transactionnelles. STEP19C maximise la sûreté
par preflight intégral, revalidation avant chaque commande, postcondition après
chaque mutation, marks nettoyés et arrêt au premier écart. Il ne promet ni
rollback global, ni restauration d'un arbre arbitraire, ni reconstruction des
proportions, ni UI. `spatial-canvas-v1` demeure entièrement hors périmètre.
## STEP19D — Gestionnaire graphique QuickShell
Le Control Center expose une page dédiée sous la page système `Session`. La
page système et ses commandes de verrouillage, veille, déconnexion UWSM,
redémarrage et extinction restent distinctes. La navigation du gestionnaire
est `Sessions → Détail → Aperçu` et chaque retour remonte d'un niveau.
Le QML reste une frontière d'interface. Il appelle exclusivement
`session_snapshot.py` avec les commandes publiques `list`, `show`, `save`,
`delete`, `plan` et `apply <name> --execute`. Le chemin déployé est résolu par
`$XDG_CONFIG_HOME/quickshell/labfy-sway/session/session_snapshot.py`, avec le
repli XDG standard `$HOME/.config/...`. Aucun chemin utilisateur ou chemin de
checkout n'est figé dans le QML.
Un unique `Process` asynchrone sérialise les opérations. Ses commandes sont
toujours des tableaux argv directs ; aucun shell, aucune interpolation de nom
de session et aucune commande Sway ne sont accessibles à l'UI. `stdout` JSON
et `stderr` diagnostic sont collectés séparément. Un résultat n'est accepté
que si le processus termine avec succès et si le JSON attendu est entier et
structurellement cohérent. Pendant `save`, `delete` ou `apply`, les autres
mutations restent désactivées.
`plan` est un aperçu strictement read-only. Après confirmation, `apply
--execute` recharge le snapshot, observe un live frais, reconstruit le plan et
refait le preflight : le QML n'exécute jamais les actions du plan et ne peut
pas transformer un ancien aperçu en autorisation. Les limites sur l'état
interne des applications et les proportions de splits sont affichées avant la
confirmation.
Il n'existe aucun déclencheur de sauvegarde ou restauration au chargement, à
la connexion, à la déconnexion ou sur minuterie. Toutes les mutations sont la
conséquence immédiate d'une action utilisateur confirmée.