codex-autorunner 1.2.0__py3-none-any.whl → 1.3.0__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 (67) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/about_car.py +12 -12
  3. codex_autorunner/core/config.py +178 -61
  4. codex_autorunner/core/context_awareness.py +1 -0
  5. codex_autorunner/core/filesystem.py +24 -0
  6. codex_autorunner/core/flows/controller.py +50 -12
  7. codex_autorunner/core/flows/runtime.py +8 -3
  8. codex_autorunner/core/hub.py +293 -16
  9. codex_autorunner/core/lifecycle_events.py +44 -5
  10. codex_autorunner/core/pma_context.py +188 -1
  11. codex_autorunner/core/pma_delivery.py +81 -0
  12. codex_autorunner/core/pma_dispatches.py +224 -0
  13. codex_autorunner/core/pma_lane_worker.py +122 -0
  14. codex_autorunner/core/pma_queue.py +167 -18
  15. codex_autorunner/core/pma_reactive.py +91 -0
  16. codex_autorunner/core/pma_safety.py +58 -0
  17. codex_autorunner/core/pma_sink.py +104 -0
  18. codex_autorunner/core/pma_transcripts.py +183 -0
  19. codex_autorunner/core/safe_paths.py +117 -0
  20. codex_autorunner/housekeeping.py +77 -23
  21. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  22. codex_autorunner/integrations/agents/wiring.py +2 -0
  23. codex_autorunner/integrations/app_server/client.py +31 -0
  24. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  25. codex_autorunner/integrations/telegram/adapter.py +1 -1
  26. codex_autorunner/integrations/telegram/config.py +1 -1
  27. codex_autorunner/integrations/telegram/constants.py +1 -1
  28. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  29. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  30. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  31. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  32. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  33. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  34. codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
  35. codex_autorunner/integrations/telegram/helpers.py +30 -2
  36. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  37. codex_autorunner/static/archive.js +274 -81
  38. codex_autorunner/static/archiveApi.js +21 -0
  39. codex_autorunner/static/constants.js +1 -1
  40. codex_autorunner/static/docChatCore.js +2 -0
  41. codex_autorunner/static/hub.js +59 -0
  42. codex_autorunner/static/index.html +70 -54
  43. codex_autorunner/static/notificationBell.js +173 -0
  44. codex_autorunner/static/notifications.js +187 -36
  45. codex_autorunner/static/pma.js +96 -35
  46. codex_autorunner/static/styles.css +431 -4
  47. codex_autorunner/static/terminalManager.js +22 -3
  48. codex_autorunner/static/utils.js +5 -1
  49. codex_autorunner/surfaces/cli/cli.py +206 -129
  50. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  51. codex_autorunner/surfaces/web/app.py +193 -5
  52. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  53. codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
  54. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  55. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  56. codex_autorunner/surfaces/web/schemas.py +11 -0
  57. codex_autorunner/tickets/agent_pool.py +6 -1
  58. codex_autorunner/tickets/outbox.py +27 -14
  59. codex_autorunner/tickets/replies.py +4 -10
  60. codex_autorunner/tickets/runner.py +1 -0
  61. codex_autorunner/workspace/paths.py +8 -3
  62. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  63. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
  64. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  65. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  66. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  67. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -83,8 +83,10 @@ class PmaQueue:
83
83
  self._lane_queues: dict[str, asyncio.Queue[PmaQueueItem]] = {}
84
84
  self._lane_locks: dict[str, asyncio.Lock] = {}
85
85
  self._lane_events: dict[str, asyncio.Event] = {}
86
+ self._lane_known_ids: dict[str, set[str]] = {}
86
87
  self._replayed_lanes: set[str] = set()
87
88
  self._lock = asyncio.Lock()
89
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
88
90
 
89
91
  def _lane_queue_path(self, lane_id: str) -> Path:
90
92
  safe_lane_id = lane_id.replace(":", "__COLON__").replace("/", "__SLASH__")
