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,522 @@
1
+ """Device manager - ADB connections, root checks, determinism controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import structlog
12
+
13
+ from android_emu_agent.errors import console_connect_error, snapshot_failed_error
14
+ from android_emu_agent.validation import get_console_port
15
+
16
+
17
+ class Orientation(Enum):
18
+ """Screen orientation values."""
19
+
20
+ PORTRAIT = 0
21
+ LANDSCAPE = 1
22
+ REVERSE_PORTRAIT = 2
23
+ REVERSE_LANDSCAPE = 3
24
+ AUTO = -1 # Special: re-enable auto-rotate
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ import uiautomator2 as u2
29
+ from adbutils import AdbDevice
30
+
31
+ logger = structlog.get_logger()
32
+
33
+
34
+ @dataclass
35
+ class DeviceInfo:
36
+ """Device information."""
37
+
38
+ serial: str
39
+ model: str
40
+ sdk_version: int
41
+ is_rooted: bool
42
+ is_emulator: bool
43
+
44
+
45
+ class DeviceManager:
46
+ """Manages ADB device connections and state."""
47
+
48
+ def __init__(self) -> None:
49
+ self._devices: dict[str, DeviceInfo] = {}
50
+ self._adb_devices: dict[str, AdbDevice] = {}
51
+ self._u2_devices: dict[str, u2.Device] = {}
52
+ self._lock = asyncio.Lock()
53
+ self._heartbeat_task: asyncio.Task[None] | None = None
54
+
55
+ async def start(self) -> None:
56
+ """Start device manager and begin device discovery."""
57
+ logger.info("device_manager_starting")
58
+ await self._discover_devices()
59
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
60
+ logger.info("device_manager_started", device_count=len(self._devices))
61
+
62
+ async def stop(self) -> None:
63
+ """Stop device manager and cleanup connections."""
64
+ logger.info("device_manager_stopping")
65
+ if self._heartbeat_task:
66
+ self._heartbeat_task.cancel()
67
+ with contextlib.suppress(asyncio.CancelledError):
68
+ await self._heartbeat_task
69
+ self._adb_devices.clear()
70
+ self._u2_devices.clear()
71
+ self._devices.clear()
72
+ logger.info("device_manager_stopped")
73
+
74
+ async def list_devices(self) -> list[dict[str, str]]:
75
+ """List all connected devices."""
76
+ await self._discover_devices()
77
+ return [
78
+ {
79
+ "serial": info.serial,
80
+ "model": info.model,
81
+ "sdk_version": str(info.sdk_version),
82
+ "is_rooted": str(info.is_rooted),
83
+ "is_emulator": str(info.is_emulator),
84
+ }
85
+ for info in self._devices.values()
86
+ ]
87
+
88
+ async def get_device(self, serial: str) -> DeviceInfo | None:
89
+ """Get device info by serial."""
90
+ return self._devices.get(serial)
91
+
92
+ async def refresh(self) -> None:
93
+ """Force device discovery refresh."""
94
+ await self._discover_devices()
95
+
96
+ async def get_adb_device(self, serial: str) -> AdbDevice | None:
97
+ """Get or create an adbutils device connection."""
98
+ await self._discover_devices()
99
+ if serial not in self._devices:
100
+ return None
101
+ if serial in self._adb_devices:
102
+ return self._adb_devices[serial]
103
+
104
+ from adbutils import adb
105
+
106
+ def _connect() -> AdbDevice:
107
+ return adb.device(serial)
108
+
109
+ device = await asyncio.to_thread(_connect)
110
+ self._adb_devices[serial] = device
111
+ return device
112
+
113
+ async def get_u2_device(self, serial: str) -> u2.Device | None:
114
+ """Get or create a uiautomator2 device connection."""
115
+ await self._discover_devices()
116
+ if serial not in self._devices:
117
+ return None
118
+ if serial in self._u2_devices:
119
+ return self._u2_devices[serial]
120
+
121
+ import uiautomator2 as u2
122
+
123
+ def _connect() -> u2.Device:
124
+ return u2.connect(serial)
125
+
126
+ device = await asyncio.to_thread(_connect)
127
+ self._u2_devices[serial] = device
128
+ return device
129
+
130
+ async def describe_device(self, serial: str) -> dict[str, Any]:
131
+ """Return device info suitable for snapshot metadata."""
132
+ info = self._devices.get(serial)
133
+ if info is None:
134
+ return {"serial": serial}
135
+ return {
136
+ "serial": info.serial,
137
+ "sdk": info.sdk_version,
138
+ "model": info.model,
139
+ "is_rooted": info.is_rooted,
140
+ "is_emulator": info.is_emulator,
141
+ }
142
+
143
+ async def set_animations(self, serial: str, enabled: bool) -> None:
144
+ """Enable or disable system animations."""
145
+ device = await self.get_adb_device(serial)
146
+ if not device:
147
+ raise RuntimeError(f"Device not found: {serial}")
148
+
149
+ scale = "1" if enabled else "0"
150
+
151
+ def _apply() -> None:
152
+ device.shell(f"settings put global window_animation_scale {scale}")
153
+ device.shell(f"settings put global transition_animation_scale {scale}")
154
+ device.shell(f"settings put global animator_duration_scale {scale}")
155
+
156
+ await asyncio.to_thread(_apply)
157
+
158
+ async def set_stay_awake(self, serial: str, enabled: bool) -> None:
159
+ """Keep device awake while plugged in."""
160
+ device = await self.get_adb_device(serial)
161
+ if not device:
162
+ raise RuntimeError(f"Device not found: {serial}")
163
+
164
+ value = "3" if enabled else "0"
165
+
166
+ def _apply() -> None:
167
+ device.shell(f"settings put global stay_on_while_plugged_in {value}")
168
+
169
+ await asyncio.to_thread(_apply)
170
+
171
+ async def app_reset(self, serial: str, package: str) -> None:
172
+ """Clear app data for a package."""
173
+ device = await self.get_adb_device(serial)
174
+ if not device:
175
+ raise RuntimeError(f"Device not found: {serial}")
176
+
177
+ def _apply() -> None:
178
+ device.shell(f"pm clear {package}")
179
+
180
+ await asyncio.to_thread(_apply)
181
+
182
+ async def set_rotation(self, serial: str, orientation: Orientation) -> None:
183
+ """Set screen rotation.
184
+
185
+ Args:
186
+ serial: Device serial
187
+ orientation: Target orientation (or AUTO to enable auto-rotate)
188
+ """
189
+ device = await self.get_adb_device(serial)
190
+ if not device:
191
+ raise RuntimeError(f"Device not found: {serial}")
192
+
193
+ def _apply() -> None:
194
+ if orientation == Orientation.AUTO:
195
+ # Enable auto-rotate
196
+ device.shell("settings put system accelerometer_rotation 1")
197
+ else:
198
+ # Disable auto-rotate and set fixed orientation
199
+ device.shell("settings put system accelerometer_rotation 0")
200
+ device.shell(f"settings put system user_rotation {orientation.value}")
201
+
202
+ await asyncio.to_thread(_apply)
203
+
204
+ async def set_wifi(self, serial: str, enabled: bool) -> None:
205
+ """Enable or disable WiFi.
206
+
207
+ Args:
208
+ serial: Device serial
209
+ enabled: True to enable, False to disable
210
+ """
211
+ device = await self.get_adb_device(serial)
212
+ if not device:
213
+ raise RuntimeError(f"Device not found: {serial}")
214
+
215
+ state = "enable" if enabled else "disable"
216
+
217
+ def _apply() -> None:
218
+ device.shell(f"svc wifi {state}")
219
+
220
+ await asyncio.to_thread(_apply)
221
+
222
+ async def set_mobile(self, serial: str, enabled: bool) -> None:
223
+ """Enable or disable mobile data.
224
+
225
+ Args:
226
+ serial: Device serial
227
+ enabled: True to enable, False to disable
228
+ """
229
+ device = await self.get_adb_device(serial)
230
+ if not device:
231
+ raise RuntimeError(f"Device not found: {serial}")
232
+
233
+ state = "enable" if enabled else "disable"
234
+
235
+ def _apply() -> None:
236
+ device.shell(f"svc data {state}")
237
+
238
+ await asyncio.to_thread(_apply)
239
+
240
+ async def set_doze(self, serial: str, enabled: bool) -> None:
241
+ """Force device into or out of doze mode.
242
+
243
+ Args:
244
+ serial: Device serial
245
+ enabled: True to force doze, False to exit doze
246
+ """
247
+ device = await self.get_adb_device(serial)
248
+ if not device:
249
+ raise RuntimeError(f"Device not found: {serial}")
250
+
251
+ def _apply() -> None:
252
+ if enabled:
253
+ device.shell("dumpsys deviceidle force-idle")
254
+ else:
255
+ device.shell("dumpsys deviceidle unforce")
256
+
257
+ await asyncio.to_thread(_apply)
258
+
259
+ async def app_launch(self, serial: str, package: str, activity: str | None = None) -> str:
260
+ """Launch an app.
261
+
262
+ Args:
263
+ serial: Device serial
264
+ package: Package name
265
+ activity: Activity name (optional, will resolve launcher if not provided)
266
+
267
+ Returns:
268
+ Activity that was launched
269
+ """
270
+ device = await self.get_adb_device(serial)
271
+ if not device:
272
+ raise RuntimeError(f"Device not found: {serial}")
273
+
274
+ def _launch() -> str:
275
+ target_activity = activity
276
+ if not target_activity:
277
+ # Resolve launcher activity
278
+ output = device.shell(
279
+ f"cmd package resolve-activity --brief "
280
+ f"-c android.intent.category.LAUNCHER {package}"
281
+ )
282
+ lines = output.strip().split("\n")
283
+ if len(lines) >= 2:
284
+ # Second line contains package/activity
285
+ resolved = lines[-1].strip()
286
+ if "/" in resolved:
287
+ target_activity = resolved.split("/")[1]
288
+
289
+ if not target_activity:
290
+ raise RuntimeError(f"Could not resolve launcher activity for {package}")
291
+
292
+ # Normalize activity name
293
+ if not target_activity.startswith(".") and "/" not in target_activity:
294
+ target_activity = f".{target_activity}"
295
+
296
+ component = f"{package}/{target_activity}"
297
+ device.shell(f"am start -n {component}")
298
+ return target_activity
299
+
300
+ return await asyncio.to_thread(_launch)
301
+
302
+ async def app_force_stop(self, serial: str, package: str) -> None:
303
+ """Force stop an app.
304
+
305
+ Args:
306
+ serial: Device serial
307
+ package: Package name
308
+ """
309
+ device = await self.get_adb_device(serial)
310
+ if not device:
311
+ raise RuntimeError(f"Device not found: {serial}")
312
+
313
+ def _stop() -> None:
314
+ device.shell(f"am force-stop {package}")
315
+
316
+ await asyncio.to_thread(_stop)
317
+
318
+ async def app_deeplink(self, serial: str, uri: str) -> None:
319
+ """Open a deeplink URI.
320
+
321
+ Args:
322
+ serial: Device serial
323
+ uri: URI to open (e.g., 'myapp://path' or 'https://example.com')
324
+ """
325
+ device = await self.get_adb_device(serial)
326
+ if not device:
327
+ raise RuntimeError(f"Device not found: {serial}")
328
+
329
+ def _open() -> None:
330
+ device.shell(f'am start -a android.intent.action.VIEW -d "{uri}"')
331
+
332
+ await asyncio.to_thread(_open)
333
+
334
+ async def list_packages(self, serial: str, scope: str = "all") -> list[str]:
335
+ """List installed packages.
336
+
337
+ Args:
338
+ serial: Device serial
339
+ scope: all|system|third-party
340
+ """
341
+ device = await self.get_adb_device(serial)
342
+ if not device:
343
+ raise RuntimeError(f"Device not found: {serial}")
344
+
345
+ scope_key = scope.lower()
346
+ if scope_key not in {"all", "system", "third-party"}:
347
+ raise ValueError(f"Invalid package scope: {scope}")
348
+
349
+ def _list() -> str:
350
+ args = ["pm", "list", "packages"]
351
+ if scope_key == "system":
352
+ args.append("-s")
353
+ elif scope_key == "third-party":
354
+ args.append("-3")
355
+ return str(device.shell(" ".join(args)))
356
+
357
+ output = await asyncio.to_thread(_list)
358
+ packages: list[str] = []
359
+ for line in output.splitlines():
360
+ line = line.strip()
361
+ if not line:
362
+ continue
363
+ if line.startswith("package:"):
364
+ packages.append(line.removeprefix("package:"))
365
+ return packages
366
+
367
+ async def _discover_devices(self) -> None:
368
+ """Discover connected ADB devices."""
369
+ from adbutils import adb
370
+
371
+ async with self._lock:
372
+
373
+ def _list() -> list[AdbDevice]:
374
+ return list(adb.device_list())
375
+
376
+ devices = await asyncio.to_thread(_list)
377
+ seen: set[str] = set()
378
+
379
+ for dev in devices:
380
+ serial = dev.serial
381
+ if not serial:
382
+ logger.warning("device_missing_serial")
383
+ continue
384
+ seen.add(serial)
385
+ self._adb_devices[serial] = dev
386
+ if serial not in self._devices:
387
+ info = await self._build_device_info(dev)
388
+ self._devices[serial] = info
389
+ logger.info("device_discovered", serial=serial, model=info.model)
390
+
391
+ # Remove disconnected devices
392
+ disconnected = set(self._devices.keys()) - seen
393
+ for serial in disconnected:
394
+ self._devices.pop(serial, None)
395
+ self._adb_devices.pop(serial, None)
396
+ self._u2_devices.pop(serial, None)
397
+ logger.info("device_disconnected", serial=serial)
398
+
399
+ async def _build_device_info(self, device: AdbDevice) -> DeviceInfo:
400
+ """Build DeviceInfo from adb device."""
401
+
402
+ def _props() -> dict[str, str]:
403
+ props = device.prop
404
+ return {
405
+ "model": props.model or props.get("ro.product.model") or "unknown",
406
+ "sdk": props.get("ro.build.version.sdk") or "0",
407
+ "is_emulator": props.get("ro.kernel.qemu") or "0",
408
+ }
409
+
410
+ props = await asyncio.to_thread(_props)
411
+
412
+ serial = device.serial
413
+ if not serial:
414
+ raise RuntimeError("Device serial missing")
415
+ model = props["model"]
416
+ sdk_version = int(props["sdk"]) if props["sdk"].isdigit() else 0
417
+ is_emulator = serial.startswith("emulator-") or props["is_emulator"] == "1"
418
+ is_rooted = await self._check_root(device)
419
+
420
+ return DeviceInfo(
421
+ serial=serial,
422
+ model=model,
423
+ sdk_version=sdk_version,
424
+ is_rooted=is_rooted,
425
+ is_emulator=is_emulator,
426
+ )
427
+
428
+ async def _check_root(self, device: AdbDevice) -> bool:
429
+ """Best-effort root detection."""
430
+
431
+ def _run() -> str:
432
+ try:
433
+ return str(device.shell("su -c id"))
434
+ except Exception:
435
+ return ""
436
+
437
+ output = await asyncio.to_thread(_run)
438
+ return "uid=0" in output
439
+
440
+ async def _heartbeat_loop(self) -> None:
441
+ """Periodic heartbeat to check device connectivity."""
442
+ while True:
443
+ await asyncio.sleep(30)
444
+ try:
445
+ await self._discover_devices()
446
+ except Exception:
447
+ logger.exception("heartbeat_error")
448
+
449
+ async def evict_device(self, serial: str) -> None:
450
+ """Remove cached connections for a device, forcing reconnect on next use.
451
+
452
+ Args:
453
+ serial: Device serial to evict
454
+ """
455
+ async with self._lock:
456
+ evicted_adb = self._adb_devices.pop(serial, None) is not None
457
+ evicted_u2 = self._u2_devices.pop(serial, None) is not None
458
+
459
+ if evicted_adb or evicted_u2:
460
+ logger.info("device_evicted", serial=serial, adb=evicted_adb, u2=evicted_u2)
461
+
462
+ async def emulator_snapshot_save(self, serial: str, name: str) -> None:
463
+ """Save emulator snapshot.
464
+
465
+ Args:
466
+ serial: Emulator serial (e.g., 'emulator-5554')
467
+ name: Snapshot name
468
+ """
469
+ port = get_console_port(serial)
470
+ await self._send_console_command(port, f"avd snapshot save {name}")
471
+ logger.info("snapshot_saved", serial=serial, name=name)
472
+
473
+ async def emulator_snapshot_restore(self, serial: str, name: str) -> None:
474
+ """Restore emulator snapshot.
475
+
476
+ Args:
477
+ serial: Emulator serial (e.g., 'emulator-5554')
478
+ name: Snapshot name
479
+ """
480
+ port = get_console_port(serial)
481
+ await self._send_console_command(port, f"avd snapshot load {name}")
482
+ logger.info("snapshot_restored", serial=serial, name=name)
483
+
484
+ async def _send_console_command(self, port: int, command: str) -> str:
485
+ """Send command to emulator console.
486
+
487
+ Args:
488
+ port: Console port number
489
+ command: Command to send
490
+
491
+ Returns:
492
+ Console response
493
+
494
+ Raises:
495
+ AgentError: If connection fails or command returns error
496
+ """
497
+ try:
498
+ reader, writer = await asyncio.open_connection("localhost", port)
499
+ except OSError as e:
500
+ raise console_connect_error(port) from e
501
+
502
+ try:
503
+ # Read initial banner until OK
504
+ await asyncio.wait_for(reader.read(1024), timeout=5.0)
505
+
506
+ # Send command
507
+ writer.write(f"{command}\n".encode())
508
+ await writer.drain()
509
+
510
+ # Read response
511
+ response = await asyncio.wait_for(reader.read(1024), timeout=30.0)
512
+ response_str = response.decode()
513
+
514
+ if "OK" not in response_str:
515
+ # Extract error message
516
+ error_msg = response_str.strip() or "Unknown error"
517
+ raise snapshot_failed_error(command.split()[-1], error_msg)
518
+
519
+ return response_str
520
+ finally:
521
+ writer.close()
522
+ await writer.wait_closed()
@@ -0,0 +1,129 @@
1
+ """Session manager - session lifecycle and state persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import structlog
11
+
12
+ from android_emu_agent.db.models import Database
13
+
14
+ if TYPE_CHECKING:
15
+ pass
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ @dataclass
21
+ class Session:
22
+ """Represents an active session with a device."""
23
+
24
+ session_id: str
25
+ device_serial: str
26
+ created_at: datetime = field(default_factory=datetime.now)
27
+ generation: int = 0 # Snapshot generation counter
28
+ last_snapshot: dict[str, Any] | None = None
29
+ last_snapshot_json: str | None = None
30
+
31
+
32
+ class SessionManager:
33
+ """Manages session lifecycle and persistence."""
34
+
35
+ def __init__(self, database: Database) -> None:
36
+ self._sessions: dict[str, Session] = {}
37
+ self._db = database
38
+
39
+ async def start(self) -> None:
40
+ """Start session manager and restore persisted sessions."""
41
+ logger.info("session_manager_starting")
42
+ sessions = await self._db.list_sessions()
43
+ for row in sessions:
44
+ created_at = datetime.fromisoformat(row["created_at"])
45
+ session = Session(
46
+ session_id=row["session_id"],
47
+ device_serial=row["device_serial"],
48
+ created_at=created_at,
49
+ generation=row.get("generation", 0),
50
+ )
51
+ self._sessions[session.session_id] = session
52
+ logger.info("session_manager_started")
53
+
54
+ async def stop(self) -> None:
55
+ """Stop session manager and cleanup."""
56
+ logger.info("session_manager_stopping")
57
+ for session in self._sessions.values():
58
+ await self._db.save_session(
59
+ session.session_id,
60
+ session.device_serial,
61
+ generation=session.generation,
62
+ )
63
+ self._sessions.clear()
64
+ logger.info("session_manager_stopped")
65
+
66
+ async def create_session(self, device_serial: str) -> Session:
67
+ """Create a new session for a device."""
68
+ session_id = f"s-{uuid.uuid4().hex[:8]}"
69
+ session = Session(session_id=session_id, device_serial=device_serial)
70
+ self._sessions[session_id] = session
71
+ await self._db.save_session(session_id, device_serial, generation=session.generation)
72
+ logger.info("session_created", session_id=session_id, device=device_serial)
73
+ return session
74
+
75
+ async def get_session(self, session_id: str) -> Session | None:
76
+ """Get session by ID."""
77
+ return self._sessions.get(session_id)
78
+
79
+ async def list_sessions(self) -> list[Session]:
80
+ """List active sessions."""
81
+ return list(self._sessions.values())
82
+
83
+ async def close_session(self, session_id: str) -> bool:
84
+ """Close and remove a session."""
85
+ if session_id in self._sessions:
86
+ del self._sessions[session_id]
87
+ await self._db.delete_session(session_id)
88
+ logger.info("session_closed", session_id=session_id)
89
+ return True
90
+ return False
91
+
92
+ async def increment_generation(self, session_id: str) -> int:
93
+ """Increment and return the new snapshot generation."""
94
+ session = self._sessions.get(session_id)
95
+ if session:
96
+ session.generation += 1
97
+ await self._db.save_session(
98
+ session.session_id,
99
+ session.device_serial,
100
+ generation=session.generation,
101
+ )
102
+ return session.generation
103
+ raise ValueError(f"Session not found: {session_id}")
104
+
105
+ async def update_snapshot(
106
+ self,
107
+ session_id: str,
108
+ snapshot: dict[str, Any],
109
+ snapshot_json: str,
110
+ ) -> None:
111
+ """Store last snapshot in memory for artifacts/debugging."""
112
+ session = self._sessions.get(session_id)
113
+ if session:
114
+ session.last_snapshot = snapshot
115
+ session.last_snapshot_json = snapshot_json
116
+
117
+ async def get_last_snapshot(self, session_id: str) -> dict[str, Any] | None:
118
+ """Get the last snapshot for a session."""
119
+ session = self._sessions.get(session_id)
120
+ if session:
121
+ return session.last_snapshot
122
+ return None
123
+
124
+ async def get_last_snapshot_json(self, session_id: str) -> str | None:
125
+ """Get the last snapshot JSON for a session."""
126
+ session = self._sessions.get(session_id)
127
+ if session:
128
+ return session.last_snapshot_json
129
+ return None