android-emu-agent 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. android_emu_agent/__init__.py +3 -0
  2. android_emu_agent/actions/__init__.py +1 -0
  3. android_emu_agent/actions/executor.py +288 -0
  4. android_emu_agent/actions/selector.py +122 -0
  5. android_emu_agent/actions/wait.py +193 -0
  6. android_emu_agent/artifacts/__init__.py +1 -0
  7. android_emu_agent/artifacts/manager.py +125 -0
  8. android_emu_agent/artifacts/py.typed +0 -0
  9. android_emu_agent/cli/__init__.py +1 -0
  10. android_emu_agent/cli/commands/__init__.py +1 -0
  11. android_emu_agent/cli/commands/action.py +158 -0
  12. android_emu_agent/cli/commands/app_cmd.py +95 -0
  13. android_emu_agent/cli/commands/artifact.py +81 -0
  14. android_emu_agent/cli/commands/daemon.py +62 -0
  15. android_emu_agent/cli/commands/device.py +122 -0
  16. android_emu_agent/cli/commands/emulator.py +46 -0
  17. android_emu_agent/cli/commands/file.py +139 -0
  18. android_emu_agent/cli/commands/reliability.py +310 -0
  19. android_emu_agent/cli/commands/session.py +65 -0
  20. android_emu_agent/cli/commands/ui.py +112 -0
  21. android_emu_agent/cli/commands/wait.py +132 -0
  22. android_emu_agent/cli/daemon_client.py +185 -0
  23. android_emu_agent/cli/main.py +52 -0
  24. android_emu_agent/cli/utils.py +171 -0
  25. android_emu_agent/daemon/__init__.py +1 -0
  26. android_emu_agent/daemon/core.py +62 -0
  27. android_emu_agent/daemon/health.py +177 -0
  28. android_emu_agent/daemon/models.py +244 -0
  29. android_emu_agent/daemon/server.py +1644 -0
  30. android_emu_agent/db/__init__.py +1 -0
  31. android_emu_agent/db/models.py +229 -0
  32. android_emu_agent/device/__init__.py +1 -0
  33. android_emu_agent/device/manager.py +522 -0
  34. android_emu_agent/device/session.py +129 -0
  35. android_emu_agent/errors.py +232 -0
  36. android_emu_agent/files/__init__.py +1 -0
  37. android_emu_agent/files/manager.py +311 -0
  38. android_emu_agent/py.typed +0 -0
  39. android_emu_agent/reliability/__init__.py +1 -0
  40. android_emu_agent/reliability/manager.py +244 -0
  41. android_emu_agent/ui/__init__.py +1 -0
  42. android_emu_agent/ui/context.py +169 -0
  43. android_emu_agent/ui/ref_resolver.py +149 -0
  44. android_emu_agent/ui/snapshotter.py +236 -0
  45. android_emu_agent/validation.py +59 -0
  46. android_emu_agent-0.1.3.dist-info/METADATA +375 -0
  47. android_emu_agent-0.1.3.dist-info/RECORD +50 -0
  48. android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
  49. android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
  50. android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,244 @@