@@ -115,6 +117,21 @@ class PmaQueue:
115
117
  self._lane_queues[lane_id] = queue
116
118
  return queue
117
119
 
120
+ def _ensure_lane_known_ids(self, lane_id: str) -> set[str]:
121
+ known = self._lane_known_ids.get(lane_id)
122
+ if known is None:
123
+ known = set()
124
+ self._lane_known_ids[lane_id] = known
125
+ return known
126
+
127
+ def _record_loop(self) -> None:
128
+ if self._loop is not None:
129
+ return
130
+ try:
131
+ self._loop = asyncio.get_running_loop()
132
+ except RuntimeError:
133
+ return
134
+
118
135
  async def enqueue(
119
136
  self,
120
137
  lane_id: str,
@@ -122,6 +139,7 @@ class PmaQueue:
122
139
  payload: dict[str, Any],
123
140
  ) -> tuple[PmaQueueItem, Optional[str]]:
124
141
  async with self._lock:
142
+ self._record_loop()
125
143
  existing = await self._find_by_idempotency_key(lane_id, idempotency_key)
126
144
  if existing:
127
145
  if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
@@ -139,10 +157,36 @@ class PmaQueue:
139
157
  await self._append_to_file(item)
140
158
  queue = self._ensure_lane_queue(lane_id)
141
159
  await queue.put(item)
160
+ self._ensure_lane_known_ids(lane_id).add(item.item_id)
142
161
  self._ensure_lane_event(lane_id).set()
143
162
  return item, None
144
163
 
164
+ def enqueue_sync(
165
+ self,
166
+ lane_id: str,
167
+ idempotency_key: str,
168
+ payload: dict[str, Any],
169
+ ) -> tuple[PmaQueueItem, Optional[str]]:
170
+ existing = self._find_by_idempotency_key_sync(lane_id, idempotency_key)
171
+ if existing:
172
+ if existing.state in (QueueItemState.PENDING, QueueItemState.RUNNING):
173
+ dedupe_item = PmaQueueItem.create(
174
+ lane_id=lane_id,
175
+ idempotency_key=idempotency_key,
176
+ payload=payload,
177
+ )
178
+ dedupe_item.state = QueueItemState.DEDUPED
179
+ dedupe_item.dedupe_reason = f"duplicate_of_{existing.item_id}"
180
+ self._append_to_file_sync(dedupe_item)
181
+ return dedupe_item, f"duplicate of {existing.item_id}"
182
+
183
+ item = PmaQueueItem.create(lane_id, idempotency_key, payload)
184
+ self._append_to_file_sync(item)
185
+ self._notify_in_memory_enqueue(item)
186
+ return item, None
187
+
145
188
  async def dequeue(self, lane_id: str) -> Optional[PmaQueueItem]:
189
+ self._record_loop()
146
190
  queue = self._lane_queues.get(lane_id)
147
191
  if queue is None or queue.empty():
148
192
  return None
@@ -206,11 +250,15 @@ class PmaQueue:
206
250
  return cancelled
207
251
 
208
252
  async def replay_pending(self, lane_id: str) -> int:
253
+ self._record_loop()
209
254
  if lane_id in self._replayed_lanes:
210
255
  return 0
211
256
  self._replayed_lanes.add(lane_id)
212
257
 
213
258
  items = await self.list_items(lane_id)
259
+ known = self._ensure_lane_known_ids(lane_id)
260
+ for item in items:
261
+ known.add(item.item_id)
214
262
  pending = [item for item in items if item.state == QueueItemState.PENDING]
215
263
  if not pending:
216
264
  return 0
@@ -222,29 +270,45 @@ class PmaQueue:
222
270
  return len(pending)
223
271
 
224
272
  async def wait_for_lane_item(
225
- self, lane_id: str, cancel_event: Optional[asyncio.Event] = None
273
+ self,
274
+ lane_id: str,
275
+ cancel_event: Optional[asyncio.Event] = None,
276
+ *,
277
+ poll_interval_seconds: float = 1.0,
226
278
  ) -> bool:
