feat(session): plan deterministic Sway session restores
This commit is contained in:
parent
2939012b63
commit
46b29e0f22
5 changed files with 1306 additions and 6 deletions
285
docs/session-restore-v1.md
Normal file
285
docs/session-restore-v1.md
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
# Restore Plan V1 — STEP19A
|
||||
|
||||
## Statut et architecture
|
||||
|
||||
**STEP19A DOES NOT RESTORE.**
|
||||
|
||||
STEP19A charge un Session Snapshot V1 persistant, observe la session Sway
|
||||
actuelle avec les quatre lectures STEP18, puis produit un plan JSON. Il ne
|
||||
lance ni ne tue aucun processus, ne change aucun workspace, ne déplace aucune
|
||||
fenêtre et n'envoie aucune commande Sway mutatrice. L'exécuteur appartient à
|
||||
STEP19B ; le Spatial Canvas appartient à STEP20.
|
||||
|
||||
```text
|
||||
snapshot persistant validé + Session Snapshot V1 live validé
|
||||
↓
|
||||
session_restore.py (fonction pure)
|
||||
↓
|
||||
labfy.sway.restore-plan / version 1
|
||||
```
|
||||
|
||||
Le point d'entrée reste :
|
||||
|
||||
```bash
|
||||
session_snapshot.py plan <name>
|
||||
```
|
||||
|
||||
`session_snapshot.py` possède le chargement sûr, le verrou partagé, la
|
||||
validation persistante et la collecte IPC. `session_restore.py` ne possède ni
|
||||
I/O fichier, ni subprocess, ni API Sway. Il reçoit deux documents normalisés
|
||||
et réutilise donc sans duplication la classification, la résolution
|
||||
DesktopEntry et l'inventaire live de STEP18.
|
||||
|
||||
## Enveloppe Restore Plan V1
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "labfy.sway.restore-plan",
|
||||
"version": 1,
|
||||
"read_only": true,
|
||||
"execution_supported": false,
|
||||
"source_session": "dev",
|
||||
"source": {},
|
||||
"live": {},
|
||||
"policy": {},
|
||||
"matches": [],
|
||||
"unmatched_source_windows": [],
|
||||
"actions": [],
|
||||
"diagnostics": {}
|
||||
}
|
||||
```
|
||||
|
||||
Le planner n'ajoute pas d'heure courante. Les seuls timestamps viennent de ses
|
||||
entrées. Un même couple snapshot/live produit donc le même plan.
|
||||
|
||||
Chaque action contient au minimum :
|
||||
|
||||
```text
|
||||
action_id
|
||||
action
|
||||
phase
|
||||
source_window
|
||||
snapshot_identity
|
||||
depends_on[]
|
||||
```
|
||||
|
||||
`depends_on` exprime les chaînes futures, par exemple lancement, placement,
|
||||
arbre puis focus. Il n'exécute pas ces opérations.
|
||||
|
||||
## Inventaire live et identité runtime
|
||||
|
||||
`live.windows[]` conserve :
|
||||
|
||||
```text
|
||||
runtime.con_id, runtime.pid, workspace, output
|
||||
app_id, class, instance, desktop_entry, executable_basename
|
||||
classification.category, classification.managed_by
|
||||
floating, fullscreen_mode, scratchpad, focused, title_hint
|
||||
```
|
||||
|
||||
Les PID et `con_id` sont exposés comme observations pour un futur exécuteur
|
||||
dans la session courante, mais `policy.runtime_identity_fields` est vide. Ils
|
||||
ne participent jamais au matching persistant : ils changent, et peuvent même
|
||||
être réutilisés numériquement, après logout ou reboot.
|
||||
|
||||
## Matching one-to-one
|
||||
|
||||
Une arête snapshot→live n'existe que si une preuve d'identité applicative
|
||||
existe. Les preuves, par poids décroissant, sont :
|
||||
|
||||
1. même DesktopEntry résolue ;
|
||||
2. même `app_id` ;
|
||||
3. même classe XWayland/`StartupWMClass` ;
|
||||
4. même instance ;
|
||||
5. même basename exécutable compatible.
|
||||
|
||||
Deux `app_id` explicites et différentes interdisent un match fondé seulement
|
||||
sur un basename. La résolution DesktopEntry n'est pas réimplémentée : elle
|
||||
provient de `resolve_desktop_entry()` STEP18 pour le snapshot comme pour le
|
||||
live.
|
||||
|
||||
Le workspace identique, le même état floating et un `title_hint` identique
|
||||
servent seulement de départage. Un titre ne crée jamais un candidat et n'est
|
||||
jamais une identité obligatoire.
|
||||
|
||||
Le planner trie globalement toutes les arêtes par preuve, puis applique un
|
||||
matching one-to-one. Une fenêtre live ne peut satisfaire qu'une fenêtre
|
||||
sauvegardée. Pour deux Firefox sauvegardées et une live, une seule est
|
||||
réutilisée ; l'autre reste absente. L'ordre snapshot ne sert qu'au dernier
|
||||
départage.
|
||||
|
||||
## Confiance
|
||||
|
||||
```text
|
||||
exact DesktopEntry identique + app_id ou classe identique
|
||||
high DesktopEntry, app_id ou classe exacte sans preuve combinée
|
||||
medium instance ou basename compatible
|
||||
ambiguous plusieurs arêtes de meilleur score subsistent
|
||||
unresolved aucune preuve suffisante
|
||||
```
|
||||
|
||||
`exact` n'est jamais déduit du PID, du `con_id`, du titre seul ou de la seule
|
||||
position dans l'arbre.
|
||||
|
||||
## Classification et lancement futur
|
||||
|
||||
### Applications utilisateur
|
||||
|
||||
Une application absente n'est proposée au lancement que si sa DesktopEntry a
|
||||
été résolue avec confiance STEP18 `exact`. Une identité heuristique ou
|
||||
absente produit `manual-required` ; aucune ligne de commande n'est inventée.
|
||||
|
||||
Le futur backend STEP19B est figé conceptuellement comme :
|
||||
|
||||
```json
|
||||
{
|
||||
"backend": "uwsm-app-desktop-entry",
|
||||
"argv": ["uwsm", "app", "--", "firefox.desktop"],
|
||||
"execute_in_step19a": false
|
||||
}
|
||||
```
|
||||
|
||||
L'audit de `uwsm 0.27.0` confirme que `uwsm app` accepte un exécutable, un
|
||||
identifiant DesktopEntry ou son chemin, et lance l'application comme scope ou
|
||||
service dans une slice graphique. Sa documentation recommande ce mécanisme
|
||||
pour ne pas accumuler les applications dans l'unité du compositeur. STEP19B
|
||||
devra donc préférer l'identifiant DesktopEntry via `uwsm app` à `gtk-launch`,
|
||||
`gio launch` ou un enfant direct de Sway. STEP19A ne l'exécute jamais.
|
||||
|
||||
Le lancement ne promet pas de restaurer l'état interne d'un terminal, les
|
||||
onglets d'un navigateur ou le contenu propre à une application.
|
||||
|
||||
### Applications gérées par autostart
|
||||
|
||||
Une vue présente est réutilisée normalement. Une vue absente produit
|
||||
`skip-autostart-managed`, jamais `launch-application`, car `sway-autostart`
|
||||
possède son lancement. C'est notamment le contrat de `limusic-app` issu de
|
||||
`exec limusic-app`. STEP19B devra attendre ou demander une intervention au
|
||||
lieu de créer une double instance.
|
||||
|
||||
### Infrastructure et daemons
|
||||
|
||||
`session-infrastructure` et `daemon` ne produisent jamais de lancement.
|
||||
QuickShell, Polkit, autotiling, wlsunset, les services systemd utilisateur et
|
||||
le listener de transparence restent possédés par leur sous-système. Une
|
||||
absence inattendue produit `manual-required`.
|
||||
|
||||
## Workspaces et outputs
|
||||
|
||||
Chaque action conserve `desired_workspace` et `desired_output`. Une fenêtre
|
||||
live au mauvais endroit produit `reuse-window`, puis `move-to-workspace`, sans
|
||||
lancement supplémentaire.
|
||||
|
||||
Les outputs sont appariés par nom de connecteur. La politique V1 est :
|
||||
|
||||
```text
|
||||
connector name preferred
|
||||
missing output = deferred-no-fallback
|
||||
```
|
||||
|
||||
Si le connecteur sauvegardé est absent, le planner produit
|
||||
`manual-required(reason=missing-output)` et n'invente ni écran ni fallback. Le
|
||||
lancement d'une application absente est lui aussi différé dans ce cas.
|
||||
|
||||
## Arbre Sway natif
|
||||
|
||||
Le planner compare une signature sémantique par vue : layout/orientation du
|
||||
workspace, chaîne des containers parents, layout/orientation/percent des
|
||||
parents, branche, index et percent de la fenêtre. Les `container_id` runtime
|
||||
sont exclus.
|
||||
|
||||
Une différence produit `restore-tree-position` avec
|
||||
`capability=partially-supported`. Sway natif sait déplacer, splitter, choisir
|
||||
un layout et ajuster des proportions, mais V1 ne prétend pas reconstruire
|
||||
atomiquement tout arbre arbitraire. STEP19B devra valider ses primitives et
|
||||
replanifier après l'apparition des fenêtres.
|
||||
|
||||
Seul `layout_engine=sway-native` est accepté. `spatial-canvas-v1` reste hors
|
||||
périmètre jusqu'à STEP20.
|
||||
|
||||
## Floating, fullscreen et scratchpad
|
||||
|
||||
Une différence live/source produit une intention distincte :
|
||||
|
||||
```text
|
||||
restore-floating
|
||||
restore-fullscreen
|
||||
restore-scratchpad-hidden
|
||||
restore-scratchpad-visible
|
||||
```
|
||||
|
||||
Une fenêtre live présente dans le scratchpad alors que le snapshot ne le
|
||||
demande pas produit `manual-required`, plutôt qu'une hypothèse destructrice.
|
||||
|
||||
## Ordre et focus
|
||||
|
||||
Le focus est toujours la dernière phase. Il n'est ajouté que si le focus live
|
||||
diffère ou si une intention antérieure pourrait le perturber.
|
||||
|
||||
```text
|
||||
1. match-managed : réutiliser/différer/bloquer
|
||||
2. launch : intentions de lancement exactes
|
||||
3. wait-match : réservé au rematching STEP19B
|
||||
4. workspace : placement workspace/output
|
||||
5. tree-layout : arbre Sway natif
|
||||
6. floating
|
||||
7. fullscreen
|
||||
8. scratchpad
|
||||
9. focus
|
||||
```
|
||||
|
||||
Le validateur refuse une dépendance future, un recul de phase ou une action
|
||||
après le focus.
|
||||
|
||||
## Actions V1 et refus explicites
|
||||
|
||||
```text
|
||||
reuse-window
|
||||
launch-application
|
||||
skip-autostart-managed
|
||||
move-to-workspace
|
||||
restore-tree-position
|
||||
restore-floating
|
||||
restore-fullscreen
|
||||
restore-scratchpad-hidden
|
||||
restore-scratchpad-visible
|
||||
restore-focus
|
||||
manual-required
|
||||
```
|
||||
|
||||
`manual-required` porte notamment les raisons suivantes :
|
||||
|
||||
```text
|
||||
no-exact-launch-identity
|
||||
missing-output
|
||||
non-restorable-category
|
||||
live-window-unexpected-scratchpad
|
||||
```
|
||||
|
||||
Dire que l'automatisation ne sait pas est préférable à une commande inventée.
|
||||
|
||||
## Garanties et limites STEP19A
|
||||
|
||||
Le module planner n'importe pas `subprocess`, ne contient pas `swaymsg`, et ne
|
||||
possède aucune fonction de lancement, d'écriture ou de suppression. Le CLI
|
||||
n'appelle que :
|
||||
|
||||
```text
|
||||
show_session(name) # lecture et validation persistante
|
||||
collect(autostart) # quatre IPC read-only STEP18
|
||||
build_restore_plan() # fonction pure
|
||||
```
|
||||
|
||||
Le plan expose :
|
||||
|
||||
```text
|
||||
read_only = true
|
||||
execution_supported = false
|
||||
mutating_commands_executed = 0
|
||||
applications_launched = 0
|
||||
snapshots_written = 0
|
||||
```
|
||||
|
||||
Avant STEP19B, aucune attente de fenêtre, aucune commande Sway mutatrice,
|
||||
aucune restauration d'état interne, aucun fallback multi-output, aucune UI
|
||||
QML et aucun comportement Spatial Canvas ne sont implémentés.
|
||||
|
|
@ -317,13 +317,16 @@ session_snapshot.py save <name>
|
|||
session_snapshot.py list
|
||||
session_snapshot.py show <name>
|
||||
session_snapshot.py delete <name>
|
||||
session_snapshot.py plan <name>
|
||||
```
|
||||
|
||||
`--compact` produit du JSON compact. `save` collecte et publie ; `list` émet un
|
||||
tableau d'objets `{name, updated_at, windows, workspaces}` ; `show` retourne le
|
||||
document strictement validé ; `delete` supprime uniquement le fichier régulier
|
||||
dérivé du nom validé. Ces commandes ne contiennent et n'appellent aucune
|
||||
opération `restore`, `launch` ou `apply`.
|
||||
dérivé du nom validé. `plan` charge ce document, collecte un second Snapshot
|
||||
V1 live puis appelle le planner pur décrit dans `docs/session-restore-v1.md`.
|
||||
Il produit uniquement des intentions : aucune commande `restore`, `launch`,
|
||||
`move`, `focus` ou `apply` n'est exécutée.
|
||||
|
||||
### Cohérence IPC, verrouillage et publication atomique
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,622 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Construire, sans l'exécuter, un Restore Plan V1 déterministe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import copy
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
PLAN_SCHEMA = "labfy.sway.restore-plan"
|
||||
PLAN_VERSION = 1
|
||||
SNAPSHOT_SCHEMA = "labfy.sway.session-snapshot"
|
||||
SNAPSHOT_VERSION = 1
|
||||
LAYOUT_ENGINE = "sway-native"
|
||||
|
||||
# CONTRACT: cet ordre est l'interface entre le planner STEP19A et le futur
|
||||
# exécuteur STEP19B. Une phase tardive ne peut jamais précéder sa dépendance.
|
||||
PHASES = (
|
||||
"match-managed",
|
||||
"launch",
|
||||
"wait-match",
|
||||
"workspace",
|
||||
"tree-layout",
|
||||
"floating",
|
||||
"fullscreen",
|
||||
"scratchpad",
|
||||
"focus",
|
||||
)
|
||||
PHASE_INDEX = {name: index for index, name in enumerate(PHASES)}
|
||||
ACTION_TYPES = {
|
||||
"reuse-window",
|
||||
"launch-application",
|
||||
"skip-autostart-managed",
|
||||
"move-to-workspace",
|
||||
"restore-floating",
|
||||
"restore-fullscreen",
|
||||
"restore-tree-position",
|
||||
"restore-scratchpad-hidden",
|
||||
"restore-scratchpad-visible",
|
||||
"restore-focus",
|
||||
"manual-required",
|
||||
}
|
||||
CONFIDENCE = {"exact", "high", "medium", "ambiguous", "unresolved"}
|
||||
NON_RESTORABLE_CATEGORIES = {"session-infrastructure", "daemon"}
|
||||
|
||||
|
||||
class RestorePlanError(RuntimeError):
|
||||
"""Violation du contrat du planner read-only."""
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise RestorePlanError(f"restore plan V1 invalide: {message}")
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _fold(value: Any) -> str | None:
|
||||
text = _text(value)
|
||||
return text.casefold() if text else None
|
||||
|
||||
|
||||
def _identity(window: Mapping[str, Any]) -> dict[str, str | None]:
|
||||
restore = window.get("restore_identity")
|
||||
restore = restore if isinstance(restore, Mapping) else {}
|
||||
xwayland = window.get("xwayland")
|
||||
xwayland = xwayland if isinstance(xwayland, Mapping) else {}
|
||||
return {
|
||||
"desktop_entry": _text(restore.get("desktop_entry")),
|
||||
"app_id": _text(window.get("app_id")) or _text(restore.get("app_id")),
|
||||
"class": _text(xwayland.get("class")) or _text(restore.get("class")),
|
||||
"instance": _text(xwayland.get("instance")) or _text(restore.get("instance")),
|
||||
"executable_basename": _text(window.get("executable_basename")),
|
||||
}
|
||||
|
||||
|
||||
def _stable_window_key(window: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
"""Départager sans PID ni con_id, qui ne sont jamais des identités."""
|
||||
identity = _identity(window)
|
||||
return tuple(
|
||||
_fold(value) or ""
|
||||
for value in (
|
||||
identity["desktop_entry"],
|
||||
identity["app_id"],
|
||||
identity["class"],
|
||||
identity["instance"],
|
||||
identity["executable_basename"],
|
||||
window.get("workspace"),
|
||||
window.get("output"),
|
||||
window.get("title_hint"),
|
||||
window.get("snapshot_identity"),
|
||||
window.get("window_id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _candidate(saved: Mapping[str, Any], live: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""Noter une preuve d'identité ; titre/workspace ne sont que des hints."""
|
||||
wanted = _identity(saved)
|
||||
current = _identity(live)
|
||||
saved_app = _fold(wanted["app_id"])
|
||||
live_app = _fold(current["app_id"])
|
||||
saved_class = _fold(wanted["class"])
|
||||
live_class = _fold(current["class"])
|
||||
desktop_equal = bool(
|
||||
_fold(wanted["desktop_entry"])
|
||||
and _fold(wanted["desktop_entry"]) == _fold(current["desktop_entry"])
|
||||
)
|
||||
app_equal = bool(saved_app and saved_app == live_app)
|
||||
class_equal = bool(saved_class and saved_class == live_class)
|
||||
instance_equal = bool(
|
||||
_fold(wanted["instance"])
|
||||
and _fold(wanted["instance"]) == _fold(current["instance"])
|
||||
)
|
||||
executable_equal = bool(
|
||||
_fold(wanted["executable_basename"])
|
||||
and _fold(wanted["executable_basename"]) == _fold(current["executable_basename"])
|
||||
)
|
||||
|
||||
# Une contradiction app_id forte interdit qu'un simple basename ou titre
|
||||
# fusionne deux applications différentes.
|
||||
if saved_app and live_app and saved_app != live_app and not desktop_equal:
|
||||
return None
|
||||
if not any((desktop_equal, app_equal, class_equal, instance_equal, executable_equal)):
|
||||
return None
|
||||
|
||||
matched_by: list[str] = []
|
||||
score = 0
|
||||
confidence = "medium"
|
||||
if desktop_equal:
|
||||
score += 500
|
||||
matched_by.append("desktop-entry")
|
||||
if app_equal:
|
||||
score += 400
|
||||
matched_by.append("app-id")
|
||||
if class_equal:
|
||||
score += 350
|
||||
matched_by.append("class")
|
||||
if instance_equal:
|
||||
score += 250
|
||||
matched_by.append("instance")
|
||||
if executable_equal:
|
||||
score += 150
|
||||
matched_by.append("executable-basename")
|
||||
|
||||
if desktop_equal and (app_equal or class_equal):
|
||||
confidence = "exact"
|
||||
elif desktop_equal or app_equal or class_equal:
|
||||
confidence = "high"
|
||||
|
||||
# Les hints ne peuvent jamais créer un candidat ; ils départagent
|
||||
# seulement plusieurs fenêtres d'une identité application déjà prouvée.
|
||||
if saved.get("workspace") == live.get("workspace"):
|
||||
score += 20
|
||||
if bool(saved.get("floating")) == bool(live.get("floating")):
|
||||
score += 10
|
||||
if _fold(saved.get("title_hint")) and _fold(saved.get("title_hint")) == _fold(live.get("title_hint")):
|
||||
score += 5
|
||||
matched_by.append("title-hint-tiebreak")
|
||||
return {"score": score, "confidence": confidence, "matched_by": matched_by}
|
||||
|
||||
|
||||
def match_windows(
|
||||
saved_windows: Sequence[Mapping[str, Any]],
|
||||
live_windows: Sequence[Mapping[str, Any]],
|
||||
) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
||||
"""Produire un matching un-à-un stable ; une vue live n'est jamais réutilisée deux fois."""
|
||||
# WHY: une boucle source gloutonne ferait prendre Firefox-B par Firefox-A
|
||||
# avant que son titre puisse départager les deux vues.
|
||||
# CONTRACT: les meilleures preuves globales gagnent ; l'ordre snapshot ne
|
||||
# sert qu'après identité, workspace, floating et title_hint.
|
||||
edges: list[tuple[int, int, tuple[str, ...], int, dict[str, Any]]] = []
|
||||
for saved_index, saved in enumerate(saved_windows):
|
||||
for live_index, live in enumerate(live_windows):
|
||||
evidence = _candidate(saved, live)
|
||||
if evidence is not None:
|
||||
edges.append(
|
||||
(-evidence["score"], saved_index, _stable_window_key(live), live_index, evidence)
|
||||
)
|
||||
edges.sort()
|
||||
used_saved: set[int] = set()
|
||||
used_live: set[int] = set()
|
||||
matches: dict[str, dict[str, Any]] = {}
|
||||
for chosen in edges:
|
||||
score, saved_index, _stable, live_index, evidence = chosen
|
||||
if saved_index in used_saved or live_index in used_live:
|
||||
continue
|
||||
used_saved.add(saved_index)
|
||||
used_live.add(live_index)
|
||||
saved_id = str(saved_windows[saved_index].get("window_id"))
|
||||
equal_competitors = sum(
|
||||
1 for item in edges
|
||||
if item[0] == score and (item[1] == saved_index or item[3] == live_index)
|
||||
)
|
||||
confidence = "ambiguous" if equal_competitors > 1 else evidence["confidence"]
|
||||
matches[saved_id] = {
|
||||
"saved_index": saved_index,
|
||||
"live_index": live_index,
|
||||
"confidence": confidence,
|
||||
"matched_by": evidence["matched_by"],
|
||||
"score": -score,
|
||||
}
|
||||
unmatched = [
|
||||
str(saved.get("window_id"))
|
||||
for index, saved in enumerate(saved_windows)
|
||||
if index not in used_saved
|
||||
]
|
||||
return matches, unmatched
|
||||
|
||||
|
||||
def _container_path(snapshot: Mapping[str, Any], window: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
containers = {
|
||||
item.get("container_id"): item
|
||||
for item in snapshot.get("containers", [])
|
||||
if isinstance(item, Mapping)
|
||||
}
|
||||
position = window.get("tree_position")
|
||||
position = position if isinstance(position, Mapping) else {}
|
||||
parent = position.get("parent_container_id")
|
||||
path: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
while isinstance(parent, str) and parent in containers and parent not in seen:
|
||||
seen.add(parent)
|
||||
item = containers[parent]
|
||||
item_position = item.get("tree_position")
|
||||
item_position = item_position if isinstance(item_position, Mapping) else {}
|
||||
path.append({
|
||||
"layout": item.get("layout"),
|
||||
"orientation": item.get("orientation"),
|
||||
"branch": item_position.get("branch"),
|
||||
"index": item_position.get("index"),
|
||||
"percent": item.get("percent"),
|
||||
})
|
||||
parent = item.get("parent_container_id")
|
||||
path.reverse()
|
||||
return path
|
||||
|
||||
|
||||
def _placement(snapshot: Mapping[str, Any], window: Mapping[str, Any]) -> dict[str, Any]:
|
||||
workspaces = {
|
||||
item.get("name"): item
|
||||
for item in snapshot.get("workspaces", [])
|
||||
if isinstance(item, Mapping)
|
||||
}
|
||||
workspace = workspaces.get(window.get("workspace"), {})
|
||||
position = window.get("tree_position")
|
||||
position = position if isinstance(position, Mapping) else {}
|
||||
return {
|
||||
"workspace_layout": workspace.get("layout"),
|
||||
"workspace_orientation": workspace.get("orientation"),
|
||||
"container_path": _container_path(snapshot, window),
|
||||
"branch": position.get("branch"),
|
||||
"index": position.get("index"),
|
||||
"percent": window.get("percent"),
|
||||
}
|
||||
|
||||
|
||||
def _window_inventory(window: Mapping[str, Any]) -> dict[str, Any]:
|
||||
runtime = window.get("runtime")
|
||||
runtime = runtime if isinstance(runtime, Mapping) else {}
|
||||
classification = window.get("classification")
|
||||
classification = classification if isinstance(classification, Mapping) else {}
|
||||
return {
|
||||
"window_id": window.get("window_id"),
|
||||
"snapshot_identity": window.get("snapshot_identity"),
|
||||
"runtime": {"con_id": runtime.get("con_id"), "pid": runtime.get("pid")},
|
||||
"workspace": window.get("workspace"),
|
||||
"output": window.get("output"),
|
||||
**_identity(window),
|
||||
"classification": {
|
||||
"category": classification.get("category"),
|
||||
"managed_by": classification.get("managed_by"),
|
||||
},
|
||||
"floating": bool(window.get("floating")),
|
||||
"fullscreen_mode": window.get("fullscreen_mode"),
|
||||
"scratchpad": copy.deepcopy(window.get("scratchpad")),
|
||||
"focused": bool(window.get("focused")),
|
||||
"title_hint": window.get("title_hint"),
|
||||
}
|
||||
|
||||
|
||||
def _validate_input(snapshot: Mapping[str, Any], label: str, *, persistent: bool) -> None:
|
||||
_require(snapshot.get("schema") == SNAPSHOT_SCHEMA, f"{label}.schema")
|
||||
_require(snapshot.get("version") == SNAPSHOT_VERSION, f"{label}.version")
|
||||
compositor = snapshot.get("compositor")
|
||||
_require(isinstance(compositor, Mapping), f"{label}.compositor")
|
||||
_require(compositor.get("layout_engine") == LAYOUT_ENGINE, f"{label}.layout_engine")
|
||||
_require(isinstance(snapshot.get("windows"), list), f"{label}.windows")
|
||||
_require(isinstance(snapshot.get("outputs"), list), f"{label}.outputs")
|
||||
_require(isinstance(snapshot.get("workspaces"), list), f"{label}.workspaces")
|
||||
metadata = snapshot.get("metadata")
|
||||
_require(isinstance(metadata, Mapping), f"{label}.metadata")
|
||||
if persistent:
|
||||
_require(bool(_text(metadata.get("session_name"))), f"{label}.metadata.session_name")
|
||||
|
||||
|
||||
def _draft(
|
||||
key: str,
|
||||
action: str,
|
||||
phase: str,
|
||||
saved: Mapping[str, Any] | None,
|
||||
*,
|
||||
depends: Sequence[str] = (),
|
||||
**payload: Any,
|
||||
) -> dict[str, Any]:
|
||||
result = {
|
||||
"_key": key,
|
||||
"_depends": list(depends),
|
||||
"action": action,
|
||||
"phase": phase,
|
||||
"source_window": saved.get("window_id") if saved else None,
|
||||
"snapshot_identity": saved.get("snapshot_identity") if saved else None,
|
||||
**payload,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _finalize_actions(drafts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
drafts.sort(key=lambda item: (PHASE_INDEX[item["phase"]], item.pop("_order"), item["action"], item["_key"]))
|
||||
key_to_id = {item["_key"]: f"a{index:03d}" for index, item in enumerate(drafts, 1)}
|
||||
actions: list[dict[str, Any]] = []
|
||||
for item in drafts:
|
||||
result = {key: value for key, value in item.items() if not key.startswith("_")}
|
||||
result["action_id"] = key_to_id[item["_key"]]
|
||||
result["depends_on"] = [key_to_id[key] for key in item["_depends"] if key in key_to_id]
|
||||
actions.append(result)
|
||||
return actions
|
||||
|
||||
|
||||
def validate_restore_plan(plan: Mapping[str, Any]) -> None:
|
||||
"""Valider l'ordre et les dépendances sans aucun effet externe."""
|
||||
_require(plan.get("schema") == PLAN_SCHEMA, "schema")
|
||||
_require(plan.get("version") == PLAN_VERSION, "version")
|
||||
_require(plan.get("read_only") is True, "read_only")
|
||||
_require(plan.get("execution_supported") is False, "execution_supported")
|
||||
actions = plan.get("actions")
|
||||
_require(isinstance(actions, list), "actions")
|
||||
known: set[str] = set()
|
||||
last_phase = -1
|
||||
focus_seen = False
|
||||
for index, action in enumerate(actions):
|
||||
_require(isinstance(action, Mapping), f"actions[{index}]")
|
||||
action_id = action.get("action_id")
|
||||
_require(isinstance(action_id, str) and action_id not in known, "action_id")
|
||||
_require(action.get("action") in ACTION_TYPES, "action type")
|
||||
phase = action.get("phase")
|
||||
_require(phase in PHASE_INDEX and PHASE_INDEX[phase] >= last_phase, "ordre phases")
|
||||
last_phase = PHASE_INDEX[phase]
|
||||
dependencies = action.get("depends_on")
|
||||
_require(isinstance(dependencies, list) and all(item in known for item in dependencies), "depends_on")
|
||||
if focus_seen:
|
||||
_require(action.get("action") == "restore-focus", "focus doit rester dernier")
|
||||
if action.get("action") == "restore-focus":
|
||||
focus_seen = True
|
||||
confidence = action.get("confidence")
|
||||
if confidence is not None:
|
||||
_require(confidence in CONFIDENCE, "confidence")
|
||||
known.add(action_id)
|
||||
|
||||
|
||||
def build_restore_plan(source: Mapping[str, Any], live: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Comparer deux snapshots validés et retourner uniquement des intentions."""
|
||||
_validate_input(source, "source", persistent=True)
|
||||
_validate_input(live, "live", persistent=False)
|
||||
source_windows = [item for item in source["windows"] if isinstance(item, Mapping)]
|
||||
live_windows = [item for item in live["windows"] if isinstance(item, Mapping)]
|
||||
matches, unmatched = match_windows(source_windows, live_windows)
|
||||
live_outputs = {
|
||||
item.get("name")
|
||||
for item in live["outputs"]
|
||||
if isinstance(item, Mapping) and item.get("active") is True
|
||||
}
|
||||
live_workspaces = {
|
||||
item.get("name")
|
||||
for item in live["workspaces"]
|
||||
if isinstance(item, Mapping)
|
||||
}
|
||||
live_focused = live.get("focus", {}).get("window_id") if isinstance(live.get("focus"), Mapping) else None
|
||||
drafts: list[dict[str, Any]] = []
|
||||
base_keys: dict[str, str] = {}
|
||||
matched_live_by_saved: dict[str, Mapping[str, Any]] = {}
|
||||
|
||||
def add(item: dict[str, Any], order: int) -> str:
|
||||
item["_order"] = order
|
||||
drafts.append(item)
|
||||
return item["_key"]
|
||||
|
||||
for order, saved in enumerate(source_windows):
|
||||
saved_id = str(saved.get("window_id"))
|
||||
desired_output = saved.get("output")
|
||||
desired_workspace = saved.get("workspace")
|
||||
category = saved.get("classification", {}).get("category") if isinstance(saved.get("classification"), Mapping) else "unknown"
|
||||
resolution = saved.get("restore_identity")
|
||||
resolution = resolution if isinstance(resolution, Mapping) else {}
|
||||
match = matches.get(saved_id)
|
||||
live_window: Mapping[str, Any] | None = None
|
||||
|
||||
if match is not None:
|
||||
live_window = live_windows[match["live_index"]]
|
||||
matched_live_by_saved[saved_id] = live_window
|
||||
key = f"reuse:{saved_id}"
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"reuse-window",
|
||||
"match-managed",
|
||||
saved,
|
||||
confidence=match["confidence"],
|
||||
matched_by=match["matched_by"],
|
||||
live_window=_window_inventory(live_window),
|
||||
), order)
|
||||
elif desired_output is not None and desired_output not in live_outputs:
|
||||
key = f"manual-output:{saved_id}"
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"manual-required",
|
||||
"match-managed",
|
||||
saved,
|
||||
confidence="unresolved",
|
||||
reason="missing-output",
|
||||
desired_output=desired_output,
|
||||
policy="deferred-no-fallback",
|
||||
), order)
|
||||
continue
|
||||
elif category == "autostart-managed-application":
|
||||
key = f"managed:{saved_id}"
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"skip-autostart-managed",
|
||||
"match-managed",
|
||||
saved,
|
||||
confidence="unresolved",
|
||||
reason="launch-owned-by-sway-autostart",
|
||||
managed_by=saved.get("classification", {}).get("managed_by"),
|
||||
), order)
|
||||
continue
|
||||
elif category in NON_RESTORABLE_CATEGORIES:
|
||||
key = f"manual-category:{saved_id}"
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"manual-required",
|
||||
"match-managed",
|
||||
saved,
|
||||
confidence="unresolved",
|
||||
reason="non-restorable-category",
|
||||
category=category,
|
||||
), order)
|
||||
continue
|
||||
elif category == "user-application" and resolution.get("confidence") == "exact" and _text(resolution.get("desktop_entry")):
|
||||
key = f"launch:{saved_id}"
|
||||
desktop_entry = resolution["desktop_entry"]
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"launch-application",
|
||||
"launch",
|
||||
saved,
|
||||
confidence="exact",
|
||||
desktop_entry=desktop_entry,
|
||||
launch={
|
||||
"backend": "uwsm-app-desktop-entry",
|
||||
"argv": ["uwsm", "app", "--", desktop_entry],
|
||||
"execute_in_step19a": False,
|
||||
},
|
||||
limitations=["application-internal-state-not-restored"],
|
||||
), order)
|
||||
else:
|
||||
key = f"manual-identity:{saved_id}"
|
||||
base_keys[saved_id] = add(_draft(
|
||||
key,
|
||||
"manual-required",
|
||||
"match-managed",
|
||||
saved,
|
||||
confidence="unresolved",
|
||||
reason="no-exact-launch-identity",
|
||||
restore_identity=copy.deepcopy(resolution),
|
||||
), order)
|
||||
continue
|
||||
|
||||
base = base_keys[saved_id]
|
||||
current_workspace = live_window.get("workspace") if live_window else None
|
||||
current_output = live_window.get("output") if live_window else None
|
||||
if desired_output is not None and desired_output not in live_outputs:
|
||||
add(_draft(
|
||||
f"manual-output:{saved_id}", "manual-required", "workspace", saved,
|
||||
depends=(base,), confidence="unresolved", reason="missing-output",
|
||||
desired_output=desired_output, policy="deferred-no-fallback",
|
||||
), order)
|
||||
continue
|
||||
if live_window is None or current_workspace != desired_workspace or current_output != desired_output:
|
||||
add(_draft(
|
||||
f"workspace:{saved_id}", "move-to-workspace", "workspace", saved,
|
||||
depends=(base,), desired_workspace=desired_workspace, desired_output=desired_output,
|
||||
workspace_exists=desired_workspace in live_workspaces,
|
||||
current_workspace=current_workspace, current_output=current_output,
|
||||
), order)
|
||||
|
||||
desired_placement = _placement(source, saved)
|
||||
current_placement = _placement(live, live_window) if live_window else None
|
||||
if current_placement != desired_placement:
|
||||
add(_draft(
|
||||
f"tree:{saved_id}", "restore-tree-position", "tree-layout", saved,
|
||||
depends=(base,), capability="partially-supported",
|
||||
desired=desired_placement, current=current_placement,
|
||||
reason="sway-native-tree-diff",
|
||||
), order)
|
||||
|
||||
if live_window is None:
|
||||
current_floating = False
|
||||
current_fullscreen = 0
|
||||
current_scratchpad: Mapping[str, Any] = {}
|
||||
else:
|
||||
current_floating = bool(live_window.get("floating"))
|
||||
current_fullscreen = int(live_window.get("fullscreen_mode") or 0)
|
||||
raw_scratchpad = live_window.get("scratchpad")
|
||||
current_scratchpad = raw_scratchpad if isinstance(raw_scratchpad, Mapping) else {}
|
||||
if current_floating != bool(saved.get("floating")):
|
||||
add(_draft(
|
||||
f"floating:{saved_id}", "restore-floating", "floating", saved,
|
||||
depends=(base,), desired_floating=bool(saved.get("floating")),
|
||||
current_floating=current_floating,
|
||||
), order)
|
||||
desired_fullscreen = int(saved.get("fullscreen_mode") or 0)
|
||||
if current_fullscreen != desired_fullscreen:
|
||||
add(_draft(
|
||||
f"fullscreen:{saved_id}", "restore-fullscreen", "fullscreen", saved,
|
||||
depends=(base,), desired_fullscreen_mode=desired_fullscreen,
|
||||
current_fullscreen_mode=current_fullscreen,
|
||||
), order)
|
||||
desired_scratchpad = saved.get("scratchpad")
|
||||
desired_scratchpad = desired_scratchpad if isinstance(desired_scratchpad, Mapping) else {}
|
||||
if desired_scratchpad.get("member") is True and dict(current_scratchpad) != dict(desired_scratchpad):
|
||||
visibility = desired_scratchpad.get("visibility")
|
||||
action = "restore-scratchpad-hidden" if visibility == "hidden" else "restore-scratchpad-visible"
|
||||
add(_draft(
|
||||
f"scratch:{saved_id}", action, "scratchpad", saved,
|
||||
depends=(base,), desired=copy.deepcopy(desired_scratchpad),
|
||||
current=copy.deepcopy(current_scratchpad),
|
||||
), order)
|
||||
elif desired_scratchpad.get("member") is not True and current_scratchpad.get("member") is True:
|
||||
add(_draft(
|
||||
f"manual-scratch:{saved_id}", "manual-required", "scratchpad", saved,
|
||||
depends=(base,), confidence="unresolved", reason="live-window-unexpected-scratchpad",
|
||||
), order)
|
||||
|
||||
source_focus = source.get("focus")
|
||||
source_focus = source_focus if isinstance(source_focus, Mapping) else {}
|
||||
focus_saved_id = source_focus.get("window_id")
|
||||
if isinstance(focus_saved_id, str) and focus_saved_id in base_keys:
|
||||
focus_live = matched_live_by_saved.get(focus_saved_id)
|
||||
focus_live_id = focus_live.get("window_id") if focus_live else None
|
||||
mutations = [
|
||||
item["_key"] for item in drafts
|
||||
if item["action"] not in {"reuse-window", "skip-autostart-managed", "manual-required"}
|
||||
]
|
||||
if focus_live_id != live_focused or mutations:
|
||||
dependencies = tuple(
|
||||
item["_key"] for item in drafts
|
||||
if item["action"] != "manual-required"
|
||||
)
|
||||
add(_draft(
|
||||
f"focus:{focus_saved_id}", "restore-focus", "focus",
|
||||
next((item for item in source_windows if item.get("window_id") == focus_saved_id), None),
|
||||
depends=dependencies, desired_focus_window=focus_saved_id,
|
||||
current_live_focus=live_focused,
|
||||
), len(source_windows))
|
||||
|
||||
actions = _finalize_actions(drafts)
|
||||
plan = {
|
||||
"schema": PLAN_SCHEMA,
|
||||
"version": PLAN_VERSION,
|
||||
"read_only": True,
|
||||
"execution_supported": False,
|
||||
"source_session": source["metadata"]["session_name"],
|
||||
"source": {
|
||||
"schema": source["schema"],
|
||||
"version": source["version"],
|
||||
"captured_at": source["metadata"].get("captured_at"),
|
||||
"layout_engine": source["compositor"]["layout_engine"],
|
||||
},
|
||||
"live": {
|
||||
"captured_at": live["metadata"].get("captured_at"),
|
||||
"outputs": [item.get("name") for item in live["outputs"] if isinstance(item, Mapping) and item.get("active")],
|
||||
"workspaces": [item.get("name") for item in live["workspaces"] if isinstance(item, Mapping)],
|
||||
"windows": [_window_inventory(item) for item in live_windows],
|
||||
},
|
||||
"policy": {
|
||||
"runtime_identity_fields": [],
|
||||
"runtime_observation_fields": ["runtime.pid", "runtime.con_id"],
|
||||
"missing_output": "deferred-no-fallback",
|
||||
"autostart_owner": "sway-autostart",
|
||||
"launch_backend_step19b": "uwsm-app-desktop-entry",
|
||||
"phase_order": list(PHASES),
|
||||
"spatial_canvas": "out-of-scope-step20",
|
||||
},
|
||||
"matches": [
|
||||
{
|
||||
"source_window": saved_id,
|
||||
"live_window": live_windows[data["live_index"]].get("window_id"),
|
||||
"confidence": data["confidence"],
|
||||
"matched_by": data["matched_by"],
|
||||
}
|
||||
for saved_id, data in sorted(matches.items(), key=lambda item: item[1]["saved_index"])
|
||||
],
|
||||
"unmatched_source_windows": unmatched,
|
||||
"actions": actions,
|
||||
"diagnostics": {
|
||||
"action_counts": dict(sorted(Counter(item["action"] for item in actions).items())),
|
||||
"match_confidence_counts": dict(sorted(Counter(item["confidence"] for item in plan_matches(matches)).items())),
|
||||
"mutating_commands_executed": 0,
|
||||
"applications_launched": 0,
|
||||
"snapshots_written": 0,
|
||||
},
|
||||
}
|
||||
validate_restore_plan(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def plan_matches(matches: Mapping[str, Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Projection minuscule utilisée pour les diagnostics déterministes."""
|
||||
return [{"confidence": item["confidence"]} for item in matches.values()]
|
||||
|
|
@ -9,6 +9,7 @@ import configparser
|
|||
import datetime as dt
|
||||
import errno
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
@ -1115,8 +1116,52 @@ def delete_session(
|
|||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _load_restore_planner() -> Callable[[Mapping[str, Any], Mapping[str, Any]], dict[str, Any]]:
|
||||
"""Charger le planner voisin sans lui donner d'accès implicite au backend."""
|
||||
path = Path(__file__).with_name("session_restore.py")
|
||||
spec = importlib.util.spec_from_file_location("labfy_session_restore", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise SnapshotError("planner de restauration indisponible")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except (OSError, ImportError, SyntaxError) as exc:
|
||||
raise SnapshotError(f"chargement du planner impossible: {exc}") from exc
|
||||
planner = getattr(module, "build_restore_plan", None)
|
||||
if not callable(planner):
|
||||
raise SnapshotError("planner de restauration invalide")
|
||||
return planner
|
||||
|
||||
|
||||
def plan_session(
|
||||
name: str,
|
||||
autostart_path: Path | None = None,
|
||||
*,
|
||||
snapshot_loader: Callable[[str], dict[str, Any]] | None = None,
|
||||
live_collector: Callable[[Path | None], dict[str, Any]] | None = None,
|
||||
planner: Callable[[Mapping[str, Any], Mapping[str, Any]], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Charger deux états validés et produire un plan sans jamais l'exécuter."""
|
||||
# WHY: STEP19A doit réutiliser exactement la validation persistante et la
|
||||
# résolution DesktopEntry STEP18, sans ouvrir une seconde voie divergente.
|
||||
# CONTRACT: loader et collector sont en lecture seule ; le planner est pur.
|
||||
# INVARIANT: aucune commande applicative ou Sway mutatrice n'est accessible ici.
|
||||
load = show_session if snapshot_loader is None else snapshot_loader
|
||||
observe = collect if live_collector is None else live_collector
|
||||
build = _load_restore_planner() if planner is None else planner
|
||||
source = load(normalize_session_name(name))
|
||||
live = observe(autostart_path)
|
||||
validate_snapshot(live)
|
||||
try:
|
||||
return build(source, live)
|
||||
except Exception as exc:
|
||||
if exc.__class__.__name__ != "RestorePlanError":
|
||||
raise
|
||||
raise SnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Sauvegarde persistante read-only d'une session Sway")
|
||||
parser = argparse.ArgumentParser(description="Snapshots persistants et restore planner read-only pour Sway")
|
||||
parser.add_argument("--stdout", action="store_true", help="émettre le JSON validé sur stdout")
|
||||
parser.add_argument("--compact", action="store_true", help="émettre un JSON compact")
|
||||
parser.add_argument(
|
||||
|
|
@ -1125,14 +1170,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
default=Path.home() / ".config/sway/autostart",
|
||||
help="fichier Sway autostart utilisé seulement pour la classification",
|
||||
)
|
||||
parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete"))
|
||||
parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete", "plan"))
|
||||
parser.add_argument("name", nargs="?")
|
||||
args = parser.parse_args(argv)
|
||||
if args.stdout and args.command is not None:
|
||||
parser.error("--stdout ne peut pas être combiné avec une commande persistante")
|
||||
if not args.stdout and args.command is None:
|
||||
parser.error("utiliser --stdout ou une commande save/list/show/delete")
|
||||
if args.command in {"save", "show", "delete"} and args.name is None:
|
||||
parser.error("utiliser --stdout ou une commande save/list/show/delete/plan")
|
||||
if args.command in {"save", "show", "delete", "plan"} and args.name is None:
|
||||
parser.error(f"{args.command} requiert un nom de session")
|
||||
if (args.stdout or args.command == "list") and args.name is not None:
|
||||
parser.error("nom de session inattendu")
|
||||
|
|
@ -1157,6 +1202,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
print(warning, file=sys.stderr)
|
||||
elif args.command == "show":
|
||||
result = show_session(args.name)
|
||||
elif args.command == "plan":
|
||||
result = plan_session(args.name, args.autostart)
|
||||
else:
|
||||
delete_session(args.name)
|
||||
result = {"deleted": args.name}
|
||||
|
|
|
|||
343
tests/test_session_restore.py
Normal file
343
tests/test_session_restore.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Contrat Restore Plan V1 : intentions déterministes et aucune exécution."""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RESTORE_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_restore.py"
|
||||
SNAPSHOT_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py"
|
||||
|
||||
|
||||
def load_module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
restore = load_module("session_restore_test", RESTORE_SOURCE)
|
||||
snapshot_backend = load_module("session_snapshot_restore_test", SNAPSHOT_SOURCE)
|
||||
|
||||
|
||||
def window(
|
||||
window_id,
|
||||
app_id,
|
||||
*,
|
||||
desktop_entry=None,
|
||||
identity_confidence="exact",
|
||||
title=None,
|
||||
workspace="1",
|
||||
output="eDP-1",
|
||||
category="user-application",
|
||||
managed_by=None,
|
||||
floating=False,
|
||||
fullscreen=0,
|
||||
focused=False,
|
||||
con_id=100,
|
||||
pid=1000,
|
||||
parent=None,
|
||||
index=0,
|
||||
xclass=None,
|
||||
instance=None,
|
||||
executable=None,
|
||||
scratchpad=None,
|
||||
):
|
||||
return {
|
||||
"window_id": window_id,
|
||||
"snapshot_identity": f"{app_id or xclass or executable or 'unknown'}#{window_id[1:]}",
|
||||
"runtime": {"con_id": con_id, "pid": pid},
|
||||
"workspace": workspace,
|
||||
"output": output,
|
||||
"app_id": app_id,
|
||||
"xwayland": {"class": xclass, "instance": instance},
|
||||
"executable_basename": executable or app_id,
|
||||
"title_hint": title,
|
||||
"restore_identity": {
|
||||
"app_id": app_id,
|
||||
"class": xclass,
|
||||
"instance": instance,
|
||||
"desktop_entry": desktop_entry,
|
||||
"confidence": identity_confidence,
|
||||
"matched_by": "desktop-id" if desktop_entry else None,
|
||||
},
|
||||
"restore_adapter": "generic-desktop" if desktop_entry else "unknown",
|
||||
"classification": {"category": category, "managed_by": managed_by},
|
||||
"focused": focused,
|
||||
"urgent": False,
|
||||
"floating": floating,
|
||||
"fullscreen_mode": fullscreen,
|
||||
"rect": {"x": 0, "y": 0, "width": 800, "height": 600},
|
||||
"percent": 1.0,
|
||||
"marks": [],
|
||||
"scratchpad": scratchpad or {"member": False, "visibility": "not-applicable", "state": "none"},
|
||||
"tree_position": {"parent_container_id": parent, "branch": "tiling", "index": index},
|
||||
}
|
||||
|
||||
|
||||
def session(
|
||||
windows,
|
||||
*,
|
||||
persistent=False,
|
||||
name="test",
|
||||
outputs=("eDP-1",),
|
||||
workspace_outputs=None,
|
||||
containers=None,
|
||||
focused=None,
|
||||
captured_at="2026-10-05T00:00:00.000Z",
|
||||
):
|
||||
workspace_outputs = workspace_outputs or {}
|
||||
workspace_names = []
|
||||
for item in windows:
|
||||
workspace = item.get("workspace")
|
||||
if workspace is not None and workspace not in workspace_names:
|
||||
workspace_names.append(workspace)
|
||||
metadata = {
|
||||
"captured_at": captured_at,
|
||||
"desktop": "sway:wlroots:swayfx",
|
||||
"session_type": "wayland",
|
||||
}
|
||||
if persistent:
|
||||
metadata.update({
|
||||
"session_name": name,
|
||||
"created_at": captured_at,
|
||||
"updated_at": captured_at,
|
||||
})
|
||||
focused_id = focused
|
||||
if focused_id is None:
|
||||
focused_id = next((item["window_id"] for item in windows if item.get("focused")), None)
|
||||
return {
|
||||
"schema": "labfy.sway.session-snapshot",
|
||||
"version": 1,
|
||||
"metadata": metadata,
|
||||
"compositor": {
|
||||
"variant": "sway",
|
||||
"version": "0.6",
|
||||
"sway_version": "1.12.0",
|
||||
"layout_engine": "sway-native",
|
||||
},
|
||||
"outputs": [
|
||||
{"name": item, "active": True, "focused": index == 0}
|
||||
for index, item in enumerate(outputs)
|
||||
],
|
||||
"workspaces": [
|
||||
{
|
||||
"name": item,
|
||||
"output": workspace_outputs.get(item, outputs[0] if outputs else None),
|
||||
"layout": "splith",
|
||||
"orientation": "horizontal",
|
||||
}
|
||||
for item in workspace_names
|
||||
],
|
||||
"containers": containers or [],
|
||||
"windows": copy.deepcopy(windows),
|
||||
"focus": {
|
||||
"output": outputs[0] if outputs else None,
|
||||
"workspace": next((item.get("workspace") for item in windows if item["window_id"] == focused_id), None),
|
||||
"container_id": None,
|
||||
"window_id": focused_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def action_types(plan):
|
||||
return [item["action"] for item in plan["actions"]]
|
||||
|
||||
|
||||
class RestorePlannerTest(unittest.TestCase):
|
||||
def test_exact_reuse_identical_session_has_no_mutation(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True)
|
||||
live = copy.deepcopy(saved)
|
||||
live["runtime"] = {"con_id": 999, "pid": 8888}
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertEqual(action_types(plan), ["reuse-window"])
|
||||
self.assertEqual(plan["matches"][0]["confidence"], "exact")
|
||||
self.assertEqual(plan["diagnostics"]["mutating_commands_executed"], 0)
|
||||
|
||||
def test_missing_exact_user_application_uses_future_uwsm_backend(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
||||
launch = next(item for item in plan["actions"] if item["action"] == "launch-application")
|
||||
self.assertEqual(launch["launch"]["argv"], ["uwsm", "app", "--", "firefox.desktop"])
|
||||
self.assertFalse(launch["launch"]["execute_in_step19a"])
|
||||
|
||||
def test_synthetic_firefox_kitty_limusic_with_only_limusic_live(self):
|
||||
saved = [
|
||||
window("w1", "firefox", desktop_entry="firefox.desktop"),
|
||||
window("w2", "kitty", desktop_entry="kitty.desktop", executable="kitty"),
|
||||
window("w3", "limusic-app", desktop_entry="limusic.desktop", workspace="10", category="autostart-managed-application", managed_by="sway-autostart"),
|
||||
]
|
||||
live = [copy.deepcopy(saved[2])]
|
||||
plan = restore.build_restore_plan(session(saved, persistent=True), session(live))
|
||||
launches = [item["desktop_entry"] for item in plan["actions"] if item["action"] == "launch-application"]
|
||||
self.assertEqual(launches, ["firefox.desktop", "kitty.desktop"])
|
||||
self.assertEqual(sum(item["action"] == "reuse-window" for item in plan["actions"]), 1)
|
||||
|
||||
def test_existing_application_on_wrong_workspace_is_reused_then_moved(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True)
|
||||
live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertIn("reuse-window", action_types(plan))
|
||||
self.assertIn("move-to-workspace", action_types(plan))
|
||||
self.assertNotIn("launch-application", action_types(plan))
|
||||
move = next(item for item in plan["actions"] if item["action"] == "move-to-workspace")
|
||||
self.assertEqual(move["desired_workspace"], "1")
|
||||
|
||||
def test_multi_window_matches_only_best_title_and_never_reuses_twice(self):
|
||||
saved_a = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — A")
|
||||
saved_b = window("w2", "firefox", desktop_entry="firefox.desktop", title="Firefox — B")
|
||||
live_b = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — B", pid=5000)
|
||||
plan = restore.build_restore_plan(session([saved_a, saved_b], persistent=True), session([live_b]))
|
||||
self.assertEqual(len(plan["matches"]), 1)
|
||||
self.assertEqual(plan["matches"][0]["source_window"], "w2")
|
||||
launch = next(item for item in plan["actions"] if item["action"] == "launch-application")
|
||||
self.assertEqual(launch["source_window"], "w1")
|
||||
|
||||
def test_title_hint_is_not_required_identity(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", title="Ancien titre")
|
||||
live = window("w9", "firefox", desktop_entry="firefox.desktop", title="Nouveau titre")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertEqual(plan["matches"][0]["source_window"], "w1")
|
||||
|
||||
def test_app_id_without_desktop_entry_is_high_confidence(self):
|
||||
saved = window("w1", "custom-app", identity_confidence="unresolved")
|
||||
live = window("w7", "custom-app", identity_confidence="unresolved")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertEqual(plan["matches"][0]["confidence"], "high")
|
||||
self.assertIn("app-id", plan["matches"][0]["matched_by"])
|
||||
|
||||
def test_xwayland_class_without_app_id_is_high_confidence(self):
|
||||
saved = window("w1", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved")
|
||||
live = window("w8", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved")
|
||||
saved["executable_basename"] = None
|
||||
live["executable_basename"] = None
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertEqual(plan["matches"][0]["confidence"], "high")
|
||||
self.assertEqual(plan["matches"][0]["matched_by"], ["class"])
|
||||
|
||||
def test_indistinguishable_live_candidates_are_ambiguous_but_one_to_one(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop")
|
||||
first = window("w1", "firefox", desktop_entry="firefox.desktop", pid=2000)
|
||||
second = window("w2", "firefox", desktop_entry="firefox.desktop", pid=3000)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([first, second]))
|
||||
self.assertEqual(len(plan["matches"]), 1)
|
||||
self.assertEqual(plan["matches"][0]["confidence"], "ambiguous")
|
||||
|
||||
def test_absent_autostart_application_is_not_launched(self):
|
||||
saved = window("w1", "limusic-app", desktop_entry="limusic.desktop", category="autostart-managed-application", managed_by="sway-autostart")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
||||
self.assertEqual(action_types(plan), ["skip-autostart-managed"])
|
||||
self.assertNotIn("launch-application", action_types(plan))
|
||||
|
||||
def test_unresolved_window_requires_manual_action(self):
|
||||
saved = window("w1", None, identity_confidence="unresolved", executable=None)
|
||||
saved["executable_basename"] = None
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
||||
self.assertEqual(action_types(plan), ["manual-required"])
|
||||
self.assertEqual(plan["actions"][0]["reason"], "no-exact-launch-identity")
|
||||
|
||||
def test_missing_output_is_explicit_and_has_no_fallback_launch(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", output="HDMI-A-1")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True, outputs=("HDMI-A-1",)), session([], outputs=("eDP-1",)))
|
||||
self.assertEqual(action_types(plan), ["manual-required"])
|
||||
self.assertEqual(plan["actions"][0]["policy"], "deferred-no-fallback")
|
||||
|
||||
def test_floating_difference_produces_intention_only(self):
|
||||
saved = window("w1", "kitty", desktop_entry="kitty.desktop", floating=True)
|
||||
live = window("w1", "kitty", desktop_entry="kitty.desktop", floating=False)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
action = next(item for item in plan["actions"] if item["action"] == "restore-floating")
|
||||
self.assertTrue(action["desired_floating"])
|
||||
|
||||
def test_fullscreen_difference_produces_intention_only(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=1)
|
||||
live = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=0)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
action = next(item for item in plan["actions"] if item["action"] == "restore-fullscreen")
|
||||
self.assertEqual(action["desired_fullscreen_mode"], 1)
|
||||
|
||||
def test_layout_difference_is_partially_supported(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", parent="c1")
|
||||
live = window("w1", "firefox", desktop_entry="firefox.desktop")
|
||||
container = {
|
||||
"container_id": "c1", "parent_container_id": None,
|
||||
"tree_position": {"branch": "tiling", "index": 0},
|
||||
"layout": "splitv", "orientation": "vertical", "percent": 1.0,
|
||||
}
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True, containers=[container]), session([live]))
|
||||
action = next(item for item in plan["actions"] if item["action"] == "restore-tree-position")
|
||||
self.assertEqual(action["capability"], "partially-supported")
|
||||
|
||||
def test_scratchpad_hidden_intention(self):
|
||||
scratch = {"member": True, "visibility": "hidden", "state": "fresh"}
|
||||
saved = window("w1", "kitty", desktop_entry="kitty.desktop", workspace=None, output=None, scratchpad=scratch)
|
||||
live = window("w1", "kitty", desktop_entry="kitty.desktop")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertIn("restore-scratchpad-hidden", action_types(plan))
|
||||
|
||||
def test_focus_is_last_after_mutating_intentions(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True)
|
||||
live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True, focused="w1"), session([live], focused="w1"))
|
||||
self.assertEqual(plan["actions"][-1]["action"], "restore-focus")
|
||||
self.assertTrue(plan["actions"][-1]["depends_on"])
|
||||
|
||||
def test_infrastructure_is_never_launched(self):
|
||||
saved = window("w1", "quickshell", desktop_entry="org.quickshell.desktop", category="session-infrastructure")
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([]))
|
||||
self.assertEqual(action_types(plan), ["manual-required"])
|
||||
self.assertNotIn("launch-application", action_types(plan))
|
||||
|
||||
def test_runtime_ids_are_observations_not_matching_identity(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop", con_id=7, pid=10)
|
||||
live = window("w9", "firefox", desktop_entry="firefox.desktop", con_id=900, pid=9999)
|
||||
plan = restore.build_restore_plan(session([saved], persistent=True), session([live]))
|
||||
self.assertEqual(plan["matches"][0]["source_window"], "w1")
|
||||
self.assertEqual(plan["policy"]["runtime_identity_fields"], [])
|
||||
self.assertEqual(plan["live"]["windows"][0]["runtime"], {"con_id": 900, "pid": 9999})
|
||||
|
||||
def test_same_inputs_produce_byte_equivalent_plan(self):
|
||||
saved = [
|
||||
window("w1", "firefox", desktop_entry="firefox.desktop", title="A"),
|
||||
window("w2", "firefox", desktop_entry="firefox.desktop", title="B"),
|
||||
]
|
||||
live = [window("w7", "firefox", desktop_entry="firefox.desktop", title="B")]
|
||||
source = session(saved, persistent=True)
|
||||
current = session(live)
|
||||
first = restore.build_restore_plan(source, current)
|
||||
second = restore.build_restore_plan(copy.deepcopy(source), copy.deepcopy(current))
|
||||
self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True))
|
||||
|
||||
def test_cli_plan_uses_only_loader_collector_and_pure_planner(self):
|
||||
saved = window("w1", "firefox", desktop_entry="firefox.desktop")
|
||||
source = session([saved], persistent=True, name="cli")
|
||||
current = session([copy.deepcopy(saved)])
|
||||
before = copy.deepcopy(source)
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(snapshot_backend, "show_session", return_value=source) as loader, mock.patch.object(
|
||||
snapshot_backend, "collect", return_value=current
|
||||
) as collector, mock.patch.object(snapshot_backend, "validate_snapshot") as validator, mock.patch("sys.stdout", output):
|
||||
self.assertEqual(snapshot_backend.main(("plan", "cli")), 0)
|
||||
result = json.loads(output.getvalue())
|
||||
self.assertEqual(result["schema"], "labfy.sway.restore-plan")
|
||||
loader.assert_called_once_with("cli")
|
||||
collector.assert_called_once()
|
||||
validator.assert_called_once_with(current)
|
||||
self.assertEqual(source, before)
|
||||
self.assertEqual(result["diagnostics"]["applications_launched"], 0)
|
||||
|
||||
def test_planner_module_has_no_process_or_filesystem_execution_api(self):
|
||||
source_text = RESTORE_SOURCE.read_text(encoding="utf-8")
|
||||
self.assertNotIn("import subprocess", source_text)
|
||||
self.assertNotIn("subprocess.", source_text)
|
||||
self.assertNotIn("swaymsg", source_text)
|
||||
self.assertNotIn("os.system", source_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue