Move office and desktop state under plugin storage

Migrate retired /usr/_office and /usr/_desktop trees from plugin startup into /usr/plugins/<plugin>. Update office document storage, desktop session/runtime paths, and context-scoped screenshots to use the plugin-owned state layout. Add focused tests for retired-state migration and the new path behavior.

Alessandro committed May 12, 2026 at 16:21 UTC 68c3b8b022cd15781a5db764e3348d83e40fe4ef
15 files changed +393 -62
helpers/state_migration.py new
+78
@@ -0,0 +1,78 @@
1 +from __future__ import annotations
2 +
3 +import shutil
4 +from pathlib import Path
5 +
6 +
7 +def migrate_retired_state_tree(
8 + *,
9 + source: Path,
10 + destination: Path,
11 + owner: str,
12 + migrated: list[str],
13 + warnings: list[str],
14 + errors: list[str],
15 +) -> None:
16 + """Move retired plugin state into its plugin-owned state directory.
17 +
18 + Existing destination data wins. Colliding source entries are preserved under
19 + a suffixed name in the destination instead of overwriting live data.
20 + """
21 +
22 + if not source.exists() and not source.is_symlink():
23 + return
24 + if _same_path(source, destination):
25 + return
26 +
27 + try:
28 + if source.is_dir() and not source.is_symlink():
29 + destination.mkdir(parents=True, exist_ok=True)
30 + for child in list(source.iterdir()):
31 + try:
32 + _move_path(child, destination / child.name, migrated)
33 + except Exception as exc:
34 + errors.append(f"{owner} state migration failed for {child}: {exc}")
35 + _remove_empty_dir(source, owner=owner, warnings=warnings)
36 + return
37 +
38 + _move_path(source, destination, migrated)
39 + except Exception as exc:
40 + errors.append(f"{owner} state migration failed from {source} to {destination}: {exc}")
41 +
42 +
43 +def _move_path(source: Path, target: Path, migrated: list[str]) -> None:
44 + if source.is_dir() and not source.is_symlink() and target.is_dir() and not target.is_symlink():
45 + for child in list(source.iterdir()):
46 + _move_path(child, target / child.name, migrated)
47 + source.rmdir()
48 + return
49 +
50 + final_target = target
51 + if target.exists() or target.is_symlink():
52 + final_target = _next_conflict_path(target)
53 + final_target.parent.mkdir(parents=True, exist_ok=True)
54 + shutil.move(str(source), str(final_target))
55 + migrated.append(f"{source} -> {final_target}")
56 +
57 +
58 +def _next_conflict_path(path: Path) -> Path:
59 + candidate = path.with_name(f"{path.name}.retired")
60 + counter = 2
61 + while candidate.exists() or candidate.is_symlink():
62 + candidate = path.with_name(f"{path.name}.retired-{counter}")
63 + counter += 1
64 + return candidate
65 +
66 +
67 +def _remove_empty_dir(path: Path, *, owner: str, warnings: list[str]) -> None:
68 + try:
69 + path.rmdir()
70 + except OSError:
71 + warnings.append(f"Retired {owner} state directory was not empty after migration: {path}")
72 +
73 +
74 +def _same_path(left: Path, right: Path) -> bool:
75 + try:
76 + return left.resolve(strict=False) == right.resolve(strict=False)
77 + except OSError:
78 + return False
helpers/virtual_desktop.py
+1 -1
@@ -15,7 +15,7 @@ from urllib.parse import quote, urlencode
15 from helpers import files
16
17
18 -STATE_DIR = Path(files.get_abs_path("usr", "_desktop", "virtual_desktop"))
18 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", "_desktop", "virtual_desktop"))
19 DEFAULT_WIDTH = 1440
20 DEFAULT_HEIGHT = 900
21 MAX_WIDTH = 1920
plugins/_desktop/api/desktop_session.py
+1
@@ -148,6 +148,7 @@ class DesktopSession(ApiHandler):
148 def _state(self, input: dict) -> dict:
149 return desktop_session.get_manager().state(
150 include_screenshot=bool(input.get("include_screenshot") is True),
151 + context_id=str(input.get("ctxid") or input.get("context_id") or ""),
152 )
153
154 def _shutdown(self, input: dict) -> dict:
plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py
+3 -1
@@ -43,5 +43,7 @@ def _prepare_runtime_safely() -> None:
43 def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
44 if result.get("errors"):
45 PrintStyle.warning("Desktop runtime preparation reported errors:", result["errors"])
46 - elif result.get("installed") or result.get("removed"):
46 + elif result.get("warnings"):
47 + PrintStyle.warning("Desktop runtime preparation reported warnings:", result["warnings"])
48 + elif result.get("installed") or result.get("removed") or result.get("migrated"):
49 PrintStyle.info("Desktop runtime prepared:", result)
plugins/_desktop/helpers/desktop_session.py
+22 -4
@@ -23,13 +23,16 @@ from plugins._office.helpers import document_store, libreoffice
23
24
25 OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
26 +PLUGIN_NAME = "_desktop"
27 SYSTEM_SESSION_ID = "agent-zero-desktop"
28 SYSTEM_FILE_ID = "system-desktop"
29 SYSTEM_TITLE = "Desktop"
29 -STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))
30 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
31 +RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
32 SESSION_DIR = STATE_DIR / "sessions"
33 PROFILE_DIR = STATE_DIR / "profiles"
34 LEGACY_SESSION_DIRS = (
35 + RETIRED_STATE_DIR / "sessions",
36 Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")),
37 )
38 DISPLAY_BASE = 120
@@ -283,10 +286,13 @@ class DesktopSessionManager:
286 "url_intents": url_intents,
287 }
288
286 - def state(self, *, include_screenshot: bool = False) -> dict[str, Any]:
289 + def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]:
290 with self._lock:
291 self._reap_dead_locked()
289 - return desktop_state.collect_state(include_screenshot=include_screenshot)
292 + return desktop_state.collect_state(
293 + include_screenshot=include_screenshot,
294 + context_id=context_id,
295 + )
296
297 def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
298 session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
@@ -558,7 +564,9 @@ class DesktopSessionManager:
564 xpra_port=xpra_port,
565 token=SYSTEM_SESSION_ID,
566 url=_xpra_url(SYSTEM_SESSION_ID),
561 - profile_dir=Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID),
567 + profile_dir=_state_path_from_retired_root(
568 + Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID)
569 + ),
570 width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
571 height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
572 process_ids=process_ids,
@@ -1614,6 +1622,16 @@ def collect_desktop_status() -> dict[str, Any]:
1622 }
1623
1624
1625 +def _state_path_from_retired_root(path: Path) -> Path:
1626 + try:
1627 + relative = path.resolve(strict=False).relative_to(
1628 + RETIRED_STATE_DIR.resolve(strict=False)
1629 + )
1630 + except ValueError:
1631 + return path
1632 + return STATE_DIR / relative
1633 +
1634 +
1635 def _runtime_preparation_status() -> dict[str, Any]:
1636 try:
1637 from plugins._desktop import hooks
plugins/_desktop/helpers/desktop_state.py
+62 -17
@@ -14,23 +14,40 @@ PROJECT_ROOT = Path(__file__).resolve().parents[3]
14
15
16 SESSION_ID = "agent-zero-desktop"
17 +PLUGIN_NAME = "_desktop"
18 BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
18 -STATE_DIR = BASE_DIR / "usr" / "_desktop"
19 +STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME
20 +RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME
21 SESSION_DIR = STATE_DIR / "sessions"
22 PROFILE_DIR = STATE_DIR / "profiles"
23 SCREENSHOT_DIR = STATE_DIR / "screenshots"
24 RECENT_SCREENSHOT_SECONDS = 600
25 +_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
26
27
28 def session_manifest_path(session_id: str = SESSION_ID) -> Path:
29 return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json")
30
31
32 +def context_screenshot_dir(context_id: str = "") -> Path:
33 + return SCREENSHOT_DIR / _safe_context_id(context_id)
34 +
35 +
36 +def _safe_context_id(context_id: str = "") -> str:
37 + raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default")
38 + return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default"
39 +
40 +
41 def session_manifest_exists(session_id: str = SESSION_ID) -> bool:
42 return session_manifest_path(session_id).exists()
43
44
33 -def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Path | None = None) -> dict[str, Any]:
45 +def collect_state(
46 + *,
47 + include_screenshot: bool = False,
48 + screenshot_path: str | Path | None = None,
49 + context_id: str = "",
50 +) -> dict[str, Any]:
51 errors: list[str] = []
52 env_info = resolve_environment(errors=errors)
53 display = env_info["display"]
@@ -46,10 +63,16 @@ def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Pa
63 pointer = collect_pointer(env, capabilities, errors)
64 active_window = collect_active_window(env, capabilities, errors)
65 windows = collect_windows(env, capabilities, errors)
49 - screenshot = latest_screenshot()
66 + screenshot = latest_screenshot(context_id=context_id)
67
68 if include_screenshot:
52 - screenshot = capture_screenshot(env, capabilities, path=screenshot_path, errors=errors)
69 + screenshot = capture_screenshot(
70 + env,
71 + capabilities,
72 + path=screenshot_path,
73 + errors=errors,
74 + context_id=context_id,
75 + )
76
77 return stable_state(
78 display=display,
@@ -70,6 +93,7 @@ def capture_screenshot(
93 *,
94 path: str | Path | None = None,
95 errors: list[str] | None = None,
96 + context_id: str = "",
97 ) -> dict[str, Any]:
98 local_errors = errors if errors is not None else []
99 capabilities = capabilities or collect_capabilities()
@@ -85,9 +109,10 @@ def capture_screenshot(
109 local_errors.append(message)
110 return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
111
88 - SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
112 + screenshot_dir = context_screenshot_dir(context_id)
113 + screenshot_dir.mkdir(parents=True, exist_ok=True)
114 timestamp = time.strftime("%Y%m%d-%H%M%S")
90 - target = Path(path) if path else SCREENSHOT_DIR / f"desktop-{timestamp}.png"
115 + target = Path(path) if path else screenshot_dir / f"desktop-{timestamp}.png"
116 target.parent.mkdir(parents=True, exist_ok=True)
117 raw_path = target.with_suffix(".xwd")
118
@@ -291,21 +316,33 @@ def resolve_environment(*, errors: list[str] | None = None, session_id: str = SE
316 display = ""
317 local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.")
318
294 - profile_dir = str(
295 - os.environ.get("A0_DESKTOP_PROFILE")
296 - or os.environ.get("A0_DESKTOP_HOME")
297 - or payload.get("profile_dir")
298 - or os.environ.get("HOME")
299 - or PROFILE_DIR / session_id
319 + profile_dir = _state_path_from_retired_root(
320 + Path(
321 + os.environ.get("A0_DESKTOP_PROFILE")
322 + or os.environ.get("A0_DESKTOP_HOME")
323 + or payload.get("profile_dir")
324 + or os.environ.get("HOME")
325 + or PROFILE_DIR / session_id
326 + )
327 )
328
329 return {
330 "display": display,
304 - "profile_dir": profile_dir,
331 + "profile_dir": str(profile_dir),
332 "manifest": str(manifest),
333 }
334
335
336 +def _state_path_from_retired_root(path: Path) -> Path:
337 + try:
338 + relative = path.resolve(strict=False).relative_to(
339 + RETIRED_STATE_DIR.resolve(strict=False)
340 + )
341 + except ValueError:
342 + return path
343 + return STATE_DIR / relative
344 +
345 +
346 def display_env(*, display: str, profile_dir: str) -> dict[str, str]:
347 env = {
348 **os.environ,
@@ -480,12 +517,13 @@ def parse_xprop(output: str) -> dict[str, str]:
517 return values
518
519
483 -def latest_screenshot() -> dict[str, Any]:
484 - if not SCREENSHOT_DIR.exists():
520 +def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
521 + screenshot_dir = context_screenshot_dir(context_id)
522 + if not screenshot_dir.exists():
523 return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
524 candidates = [
525 path
488 - for path in SCREENSHOT_DIR.iterdir()
526 + for path in screenshot_dir.iterdir()
527 if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
528 ]
529 if not candidates:
@@ -640,19 +678,25 @@ def main(argv: list[str] | None = None) -> int:
678 state_parser = subparsers.add_parser("state")
679 state_parser.add_argument("--json", action="store_true")
680 state_parser.add_argument("--screenshot", action="store_true")
681 + state_parser.add_argument("--context-id", default="")
682
683 observe_parser = subparsers.add_parser("observe")
684 observe_parser.add_argument("--json", action="store_true")
685 observe_parser.add_argument("--screenshot", action="store_true")
686 + observe_parser.add_argument("--context-id", default="")
687
688 screenshot_parser = subparsers.add_parser("screenshot")
689 screenshot_parser.add_argument("path", nargs="?")
690 screenshot_parser.add_argument("--json", action="store_true")
691 + screenshot_parser.add_argument("--context-id", default="")
692
693 args = parser.parse_args(argv)
694 command = args.command or "state"
695 if command in {"state", "observe"}:
655 - payload = collect_state(include_screenshot=bool(args.screenshot))
696 + payload = collect_state(
697 + include_screenshot=bool(args.screenshot),
698 + context_id=str(args.context_id or ""),
699 + )
700 print(json.dumps(payload, sort_keys=True))
701 return 0 if payload.get("ok") else 1
702
@@ -664,6 +708,7 @@ def main(argv: list[str] | None = None) -> int:
708 collect_capabilities(),
709 path=args.path,
710 errors=errors,
711 + context_id=str(args.context_id or ""),
712 )
713 if args.json:
714 print(json.dumps(payload, sort_keys=True))
plugins/_desktop/hooks.py
+55 -1
@@ -9,7 +9,7 @@ import urllib.request
9 from pathlib import Path
10 from typing import Any
11
12 -from helpers import system_packages
12 +from helpers import files, state_migration, system_packages
13
14 LIBREOFFICE_RUNTIME_PACKAGES = (
15 "libreoffice-core",
@@ -61,6 +61,9 @@ OPTIONAL_RUNTIME_PACKAGES = (
61 RETIRED_RUNTIME_PACKAGES = (
62 "firefox-esr",
63 )
64 +PLUGIN_NAME = "_desktop"
65 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
66 +RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
67 _preparation_lock = threading.RLock()
68 _preparation_state: dict[str, Any] = {
69 "preparing": False,
@@ -81,8 +84,13 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
84 try:
85 installed: list[str] = []
86 removed: list[str] = []
87 + migrated: list[str] = []
88 + warnings: list[str] = []
89 errors: list[str] = []
90
91 + _migrate_retired_plugin_state(migrated, warnings, errors)
92 + _migrate_unscoped_screenshots(migrated, warnings, errors)
93 +
94 retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
95 if retired_packages:
96 _purge_packages(removed, errors, installed_packages=retired_packages)
@@ -95,6 +103,8 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
103 "skipped": False,
104 "removed": removed,
105 "installed": installed,
106 + "migrated": migrated,
107 + "warnings": warnings,
108 "errors": errors,
109 }
110 return result
@@ -117,6 +127,50 @@ def runtime_preparation_status() -> dict[str, Any]:
127 }
128
129
130 +def _migrate_retired_plugin_state(
131 + migrated: list[str],
132 + warnings: list[str],
133 + errors: list[str],
134 +) -> None:
135 + state_migration.migrate_retired_state_tree(
136 + source=RETIRED_STATE_DIR,
137 + destination=STATE_DIR,
138 + owner="Desktop",
139 + migrated=migrated,
140 + warnings=warnings,
141 + errors=errors,
142 + )
143 +
144 +
145 +def _migrate_unscoped_screenshots(
146 + migrated: list[str],
147 + warnings: list[str],
148 + errors: list[str],
149 +) -> None:
150 + screenshots_dir = STATE_DIR / "screenshots"
151 + if not screenshots_dir.exists():
152 + return
153 + legacy_screenshots = [
154 + path
155 + for path in screenshots_dir.iterdir()
156 + if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
157 + ]
158 + if not legacy_screenshots:
159 + return
160 +
161 + context_dir = screenshots_dir / "default"
162 + context_dir.mkdir(parents=True, exist_ok=True)
163 + for screenshot in legacy_screenshots:
164 + state_migration.migrate_retired_state_tree(
165 + source=screenshot,
166 + destination=context_dir / screenshot.name,
167 + owner="Desktop screenshot",
168 + migrated=migrated,
169 + warnings=warnings,
170 + errors=errors,
171 + )
172 +
173 +
174 def _begin_runtime_preparation() -> None:
175 with _preparation_lock:
176 if not _preparation_state["active_count"]:
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
+10 -7
@@ -3,8 +3,8 @@ set -euo pipefail
3
4 SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}"
5 BASE_DIR="${A0_BASE_DIR:-/a0}"
6 -PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/_desktop/profiles/$SESSION}"
7 -MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/_desktop/sessions/$SESSION.json}"
6 +PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION}"
7 +MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json}"
8 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9 DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py"
10 DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}"
@@ -55,10 +55,12 @@ Usage: desktopctl.sh <command> [args]
55 Commands:
56 env Print the X11 environment used for the Desktop.
57 check Verify that xdotool can reach the Desktop display.
58 - state --json Return structured Desktop state as JSON.
59 - observe --json [--screenshot]
58 + state --json [--screenshot] [--context-id ID]
59 + Return structured Desktop state as JSON.
60 + observe --json [--screenshot] [--context-id ID]
61 Return structured state, optionally with a fresh screenshot.
61 - screenshot [PATH] Capture the Desktop to PATH, or to the default screenshot directory.
62 + screenshot [PATH] [--context-id ID]
63 + Capture the Desktop to PATH, or to the default screenshot directory.
64 active-window Print the active window name.
65 geometry PATTERN Print the first matching visible window geometry.
66 wait-window PATTERN Wait for a visible matching window and print its id.
@@ -295,7 +297,8 @@ case "$command_name" in
297 echo "state currently requires --json." >&2
298 exit 2
299 fi
298 - desktop_state state --json
300 + shift
301 + desktop_state state --json "$@"
302 ;;
303 observe)
304 if [ "${1:-}" != "--json" ]; then
@@ -310,7 +313,7 @@ case "$command_name" in
313 shift
314 desktop_state screenshot --json "$@"
315 elif [ "$#" -gt 0 ]; then
313 - desktop_state screenshot "$1"
316 + desktop_state screenshot "$@"
317 else
318 desktop_state screenshot
319 fi
plugins/_office/api/office_session.py
+4 -1
@@ -221,7 +221,10 @@ class OfficeSession(ApiHandler):
221
222 def _desktop_state(self, input: dict) -> dict:
223 include_screenshot = bool(input.get("include_screenshot") is True)
224 - return desktop_session.get_manager().state(include_screenshot=include_screenshot)
224 + return desktop_session.get_manager().state(
225 + include_screenshot=include_screenshot,
226 + context_id=str(input.get("ctxid") or input.get("context_id") or ""),
227 + )
228
229 def _desktop_shutdown(self, input: dict) -> dict:
230 save_first = input.get("save_first") is not False
plugins/_office/extensions/python/startup_migration/_20_office_routes.py
+3 -1
@@ -41,5 +41,7 @@ def _prepare_runtime_safely() -> None:
41 def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
42 if result.get("errors"):
43 PrintStyle.warning("Office document runtime preparation reported errors:", result["errors"])
44 - elif result.get("installed") or result.get("removed"):
44 + elif result.get("warnings"):
45 + PrintStyle.warning("Office document runtime preparation reported warnings:", result["warnings"])
46 + elif result.get("installed") or result.get("removed") or result.get("migrated"):
47 PrintStyle.info("Office document runtime prepared:", result)
plugins/_office/helpers/document_store.py
+1 -1
@@ -40,7 +40,7 @@ ODF_MIMETYPES = {
40 "odp": "application/vnd.oasis.opendocument.presentation",
41 }
42
43 -STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))
43 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
44 DB_PATH = STATE_DIR / "documents.sqlite3"
45 BACKUP_DIR = STATE_DIR / "backups"
46 WORKDIR = Path(files.get_abs_path("usr", "workdir"))
plugins/_office/hooks.py
+24 -4
@@ -7,14 +7,16 @@ import subprocess
7 from pathlib import Path
8 from typing import Any
9
10 -from helpers import files, system_packages
10 +from helpers import files, state_migration, system_packages
11
12
13 PROJECT_ROOT = Path(__file__).resolve().parents[2]
14 -STATE_DIR = Path(files.get_abs_path("usr", "_office"))
14 +PLUGIN_NAME = "_office"
15 +STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
16 +RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
17 DOCUMENT_STATE_DIR = STATE_DIR / "documents"
18 LEGACY_DOCUMENT_STATE_DIRS = [
17 - Path(files.get_abs_path("usr", "plugins", "_office", "documents")),
19 + RETIRED_STATE_DIR / "documents",
20 Path(files.get_abs_path("usr", "state", "_office", "documents")),
21 Path(files.get_abs_path("usr", "state", "office", "documents")),
22 ]
@@ -84,6 +86,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
86 warnings: list[str] = []
87 errors: list[str] = []
88
89 + _migrate_retired_plugin_state(migrated, warnings, errors)
90 _migrate_legacy_document_state(migrated, warnings, errors)
91
92 retired_web_paths = [
@@ -123,7 +126,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
126
127 _retire_supervisor_program(errors)
128 _ensure_runtime_dependencies(installed, errors)
126 - _ensure_desktop_runtime_compat(installed, removed, warnings, errors)
129 + _ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
130 return {
131 "ok": not errors,
132 "skipped": not cleanup_needed,
@@ -209,9 +212,25 @@ def _migrate_legacy_document_state(
212 )
213
214
215 +def _migrate_retired_plugin_state(
216 + migrated: list[str],
217 + warnings: list[str],
218 + errors: list[str],
219 +) -> None:
220 + state_migration.migrate_retired_state_tree(
221 + source=RETIRED_STATE_DIR,
222 + destination=STATE_DIR,
223 + owner="Office",
224 + migrated=migrated,
225 + warnings=warnings,
226 + errors=errors,
227 + )
228 +
229 +
230 def _ensure_desktop_runtime_compat(
231 installed: list[str],
232 removed: list[str],
233 + migrated: list[str],
234 warnings: list[str],
235 errors: list[str],
236 ) -> None:
@@ -239,6 +258,7 @@ def _ensure_desktop_runtime_compat(
258 return
259 installed.extend(str(item) for item in result.get("installed") or [])
260 removed.extend(str(item) for item in result.get("removed") or [])
261 + migrated.extend(str(item) for item in result.get("migrated") or [])
262 warnings.extend(str(item) for item in result.get("warnings") or [])
263 errors.extend(str(item) for item in result.get("errors") or [])
264
tests/test_office_canvas_setup.py
+5 -5
@@ -184,8 +184,8 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
184 assert 'data-surface-id="desktop"' in desktop_main
185 assert "virtual_desktop.session_url" in desktop_session
186 assert 'owner="desktop"' in desktop_session
187 - assert 'STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))' in desktop_session
188 - assert 'STATE_DIR = BASE_DIR / "usr" / "_desktop"' in desktop_state
187 + assert 'STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))' in desktop_session
188 + assert 'STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME' in desktop_state
189 assert "> x-component > div[x-data] > .office-panel" in desktop_web_panel
190 assert ".office-state-line > span:not(.material-symbols-outlined)" in desktop_web_panel
191
@@ -201,7 +201,7 @@ def test_plugin_owned_runtime_state_paths_are_declared():
201 docker_playwright = read("docker", "run", "fs", "ins", "install_playwright.sh")
202
203 assert 'PLUGIN_NAME = "_office"' in office_documents
204 - assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents
204 + assert 'STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))' in office_documents
205 assert 'PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")' in browser_playwright
206 assert '"usr", "plugins", "_browser", "playwright"' in browser_playwright
207 assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright
@@ -335,8 +335,8 @@ def test_office_and_desktop_skills_are_rehomed_and_renamed():
335 desktopctl = (desktop_skills / "linux-desktop" / "scripts" / "desktopctl.sh").read_text(encoding="utf-8")
336 assert "/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh" in desktop_skill
337 assert "Open in Desktop action" in desktop_skill
338 - assert "$BASE_DIR/usr/_desktop/profiles/$SESSION" in desktopctl
339 - assert "$BASE_DIR/usr/_desktop/sessions/$SESSION.json" in desktopctl
338 + assert "$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION" in desktopctl
339 + assert "$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json" in desktopctl
340
341
342 def test_skill_catalog_and_connector_boundaries_are_static_guarded():
tests/test_office_desktop_state.py
+47
@@ -189,6 +189,53 @@ def test_desktop_state_screenshot_capture_uses_xwd_and_pillow_when_available(tmp
189 assert not (tmp_path / "shot.xwd").exists()
190
191
192 +def test_desktop_state_default_screenshot_path_is_context_scoped(tmp_path, monkeypatch):
193 + monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
194 + capabilities = {"xwd": "/usr/bin/xwd"}
195 + env = {"DISPLAY": ":120"}
196 +
197 + def fake_run(command, *, env, timeout):
198 + raw_path = Path(command[command.index("-out") + 1])
199 + raw_path.write_bytes(b"xwd")
200 + return _completed(command)
201 +
202 + image_module = types.ModuleType("PIL.Image")
203 +
204 + class FakeImage:
205 + width = 320
206 + height = 240
207 +
208 + def __enter__(self):
209 + return self
210 +
211 + def __exit__(self, *_args):
212 + return False
213 +
214 + def save(self, target):
215 + Path(target).write_bytes(b"png")
216 +
217 + image_module.open = lambda _path: FakeImage()
218 + pil_module = types.ModuleType("PIL")
219 + pil_module.Image = image_module
220 +
221 + monkeypatch.setattr(desktop_state, "run", fake_run)
222 + monkeypatch.setitem(sys.modules, "PIL", pil_module)
223 + monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
224 +
225 + screenshot = desktop_state.capture_screenshot(
226 + env,
227 + capabilities,
228 + errors=[],
229 + context_id="ctx/id",
230 + )
231 +
232 + path = Path(screenshot["path"])
233 + assert screenshot["ok"] is True
234 + assert path.parent == tmp_path / "ctx_id"
235 + assert path.name.startswith("desktop-")
236 + assert desktop_state.latest_screenshot(context_id="ctx/id")["path"] == str(path)
237 +
238 +
239 def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
240 raw_path = tmp_path / "shot.xwd"
241 target = tmp_path / "shot.png"
tests/test_office_document_store.py
+77 -19
@@ -607,12 +607,12 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
607 calls = []
608
609 class FakeManager:
610 - def state(self, *, include_screenshot=False):
611 - calls.append(include_screenshot)
610 + def state(self, *, include_screenshot=False, context_id=""):
611 + calls.append((include_screenshot, context_id))
612 return {
613 "ok": True,
614 "display": ":120",
615 - "profile_dir": "/a0/usr/_desktop/profiles/agent-zero-desktop",
615 + "profile_dir": "/a0/usr/plugins/_desktop/profiles/agent-zero-desktop",
616 "size": {"width": 1440, "height": 900},
617 "pointer": {"x": 0, "y": 0, "screen": 0, "window": 0},
618 "active_window": None,
@@ -633,7 +633,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
633
634 assert default_result["ok"] is True
635 assert screenshot_result["ok"] is True
636 - assert calls == [False, True]
636 + assert calls == [(False, ""), (True, "")]
637 monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
638 api_package = sys.modules.get("plugins._office.api")
639 if api_package is not None:
@@ -1135,11 +1135,17 @@ def test_desktop_session_removes_stale_lock_file(tmp_path):
1135
1136
1137 def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1138 + state_dir = tmp_path / "usr" / "plugins" / "_office"
1139 + retired_state_dir = tmp_path / "usr" / "_office"
1140 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1141 monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1142 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1143 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1142 - monkeypatch.setattr(hooks, "CLEANUP_MARKER", tmp_path / "state" / "cleanup.done")
1144 + monkeypatch.setattr(hooks, "STATE_DIR", state_dir)
1145 + monkeypatch.setattr(hooks, "RETIRED_STATE_DIR", retired_state_dir)
1146 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", state_dir / "documents")
1147 + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1148 + monkeypatch.setattr(hooks, "CLEANUP_MARKER", state_dir / "cleanup.done")
1149 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1150 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1151 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
@@ -1148,10 +1154,31 @@ def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1154 monkeypatch.setattr(hooks.shutil, "which", lambda name: "")
1155
1156
1157 +def test_cleanup_hook_moves_retired_office_state_to_plugin_state(tmp_path, monkeypatch):
1158 + _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1159 + retired_state = tmp_path / "usr" / "_office"
1160 + plugin_state = tmp_path / "usr" / "plugins" / "_office"
1161 + (retired_state / "documents" / "backups").mkdir(parents=True)
1162 + (retired_state / "documents" / "documents.sqlite3").write_text("db\n", encoding="utf-8")
1163 + (retired_state / "documents" / "backups" / "draft.md").write_text("backup\n", encoding="utf-8")
1164 + (retired_state / "stale-cleanup-v3.done").write_text("ok\n", encoding="utf-8")
1165 + plugin_state.mkdir(parents=True)
1166 +
1167 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1168 +
1169 + result = hooks.cleanup_stale_runtime_state(force=True)
1170 +
1171 + assert result["ok"] is True
1172 + assert (plugin_state / "documents" / "documents.sqlite3").read_text(encoding="utf-8") == "db\n"
1173 + assert (plugin_state / "documents" / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n"
1174 + assert (plugin_state / "stale-cleanup-v3.done").read_text(encoding="utf-8") == "ok\n"
1175 + assert not retired_state.exists()
1176 +
1177 +
1178 def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch):
1179 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1153 - legacy_documents = tmp_path / "usr" / "plugins" / "_office" / "documents"
1154 - document_state = tmp_path / "usr" / "_office" / "documents"
1180 + legacy_documents = tmp_path / "usr" / "state" / "_office" / "documents"
1181 + document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1182 legacy_documents.mkdir(parents=True)
1183 (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1184 (legacy_documents / "backups").mkdir()
@@ -1159,7 +1186,7 @@ def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp
1186
1187 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1188 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1162 - monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1189 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1190
1191 result = hooks.cleanup_stale_runtime_state(force=True)
1192
@@ -1173,7 +1200,7 @@ def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp
1200 def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch):
1201 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1202 legacy_documents = tmp_path / "legacy-documents"
1176 - document_state = tmp_path / "usr" / "_office" / "documents"
1203 + document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1204 legacy_documents.mkdir(parents=True)
1205 document_state.mkdir(parents=True)
1206 (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
@@ -1181,7 +1208,7 @@ def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path
1208
1209 monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1210 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1184 - monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1211 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1212
1213 result = hooks.cleanup_stale_runtime_state(force=True)
1214
@@ -1201,19 +1228,22 @@ def test_office_hook_desktop_compat_forwards_runtime_result(monkeypatch):
1228 lambda: {
1229 "installed": ["xpra-server"],
1230 "removed": ["firefox-esr"],
1231 + "migrated": ["desktop state"],
1232 "warnings": ["desktop warning"],
1233 "errors": ["desktop error"],
1234 },
1235 )
1236 installed = []
1237 removed = []
1238 + migrated = []
1239 warnings = []
1240 errors = []
1241
1213 - hooks._ensure_desktop_runtime_compat(installed, removed, warnings, errors)
1242 + hooks._ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
1243
1244 assert installed == ["xpra-server"]
1245 assert removed == ["firefox-esr"]
1246 + assert migrated == ["desktop state"]
1247 assert warnings == ["desktop warning"]
1248 assert errors == ["desktop error"]
1249
@@ -1266,14 +1296,15 @@ def test_installed_retired_web_packages_discovers_collabora_split_packages(monke
1296
1297 def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path, monkeypatch):
1298 _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1269 - monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1299 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1300 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1301 calls = []
1302
1273 - def fake_desktop_compat(installed, removed, warnings, errors):
1303 + def fake_desktop_compat(installed, removed, migrated, warnings, errors):
1304 calls.append("desktop")
1305 installed.append("xpra-server")
1306 removed.append("firefox-esr")
1307 + migrated.append("desktop state migrated")
1308 warnings.append("desktop runtime prepared through office compatibility hook")
1309
1310 monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", fake_desktop_compat)
@@ -1283,6 +1314,7 @@ def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path,
1314 assert calls == ["desktop"]
1315 assert result["installed"] == ["xpra-server"]
1316 assert result["removed"] == ["firefox-esr"]
1317 + assert result["migrated"] == ["desktop state migrated"]
1318 assert result["warnings"] == ["desktop runtime prepared through office compatibility hook"]
1319
1320
@@ -1310,12 +1342,12 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1342 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor)
1343 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir, legacy_cool_dir, legacy_collabora_dir])
1344 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1313 - monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1345 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1346 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1347 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1348 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1349 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1318 - monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1350 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1351
1352 def fake_ensure(installed, errors):
1353 assert not source.exists()
@@ -1397,7 +1429,7 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1429 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1430 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1431 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1400 - monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1432 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1433 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1434 retired_web_packages = [
1435 "coolwsd",
@@ -1409,7 +1441,7 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1441 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1442 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1443 monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1412 - monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1444 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1445
1446 def fake_purge(removed, errors, **kwargs):
1447 removed.extend(kwargs["installed_packages"])
@@ -1433,12 +1465,12 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1465 monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1466 monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1467 monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1436 - monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1468 + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1469 monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1470 monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1471 monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1472 monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1441 - monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1473 + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1474 monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
1475
1476 def fake_supervisorctl(*args):
@@ -1515,6 +1547,32 @@ def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
1547 assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
1548
1549
1550 +def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
1551 + retired_state = tmp_path / "usr" / "_desktop"
1552 + plugin_state = tmp_path / "usr" / "plugins" / "_desktop"
1553 + (retired_state / "profiles" / "agent-zero-desktop").mkdir(parents=True)
1554 + (retired_state / "profiles" / "agent-zero-desktop" / "profile.txt").write_text("profile\n", encoding="utf-8")
1555 + (retired_state / "sessions").mkdir()
1556 + (retired_state / "sessions" / "agent-zero-desktop.json").write_text("{}\n", encoding="utf-8")
1557 + (retired_state / "screenshots").mkdir()
1558 + (retired_state / "screenshots" / "desktop.png").write_bytes(b"png")
1559 + plugin_state.mkdir(parents=True)
1560 +
1561 + monkeypatch.setattr(desktop_hooks, "STATE_DIR", plugin_state)
1562 + monkeypatch.setattr(desktop_hooks, "RETIRED_STATE_DIR", retired_state)
1563 + monkeypatch.setattr(desktop_hooks, "_installed_packages", lambda packages: [])
1564 + monkeypatch.setattr(desktop_hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1565 + monkeypatch.setattr(desktop_hooks, "_cleanup_desktop_sessions", lambda errors: None)
1566 +
1567 + result = desktop_hooks.cleanup_stale_runtime_state(force=True)
1568 +
1569 + assert result["ok"] is True
1570 + assert (plugin_state / "profiles" / "agent-zero-desktop" / "profile.txt").read_text(encoding="utf-8") == "profile\n"
1571 + assert (plugin_state / "sessions" / "agent-zero-desktop.json").read_text(encoding="utf-8") == "{}\n"
1572 + assert (plugin_state / "screenshots" / "default" / "desktop.png").read_bytes() == b"png"
1573 + assert not retired_state.exists()
1574 +
1575 +
1576 def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch):
1577 calls = []
1578 installed_state = {"xpra": False}