279
+ self._record_loop()
227
280
  event = self._ensure_lane_event(lane_id)
228
281
  if event.is_set():
229
282
  event.clear()
230
283
  return True
231
284
 
232
- wait_tasks = [asyncio.create_task(event.wait())]
233
- if cancel_event is not None:
234
- wait_tasks.append(asyncio.create_task(cancel_event.wait()))
285
+ poll_interval = max(0.1, poll_interval_seconds)
286
+ while True:
287
+ wait_tasks = [asyncio.create_task(event.wait())]
288
+ if cancel_event is not None:
289
+ wait_tasks.append(asyncio.create_task(cancel_event.wait()))
235
290
 
236
- done, pending = await asyncio.wait(
237
- wait_tasks, return_when=asyncio.FIRST_COMPLETED
238
- )
239
- for task in pending:
240
- task.cancel()
291
+ try:
292
+ done, pending = await asyncio.wait(
293
+ wait_tasks,
294
+ timeout=poll_interval,
295
+ return_when=asyncio.FIRST_COMPLETED,
296
+ )
297
+ finally:
298
+ for task in wait_tasks:
299
+ if not task.done():
300
+ task.cancel()
241
301
 
242
- if cancel_event is not None and cancel_event.is_set():
243
- return False
302
+ if cancel_event is not None and cancel_event.is_set():
303
+ return False
244
304
 
245
- if event.is_set():
246
- event.clear()
247
- return True
305
+ if event.is_set():
306
+ event.clear()
307
+ return True
308
+
309
+ added = await self._refresh_lane_from_disk(lane_id)
310
+ if added:
311
+ return True
248
312
 
249
313
  async def list_items(self, lane_id: str) -> list[PmaQueueItem]:
250
314
  path = self._lane_queue_path(lane_id)
@@ -253,10 +317,11 @@ class PmaQueue:
253
317
 
254
318
  items: list[PmaQueueItem] = []
255
319
  async with self._ensure_lane_lock(lane_id):
256
- try:
257
- content = path.read_text(encoding="utf-8")
258
- except OSError:
259
- return []
320
+ with file_lock(self._lane_queue_lock_path(lane_id)):
321
+ try:
322
+ content = path.read_text(encoding="utf-8")
323
+ except OSError:
324
+ return []
260
325
 
261
326
  for line in content.strip().splitlines():
262
327
  line = line.strip()
@@ -270,6 +335,29 @@ class PmaQueue:
270
335
 
271
336
  return items
272
337
 
338
+ async def _refresh_lane_from_disk(self, lane_id: str) -> int:
339
+ items = await self.list_items(lane_id)
340
+ if not items:
341
+ return 0
342
+
343
+ known = self._ensure_lane_known_ids(lane_id)
344
+ new_pending: list[PmaQueueItem] = []
345
+ for item in items:
346
+ if item.item_id in known:
347
+ continue
348
+ known.add(item.item_id)
349
+ if item.state == QueueItemState.PENDING:
350
+ new_pending.append(item)
351
+
352
+ if not new_pending:
353
+ return 0
354
+
355
+ queue = self._ensure_lane_queue(lane_id)
356
+ for item in new_pending:
357
+ await queue.put(item)
358
+ self._ensure_lane_event(lane_id).set()
359
+ return len(new_pending)
360
+
273
361
  async def _find_by_idempotency_key(
274
362
  self, lane_id: str, idempotency_key: str
275
363
  ) -> Optional[PmaQueueItem]:
@@ -324,6 +412,67 @@ class PmaQueue:
324
412
  if updated:
325
413
  path.write_text("\n".join(lines) + "\n", encoding="utf-8")
326
414
 
