Dotfiles_ArchAP/docs/session-restore-v1.md

26 KiB

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.

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 :

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

{
  "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 :

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 :

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

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 :

{
  "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 :

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 :

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.

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

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 :

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 :

show_session(name)   # lecture et validation persistante
collect(autostart)   # quatre IPC read-only STEP18
build_restore_plan() # fonction pure

Le plan expose :

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.

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 :

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 :

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 :

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 :

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 :

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 :

["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 :

[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 :

swayfx version 0.6 (based on sway 1.12.0)
swaymsg version 0.6 (based on sway 1.12.0)

sway(5) confirme localement :

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

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 :

set-container-layout
set-split-orientation
move-relative(relation=after)

Les opérations scratchpad sont portées par les actions du plan :

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 :

[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 :

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 :

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é :

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 :

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.