1
+ """Reliability manager - ADB diagnostics and forensics commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ import structlog
16
+
17
+ from android_emu_agent.errors import (
18
+ AgentError,
19
+ adb_command_error,
20
+ adb_not_found_error,
21
+ permission_error,
22
+ process_not_found_error,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from adbutils import AdbDevice
27
+
28
+ logger = structlog.get_logger()
29
+
30
+ DEFAULT_EVENTS_PATTERN = r"am_proc_died|am_anr|am_crash|am_low_memory|wm_on_paused|wm_on_resumed"
31
+
32
+ TRIM_LEVELS = {
33
+ "RUNNING_MODERATE",
34
+ "RUNNING_LOW",
35
+ "RUNNING_CRITICAL",
36
+ "UI_HIDDEN",
37
+ "BACKGROUND",
38
+ "MODERATE",
39
+ "COMPLETE",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CommandOutput:
45
+ output: str
46
+ line_count: int
47
+ total_lines: int
48
+
49
+
50
+ class ReliabilityManager:
51
+ """Runs reliability diagnostics via ADB."""
52
+
53
+ def __init__(self, output_dir: Path | None = None) -> None:
54
+ default_dir = Path.home() / ".android-emu-agent" / "artifacts" / "reliability"
55
+ self.output_dir = output_dir or default_dir
56
+ self.output_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ async def exit_info(self, device: AdbDevice, package: str, list_only: bool) -> str:
59
+ cmd = f"dumpsys activity exit-info {package}"
60
+ if list_only:
61
+ cmd += " list"
62
+ return await self._shell(device, cmd)
63
+
64
+ async def bugreport(self, serial: str, filename: str | None = None) -> Path:
65
+ timestamp = self._timestamp()
66
+ name = filename or f"bugreport_{serial}_{timestamp}.zip"
67
+ if not name.endswith(".zip"):
68
+ name = f"{name}.zip"
69
+ output_path = self.output_dir / name
70
+ await self._run_adb(serial, ["bugreport", str(output_path)])
71
+ logger.info("bugreport_saved", serial=serial, path=str(output_path))
72
+ return output_path
73
+
74
+ async def logcat_events(
75
+ self, device: AdbDevice, pattern: str | None, since: str | None
76
+ ) -> CommandOutput:
77
+ cmd = "logcat -b events -d"
78
+ if since:
79
+ cmd += f" -t {shlex.quote(since)}"
80
+ output = await self._shell(device, cmd)
81
+ lines = output.splitlines()
82
+ if pattern:
83
+ try:
84
+ regex = re.compile(pattern)
85
+ except re.error as exc: # pragma: no cover - depends on user input
86
+ raise AgentError(
87
+ code="ERR_INVALID_PATTERN",
88
+ message=f"Invalid regex pattern: {pattern}",
89
+ context={"pattern": pattern},
90
+ remediation="Provide a valid regex for --pattern",
91
+ ) from exc
92
+ filtered = [line for line in lines if regex.search(line)]
93
+ else:
94
+ filtered = lines
95
+ return CommandOutput(
96
+ output="\n".join(filtered),
97
+ line_count=len(filtered),
98
+ total_lines=len(lines),
99
+ )
100
+
101
+ async def dropbox_list(self, device: AdbDevice, package: str | None) -> str:
102
+ output = await self._shell(device, "dumpsys dropbox")
103
+ if package:
104
+ filtered = [line for line in output.splitlines() if package in line]
105
+ return "\n".join(filtered)
106
+ return output
107
+
108
+ async def dropbox_print(self, device: AdbDevice, tag: str) -> str:
109
+ return await self._shell(device, f"dumpsys dropbox --print {tag}")
110
+
111
+ async def background_restrictions(self, device: AdbDevice, package: str) -> dict[str, str]:
112
+ appops = await self._shell(device, f"cmd appops get {package} RUN_IN_BACKGROUND")
113
+ standby = await self._shell(device, f"am get-standby-bucket {package}")
114
+ return {"appops": appops, "standby_bucket": standby}
115
+
116
+ async def last_anr(self, device: AdbDevice) -> str:
117
+ return await self._shell(device, "dumpsys activity lastanr")
118
+
119
+ async def jobscheduler(self, device: AdbDevice, package: str) -> str:
120
+ return await self._shell(device, f"dumpsys jobscheduler {package}")
121
+
122
+ async def compile_package(self, device: AdbDevice, package: str, mode: str) -> str:
123
+ if mode == "reset":
124
+ return await self._shell(device, f"cmd package compile --reset {package}")
125
+ if mode == "speed":
126
+ return await self._shell(device, f"cmd package compile -m speed -f {package}")
127
+ raise AgentError(
128
+ code="ERR_INVALID_MODE",
129
+ message=f"Invalid compile mode: {mode}",
130
+ context={"mode": mode},
131
+ remediation="Use 'reset' or 'speed'",
132
+ )
133
+
134
+ async def always_finish_activities(self, device: AdbDevice, enabled: bool) -> str:
135
+ value = "1" if enabled else "0"
136
+ return await self._shell(device, f"settings put global always_finish_activities {value}")
137
+
138
+ async def run_as_ls(self, device: AdbDevice, package: str, path: str) -> str:
139
+ safe_path = shlex.quote(path)
140
+ return await self._shell(device, f"run-as {package} ls -R {safe_path}")
141
+
142
+ async def dump_heap(
143
+ self,
144
+ device: AdbDevice,
145
+ serial: str,
146
+ package: str,
147
+ keep_remote: bool,
148
+ ) -> Path:
149
+ timestamp = self._timestamp()
150
+ safe_pkg = package.replace(".", "_")
151
+ remote_path = f"/data/local/tmp/{safe_pkg}_{timestamp}.hprof"
152
+ await self._shell(device, f"am dumpheap {package} {remote_path}")
153
+
154
+ local_path = self.output_dir / f"heap_{safe_pkg}_{timestamp}.hprof"
155
+ await self._run_adb(serial, ["pull", remote_path, str(local_path)])
156
+
157
+ if not keep_remote:
158
+ await self._shell(device, f"rm -f {remote_path}")
159
+
160
+ logger.info("heap_dump_saved", serial=serial, path=str(local_path))
161
+ return local_path
162
+
163
+ async def sigquit(self, device: AdbDevice, package: str) -> int:
164
+ pid = await self._pidof(device, package)
165
+ await self._shell(device, f"kill -3 {pid}")
166
+ return pid
167
+
168
+ async def oom_score_adj(self, device: AdbDevice, package: str, score: int) -> int:
169
+ pid = await self._pidof(device, package)
170
+ await self._shell_su(device, f"echo {score} > /proc/{pid}/oom_score_adj")
171
+ return pid
172
+
173
+ async def trim_memory(self, device: AdbDevice, package: str, level: str) -> str:
174
+ return await self._shell(device, f"am send-trim-memory {package} {level}")
175
+
176
+ async def pull_root_dir(
177
+ self,
178
+ device: AdbDevice,
179
+ serial: str,
180
+ remote_dir: str,
181
+ name: str,
182
+ ) -> Path:
183
+ stage_parent = "/data/local/tmp/android-emu-agent"
184
+ stage_dir = f"{stage_parent}/{name}"
185
+ stage_cmd = (
186
+ f"rm -rf {stage_dir} && mkdir -p {stage_parent} && cp -r {remote_dir} {stage_parent}"
187
+ )
188
+ await self._shell_su(device, stage_cmd)
189
+
190
+ local_path = self.output_dir / f"{serial}_{self._timestamp()}_{name}"
191
+ await self._run_adb(serial, ["pull", stage_dir, str(local_path)])
192
+
193
+ await self._shell(device, f"rm -rf {stage_dir}")
194
+ logger.info("root_dir_pulled", serial=serial, remote=remote_dir, local=str(local_path))
195
+ return local_path
196
+
197
+ async def _shell(self, device: AdbDevice, command: str) -> str:
198
+ def _run() -> str:
199
+ result = device.shell(command)
200
+ output = getattr(result, "output", None)
201
+ return output if isinstance(output, str) else str(result)
202
+
203
+ return await asyncio.to_thread(_run)
204
+
205
+ async def _shell_su(self, device: AdbDevice, command: str) -> str:
206
+ return await self._shell(device, f"su -c {shlex.quote(command)}")
207
+
208
+ async def _pidof(self, device: AdbDevice, package: str) -> int:
209
+ output = await self._shell(device, f"pidof {package}")
210
+ pid = output.strip().split(" ")[0] if output.strip() else ""
211
+ if not pid.isdigit():
212
+ raise process_not_found_error(package)
213
+ return int(pid)
214
+
215
+ async def _run_adb(self, serial: str, args: list[str]) -> subprocess.CompletedProcess[str]:
216
+ def _run() -> subprocess.CompletedProcess[str]:
217
+ adb_path = shutil.which("adb")
218
+ if not adb_path:
219
+ raise adb_not_found_error()
220
+ return subprocess.run(
221
+ [adb_path, "-s", serial, *args],
222
+ check=True,
223
+ capture_output=True,
224
+ text=True,
225
+ )
226
+
227
+ try:
228
+ return await asyncio.to_thread(_run)
229
+ except AgentError:
230
+ raise
231
+ except FileNotFoundError as exc:
232
+ raise adb_not_found_error() from exc
233
+ except subprocess.CalledProcessError as exc:
234
+ reason = (exc.stderr or exc.stdout or str(exc)).strip()
235
+ raise adb_command_error(" ".join(args), reason) from exc
236
+
237
+ @staticmethod
238
+ def _timestamp() -> str:
239
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
240
+
241
+
242
+ def require_root(is_rooted: bool, operation: str) -> None:
243
+ if not is_rooted:
244
+ raise permission_error(operation)
@@ -0,0 +1 @@
1
+ """UI - Snapshot generation, context resolution, ref management."""
@@ -0,0 +1,169 @@
1
+ """Context resolver - Activity, window, IME, and dialog detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, TypeVar, cast
9
+
10
+ import structlog
11
+
12
+ if TYPE_CHECKING:
13
+ from adbutils import AdbDevice
14
+
15
+ logger = structlog.get_logger()
16
+ T = TypeVar("T")
17
+
18
+
19
+ @dataclass
20
+ class UIContext:
21
+ """Current UI context from the device."""
22
+
23
+ package: str | None
24
+ activity: str | None
25
+ top_resumed_activity: str | None
26
+ top_window: str | None
27
+ orientation: str
28
+ window_focused: bool
29
+ ime_visible: bool
30
+ ime_package: str | None
31
+ system_dialogs: list[str]
32
+
33
+
34
+ class ContextResolver:
35
+ """Resolves current UI context from device state."""
36
+
37
+ async def resolve(self, device: AdbDevice) -> UIContext:
38
+ """Resolve current UI context from device."""
39
+ # Run multiple shell commands in parallel
40
+ results = await asyncio.gather(
41
+ self._get_focused_activity(device),
42
+ self._get_window_info(device),
43
+ self._get_ime_info(device),
44
+ return_exceptions=True,
45
+ )
46
+
47
+ activity_info = self._unwrap_result(results[0], default={})
48
+ window_info = self._unwrap_result(results[1], default={})
49
+ ime_info = self._unwrap_result(results[2], default={})
50
+
51
+ return UIContext(
52
+ package=activity_info.get("package"),
53
+ activity=activity_info.get("activity"),
54
+ top_resumed_activity=activity_info.get("top_resumed"),
55
+ top_window=self._string_or_none(window_info.get("top_window")),
56
+ orientation=self._string_value(window_info.get("orientation"), "PORTRAIT"),
57
+ window_focused=bool(window_info.get("focused", True)),
58
+ ime_visible=bool(ime_info.get("visible", False)),
59
+ ime_package=self._string_or_none(ime_info.get("package")),
60
+ system_dialogs=self._detect_system_dialogs(activity_info, window_info),
61
+ )
62
+
63
+ async def _get_focused_activity(self, device: AdbDevice) -> dict[str, str]:
64
+ """Get focused activity info via dumpsys."""
65
+
66
+ def _run() -> str:
67
+ return cast(
68
+ str,
69
+ device.shell(
70
+ "dumpsys activity activities | grep -E 'mResumedActivity|mFocusedActivity'"
71
+ ),
72
+ )
73
+
74
+ output = await asyncio.to_thread(_run)
75
+ result: dict[str, str] = {}
76
+
77
+ # Parse mResumedActivity: ActivityRecord{... com.foo/.MainActivity ...}
78
+ resumed_match = re.search(r"mResumedActivity.*?(\S+/\S+)", output)
79
+ if resumed_match:
80
+ full = resumed_match.group(1)
81
+ result["top_resumed"] = full
82
+ if "/" in full:
83
+ pkg, activity = full.split("/", 1)
84
+ result["package"] = pkg
85
+ result["activity"] = activity
86
+
87
+ return result
88
+
89
+ async def _get_window_info(self, device: AdbDevice) -> dict[str, str | bool]:
90
+ """Get window focus and orientation info."""
91
+
92
+ def _run() -> str:
93
+ focus = device.shell("dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp'")
94
+ orientation = device.shell("dumpsys input | grep -E 'SurfaceOrientation'")
95
+ return f"{focus}\n{orientation}"
96
+
97
+ output = await asyncio.to_thread(_run)
98
+ result: dict[str, str | bool] = {"focused": True}
99
+
100
+ focus_match = re.search(r"mCurrentFocus=Window\{([^}]+)\}", output)
101
+ if focus_match:
102
+ result["top_window"] = focus_match.group(1)
103
+
104
+ orientation = "PORTRAIT"
105
+ orientation_match = re.search(r"SurfaceOrientation:\s*(\d)", output)
106
+ if orientation_match:
107
+ value = orientation_match.group(1)
108
+ if value in {"1", "3"}:
109
+ orientation = "LANDSCAPE"
110
+ result["orientation"] = orientation
111
+
112
+ return result
113
+
114
+ async def _get_ime_info(self, device: AdbDevice) -> dict[str, str | bool]:
115
+ """Get IME (keyboard) visibility info."""
116
+
117
+ def _run() -> str:
118
+ return cast(str, device.shell("dumpsys input_method | grep -E 'mInputShown|mCurId'"))
119
+
120
+ output = await asyncio.to_thread(_run)
121
+ result: dict[str, str | bool] = {"visible": False}
122
+
123
+ if "mInputShown=true" in output:
124
+ result["visible"] = True
125
+
126
+ id_match = re.search(r"mCurId=(\S+)", output)
127
+ if id_match:
128
+ result["package"] = id_match.group(1)
129
+
130
+ return result
131
+
132
+ def _detect_system_dialogs(
133
+ self,
134
+ activity_info: dict[str, str], # noqa: ARG002
135
+ window_info: dict[str, str | bool],
136
+ ) -> list[str]:
137
+ """Detect common system dialogs."""
138
+ dialogs: list[str] = []
139
+ top_window = str(window_info.get("top_window", ""))
140
+
141
+ # Common system dialog patterns
142
+ if "GrantPermissions" in top_window:
143
+ dialogs.append("runtime_permission")
144
+ if "Chooser" in top_window:
145
+ dialogs.append("chooser")
146
+ if "ResolverActivity" in top_window:
147
+ dialogs.append("app_chooser")
148
+
149
+ return dialogs
150
+
151
+ @staticmethod
152
+ def _unwrap_result(result: T | BaseException, *, default: T) -> T:
153
+ if isinstance(result, BaseException):
154
+ return default
155
+ return result
156
+
157
+ @staticmethod
158
+ def _string_or_none(value: object | None) -> str | None:
159
+ if value is None:
160
+ return None
161
+ return str(value)
162
+
163
+ @staticmethod
164
+ def _string_value(value: object | None, fallback: str) -> str:
165
+ if value is None:
166
+ return fallback
167
+ if isinstance(value, bool):
168
+ return fallback
169
+ return str(value)
@@ -0,0 +1,149 @@
1
+ """Ref resolver - Locator bundles, drift detection, stale ref handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import structlog
10
+
11
+ logger = structlog.get_logger()
12
+
13
+
14
+ @dataclass
15
+ class LocatorBundle:
16
+ """Bundle of locator strategies for an element."""
17
+
18
+ ref: str
19
+ generation: int
20
+ resource_id: str | None
21
+ content_desc: str | None
22
+ text: str | None
23
+ class_name: str
24
+ bounds: list[int]
25
+ ancestry_hash: str
26
+ index: int # Position among siblings
27
+ ancestry_path: str = "" # Path from root like "FrameLayout/LinearLayout/Button"
28
+ element_hash: str = "" # Stable hash for identification
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ """Convert to dict for storage."""
32
+ return {
33
+ "ref": self.ref,
34
+ "generation": self.generation,
35
+ "resource_id": self.resource_id,
36
+ "content_desc": self.content_desc,
37
+ "text": self.text,
38
+ "class_name": self.class_name,
39
+ "bounds": self.bounds,
40
+ "ancestry_hash": self.ancestry_hash,
41
+ "index": self.index,
42
+ "ancestry_path": self.ancestry_path,
43
+ "element_hash": self.element_hash,
44
+ }
45
+
46
+
47
+ class RefResolver:
48
+ """Manages element refs and locator resolution."""
49
+
50
+ def __init__(self) -> None:
51
+ # session_id -> (generation -> (ref -> LocatorBundle))
52
+ self._ref_maps: dict[str, dict[int, dict[str, LocatorBundle]]] = {}
53
+
54
+ def store_refs(
55
+ self,
56
+ session_id: str,
57
+ generation: int,
58
+ elements: list[dict[str, Any]],
59
+ ) -> None:
60
+ """Store ref -> locator mappings for a snapshot generation."""
61
+ if session_id not in self._ref_maps:
62
+ self._ref_maps[session_id] = {}
63
+
64
+ ref_map: dict[str, LocatorBundle] = {}
65
+ for i, elem in enumerate(elements):
66
+ bundle = LocatorBundle(
67
+ ref=elem["ref"],
68
+ generation=generation,
69
+ resource_id=elem.get("resource_id"),
70
+ content_desc=elem.get("content_desc"),
71
+ text=elem.get("text"),
72
+ class_name=elem.get("class", ""),
73
+ bounds=elem.get("bounds", [0, 0, 0, 0]),
74
+ ancestry_hash=self._compute_ancestry_hash(elem),
75
+ index=i,
76
+ )
77
+ ref_map[elem["ref"]] = bundle
78
+
79
+ self._ref_maps[session_id][generation] = ref_map
80
+ logger.info(
81
+ "refs_stored",
82
+ session_id=session_id,
83
+ generation=generation,
84
+ count=len(ref_map),
85
+ )
86
+
87
+ # Cleanup old generations (keep last 3)
88
+ self._cleanup_old_generations(session_id, generation)
89
+
90
+ def resolve_ref(
91
+ self,
92
+ session_id: str,
93
+ ref: str,
94
+ current_generation: int,
95
+ ) -> tuple[LocatorBundle | None, bool]:
96
+ """
97
+ Resolve a ref to its locator bundle.
98
+
99
+ Returns:
100
+ (bundle, is_stale): Bundle if found, and whether it's from an old generation.
101
+ """
102
+ if session_id not in self._ref_maps:
103
+ return None, False
104
+
105
+ session_refs = self._ref_maps[session_id]
106
+
107
+ # Check current generation first
108
+ if current_generation in session_refs and ref in session_refs[current_generation]:
109
+ return session_refs[current_generation][ref], False
110
+
111
+ # Check previous generations (stale but might work)
112
+ for gen in sorted(session_refs.keys(), reverse=True):
113
+ if gen < current_generation and ref in session_refs[gen]:
114
+ logger.warning(
115
+ "stale_ref_resolved",
116
+ ref=ref,
117
+ ref_generation=gen,
118
+ current_generation=current_generation,
119
+ )
120
+ return session_refs[gen][ref], True
121
+
122
+ return None, False
123
+
124
+ def clear_session(self, session_id: str) -> None:
125
+ """Clear all refs for a session."""
126
+ if session_id in self._ref_maps:
127
+ del self._ref_maps[session_id]
128
+ logger.info("session_refs_cleared", session_id=session_id)
129
+
130
+ def _compute_ancestry_hash(self, elem: dict[str, Any]) -> str:
131
+ """Compute hash of element's identifying features."""
132
+ key_parts = [
133
+ elem.get("resource_id", ""),
134
+ elem.get("class", ""),
135
+ str(elem.get("bounds", [])),
136
+ ]
137
+ key = "|".join(key_parts)
138
+ return hashlib.md5(key.encode()).hexdigest()[:8]
139
+
140
+ def _cleanup_old_generations(self, session_id: str, current: int) -> None:
141
+ """Remove generations older than current - 2."""
142
+ if session_id not in self._ref_maps:
143
+ return
144
+
145
+ session_refs = self._ref_maps[session_id]
146
+ old_gens = [g for g in session_refs if g < current - 2]
147
+ for gen in old_gens:
148
+ del session_refs[gen]
149
+ logger.debug("generation_cleaned", session_id=session_id, generation=gen)