415
+ def _append_to_file_sync(self, item: PmaQueueItem) -> None:
416
+ path = self._lane_queue_path(item.lane_id)
417
+ with file_lock(self._lane_queue_lock_path(item.lane_id)):
418
+ path.parent.mkdir(parents=True, exist_ok=True)
419
+ line = json.dumps(item.to_dict(), separators=(",", ":")) + "\n"
420
+ with path.open("a", encoding="utf-8") as f:
421
+ f.write(line)
422
+
423
+ def _read_items_sync(self, lane_id: str) -> list[PmaQueueItem]:
424
+ path = self._lane_queue_path(lane_id)
425
+ if not path.exists():
426
+ return []
427
+
428
+ items: list[PmaQueueItem] = []
429
+ with file_lock(self._lane_queue_lock_path(lane_id)):
430
+ try:
431
+ content = path.read_text(encoding="utf-8")
432
+ except OSError:
433
+ return []
434
+
435
+ for line in content.strip().splitlines():
436
+ line = line.strip()
437
+ if not line:
438
+ continue
439
+ try:
440
+ data = json.loads(line)
441
+ items.append(PmaQueueItem.from_dict(data))
442
+ except (json.JSONDecodeError, ValueError):
443
+ continue
444
+
445
+ return items
446
+
447
+ def _find_by_idempotency_key_sync(
448
+ self, lane_id: str, idempotency_key: str
449
+ ) -> Optional[PmaQueueItem]:
450
+ items = self._read_items_sync(lane_id)
451
+ for item in items:
452
+ if item.idempotency_key == idempotency_key and item.state in (
453
+ QueueItemState.PENDING,
454
+ QueueItemState.RUNNING,
455
+ ):
456
+ return item
457
+ return None
458
+
459
+ def _notify_in_memory_enqueue(self, item: PmaQueueItem) -> None:
460
+ self._ensure_lane_known_ids(item.lane_id).add(item.item_id)
461
+ queue = self._lane_queues.get(item.lane_id)
462
+ event = self._lane_events.get(item.lane_id)
463
+ loop = self._loop
464
+ if loop is None or loop.is_closed() or queue is None or event is None:
465
+ return
466
+
467
+ def _enqueue() -> None:
468
+ queue.put_nowait(item)
469
+ event.set()
470
+
471
+ try:
472
+ loop.call_soon_threadsafe(_enqueue)
473
+ except RuntimeError:
474
+ return
475
+
327
476
  async def get_lane_stats(self, lane_id: str) -> dict[str, Any]:
328
477
  items = await self.list_items(lane_id)
