pairling 0.0.1 → 0.1.0

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 (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,1990 @@
1
+ #!/usr/bin/env python3
2
+ """Local Pairling push registration and delivery state.
3
+
4
+ This is the Mac-side durable registry for APNs-capable paired devices. The
5
+ normal registry never stores raw APNs tokens; local development APNs sends use a
6
+ separate private secret store so delivery can be proven without leaking tokens
7
+ through status/audit responses.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import stat
15
+ import base64
16
+ import hashlib
17
+ import hmac
18
+ import subprocess
19
+ import tempfile
20
+ import threading
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ import uuid
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ try:
29
+ from cryptography.hazmat.primitives import hashes, serialization
30
+ from cryptography.hazmat.primitives.asymmetric import ec, utils
31
+ except Exception: # pragma: no cover - provider status still works without sends.
32
+ hashes = None
33
+ serialization = None
34
+ ec = None
35
+ utils = None
36
+
37
+
38
+ CONTRACT_VERSION = "pairling-push-devices-v1"
39
+ DEFAULT_PREFERENCES = {
40
+ "standard_push_enabled": False,
41
+ "live_activity_enabled": False,
42
+ "worker_sentinel_enabled": False,
43
+ "turn_done_enabled": False,
44
+ "push_diagnostics_enabled": True,
45
+ "push_snoozed_until": None,
46
+ "quiet_hours": None,
47
+ }
48
+ APNS_TOKEN_MIN_HEX_CHARS = 16
49
+ APNS_TOKEN_MAX_HEX_CHARS = 4096
50
+ DEFAULT_APNS_TOPIC = "dev.pairling.ios"
51
+ DEFAULT_TEAM_ID = "965AVD34A3"
52
+ APNS_ENVIRONMENTS = {"development", "sandbox", "production"}
53
+ APNS_KEY_ENVIRONMENTS = {"development", "sandbox", "production", "both"}
54
+
55
+ KIND_CATEGORY = {
56
+ "session_attention": "PAIRLING_SESSION_ATTENTION",
57
+ "turn_done": "PAIRLING_TURN_DONE",
58
+ "mac_health": "PAIRLING_MAC_HEALTH",
59
+ "worker_sentinel": "PAIRLING_WORKER_SENTINEL",
60
+ "action_required": "PAIRLING_SESSION_ATTENTION",
61
+ "turn_result": "PAIRLING_TURN_DONE",
62
+ "turn_failed": "PAIRLING_SESSION_ATTENTION",
63
+ "tool_risk": "PAIRLING_SESSION_ATTENTION",
64
+ "mac_route_risk": "PAIRLING_MAC_HEALTH",
65
+ "worker_pressure": "PAIRLING_WORKER_SENTINEL",
66
+ "deploy_result": "PAIRLING_TURN_DONE",
67
+ "push_diagnostic": "PAIRLING_PUSH_DIAGNOSTIC",
68
+ }
69
+ KIND_ALERT = {
70
+ "session_attention": ("Pairling needs input", "A session is waiting for your decision."),
71
+ "turn_done": ("Pairling result ready", "A useful turn result is ready."),
72
+ "mac_health": ("Pairling Mac health", "The paired Mac helper needs attention."),
73
+ "worker_sentinel": ("Pairling worker warning", "Worker automation needs review."),
74
+ "action_required": ("Pairling needs approval", "Review the requested action before work continues."),
75
+ "turn_result": ("Pairling result ready", "A useful turn result is ready."),
76
+ "turn_failed": ("Pairling turn failed", "A turn failed and needs review."),
77
+ "tool_risk": ("Pairling tool risk", "A tool signal needs review."),
78
+ "mac_route_risk": ("Mac route timed out", "The paired Mac route needs attention."),
79
+ "worker_pressure": ("Pairling worker pressure", "Worker or token pressure needs review."),
80
+ "deploy_result": ("Deploy result ready", "A build or deploy result is available."),
81
+ "push_diagnostic": ("Pairling push test", "Push delivery is configured for this device."),
82
+ }
83
+ TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure"}
84
+
85
+
86
+ class PushDispatcherError(Exception):
87
+ def __init__(self, code: str, message: str, status: int = 400):
88
+ super().__init__(message)
89
+ self.code = code
90
+ self.message = message
91
+ self.status = status
92
+
93
+
94
+ class LocalAPNSProvider:
95
+ """Small APNs HTTP/2 sender for local developer-device validation."""
96
+
97
+ def __init__(self, *, config_path: Path | None = None, now_fn=time.time, run_fn=subprocess.run):
98
+ self.config_path = config_path
99
+ self.now_fn = now_fn
100
+ self.run_fn = run_fn
101
+
102
+ def status(self) -> dict[str, Any]:
103
+ config = self._config()
104
+ return {
105
+ "mode": config["mode"],
106
+ "configured": config["configured"],
107
+ "local_apns_key_configured": config["local_apns_key_configured"],
108
+ "relay_url_configured": bool(config["relay_url"]),
109
+ "relay_url": config["relay_url"] or None,
110
+ "topic": config["topic"],
111
+ "environment": config["environment"],
112
+ "key_environment": config["key_environment"],
113
+ "key_id": config["key_id"] if config["local_apns_key_configured"] else None,
114
+ }
115
+
116
+ def send_alert(
117
+ self,
118
+ *,
119
+ token: str,
120
+ event_id: str,
121
+ kind: str,
122
+ route: str,
123
+ title: str | None = None,
124
+ body: str | None = None,
125
+ thread_id: str | None = None,
126
+ pairling_extra: dict[str, Any] | None = None,
127
+ interruption_level: str | None = None,
128
+ ) -> dict[str, Any]:
129
+ config = self._config()
130
+ if not config["local_apns_configured"]:
131
+ raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
132
+ _validate_apns_token(token, "apns_token")
133
+ kind = kind if kind in KIND_CATEGORY else "push_diagnostic"
134
+ default_title, default_body = KIND_ALERT[kind]
135
+ title = _bounded_optional(title, 90) or default_title
136
+ body = _bounded_optional(body, 220) or default_body
137
+ pairling_payload = {
138
+ "event_id": event_id,
139
+ "kind": kind,
140
+ "route": route,
141
+ }
142
+ if isinstance(pairling_extra, dict):
143
+ for key, value in pairling_extra.items():
144
+ if key in {"event_id", "kind", "route"}:
145
+ continue
146
+ if isinstance(value, (str, int, float, bool)) or value is None:
147
+ pairling_payload[str(key)[:80]] = _bounded_optional(value, 180) if isinstance(value, str) else value
148
+ payload = {
149
+ "aps": {
150
+ "alert": {"title": title, "body": body},
151
+ "sound": "default",
152
+ "category": KIND_CATEGORY[kind],
153
+ "thread-id": _bounded_optional(thread_id, 120) or _thread_id(kind, route),
154
+ },
155
+ "pairling": pairling_payload,
156
+ }
157
+ level = str(interruption_level or "").strip()
158
+ if level in {"passive", "active", "time-sensitive"}:
159
+ payload["aps"]["interruption-level"] = level
160
+ elif kind in TIME_SENSITIVE_KINDS:
161
+ payload["aps"]["interruption-level"] = "time-sensitive"
162
+ return self._send(
163
+ token=token,
164
+ payload=payload,
165
+ push_type="alert",
166
+ topic=config["topic"],
167
+ priority="10",
168
+ event_id=event_id,
169
+ config=config,
170
+ )
171
+
172
+ def send_live_activity(
173
+ self,
174
+ *,
175
+ token: str,
176
+ event_id: str,
177
+ event: str,
178
+ content_state: dict[str, Any],
179
+ stale_seconds: int = 75,
180
+ dismissal_seconds: int = 300,
181
+ ) -> dict[str, Any]:
182
+ config = self._config()
183
+ if not config["local_apns_configured"]:
184
+ raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
185
+ _validate_apns_token(token, "live_activity_token")
186
+ now = int(self.now_fn())
187
+ activity_event = "end" if event == "end" else "update"
188
+ content = _bounded_content_state(content_state, event_id=event_id, now=now)
189
+ aps: dict[str, Any] = {
190
+ "timestamp": now,
191
+ "event": activity_event,
192
+ "content-state": content,
193
+ }
194
+ if activity_event == "end":
195
+ aps["dismissal-date"] = now + max(0, int(dismissal_seconds))
196
+ else:
197
+ aps["stale-date"] = now + max(30, int(stale_seconds))
198
+ if content["state"] in {"attention", "failed"}:
199
+ aps["alert"] = {
200
+ "title": "Pairling",
201
+ "body": _live_activity_alert_body(content),
202
+ }
203
+ payload = {"aps": aps}
204
+ return self._send(
205
+ token=token,
206
+ payload=payload,
207
+ push_type="liveactivity",
208
+ topic=config["live_activity_topic"],
209
+ priority="10" if content["state"] in {"attention", "tool", "done", "failed"} else "5",
210
+ event_id=event_id,
211
+ config=config,
212
+ )
213
+
214
+ def probe_credentials(self) -> dict[str, Any]:
215
+ """Probe APNs auth with a synthetic token without touching device tokens."""
216
+ config = self._config()
217
+ if not config["local_apns_configured"]:
218
+ raise PushDispatcherError("local_apns_not_configured", "local APNs provider is not configured", 503)
219
+ synthetic_token = "0" * 64
220
+ result = self._send(
221
+ token=synthetic_token,
222
+ payload={
223
+ "aps": {
224
+ "alert": {
225
+ "title": "Pairling APNs credential probe",
226
+ "body": "Synthetic-token credential probe.",
227
+ },
228
+ "sound": "default",
229
+ },
230
+ "pairling": {
231
+ "event_id": "apns_credential_probe",
232
+ "kind": "push_diagnostic",
233
+ "route": "pairling://settings/push",
234
+ },
235
+ },
236
+ push_type="alert",
237
+ topic=config["topic"],
238
+ priority="10",
239
+ event_id=f"apns_credential_probe_{int(self.now_fn() * 1000)}",
240
+ config=config,
241
+ )
242
+ authenticated = result.get("apns_status") == 400 and result.get("apns_reason") == "BadDeviceToken"
243
+ return {
244
+ "ok": authenticated,
245
+ "authenticated": authenticated,
246
+ "expected_reason": "BadDeviceToken",
247
+ "synthetic_token_used": True,
248
+ "provider": self.status(),
249
+ "result": {key: value for key, value in result.items() if key != "apns_id"},
250
+ "apns_id_present": bool(result.get("apns_id")),
251
+ }
252
+
253
+ def _send(
254
+ self,
255
+ *,
256
+ token: str,
257
+ payload: dict[str, Any],
258
+ push_type: str,
259
+ topic: str,
260
+ priority: str,
261
+ event_id: str,
262
+ config: dict[str, Any],
263
+ ) -> dict[str, Any]:
264
+ jwt = self._jwt(config)
265
+ apns_id = str(uuid.uuid4()).upper()
266
+ host = "api.sandbox.push.apple.com" if config["environment"] in {"development", "sandbox"} else "api.push.apple.com"
267
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
268
+ with tempfile.NamedTemporaryFile("wb", delete=False) as payload_file:
269
+ payload_file.write(body)
270
+ payload_file.flush()
271
+ os.fsync(payload_file.fileno())
272
+ payload_path = payload_file.name
273
+ with tempfile.NamedTemporaryFile("w", delete=False) as config_file:
274
+ config_file.write("\n".join([
275
+ "silent",
276
+ "show-error",
277
+ "http2",
278
+ "request = \"POST\"",
279
+ f"url = \"https://{host}/3/device/{token}\"",
280
+ f"header = \"authorization: bearer {jwt}\"",
281
+ f"header = \"apns-topic: {topic}\"",
282
+ f"header = \"apns-push-type: {push_type}\"",
283
+ f"header = \"apns-priority: {priority}\"",
284
+ f"header = \"apns-id: {apns_id}\"",
285
+ f"data-binary = \"@{payload_path}\"",
286
+ "write-out = \"\\n%{http_code}\"",
287
+ "connect-timeout = 10",
288
+ "max-time = 20",
289
+ "",
290
+ ]))
291
+ config_file.flush()
292
+ os.fsync(config_file.fileno())
293
+ config_path = config_file.name
294
+ try:
295
+ os.chmod(config_path, 0o600)
296
+ os.chmod(payload_path, 0o600)
297
+ proc = self.run_fn(
298
+ ["/usr/bin/curl", "--config", config_path],
299
+ capture_output=True,
300
+ text=True,
301
+ timeout=25,
302
+ )
303
+ finally:
304
+ for path in [config_path, payload_path]:
305
+ try:
306
+ os.unlink(path)
307
+ except OSError:
308
+ pass
309
+ response_text, http_status = _split_curl_status(proc.stdout)
310
+ reason = None
311
+ if response_text.strip():
312
+ try:
313
+ parsed = json.loads(response_text)
314
+ reason = parsed.get("reason") if isinstance(parsed, dict) else None
315
+ except json.JSONDecodeError:
316
+ reason = response_text.strip()[:200]
317
+ sent = proc.returncode == 0 and http_status == 200
318
+ return {
319
+ "sent": sent,
320
+ "outcome": "sent" if sent else _apns_outcome(http_status, reason, proc.returncode),
321
+ "apns_status": http_status,
322
+ "apns_reason": reason,
323
+ "curl_exit_code": proc.returncode,
324
+ "apns_id": apns_id,
325
+ "retryable": http_status in {429, 500, 503} or proc.returncode != 0,
326
+ "invalid_token": http_status == 410 or reason in {"BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"},
327
+ }
328
+
329
+ def _jwt(self, config: dict[str, Any]) -> str:
330
+ if not all([hashes, serialization, ec, utils]):
331
+ raise PushDispatcherError("apns_signing_unavailable", "cryptography is required for APNs signing", 500)
332
+ key_path = Path(config["auth_key_path"])
333
+ private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
334
+ if not isinstance(private_key, ec.EllipticCurvePrivateKey) or private_key.curve.name != "secp256r1":
335
+ raise PushDispatcherError("invalid_apns_key", "APNs auth key must be a P-256 EC private key", 500)
336
+ header = {"alg": "ES256", "kid": config["key_id"]}
337
+ claims = {"iss": config["team_id"], "iat": int(self.now_fn())}
338
+ signing_input = ".".join([
339
+ _b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode("utf-8")),
340
+ _b64url(json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")),
341
+ ]).encode("ascii")
342
+ signature = private_key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
343
+ r, s = utils.decode_dss_signature(signature)
344
+ raw_signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
345
+ return signing_input.decode("ascii") + "." + _b64url(raw_signature)
346
+
347
+ def _config(self) -> dict[str, Any]:
348
+ config = self._push_config()
349
+ mode = self._setting(config, "PAIRLING_PUSH_PROVIDER_MODE", "provider_mode", "not_configured")
350
+ relay_url = self._setting(config, "PAIRLING_PUSH_RELAY_URL", "relay_url", "")
351
+ auth_key_path = self._setting(config, "PAIRLING_APNS_AUTH_KEY_PATH", "apns_auth_key_path", "")
352
+ key_id = self._setting(config, "PAIRLING_APNS_KEY_ID", "apns_key_id", "") or _infer_apns_key_id(auth_key_path)
353
+ team_id = self._setting(config, "PAIRLING_APNS_TEAM_ID", "apns_team_id", DEFAULT_TEAM_ID)
354
+ topic = self._setting(config, "PAIRLING_APNS_TOPIC", "apns_topic", DEFAULT_APNS_TOPIC)
355
+ live_activity_topic = self._setting(
356
+ config,
357
+ "PAIRLING_APNS_LIVE_ACTIVITY_TOPIC",
358
+ "apns_live_activity_topic",
359
+ topic + ".push-type.liveactivity",
360
+ )
361
+ environment = _normalize_apns_environment(
362
+ self._setting(config, "PAIRLING_APNS_ENVIRONMENT", "apns_environment", "development")
363
+ )
364
+ key_environment = _normalize_apns_key_environment(
365
+ self._setting(config, "PAIRLING_APNS_KEY_ENVIRONMENT", "apns_key_environment", environment)
366
+ )
367
+ local_ready = (
368
+ mode == "local_apns"
369
+ and bool(auth_key_path)
370
+ and Path(auth_key_path).is_file()
371
+ and bool(key_id)
372
+ and bool(team_id)
373
+ and bool(topic)
374
+ )
375
+ return {
376
+ "mode": mode,
377
+ "relay_url": relay_url,
378
+ "configured": bool(relay_url or mode == "relay" or local_ready),
379
+ "local_apns_configured": local_ready,
380
+ "local_apns_key_configured": local_ready,
381
+ "auth_key_path": auth_key_path,
382
+ "key_id": key_id,
383
+ "team_id": team_id,
384
+ "topic": topic,
385
+ "live_activity_topic": live_activity_topic,
386
+ "environment": environment,
387
+ "key_environment": key_environment,
388
+ }
389
+
390
+ def _push_config(self) -> dict[str, Any]:
391
+ if not self.config_path:
392
+ return {}
393
+ try:
394
+ data = json.loads(self.config_path.read_text(encoding="utf-8"))
395
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
396
+ return {}
397
+ push = data.get("push") if isinstance(data, dict) else None
398
+ return push if isinstance(push, dict) else {}
399
+
400
+ def _setting(self, config: dict[str, Any], env_key: str, config_key: str, default: str) -> str:
401
+ value = os.environ.get(env_key)
402
+ if value is None:
403
+ value = config.get(config_key, default)
404
+ return str(value or "").strip()
405
+
406
+
407
+ class RelayEventSender:
408
+ """Mac-to-relay event client using the paired HMAC secret."""
409
+
410
+ def __init__(self, *, now_fn=time.time, opener=urllib.request.urlopen):
411
+ self.now_fn = now_fn
412
+ self.opener = opener
413
+
414
+ def submit_event(
415
+ self,
416
+ *,
417
+ relay_url: str,
418
+ relay_pair_secret: str,
419
+ event_body: dict[str, Any],
420
+ ) -> dict[str, Any]:
421
+ body_hash = _b64url(hashlib.sha256(_json_dump(event_body).encode("utf-8")).digest())
422
+ timestamp = int(self.now_fn())
423
+ event_id = str(event_body.get("event_id") or "")
424
+ canonical = f"POST\n/v1/events/submit\n{timestamp}\n{event_id}\n{body_hash}"
425
+ signature = _b64url(hmac.new(
426
+ relay_pair_secret.encode("utf-8"),
427
+ canonical.encode("utf-8"),
428
+ hashlib.sha256,
429
+ ).digest())
430
+ payload = {
431
+ "body": event_body,
432
+ "body_hash": body_hash,
433
+ "timestamp": timestamp,
434
+ "event_id": event_id,
435
+ "signature": signature,
436
+ }
437
+ data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
438
+ request = urllib.request.Request(
439
+ relay_url.rstrip("/") + "/v1/events/submit",
440
+ data=data,
441
+ headers={"Content-Type": "application/json"},
442
+ method="POST",
443
+ )
444
+ try:
445
+ with self.opener(request, timeout=20) as response:
446
+ status = int(getattr(response, "status", 200))
447
+ response_body = response.read()
448
+ except urllib.error.HTTPError as exc:
449
+ status = int(exc.code)
450
+ response_body = exc.read()
451
+ except Exception as exc:
452
+ return {
453
+ "accepted": False,
454
+ "outcome": "relay_network_error",
455
+ "relay_status": None,
456
+ "relay_error": type(exc).__name__,
457
+ "retryable": True,
458
+ "invalid_token": False,
459
+ }
460
+ try:
461
+ parsed = json.loads(response_body.decode("utf-8") or "{}")
462
+ except Exception:
463
+ parsed = {}
464
+ ok = 200 <= status < 300 and bool(parsed.get("ok"))
465
+ error = parsed.get("error") if isinstance(parsed.get("error"), dict) else {}
466
+ return {
467
+ "accepted": ok,
468
+ "outcome": str(parsed.get("state") or ("queued" if ok else error.get("code") or f"relay_http_{status}")),
469
+ "state": parsed.get("state"),
470
+ "relay_status": status,
471
+ "relay_error": error.get("code"),
472
+ "retryable": status >= 500,
473
+ "invalid_token": False,
474
+ }
475
+
476
+
477
+ class PairlingPushDispatcher:
478
+ def __init__(
479
+ self,
480
+ registry_path: Path,
481
+ *,
482
+ secret_path: Path | None = None,
483
+ now_fn=time.time,
484
+ apns_sender=None,
485
+ relay_sender=None,
486
+ ):
487
+ self.registry_path = registry_path
488
+ self.secret_path = secret_path or registry_path.with_name("push-secrets.json")
489
+ self.now_fn = now_fn
490
+ self.apns_sender = apns_sender or LocalAPNSProvider(config_path=registry_path.parent / "config.json", now_fn=now_fn)
491
+ self.relay_sender = relay_sender or RelayEventSender(now_fn=now_fn)
492
+ self._lock = threading.RLock()
493
+
494
+ def backfill_live_activity_environments(self, *, device_id: str | None = None) -> dict[str, Any]:
495
+ """Repair older Live Activity token rows that predate explicit APNs environments."""
496
+ data = self._read()
497
+ secrets_payload = self._read_secrets()
498
+ public_updates = 0
499
+ secret_updates = 0
500
+ for device in data.get("devices", []):
501
+ current_device_id = str(device.get("device_id") or "")
502
+ if not current_device_id or (device_id and current_device_id != device_id):
503
+ continue
504
+ secret_device = secrets_payload.setdefault("devices", {}).setdefault(current_device_id, {})
505
+ fallback = _normalize_apns_environment(device.get("apns_environment") or secret_device.get("apns_environment"))
506
+ has_live_activity = bool(device.get("live_activities")) or bool(secret_device.get("live_activity_tokens"))
507
+ if has_live_activity and not device.get("apns_environment"):
508
+ device["apns_environment"] = fallback
509
+ public_updates += 1
510
+ if has_live_activity and not secret_device.get("apns_environment"):
511
+ secret_device["apns_environment"] = fallback
512
+ secret_updates += 1
513
+ for item in device.get("live_activities") or []:
514
+ if isinstance(item, dict) and not item.get("apns_environment"):
515
+ item["apns_environment"] = fallback
516
+ public_updates += 1
517
+ live_tokens = secret_device.get("live_activity_tokens")
518
+ if isinstance(live_tokens, dict):
519
+ for item in live_tokens.values():
520
+ if isinstance(item, dict) and not item.get("apns_environment"):
521
+ item["apns_environment"] = fallback
522
+ secret_updates += 1
523
+ if public_updates:
524
+ data["updated_at"] = self.now_fn()
525
+ self._write(data)
526
+ if secret_updates:
527
+ self._write_secrets(secrets_payload)
528
+ return {
529
+ "ok": True,
530
+ "public_updates": public_updates,
531
+ "secret_updates": secret_updates,
532
+ }
533
+
534
+ def status(self, *, device_id: str | None = None) -> dict[str, Any]:
535
+ payload = self._read()
536
+ devices = payload.get("devices", [])
537
+ outbox = payload.get("delivery_outbox", [])
538
+ deliveries = payload.get("deliveries", [])
539
+ if device_id:
540
+ devices = [item for item in devices if item.get("device_id") == device_id]
541
+ outbox = [item for item in outbox if item.get("device_id") == device_id]
542
+ deliveries = [item for item in deliveries if item.get("device_id") == device_id]
543
+ return {
544
+ "ok": True,
545
+ "contract_version": CONTRACT_VERSION,
546
+ "provider": self._provider_status(),
547
+ "devices": devices,
548
+ "delivery_outbox": outbox[-50:],
549
+ "deliveries": deliveries[-100:],
550
+ "events": payload.get("events", [])[-20:],
551
+ "updated_at": payload.get("updated_at"),
552
+ }
553
+
554
+ def update_preferences(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
555
+ device_id = _nonempty(device_id, "device_id")
556
+ data = self._read()
557
+ device = self._device_record(data, device_id, create=True)
558
+ now = self.now_fn()
559
+ device.setdefault("created_at", now)
560
+ device["last_registered_at"] = now
561
+
562
+ relay_device_id = payload.get("relay_device_id")
563
+ if isinstance(relay_device_id, str):
564
+ device["relay_device_id"] = relay_device_id.strip() or None
565
+
566
+ apns_environment = _normalize_apns_environment(payload.get("apns_environment") or device.get("apns_environment"))
567
+ if apns_environment:
568
+ device["apns_environment"] = apns_environment
569
+
570
+ apns_token = str(payload.get("apns_token") or "").strip().lower()
571
+ if apns_token:
572
+ _validate_apns_token(apns_token, "apns_token")
573
+ device["apns_token_hash"] = _sha256_hex(apns_token)
574
+ device["apns_environment"] = apns_environment
575
+ device["apns_registered_at"] = now
576
+ secrets_payload = self._read_secrets()
577
+ secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
578
+ secret_device["apns_token"] = apns_token
579
+ secret_device["apns_token_hash"] = device["apns_token_hash"]
580
+ secret_device["apns_environment"] = apns_environment
581
+ secret_device["updated_at"] = now
582
+ self._write_secrets(secrets_payload)
583
+
584
+ relay_pair_secret = str(payload.get("relay_pair_secret") or "").strip()
585
+ if relay_pair_secret:
586
+ relay_secret_ref = str(payload.get("relay_pair_secret_ref") or _sha256_hex(relay_pair_secret)).strip()
587
+ mac_install_id = str(payload.get("mac_install_id") or os.environ.get("PAIRLING_MAC_INSTALL_ID") or "").strip()
588
+ secrets_payload = self._read_secrets()
589
+ secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
590
+ secret_device["relay_pair_secret"] = relay_pair_secret
591
+ secret_device["relay_pair_secret_ref"] = relay_secret_ref
592
+ secret_device["relay_device_id"] = device.get("relay_device_id")
593
+ secret_device["mac_install_id"] = mac_install_id
594
+ secret_device["updated_at"] = now
595
+ device["relay_pair_secret_ref"] = relay_secret_ref
596
+ if mac_install_id:
597
+ device["mac_install_id"] = mac_install_id
598
+ self._write_secrets(secrets_payload)
599
+
600
+ for key in DEFAULT_PREFERENCES:
601
+ if key in payload:
602
+ if key == "quiet_hours":
603
+ device[key] = _quiet_hours(payload[key])
604
+ elif key == "push_snoozed_until":
605
+ device[key] = _optional_epoch(payload[key])
606
+ else:
607
+ device[key] = bool(payload[key])
608
+
609
+ data["updated_at"] = now
610
+ self._append_event(data, {
611
+ "event": "push.preferences.updated",
612
+ "device_id": device_id,
613
+ "outcome": "ok",
614
+ })
615
+ self._write(data)
616
+ return {"ok": True, "device": device, "provider": self._provider_status()}
617
+
618
+ def record_event(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
619
+ """Record and dispatch a production standard APNs alert event."""
620
+ return self._record_alert_delivery(
621
+ device_id=device_id,
622
+ payload=payload,
623
+ audit_event="push.event",
624
+ default_event_prefix="push",
625
+ )
626
+
627
+ def record_test(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
628
+ return self._record_alert_delivery(
629
+ device_id=device_id,
630
+ payload=payload,
631
+ audit_event="push.test",
632
+ default_event_prefix="push_test",
633
+ )
634
+
635
+ def _record_alert_delivery(
636
+ self,
637
+ *,
638
+ device_id: str,
639
+ payload: dict[str, Any],
640
+ audit_event: str,
641
+ default_event_prefix: str,
642
+ ) -> dict[str, Any]:
643
+ device_id = _nonempty(device_id, "device_id")
644
+ data = self._read()
645
+ device = self._device_record(data, device_id, create=True)
646
+ kind = str(payload.get("kind") or "push_diagnostic")[:80]
647
+ route = str(payload.get("route") or "pairling://settings/push")[:300]
648
+ title = str(payload.get("title") or "")[:90] or None
649
+ body = str(payload.get("body") or "")[:220] or None
650
+ thread_id = str(payload.get("thread_id") or "")[:120] or None
651
+ interruption_level = str(payload.get("interruption_level") or "").strip()[:40] or None
652
+ pairling_extra = _alert_pairling_extra(payload)
653
+ provider = self._provider_status()
654
+ event_id = str(payload.get("event_id") or f"{default_event_prefix}_{int(self.now_fn() * 1000)}")[:120]
655
+ metadata = _outbox_metadata_from_payload(payload, sent_at=self.now_fn())
656
+ idempotent = self._idempotent_delivery(
657
+ data,
658
+ event_id=event_id,
659
+ push_type="alert",
660
+ provider=provider,
661
+ audit_event=audit_event,
662
+ )
663
+ if idempotent:
664
+ return idempotent
665
+ sent = False
666
+ outcome = "not_configured"
667
+ delivery_extra: dict[str, Any] = {}
668
+ token_hash = device.get("apns_token_hash")
669
+ if not _alert_enabled_for_device(device, kind):
670
+ outcome = "disabled"
671
+ elif kind != "push_diagnostic" and _future_epoch(device.get("push_snoozed_until"), self.now_fn()):
672
+ outcome = "snoozed"
673
+ elif provider["mode"] == "local_apns" and provider["configured"]:
674
+ secret = self._secret_for_device(device_id)
675
+ token = secret.get("apns_token")
676
+ token_hash = secret.get("apns_token_hash") or token_hash
677
+ if not token:
678
+ outcome = "missing_token"
679
+ elif _key_environment_mismatch(provider):
680
+ outcome = "key_environment_mismatch"
681
+ delivery_extra = {
682
+ "provider_environment": _provider_environment(provider),
683
+ "key_environment": _key_environment(provider),
684
+ "retryable": False,
685
+ "invalid_token": False,
686
+ }
687
+ elif _token_environment(secret.get("apns_environment") or device.get("apns_environment")) != _provider_environment(provider):
688
+ outcome = "token_environment_mismatch"
689
+ delivery_extra = {
690
+ "provider_environment": _provider_environment(provider),
691
+ "token_environment": _token_environment(secret.get("apns_environment") or device.get("apns_environment")),
692
+ "retryable": False,
693
+ "invalid_token": False,
694
+ }
695
+ else:
696
+ outbox_row = self._upsert_outbox(
697
+ data,
698
+ event_id=event_id,
699
+ device_id=device_id,
700
+ push_type="alert",
701
+ route=route,
702
+ kind=kind,
703
+ token_hash=token_hash,
704
+ provider=provider,
705
+ state="sending",
706
+ increment_attempt=True,
707
+ metadata=metadata,
708
+ )
709
+ data["updated_at"] = self.now_fn()
710
+ self._write(data)
711
+ apns_result = self.apns_sender.send_alert(
712
+ token=token,
713
+ event_id=event_id,
714
+ kind=kind,
715
+ route=route,
716
+ title=title,
717
+ body=body,
718
+ thread_id=thread_id,
719
+ pairling_extra=pairling_extra,
720
+ interruption_level=interruption_level,
721
+ )
722
+ sent = bool(apns_result.get("sent"))
723
+ outcome = str(apns_result.get("outcome") or ("sent" if sent else "failed"))
724
+ delivery_extra = {k: v for k, v in apns_result.items() if k != "sent"}
725
+ if apns_result.get("invalid_token"):
726
+ device["last_delivery_error"] = outcome
727
+ self._complete_outbox(
728
+ data,
729
+ outbox_row,
730
+ sent=sent,
731
+ outcome=outcome,
732
+ delivery_extra=delivery_extra,
733
+ )
734
+ elif provider["mode"] == "relay" and provider["configured"]:
735
+ sent_at = float(self.now_fn())
736
+ metadata = _outbox_metadata_from_payload(payload, sent_at=sent_at)
737
+ relay_extra = self._submit_relay_event(
738
+ device_id=device_id,
739
+ device=device,
740
+ event_id=event_id,
741
+ kind=kind,
742
+ route=route,
743
+ push_type="alert",
744
+ provider=provider,
745
+ extra_body={
746
+ "title": title,
747
+ "body": body,
748
+ "thread_id": thread_id,
749
+ "interruption_level": interruption_level,
750
+ "pairling_extra": pairling_extra,
751
+ **metadata,
752
+ },
753
+ )
754
+ sent = bool(relay_extra.pop("accepted", False))
755
+ outcome = str(relay_extra.pop("outcome", "queued" if sent else "relay_failed"))
756
+ delivery_extra = relay_extra
757
+ device["last_delivery_error"] = None if sent else outcome
758
+ outbox_row = self._find_outbox(data, event_id=event_id, push_type="alert")
759
+ if outbox_row is None:
760
+ outbox_row = self._upsert_outbox(
761
+ data,
762
+ event_id=event_id,
763
+ device_id=device_id,
764
+ push_type="alert",
765
+ route=route,
766
+ kind=kind,
767
+ token_hash=token_hash,
768
+ provider=provider,
769
+ state=self._state_for_outcome(sent=sent, outcome=outcome, delivery_extra=delivery_extra),
770
+ increment_attempt=False,
771
+ metadata=metadata,
772
+ )
773
+ self._complete_outbox(
774
+ data,
775
+ outbox_row,
776
+ sent=sent,
777
+ outcome=outcome,
778
+ delivery_extra=delivery_extra,
779
+ )
780
+ event = {
781
+ "event": audit_event,
782
+ "event_id": event_id,
783
+ "device_id": device_id,
784
+ "kind": kind,
785
+ "route": route,
786
+ "sent": sent,
787
+ "outcome": outcome,
788
+ "provider_mode": provider["mode"],
789
+ "provider_environment": provider.get("environment"),
790
+ **delivery_extra,
791
+ }
792
+ self._append_event(data, event)
793
+ data["updated_at"] = self.now_fn()
794
+ self._write(data)
795
+ return {"ok": sent, "delivery": event, "provider": provider}
796
+
797
+ def record_live_activity_token(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
798
+ device_id = _nonempty(device_id, "device_id")
799
+ token = _nonempty(str(payload.get("live_activity_token") or ""), "live_activity_token").lower()
800
+ _validate_apns_token(token, "live_activity_token")
801
+ session_id = _nonempty(str(payload.get("session_id") or ""), "session_id")[:120]
802
+ activity_id = str(payload.get("activity_id") or "")[:160] or None
803
+ apns_environment = _normalize_apns_environment(payload.get("apns_environment"))
804
+ now = self.now_fn()
805
+ data = self._read()
806
+ device = self._device_record(data, device_id, create=True)
807
+ if not apns_environment:
808
+ apns_environment = _normalize_apns_environment(device.get("apns_environment"))
809
+ device["apns_environment"] = apns_environment
810
+ token_hash = _sha256_hex(token)
811
+ activities = device.setdefault("live_activities", [])
812
+ activities.append({
813
+ "session_id": session_id,
814
+ "activity_id": activity_id,
815
+ "token_hash": token_hash,
816
+ "apns_environment": apns_environment,
817
+ "registered_at": now,
818
+ "invalidated_at": None,
819
+ })
820
+ del activities[:-20]
821
+ secrets_payload = self._read_secrets()
822
+ secret_device = secrets_payload.setdefault("devices", {}).setdefault(device_id, {})
823
+ live_tokens = secret_device.setdefault("live_activity_tokens", {})
824
+ live_tokens[session_id] = {
825
+ "token": token,
826
+ "token_hash": token_hash,
827
+ "activity_id": activity_id,
828
+ "apns_environment": apns_environment,
829
+ "updated_at": now,
830
+ }
831
+ self._write_secrets(secrets_payload)
832
+ self._append_event(data, {
833
+ "event": "push.live_activity_token.registered",
834
+ "device_id": device_id,
835
+ "session_id": session_id,
836
+ "activity_id": activity_id,
837
+ "token_hash": token_hash,
838
+ "outcome": "ok",
839
+ })
840
+ data["updated_at"] = now
841
+ self._write(data)
842
+ return {"ok": True, "device": device, "provider": self._provider_status()}
843
+
844
+ def record_live_activity_event(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
845
+ """Record and dispatch a bounded production Live Activity update/end event."""
846
+ return self._record_live_activity_delivery(
847
+ device_id=device_id,
848
+ payload=payload,
849
+ audit_event="push.live_activity_event",
850
+ default_event_prefix="la",
851
+ )
852
+
853
+ def record_live_activity_test(self, *, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
854
+ return self._record_live_activity_delivery(
855
+ device_id=device_id,
856
+ payload=payload,
857
+ audit_event="push.live_activity_test",
858
+ default_event_prefix="la_test",
859
+ )
860
+
861
+ def _record_live_activity_delivery(
862
+ self,
863
+ *,
864
+ device_id: str,
865
+ payload: dict[str, Any],
866
+ audit_event: str,
867
+ default_event_prefix: str,
868
+ ) -> dict[str, Any]:
869
+ device_id = _nonempty(device_id, "device_id")
870
+ self.backfill_live_activity_environments(device_id=device_id)
871
+ session_id = _nonempty(str(payload.get("session_id") or ""), "session_id")[:120]
872
+ activity_event = str(payload.get("event") or "update").strip()
873
+ if activity_event not in {"update", "end"}:
874
+ raise PushDispatcherError("invalid_live_activity_event", "event must be update or end")
875
+ event_id = str(payload.get("event_id") or f"{default_event_prefix}_{int(self.now_fn() * 1000)}")[:120]
876
+ provider = self._provider_status()
877
+ idempotent = self._idempotent_delivery(
878
+ data := self._read(),
879
+ event_id=event_id,
880
+ push_type="liveactivity",
881
+ provider=provider,
882
+ audit_event=audit_event,
883
+ )
884
+ if idempotent:
885
+ return idempotent
886
+ sent = False
887
+ outcome = "not_configured"
888
+ delivery_extra: dict[str, Any] = {}
889
+ device = self._device_record(data, device_id, create=True)
890
+ token_hash = None
891
+ content_state = _live_activity_content_state(payload, activity_event=activity_event, event_id=event_id, now=int(self.now_fn()))
892
+ bounded_content_state = _bounded_content_state(content_state, event_id=event_id, now=int(self.now_fn()))
893
+ metadata = _live_activity_outbox_metadata(payload, content_state=bounded_content_state, sent_at=float(self.now_fn()))
894
+ if not device.get("live_activity_enabled"):
895
+ outcome = "disabled"
896
+ elif provider["mode"] == "local_apns" and provider["configured"]:
897
+ token_record = self._secret_for_device(device_id).get("live_activity_tokens", {}).get(session_id)
898
+ token = token_record.get("token") if isinstance(token_record, dict) else None
899
+ token_hash = token_record.get("token_hash") if isinstance(token_record, dict) else None
900
+ if not token:
901
+ outcome = "missing_live_activity_token"
902
+ elif _key_environment_mismatch(provider):
903
+ outcome = "key_environment_mismatch"
904
+ delivery_extra = {
905
+ "provider_environment": _provider_environment(provider),
906
+ "key_environment": _key_environment(provider),
907
+ "retryable": False,
908
+ "invalid_token": False,
909
+ }
910
+ elif _token_environment(token_record.get("apns_environment")) != _provider_environment(provider):
911
+ outcome = "token_environment_mismatch"
912
+ delivery_extra = {
913
+ "provider_environment": _provider_environment(provider),
914
+ "token_environment": _token_environment(token_record.get("apns_environment")),
915
+ "retryable": False,
916
+ "invalid_token": False,
917
+ }
918
+ else:
919
+ outbox_row = self._upsert_outbox(
920
+ data,
921
+ event_id=event_id,
922
+ device_id=device_id,
923
+ push_type="liveactivity",
924
+ route="pairling://session/" + session_id,
925
+ kind="live_activity_" + activity_event,
926
+ token_hash=token_hash,
927
+ provider=provider,
928
+ state="sending",
929
+ increment_attempt=True,
930
+ metadata=metadata,
931
+ )
932
+ data["updated_at"] = self.now_fn()
933
+ self._write(data)
934
+ apns_result = self.apns_sender.send_live_activity(
935
+ token=token,
936
+ event_id=event_id,
937
+ event=activity_event,
938
+ content_state=content_state,
939
+ stale_seconds=int(payload.get("stale_seconds") or 75),
940
+ dismissal_seconds=int(payload.get("dismissal_seconds") or 300),
941
+ )
942
+ sent = bool(apns_result.get("sent"))
943
+ outcome = str(apns_result.get("outcome") or ("sent" if sent else "failed"))
944
+ delivery_extra = {k: v for k, v in apns_result.items() if k != "sent"}
945
+ if apns_result.get("invalid_token"):
946
+ self._mark_live_activity_invalid(device, session_id, event_id, outcome)
947
+ self._complete_outbox(
948
+ data,
949
+ outbox_row,
950
+ sent=sent,
951
+ outcome=outcome,
952
+ delivery_extra=delivery_extra,
953
+ )
954
+ elif provider["mode"] == "relay" and provider["configured"]:
955
+ sent_at = float(self.now_fn())
956
+ metadata = _live_activity_outbox_metadata(payload, content_state=bounded_content_state, sent_at=sent_at)
957
+ relay_extra = self._submit_relay_event(
958
+ device_id=device_id,
959
+ device=device,
960
+ event_id=event_id,
961
+ kind="live_activity_" + activity_event,
962
+ route="pairling://session/" + session_id,
963
+ push_type="liveactivity",
964
+ provider=provider,
965
+ extra_body={
966
+ "session_id": session_id,
967
+ "activity_event": activity_event,
968
+ "content_state": bounded_content_state,
969
+ "stale_seconds": _bounded_int(payload.get("stale_seconds"), default=75, minimum=30, maximum=3600),
970
+ "dismissal_seconds": _bounded_int(payload.get("dismissal_seconds"), default=300, minimum=0, maximum=86400),
971
+ **metadata,
972
+ },
973
+ )
974
+ sent = bool(relay_extra.pop("accepted", False))
975
+ outcome = str(relay_extra.pop("outcome", "queued" if sent else "relay_failed"))
976
+ delivery_extra = relay_extra
977
+ device["last_delivery_error"] = None if sent else outcome
978
+ outbox_row = self._find_outbox(data, event_id=event_id, push_type="liveactivity")
979
+ if outbox_row is None:
980
+ outbox_row = self._upsert_outbox(
981
+ data,
982
+ event_id=event_id,
983
+ device_id=device_id,
984
+ push_type="liveactivity",
985
+ route="pairling://session/" + session_id,
986
+ kind="live_activity_" + activity_event,
987
+ token_hash=token_hash,
988
+ provider=provider,
989
+ state=self._state_for_outcome(sent=sent, outcome=outcome, delivery_extra=delivery_extra),
990
+ increment_attempt=False,
991
+ metadata=metadata,
992
+ )
993
+ self._complete_outbox(
994
+ data,
995
+ outbox_row,
996
+ sent=sent,
997
+ outcome=outcome,
998
+ delivery_extra=delivery_extra,
999
+ )
1000
+ _apply_outbox_metadata(outbox_row, _live_activity_outbox_metadata(payload, content_state=content_state, sent_at=float(self.now_fn()), apns_outcome=outcome))
1001
+ event = {
1002
+ "event": audit_event,
1003
+ "event_id": event_id,
1004
+ "device_id": device_id,
1005
+ "session_id": session_id,
1006
+ "activity_event": activity_event,
1007
+ "sent": sent,
1008
+ "outcome": outcome,
1009
+ "provider_mode": provider["mode"],
1010
+ "provider_environment": provider.get("environment"),
1011
+ **delivery_extra,
1012
+ }
1013
+ self._append_event(data, event)
1014
+ data["updated_at"] = self.now_fn()
1015
+ self._write(data)
1016
+ return {"ok": sent, "delivery": event, "provider": provider}
1017
+
1018
+ def _idempotent_delivery(
1019
+ self,
1020
+ data: dict[str, Any],
1021
+ *,
1022
+ event_id: str,
1023
+ push_type: str,
1024
+ provider: dict[str, Any],
1025
+ audit_event: str | None = None,
1026
+ ) -> dict[str, Any] | None:
1027
+ row = self._find_outbox(data, event_id=event_id, push_type=push_type)
1028
+ if not row:
1029
+ return None
1030
+ state = row.get("state")
1031
+ if state == "pending" and float(row.get("next_attempt_at") or 0) <= self.now_fn():
1032
+ return None
1033
+ delivery = self._latest_delivery(data, event_id=event_id, push_type=push_type) or {}
1034
+ response = {
1035
+ **delivery,
1036
+ "event": audit_event or ("push.live_activity_test" if push_type == "liveactivity" else "push.test"),
1037
+ "event_id": event_id,
1038
+ "device_id": row.get("device_id"),
1039
+ "sent": state == "sent",
1040
+ "outcome": row.get("last_outcome") or delivery.get("outcome") or state,
1041
+ "provider_mode": row.get("provider_mode"),
1042
+ "provider_environment": row.get("provider_environment"),
1043
+ "idempotent": True,
1044
+ }
1045
+ return {"ok": state == "sent", "delivery": response, "provider": provider}
1046
+
1047
+ def _find_outbox(self, data: dict[str, Any], *, event_id: str, push_type: str) -> dict[str, Any] | None:
1048
+ for item in data.setdefault("delivery_outbox", []):
1049
+ if item.get("event_id") == event_id and item.get("push_type") == push_type:
1050
+ return item
1051
+ return None
1052
+
1053
+ def _latest_delivery(self, data: dict[str, Any], *, event_id: str, push_type: str) -> dict[str, Any] | None:
1054
+ for item in reversed(data.setdefault("deliveries", [])):
1055
+ if item.get("event_id") == event_id and item.get("push_type") == push_type:
1056
+ return item
1057
+ return None
1058
+
1059
+ def _upsert_outbox(
1060
+ self,
1061
+ data: dict[str, Any],
1062
+ *,
1063
+ event_id: str,
1064
+ device_id: str,
1065
+ push_type: str,
1066
+ route: str,
1067
+ kind: str,
1068
+ token_hash: str | None,
1069
+ provider: dict[str, Any],
1070
+ state: str,
1071
+ increment_attempt: bool,
1072
+ metadata: dict[str, Any] | None = None,
1073
+ ) -> dict[str, Any]:
1074
+ now = self.now_fn()
1075
+ row = self._find_outbox(data, event_id=event_id, push_type=push_type)
1076
+ if row is None:
1077
+ row = {
1078
+ "event_id": event_id,
1079
+ "device_id": device_id,
1080
+ "push_type": push_type,
1081
+ "kind": kind,
1082
+ "route": route,
1083
+ "token_hash": token_hash,
1084
+ "state": "pending",
1085
+ "next_attempt_at": now,
1086
+ "attempt_count": 0,
1087
+ "created_at": now,
1088
+ "updated_at": now,
1089
+ "provider_mode": provider.get("mode"),
1090
+ "provider_environment": provider.get("environment"),
1091
+ "key_environment": provider.get("key_environment"),
1092
+ "last_outcome": None,
1093
+ }
1094
+ data.setdefault("delivery_outbox", []).append(row)
1095
+ row["state"] = state
1096
+ row["updated_at"] = now
1097
+ row["token_hash"] = token_hash or row.get("token_hash")
1098
+ row["provider_mode"] = provider.get("mode")
1099
+ row["provider_environment"] = provider.get("environment")
1100
+ row["key_environment"] = provider.get("key_environment")
1101
+ if metadata:
1102
+ for key in [
1103
+ "source",
1104
+ "phase",
1105
+ "project",
1106
+ "observed_at",
1107
+ "sent_at",
1108
+ "collapse_id",
1109
+ "freshness_seconds_at_send",
1110
+ "content_state_hash",
1111
+ "apns_outcome",
1112
+ ]:
1113
+ if key in metadata:
1114
+ row[key] = metadata[key]
1115
+ if increment_attempt:
1116
+ row["attempt_count"] = int(row.get("attempt_count") or 0) + 1
1117
+ row["locked_at"] = now
1118
+ del data.setdefault("delivery_outbox", [])[:-200]
1119
+ return row
1120
+
1121
+ def _complete_outbox(
1122
+ self,
1123
+ data: dict[str, Any],
1124
+ row: dict[str, Any],
1125
+ *,
1126
+ sent: bool,
1127
+ outcome: str,
1128
+ delivery_extra: dict[str, Any],
1129
+ ) -> None:
1130
+ attempt_count = int(row.get("attempt_count") or 0)
1131
+ state = self._state_for_outcome(
1132
+ sent=sent,
1133
+ outcome=outcome,
1134
+ delivery_extra={**delivery_extra, "attempt_count": attempt_count},
1135
+ )
1136
+ now = self.now_fn()
1137
+ row["state"] = state
1138
+ row["updated_at"] = now
1139
+ row["last_outcome"] = outcome
1140
+ row["locked_at"] = None
1141
+ row["sent_at"] = now
1142
+ row["apns_outcome"] = outcome
1143
+ retryable = bool(delivery_extra.get("retryable"))
1144
+ if state == "pending" and retryable:
1145
+ row["next_attempt_at"] = now + min(300, 15 * (2 ** max(0, attempt_count - 1)))
1146
+ elif state == "dead_letter":
1147
+ row["next_attempt_at"] = None
1148
+ elif state != "pending":
1149
+ row["next_attempt_at"] = None
1150
+ final_outcome = "retry_scheduled" if state == "pending" and retryable else outcome
1151
+ data.setdefault("deliveries", []).append({
1152
+ "event_id": row.get("event_id"),
1153
+ "device_id": row.get("device_id"),
1154
+ "push_type": row.get("push_type"),
1155
+ "token_hash": row.get("token_hash"),
1156
+ "attempt_count": row.get("attempt_count") or 0,
1157
+ "state": state,
1158
+ "outcome": outcome,
1159
+ "final_outcome": final_outcome,
1160
+ "apns_id": delivery_extra.get("apns_id"),
1161
+ "apns_status": delivery_extra.get("apns_status"),
1162
+ "apns_reason": delivery_extra.get("apns_reason"),
1163
+ "apns_outcome": outcome,
1164
+ "retryable": retryable,
1165
+ "invalid_token": bool(delivery_extra.get("invalid_token")),
1166
+ "ts": now,
1167
+ })
1168
+ del data.setdefault("deliveries", [])[:-300]
1169
+
1170
+ def _state_for_outcome(self, *, sent: bool, outcome: str, delivery_extra: dict[str, Any]) -> str:
1171
+ if sent:
1172
+ return "sent"
1173
+ if delivery_extra.get("invalid_token"):
1174
+ return "invalidated"
1175
+ if delivery_extra.get("retryable"):
1176
+ return "dead_letter" if int(delivery_extra.get("attempt_count") or 0) >= 3 else "pending"
1177
+ if outcome in {"disabled", "snoozed"}:
1178
+ return "suppressed"
1179
+ if outcome in {
1180
+ "not_configured",
1181
+ "missing_token",
1182
+ "missing_live_activity_token",
1183
+ "key_environment_mismatch",
1184
+ "token_environment_mismatch",
1185
+ }:
1186
+ return "credential_blocked"
1187
+ return "dead_letter"
1188
+
1189
+ def _provider_status(self) -> dict[str, Any]:
1190
+ return self.apns_sender.status()
1191
+
1192
+ def _submit_relay_event(
1193
+ self,
1194
+ *,
1195
+ device_id: str,
1196
+ device: dict[str, Any],
1197
+ event_id: str,
1198
+ kind: str,
1199
+ route: str,
1200
+ push_type: str,
1201
+ provider: dict[str, Any],
1202
+ extra_body: dict[str, Any] | None = None,
1203
+ ) -> dict[str, Any]:
1204
+ secret = self._secret_for_device(device_id)
1205
+ relay_pair_secret = str(secret.get("relay_pair_secret") or "").strip()
1206
+ relay_device_id = str(device.get("relay_device_id") or secret.get("relay_device_id") or "").strip()
1207
+ mac_install_id = str(secret.get("mac_install_id") or device.get("mac_install_id") or os.environ.get("PAIRLING_MAC_INSTALL_ID") or "").strip()
1208
+ if not relay_pair_secret or not relay_device_id or not mac_install_id:
1209
+ return {
1210
+ "accepted": False,
1211
+ "outcome": "relay_pair_secret_missing",
1212
+ "retryable": False,
1213
+ "invalid_token": False,
1214
+ }
1215
+ body: dict[str, Any] = {
1216
+ "relay_device_id": relay_device_id,
1217
+ "mac_install_id": mac_install_id,
1218
+ "event_id": event_id,
1219
+ "kind": kind,
1220
+ "severity": "warning" if kind in {
1221
+ "session_attention",
1222
+ "worker_sentinel",
1223
+ "mac_health",
1224
+ "action_required",
1225
+ "turn_failed",
1226
+ "tool_risk",
1227
+ "mac_route_risk",
1228
+ "worker_pressure",
1229
+ } else "info",
1230
+ "route": route,
1231
+ "dedupe_key": _thread_id(kind, route),
1232
+ "push_type": push_type,
1233
+ }
1234
+ if extra_body:
1235
+ body.update(extra_body)
1236
+ relay_url = str(provider.get("relay_url") or "").strip()
1237
+ if not relay_url:
1238
+ return {
1239
+ "accepted": False,
1240
+ "outcome": "relay_url_missing",
1241
+ "retryable": False,
1242
+ "invalid_token": False,
1243
+ }
1244
+ return self.relay_sender.submit_event(
1245
+ relay_url=relay_url,
1246
+ relay_pair_secret=relay_pair_secret,
1247
+ event_body=body,
1248
+ )
1249
+
1250
+ def _device_record(self, data: dict[str, Any], device_id: str, *, create: bool) -> dict[str, Any]:
1251
+ devices = data.setdefault("devices", [])
1252
+ for item in devices:
1253
+ if item.get("device_id") == device_id:
1254
+ for key, value in DEFAULT_PREFERENCES.items():
1255
+ item.setdefault(key, value)
1256
+ item.setdefault("relay_device_id", None)
1257
+ item.setdefault("last_delivery_error", None)
1258
+ return item
1259
+ if not create:
1260
+ raise PushDispatcherError("push_device_not_found", "push device is not registered", 404)
1261
+ item = {
1262
+ "device_id": device_id,
1263
+ "relay_device_id": None,
1264
+ "last_registered_at": self.now_fn(),
1265
+ "last_delivery_error": None,
1266
+ **DEFAULT_PREFERENCES,
1267
+ }
1268
+ devices.append(item)
1269
+ return item
1270
+
1271
+ def _append_event(self, data: dict[str, Any], event: dict[str, Any]) -> None:
1272
+ events = data.setdefault("events", [])
1273
+ events.append({"ts": self.now_fn(), **event})
1274
+ del events[:-100]
1275
+
1276
+ def _read(self) -> dict[str, Any]:
1277
+ with self._lock:
1278
+ try:
1279
+ raw = self.registry_path.read_text()
1280
+ data = json.loads(raw)
1281
+ except FileNotFoundError:
1282
+ data = {}
1283
+ except json.JSONDecodeError as exc:
1284
+ data = self._recover_registry_json(raw, exc)
1285
+ if not isinstance(data, dict):
1286
+ raise PushDispatcherError("push_registry_corrupt", "push registry root is not an object", 500)
1287
+ data.setdefault("schema_version", 1)
1288
+ data.setdefault("contract_version", CONTRACT_VERSION)
1289
+ data.setdefault("devices", [])
1290
+ data.setdefault("events", [])
1291
+ data.setdefault("delivery_outbox", [])
1292
+ data.setdefault("deliveries", [])
1293
+ repaired = self._rehydrate_registry_from_quarantine_backup(data)
1294
+ if self._quarantine_malformed_registry_records(data):
1295
+ repaired = True
1296
+ if repaired:
1297
+ data["updated_at"] = self.now_fn()
1298
+ self._write(data)
1299
+ return data
1300
+
1301
+ def _quarantine_malformed_registry_records(self, data: dict[str, Any]) -> bool:
1302
+ repaired = False
1303
+ devices = data.get("devices")
1304
+ if not isinstance(devices, list):
1305
+ self._append_quarantine(
1306
+ data,
1307
+ bucket="quarantined_devices",
1308
+ reason="devices_not_list",
1309
+ index=None,
1310
+ value=devices,
1311
+ )
1312
+ data["devices"] = []
1313
+ repaired = True
1314
+ else:
1315
+ valid_devices: list[dict[str, Any]] = []
1316
+ for index, item in enumerate(devices):
1317
+ if not isinstance(item, dict):
1318
+ self._append_quarantine(
1319
+ data,
1320
+ bucket="quarantined_devices",
1321
+ reason="device_record_not_object",
1322
+ index=index,
1323
+ value=item,
1324
+ )
1325
+ repaired = True
1326
+ continue
1327
+ device_id = str(item.get("device_id") or "").strip()
1328
+ if not device_id:
1329
+ self._append_quarantine(
1330
+ data,
1331
+ bucket="quarantined_devices",
1332
+ reason="device_record_missing_device_id",
1333
+ index=index,
1334
+ value=item,
1335
+ )
1336
+ repaired = True
1337
+ continue
1338
+ item["device_id"] = device_id
1339
+ valid_devices.append(item)
1340
+ if len(valid_devices) != len(devices):
1341
+ data["devices"] = valid_devices
1342
+ repaired = True
1343
+
1344
+ for key in ("events", "delivery_outbox", "deliveries"):
1345
+ value = data.get(key)
1346
+ if isinstance(value, list):
1347
+ continue
1348
+ self._append_quarantine(
1349
+ data,
1350
+ bucket="quarantined_records",
1351
+ reason=f"{key}_not_list",
1352
+ index=None,
1353
+ value=value,
1354
+ )
1355
+ data[key] = []
1356
+ repaired = True
1357
+
1358
+ if repaired:
1359
+ self._append_event(data, {
1360
+ "event": "push.registry.quarantined",
1361
+ "outcome": "repaired",
1362
+ })
1363
+ return repaired
1364
+
1365
+ def _append_quarantine(
1366
+ self,
1367
+ data: dict[str, Any],
1368
+ *,
1369
+ bucket: str,
1370
+ reason: str,
1371
+ index: int | None,
1372
+ value: Any,
1373
+ ) -> None:
1374
+ records = data.get(bucket)
1375
+ if not isinstance(records, list):
1376
+ records = []
1377
+ data[bucket] = records
1378
+ entry: dict[str, Any] = {
1379
+ "ts": self.now_fn(),
1380
+ "reason": reason,
1381
+ "value_type": type(value).__name__,
1382
+ "value_preview": repr(value)[:1000],
1383
+ }
1384
+ if index is not None:
1385
+ entry["index"] = index
1386
+ records.append(entry)
1387
+ del records[:-100]
1388
+
1389
+ def _recover_registry_json(self, raw: str, exc: json.JSONDecodeError) -> dict[str, Any]:
1390
+ decoder = json.JSONDecoder()
1391
+ try:
1392
+ data, end = decoder.raw_decode(raw)
1393
+ except json.JSONDecodeError:
1394
+ return self._quarantine_unreadable_registry(raw, exc)
1395
+ if not isinstance(data, dict):
1396
+ return self._quarantine_unreadable_registry(raw, exc)
1397
+ if raw[end:].strip() == "":
1398
+ return self._quarantine_unreadable_registry(raw, exc)
1399
+
1400
+ self._backup_corrupt_registry(raw)
1401
+ self._write(data)
1402
+ return data
1403
+
1404
+ def _quarantine_unreadable_registry(self, raw: str, exc: json.JSONDecodeError) -> dict[str, Any]:
1405
+ backup = self._backup_corrupt_registry(raw)
1406
+ salvaged, bucket_errors = self._salvage_registry_members(raw)
1407
+ data: dict[str, Any] = {
1408
+ "schema_version": 1,
1409
+ "contract_version": CONTRACT_VERSION,
1410
+ "devices": salvaged.get("devices") or [],
1411
+ "events": salvaged.get("events") or [],
1412
+ "delivery_outbox": salvaged.get("delivery_outbox") or [],
1413
+ "deliveries": salvaged.get("deliveries") or [],
1414
+ "updated_at": self.now_fn(),
1415
+ }
1416
+ self._append_quarantine(
1417
+ data,
1418
+ bucket="quarantined_records",
1419
+ reason="registry_json_decode_error",
1420
+ index=None,
1421
+ value={
1422
+ "message": exc.msg,
1423
+ "line": exc.lineno,
1424
+ "column": exc.colno,
1425
+ "position": exc.pos,
1426
+ "backup_path": str(backup) if backup else None,
1427
+ },
1428
+ )
1429
+ record = data["quarantined_records"][-1]
1430
+ record["line"] = exc.lineno
1431
+ record["column"] = exc.colno
1432
+ record["position"] = exc.pos
1433
+ if backup:
1434
+ record["backup_path"] = str(backup)
1435
+ for key, error in bucket_errors.items():
1436
+ self._append_quarantine(
1437
+ data,
1438
+ bucket="quarantined_records",
1439
+ reason=f"{key}_json_decode_error",
1440
+ index=None,
1441
+ value={
1442
+ "message": error,
1443
+ "backup_path": str(backup) if backup else None,
1444
+ },
1445
+ )
1446
+ self._append_event(data, {
1447
+ "event": "push.registry.quarantined",
1448
+ "outcome": "repaired",
1449
+ "reason": "registry_json_decode_error",
1450
+ })
1451
+ self._write(data)
1452
+ return data
1453
+
1454
+ def _salvage_registry_members(self, raw: str) -> tuple[dict[str, Any], dict[str, str]]:
1455
+ salvaged: dict[str, Any] = {}
1456
+ errors: dict[str, str] = {}
1457
+ for key in ("devices", "events", "delivery_outbox", "deliveries"):
1458
+ value, error = self._extract_json_member(raw, key)
1459
+ if error:
1460
+ errors[key] = error
1461
+ elif isinstance(value, list):
1462
+ salvaged[key] = value
1463
+ elif value is not None:
1464
+ errors[key] = f"{key} is {type(value).__name__}, not list"
1465
+ return salvaged, errors
1466
+
1467
+ def _extract_json_member(self, raw: str, key: str) -> tuple[Any | None, str | None]:
1468
+ needle = '"' + key + '"'
1469
+ pos = raw.find(needle)
1470
+ if pos < 0:
1471
+ return None, "missing"
1472
+ colon = raw.find(":", pos + len(needle))
1473
+ if colon < 0:
1474
+ return None, "missing colon"
1475
+ start = colon + 1
1476
+ while start < len(raw) and raw[start].isspace():
1477
+ start += 1
1478
+ if start >= len(raw):
1479
+ return None, "missing value"
1480
+ opener = raw[start]
1481
+ closer = {"[": "]", "{": "}"}.get(opener)
1482
+ if closer is None:
1483
+ return None, "value is not a JSON container"
1484
+ depth = 0
1485
+ in_string = False
1486
+ escape = False
1487
+ for index in range(start, len(raw)):
1488
+ char = raw[index]
1489
+ if in_string:
1490
+ if escape:
1491
+ escape = False
1492
+ elif char == "\\":
1493
+ escape = True
1494
+ elif char == '"':
1495
+ in_string = False
1496
+ continue
1497
+ if char == '"':
1498
+ in_string = True
1499
+ elif char == opener:
1500
+ depth += 1
1501
+ elif char == closer:
1502
+ depth -= 1
1503
+ if depth == 0:
1504
+ text = raw[start:index + 1]
1505
+ try:
1506
+ return json.loads(text), None
1507
+ except json.JSONDecodeError as exc:
1508
+ return None, str(exc)
1509
+ return None, "unclosed container"
1510
+
1511
+ def _rehydrate_registry_from_quarantine_backup(self, data: dict[str, Any]) -> bool:
1512
+ records = data.get("quarantined_records")
1513
+ if not isinstance(records, list):
1514
+ return False
1515
+ devices = data.setdefault("devices", [])
1516
+ if not isinstance(devices, list):
1517
+ return False
1518
+ existing_ids = {
1519
+ str(item.get("device_id") or "").strip()
1520
+ for item in devices
1521
+ if isinstance(item, dict)
1522
+ }
1523
+ for record in reversed(records):
1524
+ if not isinstance(record, dict):
1525
+ continue
1526
+ if record.get("reason") != "registry_json_decode_error":
1527
+ continue
1528
+ backup_path = str(record.get("backup_path") or "").strip()
1529
+ if not backup_path:
1530
+ continue
1531
+ try:
1532
+ raw = Path(backup_path).read_text(encoding="utf-8", errors="replace")
1533
+ except OSError:
1534
+ continue
1535
+ salvaged, _ = self._salvage_registry_members(raw)
1536
+ restored: list[dict[str, Any]] = []
1537
+ for item in salvaged.get("devices") or []:
1538
+ if not isinstance(item, dict):
1539
+ continue
1540
+ device_id = str(item.get("device_id") or "").strip()
1541
+ if not device_id or device_id in existing_ids:
1542
+ continue
1543
+ item["device_id"] = device_id
1544
+ devices.append(item)
1545
+ existing_ids.add(device_id)
1546
+ restored.append(item)
1547
+ if restored:
1548
+ self._append_quarantine(
1549
+ data,
1550
+ bucket="quarantined_records",
1551
+ reason="devices_rehydrated_from_backup",
1552
+ index=None,
1553
+ value={
1554
+ "backup_path": backup_path,
1555
+ "device_count": len(restored),
1556
+ },
1557
+ )
1558
+ self._append_event(data, {
1559
+ "event": "push.registry.rehydrated",
1560
+ "outcome": "repaired",
1561
+ "device_count": len(restored),
1562
+ })
1563
+ return True
1564
+ return False
1565
+
1566
+ def _backup_corrupt_registry(self, raw: str) -> Path | None:
1567
+ backup = self.registry_path.with_name(
1568
+ f"{self.registry_path.name}.corrupt-{int(self.now_fn())}-{uuid.uuid4().hex[:8]}"
1569
+ )
1570
+ try:
1571
+ backup.write_text(raw, encoding="utf-8")
1572
+ os.chmod(backup, 0o600)
1573
+ return backup
1574
+ except OSError:
1575
+ return None
1576
+
1577
+ def _read_secrets(self) -> dict[str, Any]:
1578
+ try:
1579
+ data = json.loads(self.secret_path.read_text())
1580
+ except FileNotFoundError:
1581
+ data = {}
1582
+ except json.JSONDecodeError as exc:
1583
+ raise PushDispatcherError("push_secret_store_corrupt", f"push secret store is corrupt: {exc}", 500)
1584
+ if not isinstance(data, dict):
1585
+ raise PushDispatcherError("push_secret_store_corrupt", "push secret store root is not an object", 500)
1586
+ data.setdefault("schema_version", 1)
1587
+ data.setdefault("devices", {})
1588
+ return data
1589
+
1590
+ def _secret_for_device(self, device_id: str) -> dict[str, Any]:
1591
+ try:
1592
+ data = self._read_secrets()
1593
+ except PushDispatcherError:
1594
+ return {}
1595
+ device = data.get("devices", {}).get(device_id)
1596
+ return device if isinstance(device, dict) else {}
1597
+
1598
+ def _mark_live_activity_invalid(self, device: dict[str, Any], session_id: str, event_id: str, outcome: str) -> None:
1599
+ for item in device.get("live_activities", []):
1600
+ if item.get("session_id") == session_id and not item.get("invalidated_at"):
1601
+ item["invalidated_at"] = self.now_fn()
1602
+ item["invalidated_by_event_id"] = event_id
1603
+ item["invalidated_reason"] = outcome
1604
+
1605
+ def _write(self, payload: dict[str, Any]) -> None:
1606
+ with self._lock:
1607
+ self.registry_path.parent.mkdir(parents=True, exist_ok=True)
1608
+ try:
1609
+ # A push registry directory must be user-private; 0o700 is stricter than world-readable defaults.
1610
+ os.chmod(self.registry_path.parent, stat.S_IRWXU)
1611
+ except OSError:
1612
+ pass
1613
+ tmp = self.registry_path.with_name(
1614
+ f"{self.registry_path.name}.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex}.tmp"
1615
+ )
1616
+ try:
1617
+ with tmp.open("w") as fh:
1618
+ json.dump(payload, fh, indent=2, sort_keys=True)
1619
+ fh.write("\n")
1620
+ fh.flush()
1621
+ os.fsync(fh.fileno())
1622
+ os.replace(tmp, self.registry_path)
1623
+ finally:
1624
+ try:
1625
+ tmp.unlink()
1626
+ except FileNotFoundError:
1627
+ pass
1628
+ except OSError:
1629
+ pass
1630
+ try:
1631
+ os.chmod(self.registry_path, 0o600)
1632
+ except OSError:
1633
+ pass
1634
+
1635
+ def _write_secrets(self, payload: dict[str, Any]) -> None:
1636
+ self.secret_path.parent.mkdir(parents=True, exist_ok=True)
1637
+ try:
1638
+ os.chmod(self.secret_path.parent, stat.S_IRWXU)
1639
+ except OSError:
1640
+ pass
1641
+ tmp = self.secret_path.with_suffix(self.secret_path.suffix + ".tmp")
1642
+ with tmp.open("w") as fh:
1643
+ json.dump(payload, fh, indent=2, sort_keys=True)
1644
+ fh.write("\n")
1645
+ fh.flush()
1646
+ os.fsync(fh.fileno())
1647
+ os.replace(tmp, self.secret_path)
1648
+ try:
1649
+ os.chmod(self.secret_path, 0o600)
1650
+ except OSError:
1651
+ pass
1652
+
1653
+
1654
+ def _nonempty(value: str | None, field: str) -> str:
1655
+ text = str(value or "").strip()
1656
+ if not text:
1657
+ raise PushDispatcherError("missing_" + field, field.replace("_", " ") + " is required")
1658
+ return text
1659
+
1660
+
1661
+ def _validate_apns_token(token: str, field: str) -> None:
1662
+ if len(token) < APNS_TOKEN_MIN_HEX_CHARS or len(token) > APNS_TOKEN_MAX_HEX_CHARS:
1663
+ raise PushDispatcherError("invalid_" + field, field.replace("_", " ") + " length is invalid")
1664
+ try:
1665
+ int(token, 16)
1666
+ except ValueError:
1667
+ raise PushDispatcherError("invalid_" + field, field.replace("_", " ") + " must be hex")
1668
+
1669
+
1670
+ def _normalize_apns_environment(value: Any) -> str:
1671
+ text = str(value or "").strip().lower()
1672
+ if text == "sandbox":
1673
+ return "development"
1674
+ if text in APNS_ENVIRONMENTS:
1675
+ return text
1676
+ return "development"
1677
+
1678
+
1679
+ def _normalize_apns_key_environment(value: Any) -> str:
1680
+ text = str(value or "").strip().lower()
1681
+ if text in {"any", "all"}:
1682
+ return "both"
1683
+ if text == "sandbox":
1684
+ return "development"
1685
+ if text in APNS_KEY_ENVIRONMENTS:
1686
+ return text
1687
+ return "development"
1688
+
1689
+
1690
+ def _token_environment(value: Any) -> str:
1691
+ return _normalize_apns_environment(value)
1692
+
1693
+
1694
+ def _provider_environment(provider: dict[str, Any]) -> str:
1695
+ return _normalize_apns_environment(provider.get("environment"))
1696
+
1697
+
1698
+ def _key_environment(provider: dict[str, Any]) -> str:
1699
+ return _normalize_apns_key_environment(provider.get("key_environment") or provider.get("environment"))
1700
+
1701
+
1702
+ def _key_environment_mismatch(provider: dict[str, Any]) -> bool:
1703
+ key_environment = _key_environment(provider)
1704
+ return key_environment != "both" and key_environment != _provider_environment(provider)
1705
+
1706
+
1707
+ def _b64url(data: bytes) -> str:
1708
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
1709
+
1710
+
1711
+ def _json_dump(obj: Any) -> str:
1712
+ return json.dumps(obj, separators=(",", ":"), sort_keys=True)
1713
+
1714
+
1715
+ def _sha256_hex(value: str) -> str:
1716
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
1717
+
1718
+
1719
+ def _infer_apns_key_id(path: str) -> str:
1720
+ name = Path(path).name
1721
+ if name.startswith("AuthKey_") and name.endswith(".p8"):
1722
+ return name[len("AuthKey_"):-len(".p8")]
1723
+ return ""
1724
+
1725
+
1726
+ def _thread_id(kind: str, route: str) -> str:
1727
+ if "/session/" in route:
1728
+ return "pairling.session." + route.rsplit("/", 1)[-1][:80]
1729
+ if kind in {"mac_health", "mac_route_risk"}:
1730
+ return "pairling.health"
1731
+ if kind in {"worker_sentinel", "worker_pressure"}:
1732
+ return "pairling.workers"
1733
+ return "pairling.push"
1734
+
1735
+
1736
+ def _alert_enabled_for_device(device: dict[str, Any], kind: str) -> bool:
1737
+ if not device.get("standard_push_enabled"):
1738
+ return False
1739
+ if kind == "push_diagnostic":
1740
+ return bool(device.get("push_diagnostics_enabled"))
1741
+ if kind in {"turn_done", "turn_result", "deploy_result"}:
1742
+ return bool(device.get("turn_done_enabled"))
1743
+ if kind in {"worker_sentinel", "worker_pressure"}:
1744
+ return bool(device.get("worker_sentinel_enabled"))
1745
+ return True
1746
+
1747
+
1748
+ def _alert_pairling_extra(payload: dict[str, Any]) -> dict[str, Any]:
1749
+ allowed = {
1750
+ "session_id",
1751
+ "provider",
1752
+ "source",
1753
+ "phase",
1754
+ "project",
1755
+ "observed_at",
1756
+ "collapse_id",
1757
+ "dedupe_key",
1758
+ "result_summary",
1759
+ "required_action",
1760
+ "risk_summary",
1761
+ "route_health",
1762
+ "worker_summary",
1763
+ "build_label",
1764
+ "sentinel_event_id",
1765
+ "sentinel_level",
1766
+ "sentinel_key",
1767
+ "health_posture",
1768
+ "health_severity",
1769
+ "health_summary",
1770
+ }
1771
+ out: dict[str, Any] = {}
1772
+ for key in allowed:
1773
+ if key not in payload:
1774
+ continue
1775
+ value = payload[key]
1776
+ if isinstance(value, str):
1777
+ out[key] = value[:180]
1778
+ elif isinstance(value, (int, float, bool)) or value is None:
1779
+ out[key] = value
1780
+ return out
1781
+
1782
+
1783
+ def _split_curl_status(stdout: str) -> tuple[str, int | None]:
1784
+ if "\n" not in stdout:
1785
+ return stdout, None
1786
+ response_text, status_text = stdout.rsplit("\n", 1)
1787
+ try:
1788
+ return response_text, int(status_text.strip())
1789
+ except ValueError:
1790
+ return stdout, None
1791
+
1792
+
1793
+ def _apns_outcome(status: int | None, reason: str | None, curl_exit_code: int) -> str:
1794
+ if curl_exit_code != 0:
1795
+ return f"curl_error_{curl_exit_code}"
1796
+ if status is None:
1797
+ return "apns_unknown"
1798
+ if reason:
1799
+ return f"apns_{status}_{reason}"
1800
+ return f"apns_{status}"
1801
+
1802
+
1803
+ def _bounded_content_state(content_state: dict[str, Any], *, event_id: str, now: int) -> dict[str, Any]:
1804
+ state = str(content_state.get("state") or "starting")[:40]
1805
+ if state not in {"starting", "thinking", "tool", "responding", "attention", "stale", "done", "failed", "idle"}:
1806
+ state = "starting"
1807
+ attention = content_state.get("attentionLevel")
1808
+ if attention is not None:
1809
+ attention = str(attention)[:20]
1810
+ if attention not in {"info", "warning", "critical"}:
1811
+ attention = None
1812
+ tokens = content_state.get("tokens")
1813
+ try:
1814
+ parsed_tokens = int(tokens) if tokens is not None else None
1815
+ except (TypeError, ValueError):
1816
+ parsed_tokens = None
1817
+ phase = str(content_state.get("phase") or state)[:32]
1818
+ if phase not in {"starting", "thinking", "tool", "responding", "attention", "stale", "done", "failed", "idle", "risk"}:
1819
+ phase = state
1820
+ return {
1821
+ "state": state,
1822
+ "phase": phase,
1823
+ "tool": _bounded_optional(content_state.get("tool"), 80),
1824
+ "effort": _bounded_optional(content_state.get("effort"), 40),
1825
+ "tokens": parsed_tokens,
1826
+ "verb": str(content_state.get("verb") or "Working")[:40],
1827
+ "attentionLevel": attention,
1828
+ "updatedAtEpoch": float(content_state.get("updatedAtEpoch") or now),
1829
+ "eventId": str(content_state.get("eventId") or event_id)[:120],
1830
+ "sessionTitle": _bounded_optional(content_state.get("sessionTitle"), 60),
1831
+ "provider": _bounded_optional(content_state.get("provider"), 32),
1832
+ "project": _bounded_optional(content_state.get("project"), 60),
1833
+ "currentStep": _bounded_optional(content_state.get("currentStep"), 60),
1834
+ "latestEvent": _bounded_optional(content_state.get("latestEvent"), 120),
1835
+ "resultSummary": _bounded_optional(content_state.get("resultSummary"), 120),
1836
+ "requiredAction": _bounded_optional(content_state.get("requiredAction"), 120),
1837
+ "freshness": _bounded_optional(content_state.get("freshness"), 40),
1838
+ "riskLevel": _bounded_optional(content_state.get("riskLevel"), 32),
1839
+ "riskSummary": _bounded_optional(content_state.get("riskSummary"), 120),
1840
+ "routeHealth": _bounded_optional(content_state.get("routeHealth"), 120),
1841
+ "workerSummary": _bounded_optional(content_state.get("workerSummary"), 120),
1842
+ "buildLabel": _bounded_optional(content_state.get("buildLabel"), 60),
1843
+ "actionRoute": _bounded_optional(content_state.get("actionRoute"), 300),
1844
+ }
1845
+
1846
+
1847
+ def _live_activity_content_state(payload: dict[str, Any], *, activity_event: str, event_id: str, now: int) -> dict[str, Any]:
1848
+ content_state = payload.get("content_state")
1849
+ if isinstance(content_state, dict):
1850
+ return content_state
1851
+ state = str(payload.get("state") or ("done" if activity_event == "end" else "tool"))
1852
+ return {
1853
+ "state": state,
1854
+ "phase": payload.get("phase") or state,
1855
+ "tool": payload.get("tool"),
1856
+ "effort": payload.get("effort"),
1857
+ "tokens": payload.get("tokens"),
1858
+ "verb": str(payload.get("verb") or ("Done" if activity_event == "end" else "Using")),
1859
+ "attentionLevel": payload.get("attentionLevel"),
1860
+ "updatedAtEpoch": payload.get("updatedAtEpoch") or now,
1861
+ "eventId": event_id,
1862
+ }
1863
+
1864
+
1865
+ def _bounded_optional(value: Any, limit: int) -> str | None:
1866
+ if value in (None, ""):
1867
+ return None
1868
+ return str(value)[:limit]
1869
+
1870
+
1871
+ def _optional_float(value: Any) -> float | None:
1872
+ try:
1873
+ if value in (None, ""):
1874
+ return None
1875
+ return float(value)
1876
+ except (TypeError, ValueError):
1877
+ return None
1878
+
1879
+
1880
+ def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
1881
+ try:
1882
+ parsed = int(value)
1883
+ except (TypeError, ValueError):
1884
+ parsed = default
1885
+ return max(minimum, min(maximum, parsed))
1886
+
1887
+
1888
+ def _outbox_metadata_from_payload(
1889
+ payload: dict[str, Any],
1890
+ *,
1891
+ sent_at: float,
1892
+ content_state: dict[str, Any] | None = None,
1893
+ apns_outcome: str | None = None,
1894
+ ) -> dict[str, Any]:
1895
+ observed = _optional_float(payload.get("observed_at")) or sent_at
1896
+ freshness = payload.get("freshness_seconds_at_send", payload.get("freshness_seconds"))
1897
+ if freshness is None:
1898
+ freshness_at_send = max(0.0, float(sent_at) - float(observed))
1899
+ else:
1900
+ try:
1901
+ freshness_at_send = max(0.0, float(freshness))
1902
+ except (TypeError, ValueError):
1903
+ freshness_at_send = max(0.0, float(sent_at) - float(observed))
1904
+ content_hash = None
1905
+ if isinstance(content_state, dict):
1906
+ content_hash = _sha256_hex(_json_dump(content_state))
1907
+ return {
1908
+ "source": _bounded_optional(payload.get("source"), 80),
1909
+ "phase": _bounded_optional(payload.get("phase"), 32),
1910
+ "project": _bounded_optional(payload.get("project"), 60),
1911
+ "observed_at": observed,
1912
+ "sent_at": float(sent_at),
1913
+ "collapse_id": _bounded_optional(payload.get("collapse_id"), 160),
1914
+ "freshness_seconds_at_send": freshness_at_send,
1915
+ "content_state_hash": _bounded_optional(payload.get("content_state_hash"), 80) or content_hash,
1916
+ "apns_outcome": _bounded_optional(apns_outcome, 120),
1917
+ }
1918
+
1919
+
1920
+ def _live_activity_outbox_metadata(
1921
+ payload: dict[str, Any],
1922
+ *,
1923
+ content_state: dict[str, Any],
1924
+ sent_at: float,
1925
+ apns_outcome: str | None = None,
1926
+ ) -> dict[str, Any]:
1927
+ observed = _optional_float(payload.get("observed_at")) or sent_at
1928
+ freshness = payload.get("freshness_seconds")
1929
+ if freshness is None:
1930
+ freshness_at_send = max(0.0, float(sent_at) - float(observed))
1931
+ else:
1932
+ try:
1933
+ freshness_at_send = float(freshness)
1934
+ except (TypeError, ValueError):
1935
+ freshness_at_send = max(0.0, float(sent_at) - float(observed))
1936
+ bounded = _bounded_content_state(content_state, event_id=str(payload.get("event_id") or ""), now=int(sent_at))
1937
+ return {
1938
+ "source": _bounded_optional(payload.get("source"), 80),
1939
+ "phase": _bounded_optional(payload.get("phase") or bounded.get("phase"), 32),
1940
+ "project": _bounded_optional(payload.get("project") or bounded.get("project"), 60),
1941
+ "observed_at": observed,
1942
+ "sent_at": float(sent_at),
1943
+ "collapse_id": _bounded_optional(payload.get("collapse_id"), 160),
1944
+ "freshness_seconds_at_send": freshness_at_send,
1945
+ "content_state_hash": _sha256_hex(_json_dump(bounded)),
1946
+ "apns_outcome": _bounded_optional(apns_outcome, 120),
1947
+ }
1948
+
1949
+
1950
+ def _apply_outbox_metadata(row: dict[str, Any], metadata: dict[str, Any]) -> None:
1951
+ for key, value in metadata.items():
1952
+ if value is not None:
1953
+ row[key] = value
1954
+
1955
+
1956
+ def _live_activity_alert_body(content: dict[str, Any]) -> str:
1957
+ if content.get("state") == "attention":
1958
+ return "Pairling needs your attention."
1959
+ if content.get("state") == "failed":
1960
+ return "Pairling activity failed."
1961
+ return "Pairling activity updated."
1962
+
1963
+
1964
+ def _quiet_hours(value: Any) -> dict[str, Any] | None:
1965
+ if value in (None, "", False):
1966
+ return None
1967
+ if not isinstance(value, dict):
1968
+ raise PushDispatcherError("invalid_quiet_hours", "quiet_hours must be an object or null")
1969
+ start = str(value.get("start") or "").strip()
1970
+ end = str(value.get("end") or "").strip()
1971
+ if not start or not end:
1972
+ return None
1973
+ return {"start": start[:5], "end": end[:5]}
1974
+
1975
+
1976
+ def _optional_epoch(value: Any) -> float | None:
1977
+ if value in (None, "", False):
1978
+ return None
1979
+ try:
1980
+ parsed = float(value)
1981
+ except (TypeError, ValueError):
1982
+ return None
1983
+ return max(0.0, parsed)
1984
+
1985
+
1986
+ def _future_epoch(value: Any, now: float) -> bool:
1987
+ try:
1988
+ return float(value or 0) > float(now)
1989
+ except (TypeError, ValueError):
1990
+ return False