feat(session-v2): save and restore Sway scratchpad with guarded recovery

This commit is contained in:
fy59 2026-10-22 07:33:22 +02:00
parent 5aeff41d2c
commit d16aa69f9a
28 changed files with 2063 additions and 61 deletions

View file

@ -270,7 +270,71 @@ Voir le [diagnostic ciblé, les preuves et la commande hôte](session-v2-path-st
- Contenu et processus terminal non restaurés.
- Ratios exacts non restaurés.
- Fullscreen et scratchpad non pris en charge actuellement.
- Fullscreen non pris en charge. Scratchpad pris en charge par le snapshot version 2 pour les feuilles flottantes individuelles ; les groupes restent refusés.
- Multi-output physique non validé.
- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils
simultanés ne sont pas pris en charge.
## Snapshot version 2 — scratchpad (22 octobre 2026)
Les nouvelles captures conservent `schema=labfy.sway.session-v2` et écrivent
`version=2`. Le contrat V1 et `schema-v1.json` restent inchangés : les anciennes
sessions sont lues, validées et restaurées directement, sans conversion ni
invention d’appartenance scratchpad. `schema-v2.json` décrit la nouvelle forme ;
`schema.py` ajoute les contraintes référentielles (unicité, cardinalité, chemins,
focus et association workspace/output) aux contraintes locales du schéma JSON.
Le bundle de développement contient les deux schémas.
Dans V2, `state.scratchpad` vaut `none`, `hidden` ou `shown`. Une feuille
scratchpad est nécessairement flottante, avec son rectangle IPC entier
`x/y/width/height`. Une feuille cachée a `workspace=null` et `tree_path=[]` ;
`__i3_scratch` n’entre jamais dans les workspaces, sorties ou ancres persistés.
Une feuille montrée garde le workspace/output de visibilité. `focus` reste une
référence de slot ou null, jamais une feuille déduite d’un split. Le champ V2
`focus_workspace` conserve le workspace effectivement focalisé, y compris un
workspace vide lorsque toutes les fenêtres sont cachées. Ce seul workspace vide
est admissible sans slot ; son output doit être disponible. Une feuille cachée
ne peut être le focus persistant.
La capture inclut les scratchpads cachés sous l’output interne Sway et les
scratchpads montrés parmi les flottants normaux. Les relectures comparent aussi
app_id, PID, appartenance scratchpad et focus ; un changement donne
`SNAPSHOT_CHANGED`. Les groupes flottants/scratchpad ne sont pas aplatis en
feuilles indépendantes : ils sont refusés précisément. Les fenêtres de même
application sans fournisseur exact restent explicitement interchangeables.
Le mapping privilégie la visibilité déjà observée pour ces slots ; une feuille
scratchpad existante n’est pas exposée pour remplir arbitrairement un slot
ordinaire. Les preuves UUID/singleton restent prioritaires, puis les identités
runtime admises comprennent con_id/PID/starttime/session/placement.
La phase de placement (`plan.placements`) est distincte de la compilation des
ancres de splits. Les scratchpads existants évitent staging et `floating disable`.
Les commandes sont ciblées par l’identité admise ; `scratchpad show` n’est émis
que pour une feuille prouvée cachée et un slot explicitement montré. L’ajout
initial au scratchpad sélectionne le workspace source, même si la cible était
inactive : cette transition est prédite avant IPC. Le journal précède chaque
commande. Les transitions `floating enable` et `scratchpad show` ont une attente
bornée de 300 ms pour laisser passer la configuration Wayland initiale ; cette
attente n’est pas une preuve de stabilité. Toute divergence encore observée
reste un échec. Le rectangle intermédiaire d’un `show` peut être converti par
Sway selon l’output ; le rectangle sauvegardé est ensuite commandé et vérifié
exactement, avec appartenance, visibilité, output, layout, identité et focus.
Les nouveaux journaux Recovery sont en version 2 et suivent l’appartenance
scratchpad. Les journaux V1 restent lisibles ; une observation scratchpad absente
de leurs preuves est refusée, jamais reconstruite implicitement. Après incident,
un scratchpad déjà exactement à son origine peut être reconnu sans IPC. Si son
placement diffère, la reprise conserve les données et donne
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW` ; elle n’affiche aucune feuille pour tenter
un rollback spéculatif. Les intentions pending et les conflits de focus restent
bloquants. L’ancien journal utilisateur `RECOVERY_REQUIRED` n’est ni modifié ni
acquitté par ce patch.
Le dialogue d’échec de checkpoint montre maintenant le `reason` JSON exact,
avec une explication pour les cas connus, notamment `CAPABILITY_UNSUPPORTED`.
L’erreur conserve l’action en attente : annulation ou choix explicite de quitter
sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le logout.
Validation et limites détaillées :
[rapport scratchpad](session-v2-scratchpad-save-and-restore-v1.md).

View file

@ -0,0 +1,219 @@
# Session V2 — focus et récupération, 22 octobre 2026
## Portée et état
Patch isolé sur `fix/session-v2-focus-recovery-20261022`, worktree
`/home/fy59/Documents/Development/labfy-session-v2-focus-recovery-20261022`,
base `5aeff41d2ce8c91966ce65fd2fa3261f34f3f954`. Le dépôt principal était propre
à cette base. Aucun commit, déploiement, redémarrage ou IPC mutant sur le bureau.
Les tests utilisent des arbres inertes ou des compositeurs enfants headless privés.
Les trois journaux réels ont été lus et leurs SHA-256 vérifiés inchangés.
Le choix réel `startup.json=new` ne donne aucune autorisation de reprise.
La fausse classification d'un changement de chemin est reproduite et corrigée
pour les nouveaux journaux disposant d'une preuve de structure. L'ordre de fin
du helper d'ancres est corrigé et les frontières du watchdog sont journalisées.
**La cause historique exacte du focus Firefox → Kitty reste non démontrée.**
Ce patch ne constitue donc pas une preuve de correction définitive du login réel.
Aucune divergence n'est réinterprétée comme un succès.
## Chronologie établie de l'incident
Le rapport réel indique trois applications observées, trois slots remplis,
trois ancres créées/permutées/nettoyées, un helper suspendu/repris et l'arbre
vérifié. `final_tree_verified=true` est une étape validée, pas la preuve que
l'arbre est resté identique après reprise.
Le journal conserve `operations=18`, une dernière intention `[con_id=8] focus`,
`focus_expected={con_id:8,workspace:"1"}` et `focus_pending=false`. Dans le code
exécuté, ces deux derniers champs ne sont publiés qu'après le retour réussi de
RUN_COMMAND et une observation de focus conforme. Cela prouve une observation
de Firefox après la commande ; cela ne prouve pas l'absence d'input concurrent.
La séquence source était :
1. Verrou V2 et refus d'une récupération active/ambiguë ; prewarm des applications,
identités/PID/starttime, puis preuve de focus stable sur deux arbres.
2. Création de Recovery et baseline, lancement du helper d'ancres sans surface,
découverte et suspension vérifiée d'autotiling via pidfd.
3. Réadmission stricte du lot, déplacements au stage, construction des ancres,
résolution Firefox, permutations et retrait des seules ancres possédées.
4. Vérifications de structure, identités, outputs et flottants ; choix du focus
final depuis le checkpoint ou l'origine prouvée ; commande vers Firefox8.
5. `guard.resume()` envoie SIGCONT et attend seulement l'état non stoppé du helper.
6. `_verify()` refait notamment l'appel provider et `validate_final()`. Sa lecture
`final-observation` constate Kitty7, enregistre la première divergence et échoue.
7. L'appel **normal** suivant à `guard.finish()` n'a donc pas encore été atteint.
Le finally appelle ensuite finish sur erreur, puis la récupération conservatrice.
Le premier conflit `after-command/final-observation` désigne une validation,
**pas une commande focus qui aurait échoué**. La fenêtre causale est postérieure
à l'observation conforme de Firefox et antérieure à cette lecture. Elle contient
la reprise des helpers et l'attente provider. L'incident ne permet pas de choisir
entre un helper, un autre client IPC, une activation Wayland/layer ou un input
utilisateur. Il ne démontre pas un défaut du déplacement initial.
## Autotiling, Sway et interface
L'autotiling installé est lancé par `exec autotiling` dans l'autostart Sway.
Son handler `switch_splitting` traite WINDOW/MODE, relit le focus puis envoie
`splith`/`splitv` sans critère con_id. SIGCONT peut donc libérer un backlog et une
commande déjà préparée ; l'ACK actuel ne prouve aucun drainage. Le code ne demande
pas directement de focus. Les splits peuvent insérer un parent unaire ; le code
Sway de `container_split` conserve le focus de son enfant lorsqu'il était focalisé.
La seule présence d'autotiling ne démontre donc pas la transition vers Kitty.
Une restauration avec le véritable autotiling dans un Lab privé réussit avant
et après patch.
Les erreurs réelles `'NoneType' object has no attribute 'type'` sont visibles
à 06:11:58.214 et 06:11:58.390, mais aussi avant à 06:11:53.835/.836. Le handler
appelle `output_name(con)` avant de vérifier `con` : un `find_focused()` nul peut
produire précisément cette erreur. Il n'existe ni traceback ni trace de commandes
ou d'input permettant d'en déduire une commande de focus causale.
Les règles Sway du dépôt ne refocalisent pas Firefox/Kitty. Le script historique
`layout-workspace1.sh` contient des commandes focus sur d'autres identités, sans
invocation dans l'autostart examiné. L'abonné d'opacité commande l'opacité et les
bordures. Le provider Firefox change `titlePreface`, sans `focused:true` ; un
événement titre peut toutefois réveiller autotiling. L'UI retire sa layer avant
le backend et vérifie le retrait sur deux ticks ; cela ne constitue pas une
barrière atomique sur tous les clients Wayland. `startup.py` n'effectue aucun IPC
mutant et le choix `new` ne déclenche aucune reprise. Ces composants restent inchangés.
Références primaires : [IPC Sway 1.12](https://github.com/swaywm/sway/blob/1.12/sway/sway-ipc.7.scd),
[autotiling](https://github.com/nwg-piotr/autotiling/blob/master/autotiling/main.py).
Les sources locales SwayFX `sway/tree/container.c`, `sway/input/seatop_default.c`
et `sway/ipc-server.c` ont aussi été inspectées. RUN_COMMAND/GET_TREE séquentiels
ne sont pas un compare-and-swap du focus et ne fournissent pas sa provenance.
## Protocole corrigé
Après le nettoyage/vérification du layout, l'executor observe le focus,
puis appelle `guard.prepare()`. Le watchdog refuse s'il reste une ancre et
quitte son helper ; il acquitte seulement après preuve de sortie sur le pidfd.
Cela se produit pendant la suspension possédée, avant le focus final et SIGCONT.
La vérification `Anchors.owned` commence par PID/app_id : les fenêtres applicatives
peuvent être vérifiées après la sortie/récolte du helper, sans dépendre d'un zombie.
Une candidate possédée exige toujours son pidfd exact.
Le moteur contrôle le focus après prepare, émet la commande finale sous les
contrôles Recovery existants, observe avant/après resume, puis avant/après finish.
La clôture préparée ne ferme plus d'ancre et n'écrit plus de quit tardif.
Les chemins d'erreur conservent le nettoyage des seules ancres possédées et la
reprise de tous les helpers dont le watchdog possède la suspension. Un helper
initialement stoppé n'est jamais repris.
Les six observations `focus_boundaries` sont durables et bornées à huit ; elles
localisent un premier constat, sans attribuer une cause. L'intention conserve
désormais explicitement `focus_after` après réponse et observation conformes.
Toute divergence refuse la suite ; aucun rattrapage/refocus automatique n'est
ajouté. Les contrôles finaux après reprise et après finish restent présents.
**Limite :** prepare est une barrière sur le processus possédé, pas un ACK
coopératif d'autotiling. Un helper repris peut toujours commander Sway. Une
nouvelle instance non découverte, un autre client ou l'input restent concurrents.
Le patch contrôle ces divergences observables et les refuse ; il ne prétend pas
établir une causalité atomique ou empêcher tout changement après la dernière lecture.
## Récupération canonique
L'ancien `_recover` exigeait `current == expected`, chemins bruts inclus.
Il pouvait donc annoncer `IDENTITY_OR_MEMBERSHIP_CHANGED` pour `[0] → [0,0]`
ou `[1] → [1,0]`, alors que la validation finale ignorait déjà ces chemins et
contrôlait ordre/placement/layout. La reproduction inerte échoue avant patch
avec exactement deux rejets Firefox/Kitty, puis passe après correction.
Les nouveaux journaux V1 contiennent une extension facultative
`structure_expected`, capturée sur l'arbre confirmé après commande. Elle utilise
la même normalisation qu'Anchors : seuls les splits unaires splith/splitv/none
sont contractés. Les feuilles sont les vrais con_id ; branches ordonnées,
layouts tabbed/stacked et appartenance aux workspaces restent significatifs.
La preuve est bornée en profondeur, nombre de branches et workspaces et soumise
à la limite durable JSON existante. Aucune version de Project DB n'est concernée.
Avant de réconcilier des chemins, Recovery exige :
- Même session et aucune intention fenêtre/focus pending.
- Ensemble exact des fenêtres gérées et étrangères, sans admission par app_id.
- Égalité complète con_id/PID/starttime/app_id, workspace/output, flottement et
géométrie flottante pertinente pour tout le lot.
- Égalité de la structure canonique à la preuve durable antérieure.
Un changement de branche/layout/ordre reste refusé même si les chemins bruts
n'ont pas changé ; chaque commande de récupération recontrôle la structure.
Seuls les chemins `expected` sont réconciliés. Origin, before, intention et
premier conflit restent conservés ; `path_reconciliations` garde les chemins
avant/après (quatre lots maximum, puis refus plutôt qu'écrasement).
Le contrôle strict avant chaque commande relit toujours l'état exact.
Les journaux historiques restent lisibles mais **sans fabrication rétroactive de
preuve structurelle**. Une différence de chemin sans preuve devient
`PATH_EQUIVALENCE_NOT_PROVEN`, une vraie différence de placement/identité reste
`IDENTITY_OR_MEMBERSHIP_CHANGED`, une structure différente devient
`STRUCTURE_OR_ORDER_CHANGED`. Aucun journal réel n'a été réécrit.
Reconnaître sans IPC une fenêtre déjà à son origine n'efface pas le conflit de
focus : le reçu reste `RECOVERY_REQUIRED` avec `FOCUS_RECOVERY_NOT_PROVEN`.
Ce patch ne débloque donc pas automatiquement la transaction réelle déjà refusée.
## Validation et preuves
Les commandes, résultats détaillés et empreintes sont conservés dans
`build/focus-recovery/`, `build/real-focus-env/` et `build/session-v2-tests/` du
worktree. Ces artefacts sont locaux, non déployés. Commande de validation composée :
```sh
LABFY_TEST_RENDERER=pixman python -B tests/session_v2/validate_login_focus.py
```
Le runner impose HOME/XDG privés et retire les sockets/bus de la session réelle.
Chaque Lab lance SwayFX 0.6 / Sway 1.12 sur un socket enfant distinct avec Pixman.
Les applications et UUID Firefox sont synthétiques. Les injections « helper »
utilisent une commande déterministe du contrôleur après reprise ; elles ne
prétendent pas reproduire une commande focus du véritable autotiling.
Les scénarios positifs exigent success, slots/permutations complets, arbre exact,
focus hors stage, recovery complete et toutes les postconditions. Les négatifs
exigent un refus, le maintien des clients et de l'incident, et l'absence de
refocalisation. Les tests d'interruption conservent les intentions pending.
`test_bootstrap.py` utilise désormais un temporaire court pour ne pas dépasser
la limite AF_UNIX dans un worktree profond ; aucun test n'a été supprimé.
Résultats du dernier contrôle composé : **29 tests focus/recovery, 71 tests
backend, 13 tests QML Python, 5 contrôles QtTest et 15 scénarios de login headless**.
Les cinq variantes de `path_stability_runtime.py` et les dix variantes de
`crashes.py` passent également. Les scénarios de succès exigent toutes les
postconditions ; les injections tardives exigent RECOVERY_FOCUS_MISMATCH après
les trois swaps, sans refocalisation.
Un élargissement à `runtime.py` a validé les 28 permutations de layouts ainsi
que les cas de performance/multi-workspace précédant le flottant. **La suite
complète ne passe pas** : sa fixture GTK flottante repasse de 400×240 à 122×82
après le resize, d'où un refus de postcondition/état. La sonde privée identique
échoue aussi contre la baseline non modifiée (`floating-baseline.log`) ; le
patch échoue avec le même retour spontané de taille (`floating-patched.log`).
Aucune tolérance géométrique ni suppression de ce test n'a été ajoutée. Ce
résultat ne permet pas de revendiquer une validation complète des flottants ;
les contrôles continuent de refuser leur géométrie divergente. Les deux cas
d'identité après le flottant dans cette suite n'ont pas été atteints.
## Validation proposée au prochain login — autorisation humaine requise
1. Faire relire le diff ; autoriser séparément son intégration/déploiement et un
essai réel au prochain login. Rien de cela n'a été exécuté par cette mission.
2. Conserver les journaux/empreintes de l'incident actuel. Un traitement de son
`RECOVERY_REQUIRED` nécessite un plan nominatif et une autorisation distincte ;
ne pas l'effacer ni utiliser le choix `new` pour le reprendre.
3. Au login autorisé, relever versions, checkpoint voulu, session/socket, outputs
et helpers ; utiliser uniquement le choix Restaurer explicite, une seule fois.
Pour le cas nominal, laisser les interactions se terminer avant la transaction.
4. Conserver tentative/journal/reçu, six frontières focus et arbres avant/après.
Vérifier identité runtime de chaque fenêtre, slots/ordre/layout/outputs,
ancres absentes, helpers repris, focus convenu hors stage et recovery complete.
5. En cas d'écart, arrêter l'essai et conserver son premier constat. Ne pas
cliquer Réessayer en boucle, refocaliser via script, relancer apply ou supprimer
RECOVERY_REQUIRED. Comparer les frontières et compléter par une trace IPC/input
explicitement autorisée pour discriminer les causes restantes.
6. Un essai séparé avec interaction volontaire doit conserver le focus extérieur
et refuser toute mutation après le conflit observable ; il demande lui aussi
une autorisation explicite. Un résultat headless ne valide pas le login physique.

View file

@ -1,5 +1,8 @@
# Premier login V2 : chemins de feuilles après Guard
Suite du diagnostic : [focus et récupération du 22 octobre](session-v2-focus-recovery-fix-v1.md).
Cette page conserve la portée et les résultats de la correction précédente.
## Résultat et portée
Correction minimale dans la seule copie

View file

@ -0,0 +1,180 @@
# SESSION_V2_SCRATCHPAD_SAVE_AND_RESTORE_V1
Travail local du 22 octobre 2026 dans
`labfy-session-v2-scratchpad-20261022`, branche
`feat/session-v2-scratchpad-20261022`, base `5aeff41d`.
Les correctifs focus/recovery préexistants restent présents. Aucun commit,
push, déploiement, reload QuickShell ou changement de configuration utilisateur.
## Résultat et fichiers
- `session_v2/snapshot.py`, `schema.py`, `schema-v2.json`, `model.py` :
capture version 2, scratchpad `none/hidden/shown`, rectangle IPC, focus de slot
et workspace focalisé explicite. Une fenêtre cachée a `workspace=null` ;
l’espace interne `__i3_scratch` n’est jamais persisté comme workspace cible.
Les groupes de fenêtres scratchpad restent refusés. Aucun titre ni argv
n’est capturé. `schema-v1.json` reste inchangé.
- `layout.py`, `slots.py`, `executor.py`, `cli.py` : phase de placements
descriptive, mapping conservant la visibilité des slots interchangeables,
staging évité pour les scratchpads existants, commandes con_id ciblées,
géométrie et état final vérifiés. Les nouvelles fenêtres arrivent par le
prewarm existant, depuis le catalogue statique de confiance.
- `recovery.py` : nouveaux journaux version 2 avec appartenance scratchpad,
lecture conservatrice des journaux V1, prédiction de focus avant chaque
commande, refus d’une feuille dont la commande affecterait un ancêtre
scratchpad non admis. Aucun conflit ni pending n’est effacé pour progresser.
- `controlcenter/SessionExitGate.qml` : `reason` JSON visible, explications,
action de sortie toujours bloquée après échec jusqu’à un choix humain.
Le ControlCenter fournit déjà ce JSON ; le diagnostic se perdait dans Gate
qui privilégiait le stderr générique.
- `tools/build-session-v2.py` : inclusion des deux schémas dans le bundle.
- Tests : `test_scratchpad.py`, `scratchpad_runtime.py`,
`validate_scratchpad.py`, extension de `qml/tst_exit_v2.qml`.
Les fonctions existantes `Guard.prepare`, les frontières de vérification finale,
la normalisation canonique des chemins et les tests focus/recovery initiaux
sont conservés. Le présent delta ne change pas la politique d’autotiling.
## Preuves et couverture
Le SwayFX installé échoue au démarrage headless dans ce sandbox faute de DRM.
Un paquet Sway 1.12 Arch a été extrait sous `/tmp/labfy-scratchpad-tool`, sans
installation système ni substitution de bibliothèque. `ldd` résout son
`libwlroots-0.20.so` installé sans dépendance manquante. Son renderer Pixman a
permis la campagne privée. Provenance :
[paquet Arch](https://archlinux.org/packages/extra/x86_64/sway/).
La prédiction du nouvel ajout scratchpad est étayée par le
[code Sway 1.12 root.c](https://github.com/swaywm/sway/blob/1.12/sway/tree/root.c)
et les observations privées : l’ajout initial refocalise le workspace source,
même si la cible était inactive. Le
[code scratchpad.c](https://github.com/swaywm/sway/blob/1.12/sway/commands/scratchpad.c)
explique également pourquoi `scratchpad show` est un toggle à ne jamais
émettre sans preuve de visibilité préalable.
La suite backend comprend 83 tests, dont 12 nouveaux tests scratchpad :
- capture/relecture déterministes, Store nommé et checkpoint automatique
temporaires, compatibilité des snapshots V1 ;
- Kitty caché + autre Kitty visible, montré et focalisé, plusieurs cachés,
workspace vide focalisé, splits et flottants ;
- références, types et versions stricts, JSON Schema V2, focus caché interdit ;
- scratchpad modifié entre lectures, fausse identité PID, output déplacé,
focus ambigu, intention interrompue conservée et groupe non admis ;
- mapping sans exposition d’un pair caché et prédiction de focus avant IPC.
Autres suites : 29 tests focus Python, 13 tests QML via PySide6, 6 résultats
QtTest (4 tests métier et initialisation/nettoyage). Les tests QML vérifient
notamment `CAPABILITY_UNSUPPORTED`, `SNAPSHOT_CHANGED`, `RECOVERY_REQUIRED` et
un code inconnu, avec zéro commande de sortie après échec.
La campagne `scratchpad_runtime.py` utilise des Kitty réels uniquement sous
le socket enfant privé. Elle conserve arbres JSON et PNG avant/après :
1. deux Kitty du même app_id (un caché et un en split), un scratchpad montré,
un flottant ordinaire et un autre split ;
2. capture, Store temporaire, perturbation puis restauration ; égalité exacte
des géométries et états, focus final vérifié ;
3. show/hide explicite après restauration, sans perte d’appartenance ;
4. reconstitution de l’appartenance depuis des fenêtres ordinaires ;
5. toutes les fenêtres cachées et workspace vide focalisé ;
6. disparition puis relancement réel des deux Kitty par le catalogue statique
TEST_ONLY, restauration cachée et second apply sans double lancement.
Les PNG avant/après de la campagne privée ont été inspectés. Ils montrent le
même arrangement ; ce sont des terminaux synthétiques noirs exécutant `sleep`,
pas une preuve de contenu applicatif ou d’une session utilisateur physique.
La trace de commandes vérifie qu’aucun move vers un workspace ni `scratchpad
show` ne cible la feuille destinée à rester cachée pendant le premier apply.
Régressions privées supplémentaires retenues sous
`/tmp/labfy-scratchpad-regressions-mivwhgzo/` : scénario Limusic V1 et refus
absent/dupliqué ; quatre scénarios de stabilité des chemins ; huit scénarios
login/focus (normal, workspace vide, fenêtre tierce, deux focus tardifs,
map dans le gap IPC, placement provider, crash en milieu d’intention).
Tous ont leurs succès ou refus attendus. Seul le nettoyage du harness a été
adapté dans un wrapper temporaire pour ne jamais envoyer SIGCONT.
Aucun scénario envoyant réellement SIGSTOP/SIGCONT à autotiling n’a été
exécuté. Son protocole est testé par simulation ; les tests headless réels
utilisent Guard sans autotiling. Aucun socket utilisateur ni fichier sous
`/run/user/1000/labfy-sway` n’est utilisé par les validations.
## Limites assumées
- **Enregistrer Kitty ne ressuscite pas btop.** Le processus interne, la commande,
le titre et le contenu du terminal ne font pas partie du snapshot. Le lancement
de production reste `/usr/bin/kitty` issu du catalogue. Le `sleep` du test vient
exclusivement d’un catalogue statique TEST_ONLY, jamais des données capturées.
- Les slots de même application sans provider exact restent interchangeables,
sans promesse de retrouver le rôle sémantique d’un terminal particulier.
- Les groupes scratchpad, fullscreen et géométries refusées par le client ou
le compositeur produisent un échec précis, pas un succès partiel.
- Pour une feuille cachée, le rectangle sauvegardé est le rectangle IPC caché.
L’ancienne transformation d’output interne à Sway n’est pas persistée. Un
futur `show` manuel peut adapter ses coordonnées à l’output alors actif.
- Le `show` intermédiaire d’une feuille explicitement montrée laisse Sway
convertir le rectangle à l’output ; la géométrie sauvegardée est ensuite
appliquée et vérifiée exactement. Le délai de 300 ms des transitions Wayland
ne remplace aucune preuve. Une modification tardive reste refusée.
- Une reprise après incident ne reconstruit pas automatiquement un scratchpad
dont le placement diffère de l’origine prouvée : reçu
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW`, fenêtres et journal conservés.
- Pas de validation de login physique, de multi-output physique, de SwayFX avec
DRM, ni de contenu Firefox/btop. Les contrôles IPC ne constituent pas un CAS.
- Aucun nouveau checkpoint réel n’a été écrit. L’ancien état utilisateur
`RECOVERY_REQUIRED` demeure bloquant ; ce patch ne l’acquitte pas.
## Reproduction sûre et future validation
Depuis ce worktree, avec un binaire Sway compatible disponible :
```sh
python -B tests/session_v2/validate_scratchpad.py \
--sway /tmp/labfy-scratchpad-tool/usr/bin/sway --renderer pixman
```
Le validateur crée ses HOME/XDG sous `/tmp`, retire les variables graphiques
héritées et lance exclusivement un compositeur headless avec configuration
TEST_ONLY. Il conserve les logs et le chemin des arbres/PNG dans son répertoire
de résultats. Aucun accès privilégié n’est requis. Les clients et compositeurs
enfants sont arrêtés par le nettoyage du laboratoire ; aucun signal de
suspension/reprise n’est envoyé.
Pour la future recette humaine : examiner d’abord le diff et les preuves
privées ; reproduire avec plusieurs fenêtres de terminal identifiables et un
scratchpad montré/caché dans un compositeur privé. Toute recette sur le bureau
réel exigera une autorisation séparée et la résolution humaine préalable du
journal ancien. Ne pas effacer ce journal pour débloquer apply, ne pas tester
via logout/reboot et ne pas recharger QuickShell pour cette vérification.
Vérifier séparément la sauvegarde d’une fenêtre Kitty et l’absence de promesse
sur le redémarrage de btop.
## Résultats finaux conservés
- Validateur reproductible : `/tmp/labfy-scratchpad-validation-imopv2no/` ;
tous les codes retour sont zéro (backend, focus, QML Python/Qt, runtime,
diff-check).
- Campagne réelle finale : `/tmp/labfy-session-v2-TEST_ONLY-1gtxan7m/` ;
snapshots, arbres, commandes, résultats, PNG avant/après/show/hide,
nouvelles appartenances, toutes cachées et lancement Kitty à froid.
- Bundle local candidat : `/tmp/labfy-scratchpad-final-evidence/bundle/` ;
compilation C17 stricte, schémas V1/V2 embarqués, non installé/non signé.
- Relecture locale du delta et vérification des patches initiaux : les diffs
initiaux hors executor/recovery restent identiques octet pour octet ; dans
ces deux fichiers, les mécanismes initiaux sont conservés et les suites de
non-régression passent. Cela ne remplace pas la revue humaine avant commit.
- `qmllint` du composant Gate, parsing AST Python, contrôle des deux JSON
Schemas et `git diff --check` : succès.
- Paquet Sway de test, SHA-256 :
`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
### Revue complémentaire du focus de layer-shell
Le dialogue de sortie QuickShell peut rendre le focus de GET_TREE non
observable. Dans ce cas, le snapshot V2 garde explicitement
focus=null et focus_workspace=null, sans inférer de fenêtre depuis les
piles internes. Plusieurs noeuds simultanément marqués focused restent
refusés avec SNAPSHOT_FOCUS_AMBIGUOUS. Les tests vérifient cette
distinction et le round-trip du Store temporaire.

View file

@ -59,7 +59,20 @@ QtObject {
return;
}
pendingAction = completedAction;
errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué.";
// CONTRACT: backend reason is diagnostic data, never exit authority.
// Keep the action gated until saved or an explicit human bypass.
const reason = response && typeof response.reason === "string"
&& /^[A-Z][A-Z0-9_]{0,95}$/.test(response.reason) ? response.reason : "";
const explanations = {
CAPABILITY_UNSUPPORTED: "Un état de fenêtre n’est pas pris en charge par le backend installé (par exemple fullscreen ou scratchpad avec un ancien backend).",
SNAPSHOT_CHANGED: "Les fenêtres ont changé pendant la capture. Réessayez une fois leur état stabilisé.",
SNAPSHOT_FOCUS_AMBIGUOUS: "Sway ne fournit pas un focus unique pour cette capture.",
FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE: "Le fournisseur d’identité Firefox nécessaire à la sauvegarde est indisponible.",
RECOVERY_REQUIRED: "Une ancienne restauration exige une inspection humaine. Elle ne sera pas reprise automatiquement.",
SCRATCHPAD_CONTAINER_UNSUPPORTED: "Ce scratchpad contient un groupe de fenêtres dont la restauration sûre n’est pas prise en charge."
};
errorMessage = reason ? reason + " — " + (explanations[reason] || "Le backend a refusé la sauvegarde. Conservez la session ouverte et examinez ce code.")
: stderrText.trim() || "Le backend de sauvegarde a échoué sans code de diagnostic.";
checkpointFailed();
}

View file

@ -30,8 +30,12 @@ class Anchors:
def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot
def owned(self, node):
# WHY : prepare a terminé/reapé le helper avant les vérifications
# finales. CONTRACT : une fenêtre étrangère n'exige pas un PID vivant
# du helper ; toute candidate possédée exige encore son pidfd exact.
if node.get("pid") != self.process.pid or node.get("app_id") not in self.allowed: return False
signal.pidfd_send_signal(self.pidfd, 0)
return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
return True
def anchor(self, slot):
require(self.active, "TRANSACTION_INACTIVE")

View file

@ -15,7 +15,7 @@ from .ipc import Sway
from .applications import Catalog
from .firefox_identity import FirefoxProvider
from .snapshot import capture
from .layout import compile_plan
from .layout import compile_plan, compile_placements
from .executor import Executor
from .observability import validate as validate_report
from .startup import acknowledge, status as startup_status
@ -83,7 +83,10 @@ def main(argv=None):
try: result = read(runtime / "session-v2-restore-attempt.json", validate_report)
except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"}
elif command == "analyze-v1-migration": result = migration(args.name)
elif command == "plan": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
elif command == "plan":
data = store.load(args.name)
result = {"operations": [asdict(x) for x in compile_plan(data, "0" * 32)],
"placements": compile_placements(data)}
else:
sway = Sway(args.socket)
catalog = Catalog()

View file

@ -18,7 +18,8 @@ from .guard import Guard
from .observability import Attempt
from .startup import record_deferred
from .slots import assign
from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status
from .layout import compile_placements
from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status, scratchpad_state
def process_owner(pid):
@ -217,7 +218,9 @@ class Executor:
recovery.admit(n)
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
for n in targets.values():
stage_node(n)
# Hidden scratchpads stay detached throughout staging; neither
# workspace moves nor floating-disable may expose them.
if prepared[n["id"]]["scratchpad"] == "none": stage_node(n)
def progress(phase, count):
values = {"phase": phase}
if phase == "anchor": values["anchors_created"] = count
@ -264,7 +267,7 @@ class Executor:
identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]])
attempt.data["providers"] = [PROVIDER]
attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()]
mapping.update(assign(members, groups[app["application_id"]], identity))
mapping.update(assign(members, groups[app["application_id"]], identity, pinned))
# CONTRACT : le provider ne peut introduire une admission hors du
# lot phase0, même si un remplacement conserve la cardinalité.
check_batch()
@ -283,6 +286,7 @@ class Executor:
for n in captured.values():
recovery.admit(n, pinned[n["id"]])
for n in captured.values():
if recovery.snapshot(n["id"])["scratchpad"] != "none": continue
if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n)
if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"):
self.sway.command(f'[con_id={n["id"]}] floating disable')
@ -290,6 +294,7 @@ class Executor:
for slot in data["window_slots"]:
if slot["state"]["floating"]: continue
target = captured[mapping[slot["slot_id"]]]
require(recovery.snapshot(target["id"])["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids)
attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1)
self.progress("swap", attempt.data["anchors_swapped"])
@ -303,6 +308,13 @@ class Executor:
self._verify(data, mapping, specs, anchors)
attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000
attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus")
# WHY : quitter le helper après SIGCONT ajoute une transition
# Wayland pendant la validation finale. CONTRACT : finir sa vie
# possédée sous suspension, contrôler le focus avant de le choisir.
# INVARIANT : aucune divergence ne donne droit à refocaliser.
recovery.boundary("before-guard-prepare")
guard.prepare()
recovery.boundary("after-guard-prepare")
# WHY : restaurer le checkpoint ne possède pas le focus d'un client
# tiers déjà actif. CONTRACT : son identité/emplacement de baseline
# reste inchangé ; le focus du snapshot ne remplace que celui du lot.
@ -312,7 +324,7 @@ class Executor:
if foreign_focus is not None:
require(placement(recovery.snapshot(origin_focus)) == placement(foreign_focus), "FOREIGN_WINDOW_CHANGED")
target_focus = origin_focus
if target_focus is None and origin_focus is not None:
if target_focus is None and origin_focus is not None and data.get("focus_workspace") is None:
# focus=null préserve la feuille réellement prouvée après mapping.
# L'identité complète reste celle de Recovery, jamais celle d'un
# app_id ou de la pile de focus d'un split arbitraire.
@ -322,20 +334,25 @@ class Executor:
"RECOVERY_FOCUS_IDENTITY_CHANGED")
target_focus = origin_focus
if target_focus is not None:
require(recovery.snapshot(target_focus)["scratchpad"] != "hidden", "RESTORE_FOCUS_UNAVAILABLE")
self.sway.command(f'[con_id={target_focus}] focus')
else:
origin_workspace = recovery.data["focus_origin"]["workspace"]
require(origin_focus is None and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
origin_workspace = data.get("focus_workspace") or recovery.data["focus_origin"]["workspace"]
require((origin_focus is None or data.get("focus_workspace")) and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
self.sway.command("workspace " + json.dumps(origin_workspace))
final_focus = recovery.data["focus_expected"]
require(final_focus["workspace"] != stage, "FINAL_FOCUS_IN_STAGE")
recovery.boundary("before-helper-resume")
answer = guard.resume()
attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification")
recovery.boundary("after-helper-resume")
self.progress("after-resume", len(mapping))
self._verify(data, mapping, specs, anchors)
recovery.check_command_focus(self.sway.tree(), final_focus, "final-verification")
recovery.boundary("before-guard-finish")
guard.finish()
guard = None
recovery.boundary("after-guard-finish")
self._verify(data, mapping, specs, anchors)
recovery.complete(lambda tree: self._verify_structure(data, mapping, specs, anchors, tree))
attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete")
@ -370,11 +387,30 @@ class Executor:
pass
def _floating(self, data, mapping, captured):
for s in data["window_slots"]:
if not s["state"]["floating"]: continue
slots = {s["slot_id"]: s for s in data["window_slots"]}
for placement_op in compile_placements(data):
s = slots[placement_op["slot_id"]]
target = mapping[s["slot_id"]]
n = next((n for n in self.sway.windows() if n["id"] == target), None)
require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED")
scratch = s["state"]["scratchpad"]
current = self.sway.recovery.snapshot(target)
if scratch in ("hidden", "shown"):
prefix = f'[con_id={target}] '
if not current["floating"]: self.sway.command(prefix + "floating enable")
if current["scratchpad"] != "hidden": self.sway.command(prefix + "move scratchpad")
if scratch == "shown":
self.sway.command("workspace " + json.dumps(s["workspace"]))
output = next(w["output"] for w in data["workspaces"] if w["workspace"] == s["workspace"])
self.sway.command("move workspace to output " + json.dumps(output))
self.sway.command(prefix + "scratchpad show")
# Hidden geometry can be set without showing the leaf. IPC rect
# is the contract, not the transform of a future user show.
g = s["state"]["geometry"]
self.sway.command(prefix + f'resize set width {g["width"]} px height {g["height"]} px')
self.sway.command(prefix + f'move absolute position {g["x"]} px {g["y"]} px')
continue
require(current["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"]))
# Sway rattache une fenêtre déplacée en coordonnées à l'espace
# visible de l'output : rendre la cible visible avant sa géométrie.
@ -386,6 +422,14 @@ class Executor:
self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px')
self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px')
# V2 can capture an empty focused workspace alongside hidden leaves.
# WHY : after launch/staging Sway may destroy it. CONTRACT : recreate
# only that explicitly persisted workspace/output, never __i3_scratch.
for w in data["workspaces"]:
if w["root"] is None and not any(s["workspace"] == w["workspace"] for s in data["window_slots"]):
self.sway.command("workspace " + json.dumps(w["workspace"]))
self.sway.command("move workspace to output " + json.dumps(w["output"]))
def _verify(self, data, mapping, specs, anchors):
# WHY : capture est une frontière externe potentiellement lente.
# CONTRACT : UUID exact conservé, puis nouvelle observation complète ;
@ -424,7 +468,9 @@ class Executor:
for s in data["window_slots"]:
n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None)
require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
scratch = s["state"]["scratchpad"]
require(scratchpad_state(n, location(tree, n["id"])[0]) == ("none" if scratch is False else scratch), "FINAL_SCRATCHPAD_MISMATCH")
if s["state"]["floating"]:
require(location(tree, n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
require(location(tree, n["id"])[0] == ("__i3_scratch" if scratch == "hidden" else s["workspace"]) and n.get("floating") in ("auto_on", "user_on") and
all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH")
return tree

View file

@ -97,6 +97,18 @@ class Guard:
self.process.wait(timeout=15)
raise
def prepare(self):
"""Le helper d'ancres doit être sorti avant focus final et SIGCONT.
CONTRACT : ACK après disparition prouvée par pidfd, pendant notre
suspension ; finish ne provoque plus cette sortie après la reprise.
"""
self.parent.sendall(b"prepare")
self.parent.settimeout(15)
answer = loads(self.parent.recv(4096), 4096)
require(answer.get("status") == "prepared", "GUARD_RECOVERY_FAILED")
return answer
def resume(self):
self.parent.sendall(b"resume")
self.parent.settimeout(15)
@ -140,6 +152,7 @@ def run(config):
stopped = []
counts = {"helpers_suspended": 0, "helpers_resumed": 0}
normal = False
prepared = False
sway = Sway(config["socket"])
def resume():
return resume_helpers(stopped, config["socket"], counts)
@ -160,7 +173,19 @@ def run(config):
if config["engine_fd"] in readable: break
if control in readable:
message = control.recv(16)
if message == b"prepare":
require(not prepared and not any(n.get("pid") == config["anchor_pid"] and
n.get("app_id") in config["allowed"] for n in sway.windows()), "ANCHOR_CLEANUP_FAILED")
os.write(config["writer_fd"], b"quit\n")
# WHY : la réponse à kill et l'écriture quit ne prouvent pas
# la fin Wayland du processus. CONTRACT : pidfd hérité exact,
# attente bornée, aucun SIGCONT avant cette barrière.
until(lambda: bool(select.select([config["anchor_fd"]], [], [], 0)[0]), 3)
prepared = True
control.sendall(b'{"status":"prepared"}')
continue
if message == b"resume":
require(prepared, "GUARD_PROTOCOL_INVALID")
require(resume(), "GUARD_RECOVERY_FAILED")
control.sendall(json.dumps({"status": "resumed", **counts}).encode())
continue
@ -171,10 +196,13 @@ def run(config):
until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3)
break
def owned(n):
if n.get("pid") != config["anchor_pid"] or n.get("app_id") not in config["allowed"]: return False
signal.pidfd_send_signal(config["anchor_fd"], 0)
return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
return True
cleaned = 0
for old in sway.windows():
# CONTRACT : après prepare, aucune fermeture ni écriture quit tardive.
# Le pidfd a déjà prouvé la sortie ; ne pas dépendre d'un zombie non reapé.
for old in ([] if prepared else sway.windows()):
if not owned(old): continue
fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None)
if fresh and owned(fresh):
@ -186,8 +214,9 @@ def run(config):
else:
sway.command(f'[con_id={fresh["id"]}] kill')
cleaned += 1
until(lambda: not any(owned(n) for n in sway.windows()))
os.write(config["writer_fd"], b"quit\n")
if not prepared:
until(lambda: not any(owned(n) for n in sway.windows()))
os.write(config["writer_fd"], b"quit\n")
# WHY : autotiling repris peut restructurer le stage avant la reprise
# et rendre son appartenance ambiguë. Finir nos obligations de fenêtres
# pendant la suspension possédée, puis toujours reprendre les helpers.

View file

@ -44,3 +44,18 @@ def compile_plan(data, tx):
emit("create", leaves(t.root)[0])
build(t.root, root=True)
return tuple(plan)
def compile_placements(data):
"""Deterministic placement phase after tiling anchors, including scratchpad.
CONTRACT : this is descriptive data, not executable launch authority. Hidden
leaves have no workspace destination. V1 false means only ordinary floating;
the historical document itself is never rewritten or silently downgraded.
"""
from .schema import validate
validate(data)
return tuple({"slot_id": s["slot_id"], "workspace": s["workspace"],
"scratchpad": s["state"]["scratchpad"] or "none",
"geometry": dict(s["state"]["geometry"])}
for s in data["window_slots"] if s["state"]["floating"])

View file

@ -16,7 +16,7 @@ class Application:
class WindowSlot:
slot_id: str
application_id: str
workspace: str
workspace: str | None
tree_path: tuple
state: dict
identity_requirement: str

View file

@ -7,9 +7,10 @@ INVARIANT : aucun kill client, aucune propriété déduite du nom du workspace.
import copy
import json
import re
import time
from pathlib import Path
from .errors import require
from .ipc import walk, location, workspace, until
from .ipc import walk, location, workspace, until, canonical
from .storage import atomic, read
JOURNAL = "session-v2-recovery.json"
@ -22,6 +23,13 @@ def process_start(pid):
def observe(tree, node, start):
# Commands addressed to a leaf can act on its floating/scratchpad ancestor
# in Sway. Such a group is outside the single-surface ownership contract.
parents = {c["id"]: n for n in walk(tree) for c in n.get("nodes", []) + n.get("floating_nodes", [])}
ancestor = parents.get(node["id"])
while ancestor and ancestor.get("type") not in ("workspace", "output", "root"):
require(ancestor.get("scratchpad_state", "none") == "none", "SCRATCHPAD_CONTAINER_UNSUPPORTED")
ancestor = parents.get(ancestor["id"])
where = location(tree, node["id"])
require(where and where[0], "RECOVERY_LOCATION_MISSING")
output = next((o["name"] for o in tree.get("nodes", [])
@ -30,18 +38,51 @@ def observe(tree, node, start):
floating = node.get("type") == "floating_con" or node.get("floating") in ("user_on", "auto_on")
return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"),
"starttime": start(node["pid"]), "workspace": where[0], "output": output,
"path": list(where[1]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
"path": list(where[1]), "scratchpad": scratchpad_state(node, where[0]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
def scratchpad_state(node, workspace_name):
"""CONTRACT : appartenance IPC et visibilité sont deux preuves distinctes."""
raw = node.get("scratchpad_state", "none")
require(raw in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
return "none" if raw == "none" else ("hidden" if workspace_name == "__i3_scratch" else "shown")
def placement(state):
return {k: v for k, v in state.items() if k != "path"} if state is not None else None
def structure(tree):
"""Preuve de topologie, avec feuilles con_id et branches ordonnées.
CONTRACT : seule la contraction des splits unaires h/v/none déjà utilisée
par Anchors est équivalente. Tabbed/stacked, branches, ordre et appartenance
restent significatifs. Les flottants sont comparés par placement exact.
WHY : un chemin brut ne permet pas de distinguer compaction et réordonnancement.
"""
identities = {n["id"]: n["id"] for n in walk(tree) if n.get("app_id") or n.get("window")}
return {n["name"]: canonical(tree, n["name"], identities) for n in walk(tree)
if n.get("type") == "workspace" and n.get("nodes")}
def command_postcondition(state, target, operation, tree):
expected = copy.deepcopy(state)
fields = ["con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry"]
if "scratchpad" in expected: fields.append("scratchpad")
if expected["con_id"] == target:
if operation.startswith("move container to workspace "):
if operation == "move scratchpad":
require(state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
expected.update(workspace="__i3_scratch", output="__i3", scratchpad="hidden")
elif operation == "scratchpad show":
require(state.get("scratchpad") == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
name = focused(tree)["workspace"]
require(name and name != "__i3_scratch", "RESTORE_FOCUS_UNAVAILABLE")
expected.update(workspace=name, output=next(o["name"] for o in tree["nodes"]
if any(w.get("type") == "workspace" and w.get("name") == name for w in walk(o))), scratchpad="shown")
# Output-coordinate conversion is compositor-owned. The exact saved
# geometry is set and checked afterwards; identity/focus stay strict.
fields.remove("geometry")
elif operation.startswith("move container to workspace "):
expected["workspace"] = json.loads(operation.removeprefix("move container to workspace "))
destination = workspace(tree, expected["workspace"])
if destination:
@ -57,7 +98,9 @@ def command_postcondition(state, target, operation, tree):
expected["floating"] = operation == "floating enable"
if expected["floating"]:
if not state["floating"]: fields.remove("geometry")
else: expected["geometry"] = None
else:
expected["geometry"] = None
if "scratchpad" in expected: expected["scratchpad"] = "none"
elif operation.startswith("resize set "):
values = [int(v) for v in re.findall(r"-?\d+", operation)]
g = expected["geometry"]
@ -162,7 +205,26 @@ def command_focus(tree, target, operation):
ws = workspace(tree, before["workspace"])
if leaves == [target] and ws and all(n["id"] == target for n in walk(ws) if n.get("app_id") or n.get("window")):
return {"con_id": None, "workspace": before["workspace"]}
if operation == "focus": return at(target)
if operation == "scratchpad show":
require(target in nodes and scratchpad_state(nodes[target], location(tree, target)[0]) == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
return {"con_id": target, "workspace": before["workspace"]}
if operation == "move scratchpad":
require(target in nodes and (nodes[target].get("app_id") or nodes[target].get("window")), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
source = location(tree, target)[0]
if source == "__i3_scratch": return before
if nodes[target].get("scratchpad_state", "none") == "none":
# root_scratchpad_add_container always selects the inactive node of
# the source workspace, even when an unrelated workspace had focus.
# With floating leaf ownership there is no tiled parent fallback.
require(nodes[target].get("floating") in ("auto_on", "user_on"), "SCRATCHPAD_FLOATING_REQUIRED")
ws = workspace(tree, source)
return inactive(ws, target) or {"con_id": None, "workspace": source}
# Floating leaves only: the removal uses the same inactive focus stack
# as move-to-workspace. No split ancestor is implicitly admitted.
return command_focus(tree, target, 'move container to workspace "__i3_scratch"')
if operation == "focus":
require(target in nodes and location(tree, target)[0] != "__i3_scratch", "FOCUS_HIDDEN_SCRATCHPAD")
return at(target)
if operation.startswith("workspace "):
name = json.loads(operation.removeprefix("workspace "))
ws = workspace(tree, name)
@ -185,7 +247,7 @@ def command_focus(tree, target, operation):
def validate(value):
require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and value.get("version") == 1,
require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and type(value.get("version")) is int and value.get("version") in (1, 2),
"RECOVERY_SCHEMA")
require(value.get("status") in ("active", "complete", "recovered", "required"), "RECOVERY_SCHEMA")
require(type(value.get("windows")) is list and len(value["windows"]) <= 256, "RECOVERY_SCHEMA")
@ -196,12 +258,16 @@ def validate(value):
require(value.get("stage") == "labfy-v2-stage-" + value["transaction_id"], "RECOVERY_SCHEMA")
def text(v): return type(v) is str and 0 < len(v) <= 256 and not any(ord(c) < 32 for c in v)
def state(v):
require(type(v) is dict and set(v) == {"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"}, "RECOVERY_SCHEMA")
require(type(v) is dict and set(v) == ({"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"} | ({"scratchpad"} if value["version"] == 2 else set())), "RECOVERY_SCHEMA")
for k in ("con_id", "pid", "starttime"):
require(type(v[k]) is int and 0 < v[k] < 2**63, "RECOVERY_SCHEMA")
require(v["app_id"] is None or text(v["app_id"]), "RECOVERY_SCHEMA")
require(text(v["workspace"]) and text(v["output"]) and type(v["floating"]) is bool, "RECOVERY_SCHEMA")
require(type(v["path"]) is list and len(v["path"]) <= 64 and all(type(i) is int and 0 <= i <= 256 for i in v["path"]), "RECOVERY_SCHEMA")
if value["version"] == 2:
require(v["scratchpad"] in ("none", "hidden", "shown") and
(v["scratchpad"] == "none" or v["floating"]) and
((v["workspace"] == "__i3_scratch") == (v["scratchpad"] == "hidden")), "RECOVERY_SCHEMA")
g = v["geometry"]
require((not v["floating"] and g is None) or (type(g) is dict and set(g) == {"x", "y", "width", "height"}
and all(type(x) is int and abs(x) < 2**31 for x in g.values())), "RECOVERY_SCHEMA")
@ -219,7 +285,48 @@ def validate(value):
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
if "focus_boundaries" in value:
rows = value["focus_boundaries"]
require(type(rows) is list and len(rows) <= 8, "RECOVERY_SCHEMA")
for row in rows:
require(type(row) is dict and set(row) == {"boundary", "expected", "observed"} and
text(row["boundary"]), "RECOVERY_SCHEMA")
for key in ("expected", "observed"):
f = row[key]
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
for key in ("focus_pending", "focus_conflict"): require(type(value.get(key)) is bool, "RECOVERY_SCHEMA")
if "path_reconciliations" in value:
rows = value["path_reconciliations"]
require(type(rows) is list and len(rows) <= 4, "RECOVERY_SCHEMA")
for batch in rows:
require(type(batch) is list and len(batch) <= 256, "RECOVERY_SCHEMA")
for row in batch:
require(type(row) is dict and set(row) == {"con_id", "before", "after"} and
type(row["con_id"]) is int and 0 < row["con_id"] < 2**63, "RECOVERY_SCHEMA")
for key in ("before", "after"):
path = row[key]
require(type(path) is list and len(path) <= 64 and
all(type(i) is int and 0 <= i <= 256 for i in path), "RECOVERY_SCHEMA")
# CONTRACT : extension facultative V1 ; l'absence dans un ancien journal
# n'autorise jamais à reconstruire une preuve depuis l'arbre post-incident.
if "structure_expected" in value:
shapes = value["structure_expected"]
require(type(shapes) is dict and len(shapes) <= 256 and all(text(k) for k in shapes), "RECOVERY_SCHEMA")
budget = [4096]
def shape(node, depth=0):
budget[0] -= 1
require(budget[0] >= 0 and depth <= 64, "RECOVERY_SCHEMA")
if type(node) is int:
require(0 < node < 2**63, "RECOVERY_SCHEMA")
return
require(type(node) is dict and len(node) == 1, "RECOVERY_SCHEMA")
layout, children = next(iter(node.items()))
require(layout in ("splith", "splitv", "none", "tabbed", "stacked") and
type(children) is list and len(children) <= 256, "RECOVERY_SCHEMA")
for child in children: shape(child, depth + 1)
for node in shapes.values(): shape(node)
return value
@ -236,7 +343,7 @@ class Recovery:
tree = sway.tree()
if proof is not None:
require(sway.session == proof[0] and focused(tree) == proof[1][0], "RESTORE_FOCUS_UNSTABLE")
self.data = {"schema": "labfy.session-v2-recovery", "version": 1,
self.data = {"schema": "labfy.session-v2-recovery", "version": 2,
"session": sway.session, "transaction_id": tx, "stage": stage,
"status": "active", "windows": [], "foreign": [], "operations": 0,
"focus_origin": focused(tree), "focus_expected": focused(tree),
@ -257,7 +364,12 @@ class Recovery:
def snapshot(self, identity, tree=None):
tree = self.sway.tree() if tree is None else tree
node = next((n for n in walk(tree) if n["id"] == identity and (n.get("app_id") or n.get("window"))), None)
return observe(tree, node, self.start) if node else None
state = observe(tree, node, self.start) if node else None
if state and self.data["version"] == 1:
# Historical journals never proved scratchpad membership. Refuse an
# observed scratchpad rather than synthesizing absent evidence.
require(state.pop("scratchpad") == "none", "LEGACY_SCRATCHPAD_UNPROVEN")
return state
def pin(self, node):
"""Observation runtime avant une attente externe, sans autorité de focus.
@ -347,6 +459,14 @@ class Recovery:
self.data["operations"] += 1
self.publish()
answer = self.sway.command(command)
if operation in ("floating enable", "scratchpad show"):
# WHY : IPC reports pending rect before Wayland configure/commit;
# a late initial configure can otherwise undo the next resize.
# CONTRACT : bounded drain only where geometry is explicitly
# compositor-owned; all predicted fields and focus are still checked
# below, before publishing the confirmed observation. No retries of
# a divergent command and no relaxation of final geometry equality.
time.sleep(.3)
if operation == "kill" and target is not None:
# Une réponse à kill n'est pas le retrait Wayland. Attendre l'anchor
# possédée évite de journaliser un chemin encore transitoire.
@ -365,10 +485,28 @@ class Recovery:
require(all(after[k] == v for k, v in expected_after[str(after["con_id"])].items()), "RECOVERY_POSTCONDITION_FAILED")
entry.update(expected=after, pending=False)
self.check_command_focus(tree, expected_focus, command)
self.data.update(focus_expected=focused(tree), focus_pending=False)
# CONTRACT : cette preuve est post-réponse RUN_COMMAND ; elle décrit
# l'observation, pas une exclusion de l'input concurrent.
self.data["intention"]["focus_after"] = focused(tree)
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
self.publish()
return answer
def boundary(self, name):
"""Observation durable bornée aux transitions du watchdog.
WHY : SIGCONT acquitté ne prouve pas le drainage des événements d'un
client IPC. CONTRACT : comparer avant toute nouvelle mutation ; aucune
attribution helper/utilisateur ni nouvelle autorité de focus.
"""
tree = self.sway.tree()
rows = self.data.setdefault("focus_boundaries", [])
require(len(rows) < 8, "RECOVERY_OPERATION_LIMIT")
rows.append({"boundary": name, "expected": copy.deepcopy(self.data["focus_expected"]),
"observed": focused(tree)})
self.publish()
self.check_command_focus(tree, self.data["focus_expected"], name)
def verify(self):
tree = self.sway.tree()
ws = workspace(tree, self.data["stage"])
@ -442,6 +580,8 @@ class Recovery:
expected = command_postcondition(observed, target, operation, tree) if observed else None
expected_focus = command_focus(tree, target, operation)
self.check_focus_preservation(tree, expected_focus, text)
require("structure_expected" not in self.data or structure(tree) == self.data["structure_expected"],
"RECOVERY_STRUCTURE_CHANGED")
require(self.data["operations"] < 4096 and len(text) <= 2048, "RECOVERY_OPERATION_LIMIT")
entry = next((e for e in self.data["windows"] if observed and e["origin"]["con_id"] == observed["con_id"]), None)
if entry is not None:
@ -467,7 +607,7 @@ class Recovery:
self.data["intention"]["observed_after"] = after
self.check_command_focus(tree, expected_focus, text)
self.data["intention"]["focus_after"] = focused(tree)
self.data.update(focus_expected=focused(tree), focus_pending=False)
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
self.publish()
def check_focus_preservation(self, tree, expected, command):
@ -545,6 +685,35 @@ class Recovery:
"reason": "RECOVERY_OBSERVATION_UNAVAILABLE"} for e in durable["windows"] if not e["recovered"]]
return self.receipt("RECOVERY_REQUIRED", windows, ["RECOVERY_OBSERVATION_UNAVAILABLE"])
def equivalent_paths(self, tree):
"""Réconcilier seulement une équivalence structurelle déjà prouvée.
INVARIANT : lot entier, identités, placement et branches doivent rester
exacts ; aucune intention pending n'est réinterprétée. L'origine et les
preuves d'incident restent immuables, même pour une reprise sans IPC.
"""
if (self.data["focus_pending"] or any(e["pending"] for e in self.data["windows"]) or
"structure_expected" not in self.data or structure(tree) != self.data["structure_expected"]):
return
expected = [e["expected"] for e in self.data["windows"]] + self.data["foreign"]
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
if {n["id"] for n in nodes} != {s["con_id"] for s in expected}: return
current = {n["id"]: observe(tree, n, self.start) for n in nodes}
if any(placement(current[s["con_id"]]) != placement(s) for s in expected): return
changed = [{"con_id": e["origin"]["con_id"], "before": list(e["expected"]["path"]),
"after": list(current[e["origin"]["con_id"]]["path"])} for e in self.data["windows"]
if e["expected"]["path"] != current[e["origin"]["con_id"]]["path"]]
if not changed: return
# WHY : réconcilier expected ne doit pas effacer la preuve exacte de la
# commande confirmée. CONTRACT : historique avant/après durable et borné ;
# à saturation refuser la normalisation, jamais écraser un ancien lot.
history = self.data.setdefault("path_reconciliations", [])
if len(history) >= 4: return
history.append(changed)
for entry in self.data["windows"]:
entry["expected"] = current[entry["origin"]["con_id"]]
self.publish()
def _recover(self):
if self.data["status"] in ("complete", "recovered"):
return {"status": self.data["status"], "windows": [], "issues": []}
@ -555,15 +724,23 @@ class Recovery:
"workspace": origin["workspace"], "output": origin["output"], "reason": reason,
"inspect": "swaymsg -t get_tree -r", "stage": self.data["stage"],
"instruction": "Vérifier identité et emplacement avec get_tree ; après autorisation humaine seulement, "
+ f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"])})
+ ("Scratchpad : examiner le journal avant toute commande." if origin.get("scratchpad", "none") != "none" else
f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"]))})
if self.sway.session != self.data["session"]:
for e in self.data["windows"]: issue(e, "SESSION_CHANGED")
issues.append("SESSION_CHANGED")
else:
recovery_focus = focused(self.sway.tree())
tree = self.sway.tree()
recovery_focus = focused(tree)
if recovery_focus != self.data["focus_expected"]:
self.record_focus_conflict({"phase": "recovery-entry", "command": None,
"before": self.data["focus_expected"], "observed": recovery_focus})
# WHY : un changement de layout peut garder tous les chemins bruts
# identiques. CONTRACT : la preuve canonique, lorsqu'elle existe,
# doit aussi précéder une reprise, même sans normalisation de chemin.
structure_changed = ("structure_expected" in self.data and
structure(tree) != self.data["structure_expected"])
self.equivalent_paths(tree)
for e in self.data["windows"]:
if e["recovered"]: continue
try:
@ -573,8 +750,22 @@ class Recovery:
self.publish()
continue
if e["pending"]: issue(e, "AMBIGUOUS_INTENTION"); continue
if current != e["expected"]: issue(e, "IDENTITY_OR_MEMBERSHIP_CHANGED"); continue
if structure_changed: issue(e, "STRUCTURE_OR_ORDER_CHANGED"); continue
if current != e["expected"]:
issue(e, "PATH_EQUIVALENCE_NOT_PROVEN" if placement(current) == placement(e["expected"])
else "IDENTITY_OR_MEMBERSHIP_CHANGED")
continue
origin = e["origin"]
if origin.get("scratchpad", "none") != "none" or current.get("scratchpad", "none") != "none":
if placement(current) == placement(origin):
e.update(recovered=True, pending=False)
self.publish()
else:
# WHY : no original output transform is persisted for
# a hidden leaf. CONTRACT : keep the client and exact
# journal; never show it as a speculative rollback.
issue(e, "SCRATCHPAD_RECOVERY_REQUIRES_REVIEW")
continue
active = {o["name"] for o in self.sway.outputs() if o.get("active")}
if origin["output"] not in active: issue(e, "OUTPUT_UNAVAILABLE"); continue
existing = workspace(self.sway.tree(), origin["workspace"])

View file

@ -0,0 +1,495 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:labfy:sway:session-v2:2",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"version",
"metadata",
"outputs",
"applications",
"window_slots",
"workspaces",
"focus",
"focus_workspace"
],
"properties": {
"schema": {
"const": "labfy.sway.session-v2"
},
"version": {
"const": 2,
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": false,
"required": [
"created_ns",
"sway_session",
"reason"
],
"properties": {
"created_ns": {
"type": "integer",
"minimum": 0,
"maximum": 9223372036854775807
},
"sway_session": {
"$ref": "#/$defs/id"
},
"reason": {
"enum": [
"manual",
"logout",
"reboot",
"poweroff"
]
}
}
},
"outputs": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"$ref": "#/$defs/place"
}
}
}
},
"applications": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/application"
}
},
"window_slots": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/slot"
}
},
"workspaces": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"workspace",
"output",
"root"
],
"properties": {
"workspace": {
"$ref": "#/$defs/place"
},
"output": {
"$ref": "#/$defs/place"
},
"root": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/tree"
}
]
}
}
}
},
"focus": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/id"
}
]
},
"focus_workspace": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/place"
}
]
}
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$"
},
"place": {
"type": "string",
"pattern": "^[A-Za-z0-9 _:-]{1,64}$",
"not": {
"const": "__i3_scratch"
}
},
"application": {
"type": "object",
"additionalProperties": false,
"required": [
"application_id",
"desktop_entry",
"strategy",
"expected_windows",
"managed_by",
"identity_provider"
],
"properties": {
"application_id": {
"$ref": "#/$defs/id"
},
"desktop_entry": {
"allOf": [
{
"$ref": "#/$defs/id"
},
{
"pattern": "\\.desktop$"
}
]
},
"strategy": {
"enum": [
"managed-autostart",
"single-window",
"multi-instance",
"terminal",
"browser-self-restore",
"unknown"
]
},
"expected_windows": {
"type": "integer",
"minimum": 1,
"maximum": 256
},
"managed_by": {
"enum": [
null,
"autostart",
"sway-autostart"
]
},
"identity_provider": {
"enum": [
null,
"firefox-session-window-uuid"
]
}
}
},
"geometry": {
"type": "object",
"additionalProperties": false,
"required": [
"x",
"y",
"width",
"height"
],
"properties": {
"x": {
"type": "integer",
"minimum": -100000,
"maximum": 100000
},
"y": {
"type": "integer",
"minimum": -100000,
"maximum": 100000
},
"width": {
"type": "integer",
"minimum": 1,
"maximum": 32768
},
"height": {
"type": "integer",
"minimum": 1,
"maximum": 32768
}
}
},
"evidence": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"id"
],
"properties": {
"type": {
"const": "firefox-session-window-uuid"
},
"id": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"id"
],
"properties": {
"type": {
"const": "application-singleton"
},
"id": {
"$ref": "#/$defs/id"
}
}
}
]
},
"slot": {
"type": "object",
"additionalProperties": false,
"required": [
"slot_id",
"application_id",
"workspace",
"tree_path",
"state",
"identity_requirement",
"identity_evidence"
],
"properties": {
"slot_id": {
"$ref": "#/$defs/id"
},
"application_id": {
"$ref": "#/$defs/id"
},
"workspace": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/place"
}
]
},
"tree_path": {
"type": "array",
"maxItems": 32,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 31
}
},
"identity_requirement": {
"enum": [
"exact",
"best-effort",
"interchangeable"
]
},
"identity_evidence": {
"$ref": "#/$defs/evidence"
},
"state": {
"type": "object",
"additionalProperties": false,
"required": [
"floating",
"geometry",
"fullscreen",
"scratchpad"
],
"properties": {
"floating": {
"type": "boolean"
},
"geometry": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/geometry"
}
]
},
"fullscreen": {
"const": false
},
"scratchpad": {
"enum": [
"none",
"hidden",
"shown"
]
}
}
}
},
"allOf": [
{
"if": {
"properties": {
"state": {
"properties": {
"scratchpad": {
"const": "hidden"
}
}
}
}
},
"then": {
"properties": {
"workspace": {
"type": "null"
}
}
},
"else": {
"properties": {
"workspace": {
"$ref": "#/$defs/place"
}
}
}
},
{
"if": {
"properties": {
"state": {
"properties": {
"scratchpad": {
"enum": [
"hidden",
"shown"
]
}
}
}
}
},
"then": {
"properties": {
"state": {
"properties": {
"floating": {
"const": true
}
}
}
}
}
},
{
"if": {
"properties": {
"state": {
"properties": {
"floating": {
"const": true
}
}
}
}
},
"then": {
"properties": {
"tree_path": {
"maxItems": 0
},
"state": {
"properties": {
"geometry": {
"$ref": "#/$defs/geometry"
}
}
}
}
},
"else": {
"properties": {
"state": {
"properties": {
"geometry": {
"type": "null"
}
}
}
}
}
}
]
},
"tree": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"slot_id"
],
"properties": {
"slot_id": {
"$ref": "#/$defs/id"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"layout",
"children"
],
"properties": {
"layout": {
"enum": [
"splith",
"splitv",
"tabbed",
"stacked"
]
},
"children": {
"type": "array",
"minItems": 2,
"maxItems": 32,
"items": {
"$ref": "#/$defs/tree"
}
}
}
}
]
}
}
}

View file

@ -32,8 +32,8 @@ def array(value, maximum=256):
def validate(data):
fields(data, "schema version metadata outputs applications window_slots workspaces focus")
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
fields(data, "schema version metadata outputs applications window_slots workspaces focus" + (" focus_workspace" if type(data) is dict and data.get("version") == 2 else ""))
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] in (1, 2), "SCHEMA_VERSION")
fields(data["metadata"], "created_ns sway_session reason")
integer(data["metadata"]["created_ns"], 0, 2**63 - 1)
text(data["metadata"]["sway_session"])
@ -43,6 +43,7 @@ def validate(data):
for o in data["outputs"]:
fields(o, "name")
text(o["name"], PLACE)
require(data["version"] == 1 or o["name"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
require(o["name"] not in outputs, "DUPLICATE_OUTPUT")
outputs[o["name"]] = o
for a in data["applications"]:
@ -60,7 +61,7 @@ def validate(data):
fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence")
text(s["slot_id"])
text(s["application_id"])
text(s["workspace"], PLACE)
if s["workspace"] is not None: text(s["workspace"], PLACE)
array(s["tree_path"], 32)
for i in s["tree_path"]: integer(i, 0, 31)
require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE")
@ -68,8 +69,18 @@ def validate(data):
state = s["state"]
fields(state, "floating geometry fullscreen scratchpad")
require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN")
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False and state["scratchpad"] is False,
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False,
"CAPABILITY_UNSUPPORTED")
# CONTRACT : V1 reste immuable. V2 distingue appartenance et visibilité ;
# le workspace interne Sway n'est jamais une destination persistante.
if data["version"] == 1:
require(state["scratchpad"] is False, "CAPABILITY_UNSUPPORTED")
else:
require(type(state["scratchpad"]) is str and state["scratchpad"] in ("none", "hidden", "shown"), "SCHEMA_SCRATCHPAD")
require(state["scratchpad"] == "none" or state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
hidden = state["scratchpad"] == "hidden"
require((s["workspace"] is None) == hidden, "SCRATCHPAD_WORKSPACE")
require(s["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
if state["floating"]:
fields(state["geometry"], "x y width height")
for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000)
@ -108,18 +119,24 @@ def validate(data):
for w in data["workspaces"]:
fields(w, "workspace output root")
text(w["workspace"], PLACE)
require(w["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
text(w["output"], PLACE)
require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE")
workspaces[w["workspace"]] = w
tree(w["root"], w["workspace"])
for s in slots.values():
require(s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
require(s["state"]["scratchpad"] == "hidden" or s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
for name in workspaces:
require(any(s["workspace"] == name for s in slots.values()), "EMPTY_WORKSPACE_UNSUPPORTED")
require(any(s["workspace"] == name for s in slots.values()) or data.get("focus_workspace") == name, "EMPTY_WORKSPACE_UNSUPPORTED")
for a in apps.values():
members = [s for s in slots.values() if s["application_id"] == a["application_id"]]
require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY")
uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER]
require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION")
require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE")
require(data["focus"] is None or slots[data["focus"]]["state"]["scratchpad"] != "hidden", "FOCUS_HIDDEN_SCRATCHPAD")
if data["version"] == 2:
name = data["focus_workspace"]
require(name is None or type(name) is str and name in workspaces, "FOCUS_WORKSPACE_REFERENCE")
require(data["focus"] is None or name is not None and slots[data["focus"]]["workspace"] == name, "FOCUS_WORKSPACE_REFERENCE")
return data

View file

@ -3,7 +3,7 @@ from .errors import require
from .schema import PROVIDER
def assign(slots, nodes, identities=None):
def assign(slots, nodes, identities=None, states=None):
require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE")
require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW")
result, remaining = {}, {n["id"] for n in nodes}
@ -21,7 +21,19 @@ def assign(slots, nodes, identities=None):
require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION")
result[s["slot_id"]] = target
remaining.remove(target)
# Ordre numérique runtime déterministe, jamais une preuve sémantique.
for s, target in zip(sorted((s for s in slots if s["slot_id"] not in result), key=lambda x: x["slot_id"]), sorted(remaining)):
# WHY : interchangeable is an explicit loss of semantic identity, never a
# permission to expose an arbitrary hidden peer of the same application.
# CONTRACT : preserve matching visibility first, deterministic runtime tie
# break; exact UUID/singleton assignments above remain authoritative.
states = states or {}
pending = sorted((s for s in slots if s["slot_id"] not in result),
key=lambda s: (s.get("state", {}).get("scratchpad", "none") != "hidden", s["slot_id"]))
for s in pending:
desired = s.get("state", {}).get("scratchpad", "none") or "none"
matching = [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == desired]
candidates = matching or [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == "none"]
require(candidates, "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
target = min(candidates)
result[s["slot_id"]] = target
remaining.remove(target)
return result

View file

@ -2,7 +2,7 @@
import time
from .errors import require
from .schema import validate, PROVIDER
from .ipc import walk
from .ipc import walk, location
def capture(sway, catalog, provider, reason="manual"):
@ -11,13 +11,14 @@ def capture(sway, catalog, provider, reason="manual"):
# Les titres, tokens et métadonnées applicatives ne participent pas à
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
node.get("app_id"), node.get("window"), node.get("pid"), node.get("scratchpad_state", "none"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
require(len(nodes) <= 256, "WINDOW_LIMIT")
specs = {}
for n in nodes:
require(not n.get("fullscreen_mode") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
require(not n.get("fullscreen_mode"), "CAPABILITY_UNSUPPORTED")
require(n.get("scratchpad_state", "none") in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
specs[n["id"]] = catalog.classify(n)
apps = {}
identities = {}
@ -32,6 +33,16 @@ def capture(sway, catalog, provider, reason="manual"):
identities.update({v: k for k, v in provider.capture(members).items()})
slots, workspaces = [], []
focused = None
selected = [n for n in walk(tree) if n.get("focused")]
# Une layer-shell (notamment le dialogue de logout) peut prendre le focus
# et ne laisser aucune feuille/workspace marqué focused dans GET_TREE.
# Capturer explicitement l'inconnu, jamais déduire une cible d'une pile IPC.
require(len(selected) <= 1, "SNAPSHOT_FOCUS_AMBIGUOUS")
focus_workspace = None
if selected:
focus_location = location(tree, selected[0]["id"])
require(focus_location and focus_location[0] and focus_location[0] != "__i3_scratch", "SNAPSHOT_FOCUS_AMBIGUOUS")
focus_workspace = focus_location[0]
def leaf(n, workspace, path, floating):
nonlocal focused
app = specs[n["id"]]
@ -43,9 +54,14 @@ def capture(sway, catalog, provider, reason="manual"):
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
else: requirement, evidence = "interchangeable", None
if n.get("focused"): focused = slot_id
scratchpad = "none" if n.get("scratchpad_state", "none") == "none" else ("hidden" if workspace is None else "shown")
require(workspace is not None or scratchpad == "hidden", "SCRATCHPAD_STATE_UNSUPPORTED")
require(scratchpad == "none" or floating, "SCRATCHPAD_FLOATING_REQUIRED")
if floating:
require(type(n.get("rect")) is dict and all(k in n["rect"] for k in ("x", "y", "width", "height")), "SNAPSHOT_GEOMETRY_MISSING")
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": False},
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": scratchpad},
"identity_requirement": requirement, "identity_evidence": evidence})
return {"slot_id": slot_id}
def convert(n, workspace, path=()):
@ -59,20 +75,27 @@ def capture(sway, catalog, provider, reason="manual"):
outputs = []
for output in tree.get("nodes", []):
for ws in output.get("nodes", []):
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue
require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue
if ws["name"] == "__i3_scratch":
require(not ws.get("nodes"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
for f in ws.get("floating_nodes", []):
require(f.get("app_id") or f.get("window"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
leaf(f, None, (), True)
continue
root = convert(ws, ws["name"])
for f in ws.get("floating_nodes", []):
children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
require(children[0] is f, "FLOATING_CONTAINER_UNSUPPORTED")
leaf(children[0], ws["name"], (), True)
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
if output["name"] not in outputs: outputs.append(output["name"])
require(len(slots) == len(nodes), "SNAPSHOT_UNPLACED_WINDOW")
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
data = {"schema": "labfy.sway.session-v2", "version": 1,
data = {"schema": "labfy.sway.session-v2", "version": 2,
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
"window_slots": slots, "workspaces": workspaces, "focus": focused}
"window_slots": slots, "workspaces": workspaces, "focus": focused, "focus_workspace": focus_workspace}
return validate(data)

View file

@ -186,6 +186,7 @@ class HelperAnchors:
class HelperGuard:
ready = dict(helpers_suspended=0)
def __init__(self, *args): pass
def prepare(self): return dict(status="prepared")
def resume(self): return dict(helpers_resumed=0)
def finish(self): return dict(helpers_resumed=0, anchors_cleaned=0)
@ -220,6 +221,39 @@ class ExtendedTests(unittest.TestCase):
self.assertEqual(focused(sway.tree()), dict(con_id=6, workspace='1'))
self.assertEqual(recovery_status(tmp)['status'], 'complete')
self.assertFalse(status(Path(tmp), sway.session, fixture(), True)['deferred'])
def test_watchdog_boundary_focus_changes_refuse_without_refocusing(self):
for phase, boundary in (('prepare', 'after-guard-prepare'),
('resume', 'after-helper-resume'),
('finish', 'after-guard-finish')):
with self.subTest(phase=phase), tempfile.TemporaryDirectory() as tmp:
sway = MutationSway(6)
sway.runtime = Path(tmp)
class ChangedGuard(HelperGuard):
def change(self):
for n in walk(sway.value): n['focused'] = n['id'] == 5
def prepare(self):
if phase == 'prepare': self.change()
return super().prepare()
def resume(self):
if phase == 'resume': self.change()
return super().resume()
def finish(self):
if phase == 'finish': self.change()
return super().finish()
with patch('session_v2.executor.Anchors', HelperAnchors), patch('session_v2.executor.Guard', ChangedGuard):
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
Executor(sway, Catalog(), None, Path(tmp), '/forbidden', process_identity=lambda pid: pid).apply(fixture(), True)
report = read(Path(tmp)/'session-v2-restore-attempt.json')
journal = read(Path(tmp)/JOURNAL)
self.assertTrue(report['final_tree_verified'])
self.assertFalse(report['final_focus_verified'])
self.assertEqual(journal['focus_conflict_observation']['command'], boundary)
self.assertEqual(journal['focus_boundaries'][-1]['boundary'], boundary)
self.assertEqual(focused(sway.tree()), dict(con_id=5, workspace='10'))
# After the unexpected focus, even recovery emits no refocus.
expected = 0 if phase == 'prepare' else 1
self.assertEqual(sum(text == '[con_id=6] focus' for text in sway.commands), expected)
def test_snapshot_focus_retained_when_baseline_belongs_to_batch(self):
with tempfile.TemporaryDirectory() as tmp:
sway = MutationSway(5)

View file

@ -13,6 +13,7 @@ from unittest.mock import patch
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.executor import Executor
from session_v2.guard import Guard
from session_v2.applications import launch as real_launch
from dataclasses import replace
from session_v2.recovery import JOURNAL, focused, process_start, Recovery, recovery_status
@ -122,7 +123,7 @@ def run_case(case):
return value
foreign = Surfaces(lab, binary)
foreign_node = None
if case in ('focus-third-party', 'preserve-foreign'):
if case in ('focus-third-party', 'preserve-foreign', 'focus-external-late'):
lab.command('workspace "TEST_ONLY_OTHER"')
foreign_node = foreign.create('TEST_ONLY_foreign')
lab.command('workspace "1"')
@ -189,6 +190,36 @@ def run_case(case):
return {UUID: nodes[0]['id']}
def capture(self, nodes): return {UUID: nodes[0]['id']}
runtime = private_dir(lab.directory / 'runtime')
helper = lab.spawn(['/usr/bin/python', '/usr/bin/autotiling', '--debug']) if case == 'autotiling' else None
def helpers(path):
# Only this private child is eligible. Never discover the host's
# /proc helpers, including when running an actual autotiling.
assert path == lab.socket
return [dict(pid=helper.pid, fd=os.pidfd_open(helper.pid), script='/usr/bin/autotiling')] if helper else []
class ObservedGuard(Guard):
def prepare(self):
answer = super().prepare()
assert self.anchors.process.poll() is not None, 'ANCHOR_NOT_EXITED_BEFORE_RESUME'
evidence['anchor_exited_before_resume'] = True
return answer
def __init__(self, sway, anchors, *args, **kwargs):
self.anchors = anchors
super().__init__(sway, anchors, *args, **kwargs)
def resume(self):
answer = super().resume()
if case in ('focus-helper-late', 'focus-external-late'):
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
target = foreign_node['id'] if case == 'focus-external-late' else kitty['id']
lab.command(f'[con_id={target}] focus')
evidence['injected_focus'] = focused(sway.tree())
return answer
def finish(self):
answer = super().finish()
if case == 'focus-finish-late':
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
lab.command(f'[con_id={kitty["id"]}] focus')
evidence['injected_focus'] = focused(sway.tree())
return answer
raw_command = sway.command
def command(text):
row = {'command': text, 'ns': time.monotonic_ns(), 'before': focused(sway.tree())}
@ -210,7 +241,7 @@ def run_case(case):
finally: row['after'] = focused(sway.tree())
sway.command = command
try:
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', return_value=[]):
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', side_effect=helpers), patch('session_v2.executor.Guard', ObservedGuard):
if case == 'launch-failure-retry':
bad_catalog = Catalog(tuple(replace(spec, argv=(str(lab.directory / 'TEST_ONLY_NO_EXECUTABLE'),))
if spec.application_id == 'firefox' else spec
@ -245,7 +276,7 @@ def run_case(case):
# Aucun kill d'un client ; les kill du guard sont sur le helper.
assert not any(row['command'] == f'[con_id={music["id"]}] kill' for row in evidence['commands'])
assert not any(n['app_id'].startswith('labfy-v2-anchor-') for n in sway.windows())
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign'):
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'autotiling'):
# Le contrat de recette exige un vrai succès. Un refus sûr
# est conservé comme BLOCKED et retourne un code non nul.
evidence['requirement_met'] = (evidence['outcome'] == 'success' and
@ -261,8 +292,18 @@ def run_case(case):
assert focused(sway.tree()) == (evidence['preserved_focus'] if case in ('preserve-empty-workspace', 'preserve-foreign') else {'con_id': firefox['id'], 'workspace': '1'})
assert not any(location(sway.tree(), n['id'])[0] == journal['stage'] for n in sway.windows())
assert focused(sway.tree())['workspace'] != journal['stage']
elif case in ('focus-helper-late', 'focus-external-late', 'focus-finish-late'):
assert evidence['outcome'] == 'RECOVERY_FOCUS_MISMATCH', evidence['outcome']
assert report['anchors_swapped'] == 3 and report['slots_filled'] == 3
assert report['final_tree_verified'] and not report['final_focus_verified']
assert journal['status'] == 'required' and journal['focus_conflict']
assert focused(sway.tree()) == evidence['injected_focus']
boundary = 'after-guard-finish' if case == 'focus-finish-late' else 'after-helper-resume'
assert journal['focus_conflict_observation']['command'] == boundary
assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
evidence['requirement_met'] = True
else:
expected = {'unowned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
expected = {'unowned-map' : 'APPLICATION_MEMBERSHIP_CHANGED',
'provider-placement': 'TARGET_OBSERVATION_CHANGED',
'pinned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
'map-in-ipc-gap': 'RECOVERY_FOCUS_MISMATCH',
@ -300,7 +341,7 @@ def run_case(case):
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc'], default='natural')
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], default='natural')
args = parser.parse_args()
row = run_case(args.case)
print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'],

View file

@ -41,6 +41,19 @@ TestCase {
compare(spy.count, 0);
}
}
function test_backend_reason_is_visible_and_logout_stays_gated() {
for (const reason of ["CAPABILITY_UNSUPPORTED", "SNAPSHOT_CHANGED", "RECOVERY_REQUIRED", "UNKNOWN_BACKEND_REASON"]) {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
g.requestAction("logout"); g.confirmAction("logout");
g.checkpointFinished(2, 0, JSON.stringify({status: "failed", reason: reason}), "Le checkpoint V2 a échoué.");
verify(g.errorMessage.indexOf(reason) >= 0);
verify(g.errorMessage.length > reason.length + 10);
compare(spy.count, 0);
compare(g.pendingAction.id, "logout");
g.cancel(); compare(spy.count, 0);
}
}
function test_quit_without_saving_requires_explicit_call() {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});

View file

@ -0,0 +1,133 @@
"""Private real compositor only; no inherited user socket, no helper suspension.
Retains synthetic trees and screenshots under /tmp. SIGCONT is deliberately not
used by this runner, even during cleanup. No installed configuration is read.
"""
import json
import subprocess
from unittest.mock import patch
from common import *
from lab import Lab as BaseLab, Surfaces, build_helper
from session_v2.snapshot import capture
from session_v2.ipc import Sway, location
from session_v2.executor import Executor
from session_v2.checkpoint import Store
from session_v2.storage import private_dir
from session_v2.recovery import Recovery
class Lab(BaseLab):
def __exit__(self, *_):
for proc in reversed(self.children):
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill(); proc.wait(timeout=3)
for log in self.logs: log.close()
def main():
with Lab() as lab:
print(lab.directory, flush=True)
binary = build(lab.directory)
sway = Sway(lab.socket)
commands = []
raw_command = sway.command
def recorded(text):
commands.append(text)
return raw_command(text)
sway.command = recorded
# Production catalog structure, synthetic IDs. No arbitrary argv launches.
static_kitty = ('/usr/bin/kitty', '--config', 'NONE', '--class', 'TEST_ONLY_kitty',
'--title', 'TEST_ONLY', '-o', 'linux_display_server=wayland',
'-o', 'confirm_os_window_close=0', '/usr/bin/sleep', '180')
cat = Catalog((Spec('kitty', 'kitty.desktop', ('TEST_ONLY_kitty',), 'terminal', static_kitty),
*(Spec(n, n+'.desktop', ('TEST_ONLY_'+n,), 'managed-autostart', ())
for n in ('shown', 'float', 'split'))))
lab.command('workspace "TEST_ONLY_TARGET"')
ids = [lab.kitty('TEST_ONLY_'+n) for n in ('kitty','kitty','shown','float','split')]
def command(i, operation): lab.command(f'[con_id={i}] '+operation)
for i in ids[:1]+ids[2:4]:
command(i, 'floating enable')
command(ids[0], 'move scratchpad')
command(ids[2], 'move scratchpad')
command(ids[2], 'scratchpad show')
# Allow GTK's real configure/ack cycle before the measured snapshot.
time.sleep(.3)
for i in ids[:1]+ids[2:4]:
command(i, 'resize set width 400 px height 240 px')
command(i, 'move absolute position 30 px 40 px')
command(ids[1], 'focus')
time.sleep(.3)
def evidence(name):
(lab.directory/(name+'.json')).write_text(json.dumps(sway.tree(),indent=2))
subprocess.run(['grim','-o','HEADLESS-1',str(lab.directory/(name+'.png'))],env=lab.env,check=True)
evidence('before')
data = capture(sway, cat, None)
store = Store(private_dir(lab.directory/'store'))
store.save('scratch',data); store.checkpoint(data)
assert store.load('scratch') == store.last() == data
(lab.directory/'snapshot.json').write_text(json.dumps(data,indent=2))
command(ids[2], 'move scratchpad')
command(ids[3], 'floating disable')
evidence('disturbed')
result = Executor(sway,cat,None,private_dir(lab.directory/'runtime'),binary,lab.env).apply(store.last(),True)
evidence('after')
(lab.directory/'result.json').write_text(json.dumps(result,indent=2))
(lab.directory/'commands.json').write_text(json.dumps(commands,indent=2))
assert not any(c.startswith(f'[con_id={ids[0]}] ') and ('workspace' in c or 'scratchpad show' in c) for c in commands)
assert result['status'] == 'success'
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
# User-like explicit show/hide after restore: prove real membership.
command(ids[0], 'scratchpad show'); time.sleep(.2); evidence('user-show')
command(ids[0], 'move scratchpad'); time.sleep(.2); evidence('user-hide')
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
# A new-session-like placement: membership must be created from normal
# leaves, with the same pinned processes and trusted static catalog.
command(ids[0], 'scratchpad show'); command(ids[0], 'floating disable')
command(ids[2], 'floating disable')
time.sleep(.3)
second = Executor(sway,cat,None,private_dir(lab.directory/'runtime-new-membership'),binary,lab.env).apply(data,True)
assert second['status'] == 'success'
evidence('new-membership')
# All hidden, with an explicitly focused empty workspace, remains a
# restorable snapshot and never persists the internal scratch workspace.
for i in ids: command(i, 'move scratchpad')
lab.command('workspace "TEST_ONLY_EMPTY"'); time.sleep(.3)
all_hidden = capture(sway,cat,None)
assert all_hidden['focus'] is None and all_hidden['focus_workspace'] == 'TEST_ONLY_EMPTY'
assert all(s['state']['scratchpad']=='hidden' for s in all_hidden['window_slots'])
third = Executor(sway,cat,None,private_dir(lab.directory/'runtime-all-hidden'),binary,lab.env).apply(all_hidden,True)
assert third['status'] == 'success'
evidence('all-hidden')
(lab.directory/'all-hidden-snapshot.json').write_text(json.dumps(all_hidden,indent=2))
# Real prewarm launches through a static test catalog, with fresh PIDs.
# Track returned children solely for TEST_ONLY cleanup; no launch is
# derived from snapshot data, titles or the former terminal process.
from session_v2.applications import launch as real_launch
old = {n['id'] for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty'}
for i in old: command(i, 'kill')
lab.wait_absent(old)
launched = []
def track_launch(spec, count, env):
children = real_launch(spec,count,env)
lab.children.extend(children);launched.extend(children)
return children
with patch('session_v2.executor.launch',side_effect=track_launch):
engine=Executor(sway,cat,None,private_dir(lab.directory/'runtime-cold-kitty'),binary,lab.env)
cold=engine.apply(all_hidden,True)
assert cold['status']=='success' and len(launched)==2
assert not old.intersection(n['id'] for n in sway.windows())
assert all(location(sway.tree(),n['id'])[0]=='__i3_scratch' for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty')
# A further apply reuses the admitted surfaces; no duplicate launch.
again=engine.apply(all_hidden,True)
assert again['status']=='success' and len(launched)==2
evidence('cold-kitty')
(lab.directory/'cold-result.json').write_text(json.dumps(cold,indent=2))
print('PASS save/store/restore/show/hide/new-membership/all-hidden/cold-kitty/no-duplicate', flush=True)
if __name__ == '__main__': main()

View file

@ -22,9 +22,9 @@ def frame(value):
class BootstrapTests(unittest.TestCase):
def setUp(self):
base = REPO / "build/bootstrap-fixtures"
base.mkdir(parents=True, exist_ok=True)
self.tmp = tempfile.TemporaryDirectory(dir=base)
# AF_UNIX est borné à 108 octets : un worktree profond ne doit pas
# rendre ces sockets TEST_ONLY dépendants du chemin du dépôt.
self.tmp = tempfile.TemporaryDirectory(prefix="labfy-v2-bootstrap-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
for name in ("home", "state", "runtime"):

View file

@ -0,0 +1,133 @@
"""Incidents du 22 octobre : arbres inertes et helper headless privé seulement."""
import unittest
import tempfile
from unittest.mock import patch
from common import *
from test_prewarm import Sway
from session_v2.recovery import Recovery, JOURNAL, focused
from session_v2.ipc import walk, workspace
from session_v2.storage import read
from session_v2.errors import Failure
class FocusRecoveryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.runtime = Path(self.tmp.name)
self.sway = Sway()
for ws in self.sway.value['nodes'][0]['nodes']: ws['layout'] = 'splith'
self.sway.add('firefox', 8, focus=True)
self.sway.add('kitty', 7, focus=False)
self.recovery = Recovery(self.sway, self.runtime, 'b'*32, 'labfy-v2-stage-'+'b'*32, start=lambda pid: 123)
for n in self.sway.windows(): self.recovery.admit(n)
self.recovery.baseline({'firefox', 'kitty', 'limusic-app'})
# Production command, independently simulated focus effect, no socket.
def command(text):
self.sway.commands.append(text)
self.assertEqual(text, '[con_id=8] focus')
for n in walk(self.sway.value): n['focused'] = n['id'] == 8
self.sway.command = command
self.recovery.command('[con_id=8] focus')
def unary(self):
ws = workspace(self.sway.value, '1')
ws['nodes'] = [dict(id=80+i, type='con', layout='splitv', nodes=[n], focused=False)
for i, n in enumerate(ws['nodes'])]
def test_firefox_command_proven_then_kitty_late_conflict_is_preserved(self):
self.assertFalse(self.recovery.data['focus_pending'])
for n in walk(self.sway.value): n['focused'] = n['id'] == 7
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
self.recovery.validate_final(self.sway.tree())
first = read(self.runtime/JOURNAL)['focus_conflict_observation']
self.assertEqual(first['before'], dict(con_id=8, workspace='1'))
self.assertEqual(first['observed'], dict(con_id=7, workspace='1'))
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertIn('FOCUS_RECOVERY_NOT_PROVEN', receipt['issues'])
self.assertEqual(self.sway.commands, [])
self.assertEqual(read(self.runtime/JOURNAL)['focus_conflict_observation'], first)
def test_equivalent_unary_paths_are_not_identity_changes(self):
self.unary()
# A conflict forbids IPC, but exact already-at-origin windows can be
# recognized without stealing the user's new focus or clearing incident.
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['windows'], [])
durable = read(self.runtime/JOURNAL)
paths = {row['con_id']: row for row in durable['path_reconciliations'][0]}
self.assertEqual(paths[8], dict(con_id=8, before=[0], after=[0, 0]))
self.assertEqual(paths[7], dict(con_id=7, before=[1], after=[1, 0]))
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertEqual(self.sway.commands, [])
def test_path_evidence_limit_refuses_without_overwriting_history(self):
self.recovery.data['path_reconciliations'] = [[] for _ in range(4)]
self.unary()
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(w['reason'] == 'PATH_EQUIVALENCE_NOT_PROVEN' for w in receipt['windows']))
self.assertEqual(read(self.runtime/JOURNAL)['path_reconciliations'], [[], [], [], []])
def test_layout_change_with_identical_raw_paths_still_refuses(self):
workspace(self.sway.value, '1')['layout'] = 'splitv'
self.sway.commands.clear()
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(e['reason'] == 'STRUCTURE_OR_ORDER_CHANGED' for e in receipt['windows']))
self.assertEqual(self.sway.commands, [])
def test_interrupted_intention_preserves_all_evidence(self):
self.unary()
self.recovery.data['windows'][1]['pending'] = True
self.recovery.data['focus_pending'] = True
self.recovery.publish()
intention = copy.deepcopy(self.recovery.data['intention'])
expected = copy.deepcopy(self.recovery.data['windows'][1]['expected'])
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(e['reason'] == 'AMBIGUOUS_INTENTION' for e in receipt['windows']))
durable = read(self.runtime/JOURNAL)
self.assertEqual(durable['intention'], intention)
self.assertEqual(durable['windows'][1]['expected'], expected)
self.assertTrue(durable['focus_pending'])
self.assertEqual(self.sway.commands, [])
def test_real_identity_order_workspace_and_branch_changes_still_refuse(self):
for mode in ('pid', 'starttime', 'app_id', 'con_id', 'order', 'workspace', 'output', 'branch', 'tabbed', 'geometry', 'floating', 'pending', 'legacy'):
with self.subTest(mode=mode):
tree = copy.deepcopy(self.sway.value)
saved = copy.deepcopy(self.recovery.data)
self.unary()
nodes = {n['id']: n for n in walk(self.sway.value)}
if mode == 'pid': nodes[8]['pid'] += 1
if mode == 'starttime': self.recovery.start = lambda pid: 999
if mode == 'app_id': nodes[8]['app_id'] = 'kitty'
if mode == 'con_id': nodes[8]['id'] = 88
if mode == 'order': workspace(self.sway.value, '1')['nodes'].reverse()
if mode == 'workspace': workspace(self.sway.value, '1')['name'] = 'OTHER'
if mode == 'output': self.sway.value['nodes'][0]['name'] = 'OTHER'
if mode == 'branch': workspace(self.sway.value, '1')['layout'] = 'splitv'
if mode == 'tabbed': nodes[80]['layout'] = 'tabbed'
if mode == 'geometry':
nodes[8].update(floating='user_on', rect=dict(x=0, y=0, width=200, height=100))
if mode == 'floating': nodes[8]['floating'] = 'user_on'
if mode == 'pending': self.recovery.data['windows'][1]['pending'] = True
if mode == 'legacy': self.recovery.data.pop('structure_expected', None)
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
self.sway.commands.clear()
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(receipt['windows'])
self.assertEqual(self.sway.commands, [])
self.sway.value, self.recovery.data = tree, saved
self.recovery.start = lambda pid: 123
if __name__ == '__main__': unittest.main()

View file

@ -0,0 +1,74 @@
"""Watchdog réel sur socketpair/pipes possédés ; aucun signal de session."""
import threading
import socket
import tempfile
import unittest
from unittest.mock import patch
from common import *
from session_v2.guard import run
from session_v2.storage import atomic, loads
class GuardProtocolTests(unittest.TestCase):
def exercise(self, messages):
with tempfile.TemporaryDirectory() as directory:
report = Path(directory)/'attempt.json'
atomic(report, {'status': 'running', 'anchors_cleaned': 0})
parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET)
parent.settimeout(5)
engine_r, engine_w = os.pipe()
anchor_r, anchor_w = os.pipe()
writer_r, writer_w = os.pipe()
rows, replies, errors = [], [], []
def helper():
# A real pipe models the quit consumed before process exit.
rows.append(('quit', os.read(writer_r, 64)))
os.close(anchor_w)
worker = threading.Thread(target=helper)
worker.start()
config = dict(control=child.detach(), engine_fd=engine_r, anchor_fd=anchor_r,
writer_fd=writer_w, anchor_pid=99999999, allowed=[], helpers=[],
socket='/TEST_ONLY', report=str(report), deadline=5)
class Sway:
def __init__(self, path): pass
def windows(self): return []
def resume(*args): rows.append(('resume', None)); return True
def guarded():
try: run(config)
except BaseException as exc: errors.append(exc)
try:
with patch('session_v2.guard.Sway', Sway), \
patch('session_v2.guard.resume_helpers', side_effect=resume), \
patch('session_v2.guard.signal.pidfd_send_signal'), \
patch('session_v2.guard.resume_pending') as recovery:
guard = threading.Thread(target=guarded)
guard.start()
replies.append(loads(parent.recv(4096)))
for message in messages:
parent.sendall(message)
replies.append(loads(parent.recv(4096)))
if message == b'prepare':
self.assertEqual(replies[-1]['status'], 'prepared')
self.assertEqual(rows, [('quit', b'quit\n')])
if message == b'resume' and b'prepare' in messages:
self.assertEqual(replies[-1]['status'], 'resumed')
guard.join(5)
self.assertFalse(guard.is_alive())
self.assertEqual(errors, [])
if messages == [b'prepare', b'resume', b'finish']: recovery.assert_not_called()
finally:
parent.close()
worker.join(5)
for fd in (engine_r, engine_w, anchor_r, writer_r): os.close(fd)
if b'prepare' in messages: self.assertEqual(rows[0], ('quit', b'quit\n'))
return replies
def test_prepare_quits_before_resume_and_finish_has_no_delayed_exit(self):
replies = self.exercise([b'prepare', b'resume', b'finish'])
self.assertEqual([r['status'] for r in replies], ['ready', 'prepared', 'resumed', 'finished'])
def test_resume_without_prepare_refuses_and_still_cleans_owned_helper(self):
self.assertEqual(self.exercise([b'resume'])[-1]['status'], 'failed')
if __name__ == '__main__': unittest.main()

View file

@ -71,7 +71,7 @@ class BoundaryTests(unittest.TestCase):
parent['nodes'].remove(node)
break
stage = json.loads(operation.removeprefix('move container to workspace '))
destination = dict(id=90, type='workspace', name=stage, nodes=[node])
destination = dict(id=90, type='workspace', name=stage, layout='splith', nodes=[node])
self.sway.value['nodes'][0]['nodes'].append(destination)
for n in walk(self.sway.value):
n['focused'] = n['id'] == focus['con_id'] if focus['con_id'] is not None else n.get('name') == focus['workspace']

View file

@ -0,0 +1,204 @@
"""Scratchpad contracts against production capture/store/journal; inert IPC."""
import json
import tempfile
import unittest
from unittest.mock import patch
from common import *
from test_prewarm import Sway
from session_v2.snapshot import capture
from session_v2.checkpoint import Store
from session_v2.storage import read, private_dir
from session_v2.ipc import walk, workspace
from session_v2.errors import Failure
from session_v2.recovery import Recovery, JOURNAL, observe, command_focus, validate as validate_recovery
from session_v2.slots import assign
from session_v2.layout import compile_plan, compile_placements
def source():
sway = Sway()
for ws in sway.value['nodes'][0]['nodes']:
ws['layout'] = 'splith'; ws['nodes'] = []
sway.add('kitty', 7, focus=True)
sway.add('kitty', 8, focus=False)
sway.add('limusic-app', 9, focus=False)
root = sway.value
hidden = dict(id=80, type='workspace', name='__i3_scratch', layout='splith', nodes=[], floating_nodes=[], focused=False)
root['nodes'].insert(0, dict(id=81, type='output', name='__i3', nodes=[hidden]))
ws = workspace(root, '1')
n = next(n for n in ws['nodes'] if n['id'] == 8)
ws['nodes'].remove(n)
n.update(scratchpad_state='fresh', floating='user_on', rect=dict(x=301,y=106,width=800,height=500))
hidden['floating_nodes'].append(n)
return sway
class ScratchpadTests(unittest.TestCase):
def test_capture_store_reload_and_no_internal_workspace(self):
sway = source()
d = capture(sway, Catalog(), None)
self.assertEqual(d['version'], 2)
self.assertEqual(len(d['window_slots']), 3)
hidden = next(s for s in d['window_slots'] if s['state']['scratchpad'] == 'hidden')
self.assertIsNone(hidden['workspace'])
self.assertEqual(hidden['state']['geometry'], dict(x=301,y=106,width=800,height=500))
self.assertEqual(hidden['identity_requirement'], 'interchangeable')
self.assertEqual(d['focus'], next(s['slot_id'] for s in d['window_slots'] if s['application_id']=='kitty' and s['workspace']=='1'))
self.assertNotIn('__i3_scratch', [o['name'] for o in d['outputs']])
self.assertTrue(all(op.workspace != '__i3_scratch' for op in compile_plan(d,'a'*32)))
self.assertEqual(compile_placements(d)[0]['scratchpad'], 'hidden')
self.assertIsNone(compile_placements(d)[0]['workspace'])
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp)); store.save('test',d); store.checkpoint(d)
self.assertEqual(store.last(),d); self.assertEqual(store.load('test'),d)
old = fixture(); store.save('old',old)
self.assertEqual(store.load('old'),old)
def test_capture_is_deterministic_for_a_stable_tree(self):
sway=source()
with patch('session_v2.snapshot.time.time_ns',return_value=123):
first=capture(sway,Catalog(),None)
self.assertEqual(first,capture(sway,Catalog(),None))
raw=json.dumps(first)
self.assertNotIn('con_id',raw);self.assertNotIn('starttime',raw)
self.assertNotIn('__i3_scratch',raw)
def test_shown_and_multiple_hidden(self):
sway=source(); hidden=workspace(sway.value,'__i3_scratch'); ws=workspace(sway.value,'1')
shown=hidden['floating_nodes'].pop(); ws.setdefault('floating_nodes',[]).append(shown)
for n in walk(sway.value): n['focused']=n['id']==8
d=capture(sway,Catalog(),None)
slot=next(s for s in d['window_slots'] if s['state']['scratchpad']=='shown')
self.assertEqual(slot['workspace'],'1'); self.assertEqual(d['focus'],slot['slot_id'])
for n in list(ws['nodes']):
n.update(floating='user_on',scratchpad_state='fresh',focused=False,rect=dict(x=0,y=0,width=100,height=100))
hidden['floating_nodes'].append(n)
ws['nodes']=[]
d=capture(sway,Catalog(),None)
self.assertEqual(sum(s['state']['scratchpad']=='hidden' for s in d['window_slots']),2)
def test_change_between_reads_and_ambiguous_focus_refuse(self):
sway=source(); original=sway.windows
def changed():
result=original(); next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
return result
sway.windows=changed
with self.assertRaisesRegex(Failure,'SNAPSHOT_CHANGED'):capture(sway,Catalog(),None)
sway=source(); next(n for n in walk(sway.value) if n['id']==8)['focused']=True
with self.assertRaisesRegex(Failure,'SNAPSHOT_FOCUS_AMBIGUOUS'):capture(sway,Catalog(),None)
def test_strict_versions_and_scratchpad_references(self):
d=capture(source(),Catalog(),None)
hidden=next(i for i,s in enumerate(d['window_slots']) if s['workspace'] is None)
mutations=[lambda v:v.update(version=1), lambda v:v['window_slots'][hidden].update(workspace='__i3_scratch'),
lambda v:v.update(focus=v['window_slots'][hidden]['slot_id']),
lambda v:v['window_slots'][hidden]['state'].update(scratchpad=True),
lambda v:v['window_slots'][hidden]['state'].update(floating=False)]
for change in mutations:
v=copy.deepcopy(d);change(v)
with self.assertRaises(Failure):validate(v)
try: import jsonschema
except ImportError: self.skipTest('jsonschema unavailable')
contract=json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.Draft202012Validator(contract).validate(d)
def test_runtime_visibility_assignment_never_exposes_hidden_peer(self):
sway=source(); d=capture(sway,Catalog(),None)
slots=[s for s in d['window_slots'] if s['application_id']=='kitty']
nodes=[n for n in sway.windows() if n['app_id']=='kitty']
states={n['id']:observe(sway.tree(),n,lambda pid:123) for n in nodes}
m=assign(slots,nodes,states=states)
hidden=next(s for s in slots if s['workspace'] is None)
self.assertEqual(m[hidden['slot_id']],8)
for s in slots:s['state']['scratchpad']='none'
with self.assertRaisesRegex(Failure,'SCRATCHPAD_ASSIGNMENT_UNPROVEN'):assign(slots,nodes,states=states)
def test_journal_detects_identity_output_scratchpad_and_interruption(self):
for change in ('identity','output','scratchpad','pending'):
with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp:
sway=source(); r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
for n in sway.windows():r.admit(n)
r.baseline({'kitty','limusic-app'})
if change=='identity': next(n for n in walk(sway.value) if n['id']==8)['pid']=999
elif change=='output':sway.value['nodes'][0]['name']='moved'
elif change=='scratchpad': next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
else:r.data['windows'][0]['pending']=True;r.data['focus_pending']=True;r.publish()
receipt=r.recover()
self.assertEqual(receipt['status'],'RECOVERY_REQUIRED');self.assertEqual(sway.commands,[])
durable=read(Path(tmp)/JOURNAL,validate_recovery)
if change=='pending':self.assertTrue(durable['focus_pending'])
def test_hidden_focus_is_rejected_before_command(self):
sway=source()
with self.assertRaisesRegex(Failure,'FOCUS_HIDDEN_SCRATCHPAD'):command_focus(sway.tree(),8,'focus')
def test_initial_scratchpad_add_predicts_source_workspace_focus(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
workspace(sway.value,'10')['floating_nodes']=[n]
# Actual root_scratchpad_add_container semantics: even an unfocused
# floating leaf causes source-workspace selection. Not focus-neutral.
self.assertEqual(command_focus(sway.tree(),8,'move scratchpad'),dict(con_id=None,workspace='10'))
self.assertEqual(command_focus(source().tree(),8,'scratchpad show'),dict(con_id=8,workspace='1'))
def test_group_ancestor_is_not_admitted_by_leaf_identity(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
ws['floating_nodes']=[dict(id=99,type='con',scratchpad_state='fresh',nodes=[n])]
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
observe(sway.tree(),n,lambda pid:123)
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
capture(sway,Catalog(),None)
def test_layer_shell_can_leave_no_sway_focus(self):
# Le panneau de déconnexion peut prendre le focus Wayland et ne
# laisser aucune feuille Sway marquée focused. L'absence est une
# observation légitime, distincte de plusieurs candidats ambigus.
sway = source()
for n in walk(sway.value): n['focused'] = False
result = capture(sway, Catalog(), None)
self.assertIsNone(result['focus'])
self.assertIsNone(result['focus_workspace'])
self.assertTrue(any(s['state']['scratchpad'] == 'hidden' for s in result['window_slots']))
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp))
store.checkpoint(result)
self.assertEqual(store.last(), result)
try:
import jsonschema
except ImportError:
return
contract = json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.validate(result, contract)
def test_multiple_focused_nodes_are_still_refused(self):
sway = source()
for n in walk(sway.value):
if n.get('id') == 9: n['focused'] = True
with self.assertRaisesRegex(Failure, 'SNAPSHOT_FOCUS_AMBIGUOUS'):
capture(sway, Catalog(), None)
def test_empty_focus_workspace_is_persisted(self):
sway=source()
for n in walk(sway.value): n['focused']=n['id']==3
d=capture(sway,Catalog(),None)
self.assertIsNone(d['focus']);self.assertEqual(d['focus_workspace'],'10')
self.assertIn('10',[w['workspace'] for w in d['workspaces']])
broken=copy.deepcopy(d);broken['focus_workspace']='absent'
with self.assertRaises(Failure):validate(broken)
def test_legacy_journal_not_reinterpreted(self):
sway=source()
with tempfile.TemporaryDirectory() as tmp:
r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
r.admit(next(n for n in sway.windows() if n['id']==7))
r.data['version']=1
for e in r.data['windows']:
for key in ('origin','expected'): e[key].pop('scratchpad',None)
r.publish()
old=Recovery(sway,tmp,start=lambda pid:123)
self.assertNotIn('scratchpad',old.snapshot(7))
with self.assertRaisesRegex(Failure,'LEGACY_SCRATCHPAD_UNPROVEN'):old.snapshot(8)
if __name__=='__main__':unittest.main()

View file

@ -29,7 +29,7 @@ tasks = {
'checkpoint-fixture': [sys.executable,'-B','-c',"import sys; sys.path.insert(0, 'tests/session_v2'); from login_focus import checkpoint; print(checkpoint()['focus'])"],
'diff-check': ['git','diff','--check'],
}
for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc'):
for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc', 'autotiling', 'focus-helper-late', 'focus-external-late', 'focus-finish-late'):
tasks['login-'+case] = [sys.executable,'-B','tests/session_v2/login_focus.py','--case',case]
def run(item):
name, argv = item

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Reproducible isolated scratchpad validation; never uses the desktop socket."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sway', required=True, type=Path, help='absolute headless-capable Sway executable')
parser.add_argument('--renderer', default='pixman', choices=('pixman', 'gles2'))
args = parser.parse_args()
if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK):
parser.error('--sway must be an existing absolute executable')
root = Path(__file__).resolve().parents[2]
output = Path(tempfile.mkdtemp(prefix='labfy-scratchpad-validation-', dir='/tmp'))
# CONTRACT : positive environment list; no graphical bus/socket, user state,
# test override for backend imports or configuration reaches subprocesses.
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1',
'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software',
'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': args.renderer,
'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
path = output / key
path.mkdir(mode=0o700)
env[key] = str(path)
checks = {
'backend': [sys.executable, '-B', '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
'focus': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
'qml-python': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
'scratchpad-runtime': [sys.executable, '-B', 'tests/session_v2/scratchpad_runtime.py'],
'diff-check': ['git', 'diff', '--check'],
}
results = {}
print(output, flush=True)
for name, argv in checks.items():
with (output / (name + '.log')).open('w') as log:
try:
result = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180)
results[name] = result.returncode
except (OSError, subprocess.TimeoutExpired) as exc:
log.write(str(exc) + '\n')
results[name] = 'UNAVAILABLE_OR_TIMEOUT'
print(name, results[name], flush=True)
(output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
return int(any(value != 0 for value in results.values()))
if __name__ == '__main__':
sys.exit(main())

View file

@ -47,7 +47,8 @@ def build(output, extension_only=False):
for name in ("session-v2.py", "session-v2-native-host.py"):
archive.add(SOURCE / name, arcname=name)
for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name)
archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
for version in (1, 2):
archive.add(SOURCE / f"session_v2/schema-v{version}.json", arcname=f"session_v2/schema-v{version}.json")
archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor")
archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in")
manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}}