329
478
  by_state: dict[str, int] = {}
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from .locks import file_lock
10
+ from .utils import atomic_write
11
+
12
+ PMA_REACTIVE_STATE_FILENAME = "reactive_state.json"
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def default_pma_reactive_state() -> dict[str, Any]:
18
+ return {
19
+ "version": 1,
20
+ "last_enqueued": {},
21
+ }
22
+
23
+
24
+ class PmaReactiveStore:
25
+ def __init__(self, hub_root: Path) -> None:
26
+ self._path = (
27
+ hub_root / ".codex-autorunner" / "pma" / PMA_REACTIVE_STATE_FILENAME
28
+ )
29
+
30
+ def _lock_path(self) -> Path:
31
+ return self._path.with_suffix(self._path.suffix + ".lock")
32
+
33
+ def load(self) -> dict[str, Any]:
34
+ with file_lock(self._lock_path()):
35
+ state = self._load_unlocked()
36
+ if state is not None:
37
+ return state
38
+ state = default_pma_reactive_state()
39
+ self._save_unlocked(state)
40
+ return state
41
+
42
+ def check_and_update(self, key: str, debounce_seconds: int) -> bool:
43
+ """
44
+ Return True if enqueue is allowed; otherwise False if debounced.
45
+ Updates the last_enqueued timestamp when allowed.
46
+ """
47
+ now = time.time()
48
+ with file_lock(self._lock_path()):
49
+ state = self._load_unlocked() or default_pma_reactive_state()
50
+ last_enqueued = state.get("last_enqueued")
51
+ if not isinstance(last_enqueued, dict):
52
+ last_enqueued = {}
53
+ state["last_enqueued"] = last_enqueued
54
+
55
+ last = last_enqueued.get(key)
56
+ if debounce_seconds > 0 and isinstance(last, (int, float)):
57
+ if now - float(last) < debounce_seconds:
58
+ return False
59
+
60
+ last_enqueued[key] = now
61
+ self._save_unlocked(state)
62
+ return True
63
+
64
+ def _load_unlocked(self) -> Optional[dict[str, Any]]:
65
+ if not self._path.exists():
66
+ return None
67
+ try:
68
+ raw = self._path.read_text(encoding="utf-8")
69
+ except OSError as exc:
70
+ logger.warning(
71
+ "Failed to read PMA reactive state at %s: %s", self._path, exc
72
+ )
73
+ return None
74
+ try:
75
+ data = json.loads(raw)
76
+ except json.JSONDecodeError:
77
+ return default_pma_reactive_state()
78
+ if not isinstance(data, dict):
79
+ return default_pma_reactive_state()
80
+ return data
81
+
82
+ def _save_unlocked(self, state: dict[str, Any]) -> None:
83
+ self._path.parent.mkdir(parents=True, exist_ok=True)
84
+ atomic_write(self._path, json.dumps(state, indent=2) + "\n")
85
+
86
+
87
+ __all__ = [
88
+ "PMA_REACTIVE_STATE_FILENAME",
89
+ "PmaReactiveStore",
90
+ "default_pma_reactive_state",
91
+ ]
@@ -141,6 +141,45 @@ class PmaSafetyChecker:
141
141
 
142
142
  return SafetyCheckResult(allowed=True)
143
143
 
