#!/usr/bin/env python3 """Exécuter le sous-ensemble contrôlé STEP19B/STEP19C d'un plan V1 frais.""" from __future__ import annotations import contextlib import copy import fcntl import hashlib import importlib.util import json import os from pathlib import Path import re import secrets import stat import subprocess import time from typing import Any, Callable, Iterator, Mapping, Sequence REPORT_SCHEMA = "labfy.sway.restore-execution" REPORT_VERSION = 1 RESTORE_LOCK_FILENAME = ".restore.lock" LAUNCH_BACKEND = "uwsm-app-desktop-entry" DEFAULT_WINDOW_TIMEOUT_SECONDS = 10.0 POLL_INTERVAL_SECONDS = 0.1 POSTCONDITION_TIMEOUT_SECONDS = 1.0 SUPPORTED_ACTIONS = { "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", } UNSUPPORTED_ACTIONS = { "manual-required", } NUMERIC_WORKSPACE = re.compile(r"\A[1-9][0-9]*\Z") DESKTOP_ENTRY_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\.desktop\Z") class RestoreExecutionError(RuntimeError): """Échec contrôlé portant un code stable et un motif auditables.""" def __init__(self, code: str, reason: str): super().__init__(f"{code}: {reason}") self.code = code self.reason = reason def snapshot_digest(snapshot: Mapping[str, Any]) -> str: """Hasher le document chargé, indépendamment de son formatage JSON sur disque.""" payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(payload.encode("utf-8")).hexdigest() def _default_runner(argv: Sequence[str], timeout: float) -> Any: """CONTRACT: exécuter un argv construit localement, sans interpréteur shell.""" if argv and argv[0] == "uwsm": # WHY: uwsm app peut rester attaché pendant toute la vie de # l'application. Attendre son exit bloquerait avant même le polling # Sway ; son PID n'est en outre jamais l'identité de la future vue. # CONTRACT: l'enfant direct reçoit uniquement l'argv allowlisté, sans # shell et sans capture bornée à gérer. Le CLI STEP19B est éphémère. # INVARIANT: seul le matching d'une nouvelle fenêtre prouve le succès. return subprocess.Popen( # type: ignore[return-value] list(argv), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True, ) return subprocess.run( list(argv), check=True, capture_output=True, text=True, timeout=timeout, ) def _direct_uwsm_command(desktop_entry: str) -> list[str]: """Reconstruire l'unique backend autorisé sans consulter launch.argv.""" return ["uwsm", "app", "--", desktop_entry] def _direct_sway_command(con_id: int, operation: str) -> list[str]: """Cibler une observation runtime validée ; operation est toujours une constante locale.""" return ["swaymsg", "-r", f"[con_id={con_id}] {operation}"] def _load_tree_compiler() -> Any: """Charger le compilateur pur voisin sans lui donner d'effet externe.""" path = Path(__file__).with_name("session_tree_restore.py") spec = importlib.util.spec_from_file_location("labfy_session_tree_restore", path) if spec is None or spec.loader is None: raise RestoreExecutionError("RESTORE_TREE_COMPILER_UNAVAILABLE", path.name) module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) except (OSError, ImportError, SyntaxError) as exc: raise RestoreExecutionError("RESTORE_TREE_COMPILER_UNAVAILABLE", str(exc)) from exc return module def _source_windows(source: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: return { str(window.get("window_id")): window for window in source.get("windows", []) if isinstance(window, Mapping) } def _runtime_con_id(window: Mapping[str, Any]) -> int | None: runtime = window.get("runtime") runtime = runtime if isinstance(runtime, Mapping) else {} value = runtime.get("con_id") return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None def _find_con_id(live: Mapping[str, Any], con_id: int) -> Mapping[str, Any] | None: return next( ( window for window in live.get("windows", []) if isinstance(window, Mapping) and _runtime_con_id(window) == con_id ), None, ) def _compatible_match( planner_module: Any, saved: Mapping[str, Any], candidate: Mapping[str, Any], ) -> bool: matches, unmatched = planner_module.match_windows([saved], [candidate]) saved_id = str(saved.get("window_id")) evidence = matches.get(saved_id) return not unmatched and evidence is not None and evidence.get("confidence") != "ambiguous" def _revalidate_runtime( source_window: Mapping[str, Any], con_id: int, *, collector: Callable[[], dict[str, Any]], planner_module: Any, allow_scratchpad: bool = False, ) -> tuple[Mapping[str, Any], dict[str, Any]]: """Refuser disparition, recyclage d'id, identité divergente ou workspace inconnu.""" live = collector() candidate = _find_con_id(live, con_id) if candidate is None: raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"con_id disparu: {con_id}") if not _compatible_match(planner_module, source_window, candidate): raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"identité modifiée pour con_id={con_id}") scratchpad = candidate.get("scratchpad") scratch_member = isinstance(scratchpad, Mapping) and scratchpad.get("member") is True if ( (not isinstance(candidate.get("workspace"), str) or not candidate.get("workspace")) and not (allow_scratchpad and scratch_member) ): raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"workspace inconnu pour con_id={con_id}") return candidate, live def _validate_desktop_entry( saved: Mapping[str, Any], action: Mapping[str, Any], *, desktop_entries: Sequence[Mapping[str, str | None]], resolve_desktop_entry: Callable[..., dict[str, Any]], ) -> str: """Recalculer l'identité exacte contre les sources XDG actuelles.""" desktop_entry = action.get("desktop_entry") launch = action.get("launch") launch = launch if isinstance(launch, Mapping) else {} classification = saved.get("classification") classification = classification if isinstance(classification, Mapping) else {} identity = saved.get("restore_identity") identity = identity if isinstance(identity, Mapping) else {} if ( not isinstance(desktop_entry, str) or DESKTOP_ENTRY_ID.fullmatch(desktop_entry) is None or launch.get("backend") != LAUNCH_BACKEND or classification.get("category") != "user-application" or identity.get("confidence") != "exact" or identity.get("desktop_entry") != desktop_entry ): raise RestoreExecutionError( "RESTORE_DESKTOP_ENTRY_REJECTED", f"identité de lancement non exacte: {action.get('source_window')}", ) xwayland = saved.get("xwayland") xwayland = xwayland if isinstance(xwayland, Mapping) else {} current = resolve_desktop_entry( saved.get("app_id"), xwayland, saved.get("executable_basename"), desktop_entries, ) if current.get("confidence") != "exact" or current.get("desktop_entry") != desktop_entry: raise RestoreExecutionError( "RESTORE_DESKTOP_ENTRY_REJECTED", f"DesktopEntry absente, ambiguë ou devenue incompatible: {desktop_entry}", ) return desktop_entry def _blocking_finding(action: Mapping[str, Any]) -> dict[str, Any] | None: action_type = action.get("action") action_id = action.get("action_id") if action_type not in SUPPORTED_ACTIONS: return { "action_id": action_id, "action": action_type, "reason": action.get("reason") or "unsupported-action-step19b", } if action_type == "reuse-window" and action.get("confidence") == "ambiguous": return { "action_id": action_id, "action": action_type, "reason": "ambiguous-runtime-match", } if action_type == "move-to-workspace": workspace = action.get("desired_workspace") if not isinstance(workspace, str) or NUMERIC_WORKSPACE.fullmatch(workspace) is None: return { "action_id": action_id, "action": action_type, "reason": "unsupported-non-numeric-workspace", "desired_workspace": workspace, } if action_type == "restore-fullscreen" and action.get("desired_fullscreen_mode") not in {0, 1}: return { "action_id": action_id, "action": action_type, "reason": "unsupported-fullscreen-mode", "desired_fullscreen_mode": action.get("desired_fullscreen_mode"), } if action_type in {"restore-scratchpad-hidden", "restore-scratchpad-visible"}: desired = action.get("desired") visibility = desired.get("visibility") if isinstance(desired, Mapping) else None expected = "hidden" if action_type.endswith("hidden") else "visible" if not isinstance(desired, Mapping) or desired.get("member") is not True or visibility != expected: return { "action_id": action_id, "action": action_type, "reason": "invalid-scratchpad-target", } return None def capability_gate( source: Mapping[str, Any], plan: Mapping[str, Any], live: Mapping[str, Any], *, planner_module: Any, tree_module: Any, desktop_entries: Sequence[Mapping[str, str | None]], resolve_desktop_entry: Callable[..., dict[str, Any]], ) -> Mapping[str, Any]: """Examiner tout le plan avant la première mutation, sans exception partielle.""" windows = _source_windows(source) findings: list[dict[str, Any]] = [] for action in plan.get("actions", []): if not isinstance(action, Mapping): findings.append({"action_id": None, "action": None, "reason": "invalid-action"}) continue finding = _blocking_finding(action) if finding is not None: findings.append(finding) continue saved = windows.get(str(action.get("source_window"))) if saved is None: findings.append({ "action_id": action.get("action_id"), "action": action.get("action"), "reason": "source-window-missing", }) continue if action.get("action") == "launch-application": try: _validate_desktop_entry( saved, action, desktop_entries=desktop_entries, resolve_desktop_entry=resolve_desktop_entry, ) except RestoreExecutionError as exc: findings.append({ "action_id": action.get("action_id"), "action": action.get("action"), "reason": exc.reason, }) if action.get("action") == "reuse-window": live_window = action.get("live_window") if not isinstance(live_window, Mapping) or _runtime_con_id(live_window) is None: findings.append({ "action_id": action.get("action_id"), "action": action.get("action"), "reason": "missing-runtime-con-id", }) if action.get("action") == "restore-scratchpad-visible": desired_workspace = saved.get("workspace") live_focus = live.get("focus") focused_workspace = live_focus.get("workspace") if isinstance(live_focus, Mapping) else None if ( not isinstance(desired_workspace, str) or NUMERIC_WORKSPACE.fullmatch(desired_workspace) is None or focused_workspace != desired_workspace ): findings.append({ "action_id": action.get("action_id"), "action": action.get("action"), "reason": "scratchpad-visible-requires-focused-target-workspace", }) try: planner_module.validate_restore_plan(plan) except Exception as exc: findings.append({"action_id": None, "action": None, "reason": f"invalid-plan: {exc}"}) if any( isinstance(action, Mapping) and action.get("action") == "restore-tree-position" for action in plan.get("actions", []) ): try: tree_compilation = tree_module.compile_tree_restore(source, live, plan) tree_module.validate_compilation(tree_compilation) except Exception as exc: findings.append({ "action_id": None, "action": "restore-tree-position", "reason": f"tree-preflight: {exc}", }) tree_compilation = {"capability": "unsupported", "operations": [], "workspaces": []} else: tree_compilation = { "capability": "supported-subset", "percent_capability": "unsupported", "operations": [], "workspaces": [], } if findings: raise RestoreExecutionError( "RESTORE_BLOCKED_UNSUPPORTED_ACTION", json.dumps(findings, ensure_ascii=False, sort_keys=True), ) return tree_compilation @contextlib.contextmanager def restore_lock(directory: Path) -> Iterator[None]: """Détenir un flock exclusif non bloquant sur un fichier privé persistant.""" directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): directory_flags |= os.O_NOFOLLOW try: directory_fd = os.open(directory, directory_flags) except OSError as exc: raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"répertoire sessions non sûr: {exc}") from exc if stat.S_IMODE(os.fstat(directory_fd).st_mode) != 0o700: os.close(directory_fd) raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "répertoire sessions non privé") flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW lock_fd = -1 try: try: lock_fd = os.open(RESTORE_LOCK_FILENAME, flags, 0o600, dir_fd=directory_fd) except OSError as exc: raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"restore lock non sûr: {exc}") from exc info = os.fstat(lock_fd) if not stat.S_ISREG(info.st_mode): raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "restore lock non régulier") os.fchmod(lock_fd, 0o600) try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: raise RestoreExecutionError("RESTORE_ALREADY_RUNNING", "une restauration est déjà active") from exc yield finally: if lock_fd >= 0: try: fcntl.flock(lock_fd, fcntl.LOCK_UN) finally: os.close(lock_fd) os.close(directory_fd) def _parse_sway_success(completed: Any) -> None: try: payload = json.loads(completed.stdout) except (AttributeError, TypeError, json.JSONDecodeError) as exc: raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", "réponse swaymsg JSON invalide") from exc if not isinstance(payload, list) or not payload or not all( isinstance(item, Mapping) and item.get("success") is True for item in payload ): raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", f"commande refusée: {payload!r}") def _wait_for_launched_window( saved: Mapping[str, Any], before_ids: set[int], *, collector: Callable[[], dict[str, Any]], planner_module: Any, timeout: float, monotonic: Callable[[], float], sleeper: Callable[[float], None], ) -> tuple[int, Mapping[str, Any]]: deadline = monotonic() + timeout while True: live = collector() candidates = [ window for window in live.get("windows", []) if isinstance(window, Mapping) and _runtime_con_id(window) is not None and _runtime_con_id(window) not in before_ids and _compatible_match(planner_module, saved, window) ] if candidates: matches, _unmatched = planner_module.match_windows([saved], candidates) match = matches.get(str(saved.get("window_id"))) if match is None: pass elif match.get("confidence") == "ambiguous": raise RestoreExecutionError( "RESTORE_LAUNCH_AMBIGUOUS", f"plusieurs nouvelles fenêtres indiscernables: {saved.get('window_id')}", ) else: chosen = candidates[int(match["live_index"])] con_id = _runtime_con_id(chosen) if con_id is not None: return con_id, chosen if monotonic() >= deadline: raise RestoreExecutionError( "RESTORE_LAUNCH_TIMEOUT", f"aucune nouvelle fenêtre compatible: {saved.get('window_id')}", ) sleeper(POLL_INTERVAL_SECONDS) def _action_status(action: Mapping[str, Any]) -> dict[str, Any]: return { "action_id": action.get("action_id"), "action": action.get("action"), "source_window": action.get("source_window"), "status": "pending", "reason": None, } def _wait_for_postcondition( saved: Mapping[str, Any], con_id: int, predicate: Callable[[Mapping[str, Any]], bool], failure_reason: str, *, collector: Callable[[], dict[str, Any]], planner_module: Any, monotonic: Callable[[], float], sleeper: Callable[[float], None], allow_scratchpad: bool = False, ) -> Mapping[str, Any]: """Attendre brièvement la visibilité IPC d'une mutation déjà acceptée.""" deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS while True: candidate, _live = _revalidate_runtime( saved, con_id, collector=collector, planner_module=planner_module, allow_scratchpad=allow_scratchpad, ) if predicate(candidate): return candidate if monotonic() >= deadline: raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", failure_reason) sleeper(POLL_INTERVAL_SECONDS) def _tree_argv( operation: Mapping[str, Any], runtime_by_source: Mapping[str, int], tree_module: Any, ) -> list[str]: """Construire une commande fermée depuis une opération prévalidée.""" tree_module.validate_operation(operation) source_id = str(operation["source_window"]) con_id = runtime_by_source.get(source_id) if con_id is None: raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"fenêtre arbre absente: {source_id}") kind = operation["operation"] if kind == "set-container-layout": command_layout = "stacking" if operation["layout"] == "stacked" else operation["layout"] sway_operation = f"layout {command_layout}" elif kind == "set-split-orientation": sway_operation = "split horizontal" if operation["layout"] == "splith" else "split vertical" else: raise RestoreExecutionError("RESTORE_TREE_OPERATION_INVALID", "move-relative requiert un mark temporaire") return _direct_sway_command(con_id, sway_operation) def _all_live_marks(live: Mapping[str, Any]) -> set[str]: result: set[str] = set() for collection in (live.get("containers", []), live.get("windows", [])): for item in collection if isinstance(collection, list) else []: if isinstance(item, Mapping): result.update(mark for mark in item.get("marks", []) if isinstance(mark, str)) return result def _move_relative_with_mark( operation: Mapping[str, Any], runtime_by_source: Mapping[str, int], *, collector: Callable[[], dict[str, Any]], runner: Callable[[Sequence[str], float], Any], monotonic: Callable[[], float], sleeper: Callable[[float], None], ) -> int: """Insérer une vue après une référence exacte sans direction géométrique.""" source_con = runtime_by_source.get(str(operation["source_window"])) reference_con = runtime_by_source.get(str(operation["reference_window"])) if source_con is None or reference_con is None or source_con == reference_con: raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", "référence move-relative invalide") before = collector() existing = _all_live_marks(before) mark = "" for _attempt in range(8): candidate = f"__labfy_restore_{secrets.token_hex(8)}" if candidate not in existing: mark = candidate break if not mark: raise RestoreExecutionError("RESTORE_TEMPORARY_MARK_COLLISION", "aucun mark unique disponible") mark_created = False commands = 0 try: completed = runner(_direct_sway_command(reference_con, f"mark --add {mark}"), 5.0) _parse_sway_success(completed) commands += 1 mark_created = True marked = collector() reference = _find_con_id(marked, reference_con) if reference is None or mark not in reference.get("marks", []): raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "mark temporaire absent") completed = runner(_direct_sway_command(source_con, f"move container to mark {mark}"), 5.0) _parse_sway_success(completed) commands += 1 finally: if mark_created: try: completed = runner(_direct_sway_command(reference_con, f"unmark {mark}"), 5.0) _parse_sway_success(completed) commands += 1 except (OSError, subprocess.SubprocessError, RestoreExecutionError): # Le caller vérifiera l'absence du mark ; l'erreur originale # reste prioritaire si le move lui-même a échoué. pass deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS while True: after = collector() if mark not in _all_live_marks(after): return commands if monotonic() >= deadline: raise RestoreExecutionError("RESTORE_TEMPORARY_MARK_LEAK", mark) sleeper(POLL_INTERVAL_SECONDS) def _execute_tree_compilation( source: Mapping[str, Any], plan: Mapping[str, Any], compilation: Mapping[str, Any], runtime_by_source: Mapping[str, int], *, collector: Callable[[], dict[str, Any]], planner_module: Any, tree_module: Any, runner: Callable[[Sequence[str], float], Any], monotonic: Callable[[], float], sleeper: Callable[[float], None], ) -> int: """Exécuter le bytecode typé avec détection de dérive entre mutations.""" source_windows = _source_windows(source) last_fingerprint: dict[str, Any] = {} commands = 0 for operation in compilation.get("operations", []): workspace = str(operation["workspace"]) before = collector() for source_id, con_id in runtime_by_source.items(): saved = source_windows.get(source_id) if saved is not None and saved.get("workspace") == workspace: candidate = _find_con_id(before, con_id) if candidate is None or not _compatible_match(planner_module, saved, candidate): raise RestoreExecutionError( "RESTORE_RUNTIME_DIVERGED", f"inventaire arbre modifié: {source_id}", ) fingerprint = tree_module.tree_fingerprint(before, workspace, runtime_by_source) if workspace in last_fingerprint and fingerprint != last_fingerprint[workspace]: raise RestoreExecutionError( "RESTORE_TREE_DRIFTED", f"arbre modifié entre deux opérations: workspace {workspace}", ) if operation["operation"] == "move-relative": commands += _move_relative_with_mark( operation, runtime_by_source, collector=collector, runner=runner, monotonic=monotonic, sleeper=sleeper, ) else: completed = runner(_tree_argv(operation, runtime_by_source, tree_module), 5.0) _parse_sway_success(completed) commands += 1 deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS while True: after = collector() changed = tree_module.tree_fingerprint(after, workspace, runtime_by_source) if changed != fingerprint: last_fingerprint[workspace] = changed break if monotonic() >= deadline: raise RestoreExecutionError( "RESTORE_POSTCONDITION_FAILED", f"opération arbre sans effet observable: {operation['operation']}", ) sleeper(POLL_INTERVAL_SECONDS) final_live = collector() try: remaining = tree_module.compile_tree_restore(source, final_live, plan) tree_module.validate_compilation(remaining) except Exception as exc: raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", f"arbre final invalide: {exc}") from exc if remaining.get("operations"): raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "arbre cible non atteint") for workspace in remaining.get("workspaces", []): if workspace.get("target_tree") != workspace.get("live_tree"): raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "topologie finale différente") return commands def execute_fresh_plan( source: Mapping[str, Any], plan: Mapping[str, Any], *, collector: Callable[[], dict[str, Any]], planner_module: Any, desktop_entries: Sequence[Mapping[str, str | None]], resolve_desktop_entry: Callable[..., dict[str, Any]], runner: Callable[[Sequence[str], float], Any] = _default_runner, timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS, monotonic: Callable[[], float] = time.monotonic, sleeper: Callable[[float], None] = time.sleep, tree_module: Any | None = None, ) -> dict[str, Any]: """Appliquer séquentiellement un plan déjà préflighté et arrêter au premier écart.""" tree_module = _load_tree_compiler() if tree_module is None else tree_module preflight_live = collector() tree_compilation = capability_gate( source, plan, preflight_live, planner_module=planner_module, tree_module=tree_module, desktop_entries=desktop_entries, resolve_desktop_entry=resolve_desktop_entry, ) windows = _source_windows(source) statuses = [_action_status(action) for action in plan["actions"]] runtime_by_source: dict[str, int] = {} initial_ids = { con_id for window in plan.get("live", {}).get("windows", []) if isinstance(window, Mapping) for con_id in [_runtime_con_id(window)] if con_id is not None } applications_launched = 0 mutating_commands = 0 stopped_reason: str | None = None tree_executed = False for action, status in zip(plan["actions"], statuses): action_type = action["action"] source_id = str(action["source_window"]) saved = windows[source_id] try: if action_type == "reuse-window": con_id = _runtime_con_id(action["live_window"]) assert con_id is not None saved_scratchpad = saved.get("scratchpad") _revalidate_runtime( saved, con_id, collector=collector, planner_module=planner_module, allow_scratchpad=( isinstance(saved_scratchpad, Mapping) and saved_scratchpad.get("member") is True ), ) runtime_by_source[source_id] = con_id status.update(status="success", reason="fenêtre live réutilisée et revalidée") elif action_type == "skip-autostart-managed": status.update(status="skipped", reason="lancement possédé par sway-autostart") elif action_type == "launch-application": desktop_entry = _validate_desktop_entry( saved, action, desktop_entries=desktop_entries, resolve_desktop_entry=resolve_desktop_entry, ) # CONTRACT: l'inventaire de référence est pris immédiatement # avant le lancement, et non réutilisé depuis le plan. Toute # apparition/disparition imprévue depuis le plan force l'arrêt. before_launch = collector() before_launch_ids = { con_id for window in before_launch.get("windows", []) if isinstance(window, Mapping) for con_id in [_runtime_con_id(window)] if con_id is not None } expected_ids = initial_ids | set(runtime_by_source.values()) if before_launch_ids != expected_ids: raise RestoreExecutionError( "RESTORE_RUNTIME_DIVERGED", "inventaire con_id modifié avant lancement", ) completed = runner(_direct_uwsm_command(desktop_entry), timeout) if getattr(completed, "returncode", 0) not in {None, 0}: raise RestoreExecutionError("RESTORE_LAUNCH_FAILED", f"uwsm a retourné {completed.returncode}") applications_launched += 1 con_id, _window = _wait_for_launched_window( saved, before_launch_ids, collector=collector, planner_module=planner_module, timeout=timeout, monotonic=monotonic, sleeper=sleeper, ) runtime_by_source[source_id] = con_id status.update(status="success", reason=f"nouvelle fenêtre compatible con_id={con_id}") else: con_id = runtime_by_source.get(source_id) if con_id is None: raise RestoreExecutionError( "RESTORE_RUNTIME_DIVERGED", f"aucun con_id validé pour {source_id}", ) saved_scratchpad = saved.get("scratchpad") _revalidate_runtime( saved, con_id, collector=collector, planner_module=planner_module, allow_scratchpad=( isinstance(saved_scratchpad, Mapping) and saved_scratchpad.get("member") is True ), ) if action_type == "restore-tree-position": if not tree_executed: mutating_commands += _execute_tree_compilation( source, plan, tree_compilation, runtime_by_source, collector=collector, planner_module=planner_module, tree_module=tree_module, runner=runner, monotonic=monotonic, sleeper=sleeper, ) tree_executed = True status.update(status="success", reason="topologie Sway cible confirmée") continue if action_type == "move-to-workspace": workspace = str(int(action["desired_workspace"])) operation = f"move container to workspace number {workspace}" elif action_type == "restore-floating": operation = "floating enable" if action["desired_floating"] else "floating disable" elif action_type == "restore-fullscreen": operation = "fullscreen enable" if action["desired_fullscreen_mode"] == 1 else "fullscreen disable" elif action_type in {"restore-scratchpad-hidden", "restore-scratchpad-visible"}: current = collector() candidate = _find_con_id(current, con_id) if candidate is None or not _compatible_match(planner_module, saved, candidate): raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"scratchpad cible absente: {con_id}") scratch = candidate.get("scratchpad") scratch = scratch if isinstance(scratch, Mapping) else {} must_hide = ( action_type == "restore-scratchpad-hidden" and scratch.get("visibility") != "hidden" ) must_admit = ( action_type == "restore-scratchpad-visible" and scratch.get("member") is not True ) if must_hide or must_admit: completed = runner(_direct_sway_command(con_id, "move scratchpad"), 5.0) _parse_sway_success(completed) mutating_commands += 1 _wait_for_postcondition( saved, con_id, lambda item: isinstance(item.get("scratchpad"), Mapping) and item["scratchpad"].get("member") is True and item["scratchpad"].get("visibility") == "hidden", "fenêtre non déplacée vers le scratchpad caché", collector=collector, planner_module=planner_module, monotonic=monotonic, sleeper=sleeper, allow_scratchpad=True, ) scratch = {"member": True, "visibility": "hidden"} if ( action_type == "restore-scratchpad-visible" and scratch.get("visibility") != "visible" ): completed = runner(_direct_sway_command(con_id, "scratchpad show"), 5.0) _parse_sway_success(completed) mutating_commands += 1 _wait_for_postcondition( saved, con_id, lambda item: isinstance(item.get("scratchpad"), Mapping) and item["scratchpad"].get("member") is True and item["scratchpad"].get("visibility") == "visible", "fenêtre scratchpad ciblée non visible", collector=collector, planner_module=planner_module, monotonic=monotonic, sleeper=sleeper, allow_scratchpad=True, ) status.update(status="success", reason="scratchpad ciblé confirmé") continue else: operation = "focus" completed = runner(_direct_sway_command(con_id, operation), 5.0) _parse_sway_success(completed) mutating_commands += 1 if action_type == "move-to-workspace": predicate = lambda item: item.get("workspace") == workspace failure_reason = "workspace cible non atteint" elif action_type == "restore-floating": predicate = lambda item: bool(item.get("floating")) == bool(action["desired_floating"]) failure_reason = "état floating non atteint" elif action_type == "restore-fullscreen": predicate = lambda item: int(item.get("fullscreen_mode") or 0) == action["desired_fullscreen_mode"] failure_reason = "état fullscreen non atteint" else: predicate = lambda item: bool(item.get("focused")) failure_reason = "focus cible non atteint" _wait_for_postcondition( saved, con_id, predicate, failure_reason, collector=collector, planner_module=planner_module, monotonic=monotonic, sleeper=sleeper, ) status.update(status="success", reason="postcondition Sway confirmée") except (OSError, subprocess.SubprocessError, RestoreExecutionError, AssertionError) as exc: if isinstance(exc, RestoreExecutionError): stopped_reason = str(exc) else: stopped_reason = f"RESTORE_EXTERNAL_COMMAND_FAILED: {exc}" status.update(status="failed", reason=stopped_reason) break return { "actions": statuses, "applications_launched": applications_launched, "mutating_commands_executed": mutating_commands, "status": "failed" if stopped_reason else "success", "reason": stopped_reason, "runtime_windows": dict(sorted(runtime_by_source.items())), } def apply_session( name: str, autostart_path: Path | None, *, sessions_path: Path, snapshot_loader: Callable[[str], dict[str, Any]], live_collector: Callable[[Path | None], dict[str, Any]], planner_module: Any, desktop_loader: Callable[[], Sequence[Mapping[str, str | None]]], resolve_desktop_entry: Callable[..., dict[str, Any]], runner: Callable[[Sequence[str], float], Any] = _default_runner, timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS, monotonic: Callable[[], float] = time.monotonic, sleeper: Callable[[float], None] = time.sleep, ) -> dict[str, Any]: """Lock, snapshot frais, live frais, plan frais, gate puis exécution contrôlée.""" report: dict[str, Any] = { "schema": REPORT_SCHEMA, "version": REPORT_VERSION, "source_session": name, "status": "blocked", "reason": None, "snapshot_hash_before": None, "snapshot_hash_after": None, "snapshot_unchanged": False, "plan": None, "actions": [], "applications_launched": 0, "mutating_commands_executed": 0, "rollback": "not-transactional-stop-on-first-critical-divergence", } try: with restore_lock(sessions_path): source = snapshot_loader(name) report["snapshot_hash_before"] = snapshot_digest(source) live = live_collector(autostart_path) plan = planner_module.build_restore_plan(source, live) report["plan"] = copy.deepcopy(plan) result = execute_fresh_plan( source, plan, collector=lambda: live_collector(autostart_path), planner_module=planner_module, desktop_entries=desktop_loader(), resolve_desktop_entry=resolve_desktop_entry, runner=runner, timeout=timeout, monotonic=monotonic, sleeper=sleeper, ) report.update(result) after = snapshot_loader(name) report["snapshot_hash_after"] = snapshot_digest(after) report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"] if not report["snapshot_unchanged"]: report["status"] = "failed" report["reason"] = "RESTORE_SNAPSHOT_CHANGED: snapshot source modifié pendant apply" except RestoreExecutionError as exc: report["status"] = "blocked" report["reason"] = str(exc) if isinstance(report.get("plan"), Mapping): report["actions"] = [] for action in report["plan"].get("actions", []): status = _action_status(action) finding = _blocking_finding(action) if finding is not None: status.update(status="blocked", reason=finding["reason"]) report["actions"].append(status) if report["snapshot_hash_before"] is not None: after = snapshot_loader(name) report["snapshot_hash_after"] = snapshot_digest(after) report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"] return report