144
+ def check_reactive_turn(self, *, key: str = "reactive") -> SafetyCheckResult:
145
+ if self._is_circuit_breaker_active():
146
+ return SafetyCheckResult(
147
+ allowed=False,
148
+ reason="circuit_breaker_active",
149
+ details={
150
+ "cooldown_remaining_seconds": (
151
+ int(
152
+ self._circuit_breaker_until
153
+ - datetime.now(timezone.utc).timestamp()
154
+ )
155
+ if self._circuit_breaker_until
156
+ else 0
157
+ )
158
+ },
159
+ )
160
+
161
+ if self._config.enable_rate_limit:
162
+ now = datetime.now(timezone.utc).timestamp()
163
+ self._action_timestamps[key] = [
164
+ ts
165
+ for ts in self._action_timestamps[key]
166
+ if now - ts < self._config.rate_limit_window_seconds
167
+ ]
168
+ if len(self._action_timestamps[key]) >= self._config.max_actions_per_window:
169
+ return SafetyCheckResult(
170
+ allowed=False,
171
+ reason="rate_limit_exceeded",
172
+ details={
173
+ "key": key,
174
+ "count": len(self._action_timestamps[key]),
175
+ "max_allowed": self._config.max_actions_per_window,
176
+ "window_seconds": self._config.rate_limit_window_seconds,
177
+ },
178
+ )
179
+ self._action_timestamps[key].append(now)
180
+
181
+ return SafetyCheckResult(allowed=True)
182
+
144
183
  def record_chat_result(
145
184
  self,
146
185
  agent: str,
@@ -159,6 +198,25 @@ class PmaSafetyChecker:
159
198
  key = f"chat:{agent}"
160
199
  self._failure_counts[key] = 0
161
200
 
201
+ def record_reactive_result(
202
+ self,
203
+ *,
204
+ status: str,
205
+ error: Optional[str] = None,
206
+ key: str = "reactive",
207
+ ) -> None:
208
+ if (
209
+ status in ("error", "failed", "interrupted")
210
+ and self._config.enable_circuit_breaker
211
+ ):
212
+ self._failure_counts[key] += 1
213
+ if self._failure_counts[key] >= self._config.circuit_breaker_threshold:
214
+ self._activate_circuit_breaker()
215
+ if error:
216
+ logger.warning("PMA reactive circuit breaker error: %s", error)
217
+ else:
218
+ self._failure_counts[key] = 0
219
+
162
220
  def record_action(
163
221
  self,
164
222
  action_type: PmaActionType,
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .locks import file_lock
9
+ from .time_utils import now_iso
10
+ from .utils import atomic_write
11
+
12
+ PMA_ACTIVE_SINK_FILENAME = "active_sink.json"
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PmaActiveSinkStore:
18
+ def __init__(self, hub_root: Path) -> None:
19
+ self._path = hub_root / ".codex-autorunner" / "pma" / PMA_ACTIVE_SINK_FILENAME
20
+
21
+ def _lock_path(self) -> Path:
22
+ return self._path.with_suffix(self._path.suffix + ".lock")
23
+
24
+ def load(self) -> Optional[dict[str, Any]]:
25
+ with file_lock(self._lock_path()):
26
+ return self._load_unlocked()
27
+
28
+ def set_web(self) -> dict[str, Any]:
29
+ payload = {
30
+ "version": 1,
31
+ "kind": "web",
32
+ "updated_at": now_iso(),
33
+ "last_delivery_turn_id": None,
34
+ }
35
+ with file_lock(self._lock_path()):
36
+ self._save_unlocked(payload)
37
+ return payload
38
+
39
+ def set_telegram(
40
+ self,
41
+ *,
42
+ chat_id: int,
43
+ thread_id: Optional[int],
44
+ topic_key: Optional[str] = None,
45
+ ) -> dict[str, Any]:
46
+ payload: dict[str, Any] = {
47
+ "version": 1,
48
+ "kind": "telegram",
49
+ "chat_id": int(chat_id),
50
+ "thread_id": int(thread_id) if thread_id is not None else None,
51
+ "updated_at": now_iso(),
52
+ "last_delivery_turn_id": None,
53
+ }
54
+ if topic_key:
55
+ payload["topic_key"] = topic_key
56
+ with file_lock(self._lock_path()):
57
+ self._save_unlocked(payload)
58
+ return payload
59
+
60
+ def clear(self) -> None:
61
+ with file_lock(self._lock_path()):
62
+ try:
63
+ self._path.unlink()
64
+ except FileNotFoundError:
65
+ return
66
+ except OSError as exc:
67
+ logger.warning("Failed to clear PMA active sink: %s", exc)
68
+
69
+ def mark_delivered(self, turn_id: str) -> bool:
70
+ if not isinstance(turn_id, str) or not turn_id:
71
+ return False
72
+ with file_lock(self._lock_path()):
73
+ payload = self._load_unlocked()
74
+ if not isinstance(payload, dict):
75
+ return False
76
+ if payload.get("last_delivery_turn_id") == turn_id:
77
+ return False
78
+ payload["last_delivery_turn_id"] = turn_id
79
+ payload["updated_at"] = now_iso()
80
+ self._save_unlocked(payload)
81
+ return True
82
+
83
+ def _load_unlocked(self) -> Optional[dict[str, Any]]:
84
+ if not self._path.exists():
85
+ return None
86
+ try:
87
+ raw = self._path.read_text(encoding="utf-8")
88
+ except OSError as exc:
89
+ logger.warning("Failed to read PMA active sink: %s", exc)
90
+ return None
91
+ try:
92
+ payload = json.loads(raw)
93
+ except json.JSONDecodeError:
94
+ return None
95
+ if not isinstance(payload, dict):
96
+ return None
97
+ return payload
98
+
99
+ def _save_unlocked(self, payload: dict[str, Any]) -> None:
100
+ self._path.parent.mkdir(parents=True, exist_ok=True)
101
+ atomic_write(self._path, json.dumps(payload, indent=2) + "\n")
102
+
103
+
104
+ __all__ = ["PmaActiveSinkStore", "PMA_ACTIVE_SINK_FILENAME"]