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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Daemon-side production publishers for standard Pairling APNs alerts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from push_event_catalog import build_push_event, standard_alert_payload
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TurnStateAlertPublisher:
|
|
18
|
+
"""Publish attention and turn-done APNs events from turn-state JSON files."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
turn_state_dir: Path,
|
|
24
|
+
push_dispatcher,
|
|
25
|
+
claude_session_resolver: Callable[[str], str] | None = None,
|
|
26
|
+
now_fn=time.time,
|
|
27
|
+
sleep_fn=time.sleep,
|
|
28
|
+
poll_interval: float = 2.0,
|
|
29
|
+
failed_retry_interval: float = 30.0,
|
|
30
|
+
max_state_age_seconds: float = 60 * 60 * 24,
|
|
31
|
+
min_turn_done_seconds: float = 180.0,
|
|
32
|
+
prime_existing: bool = True,
|
|
33
|
+
logger: Callable[[str], None] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.turn_state_dir = turn_state_dir
|
|
36
|
+
self.push_dispatcher = push_dispatcher
|
|
37
|
+
self.claude_session_resolver = claude_session_resolver or (lambda _uuid: "")
|
|
38
|
+
self.now_fn = now_fn
|
|
39
|
+
self.sleep_fn = sleep_fn
|
|
40
|
+
self.poll_interval = poll_interval
|
|
41
|
+
self.failed_retry_interval = failed_retry_interval
|
|
42
|
+
self.max_state_age_seconds = max_state_age_seconds
|
|
43
|
+
self.min_turn_done_seconds = min_turn_done_seconds
|
|
44
|
+
self.prime_existing = prime_existing
|
|
45
|
+
self.logger = logger
|
|
46
|
+
self._stop = threading.Event()
|
|
47
|
+
self._thread: threading.Thread | None = None
|
|
48
|
+
self._last_state: dict[str, dict[str, Any]] = {}
|
|
49
|
+
self._last_attempts: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
50
|
+
self._primed = not prime_existing
|
|
51
|
+
|
|
52
|
+
def start(self) -> None:
|
|
53
|
+
if self._thread and self._thread.is_alive():
|
|
54
|
+
return
|
|
55
|
+
self._stop.clear()
|
|
56
|
+
self._thread = threading.Thread(
|
|
57
|
+
target=self.run_forever,
|
|
58
|
+
name="pairling-standard-turn-publisher",
|
|
59
|
+
daemon=True,
|
|
60
|
+
)
|
|
61
|
+
self._thread.start()
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._stop.set()
|
|
65
|
+
|
|
66
|
+
def run_forever(self) -> None:
|
|
67
|
+
while not self._stop.is_set():
|
|
68
|
+
try:
|
|
69
|
+
self.scan_once()
|
|
70
|
+
except Exception as exc: # pragma: no cover - defensive daemon boundary.
|
|
71
|
+
self._log(f"standard turn publisher scan failed: {type(exc).__name__}: {exc}")
|
|
72
|
+
self.sleep_fn(self.poll_interval)
|
|
73
|
+
|
|
74
|
+
def scan_once(self) -> list[dict[str, Any]]:
|
|
75
|
+
devices = self._standard_push_devices()
|
|
76
|
+
if not devices:
|
|
77
|
+
return []
|
|
78
|
+
states = self._recent_turn_states()
|
|
79
|
+
if not self._primed:
|
|
80
|
+
self._last_state = {
|
|
81
|
+
item["state_key"]: item["visible"]
|
|
82
|
+
for item in states
|
|
83
|
+
}
|
|
84
|
+
self._primed = True
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
results: list[dict[str, Any]] = []
|
|
88
|
+
seen_state_keys = {item["state_key"] for item in states}
|
|
89
|
+
for key in list(self._last_state):
|
|
90
|
+
if key not in seen_state_keys:
|
|
91
|
+
self._last_state.pop(key, None)
|
|
92
|
+
|
|
93
|
+
for item in states:
|
|
94
|
+
previous = self._last_state.get(item["state_key"])
|
|
95
|
+
self._last_state[item["state_key"]] = item["visible"]
|
|
96
|
+
events = self._events_for_transition(item, previous)
|
|
97
|
+
for event in events:
|
|
98
|
+
for device in devices:
|
|
99
|
+
if not self._device_wants_event(device, event["kind"]):
|
|
100
|
+
continue
|
|
101
|
+
result = self._publish(device["device_id"], event)
|
|
102
|
+
if result is not None:
|
|
103
|
+
results.append(result)
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
def _recent_turn_states(self) -> list[dict[str, Any]]:
|
|
107
|
+
now = float(self.now_fn())
|
|
108
|
+
states: list[dict[str, Any]] = []
|
|
109
|
+
try:
|
|
110
|
+
paths = sorted(self.turn_state_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
111
|
+
except OSError:
|
|
112
|
+
return []
|
|
113
|
+
for path in paths[:300]:
|
|
114
|
+
try:
|
|
115
|
+
if now - path.stat().st_mtime > self.max_state_age_seconds:
|
|
116
|
+
continue
|
|
117
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, json.JSONDecodeError):
|
|
119
|
+
continue
|
|
120
|
+
if not isinstance(payload, dict):
|
|
121
|
+
continue
|
|
122
|
+
state = self._visible_state(path, payload)
|
|
123
|
+
if state is not None:
|
|
124
|
+
states.append(state)
|
|
125
|
+
return states
|
|
126
|
+
|
|
127
|
+
def _visible_state(self, path: Path, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
128
|
+
raw_session = str(payload.get("session_id") or path.stem).strip()
|
|
129
|
+
provider, native_id = _parse_session_ref(raw_session)
|
|
130
|
+
if provider == "codex":
|
|
131
|
+
route_id = f"codex:{native_id}"
|
|
132
|
+
else:
|
|
133
|
+
route_id = raw_session
|
|
134
|
+
raw_state = str(payload.get("state") or "idle").strip().lower()
|
|
135
|
+
raw_event = str(payload.get("event") or "").strip().lower()
|
|
136
|
+
started_at = _optional_float(payload.get("started_at"))
|
|
137
|
+
last_update = _optional_float(payload.get("last_update")) or float(self.now_fn())
|
|
138
|
+
visible = {
|
|
139
|
+
"route_id": route_id,
|
|
140
|
+
"raw_state": raw_state,
|
|
141
|
+
"raw_event": raw_event,
|
|
142
|
+
"started_at": started_at,
|
|
143
|
+
"last_update": last_update,
|
|
144
|
+
"provider": provider,
|
|
145
|
+
"tool": _bounded_optional(payload.get("tool"), 80),
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
"state_key": path.stem,
|
|
149
|
+
"session_id": route_id,
|
|
150
|
+
"visible": visible,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def _events_for_transition(self, item: dict[str, Any], previous: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
154
|
+
visible = item["visible"]
|
|
155
|
+
raw_state = visible["raw_state"]
|
|
156
|
+
raw_event = visible["raw_event"]
|
|
157
|
+
was_done = bool(previous and _is_done_state(previous.get("raw_state"), previous.get("raw_event")))
|
|
158
|
+
is_done = _is_done_state(raw_state, raw_event)
|
|
159
|
+
events: list[dict[str, Any]] = []
|
|
160
|
+
if is_done and previous and not was_done:
|
|
161
|
+
started = _optional_float(visible.get("started_at"))
|
|
162
|
+
duration = float(visible.get("last_update") or self.now_fn()) - started if started else 0.0
|
|
163
|
+
if duration >= self.min_turn_done_seconds:
|
|
164
|
+
events.append(self._event_payload("turn_result", item, duration_seconds=duration))
|
|
165
|
+
if raw_state in {"attention", "failed"} and (not previous or previous.get("raw_state") != raw_state):
|
|
166
|
+
events.append(self._event_payload("action_required" if raw_state == "attention" else "turn_failed", item))
|
|
167
|
+
return events
|
|
168
|
+
|
|
169
|
+
def _event_payload(self, kind: str, item: dict[str, Any], *, duration_seconds: float | None = None) -> dict[str, Any]:
|
|
170
|
+
visible = item["visible"]
|
|
171
|
+
session_id = self._route_session_id_for_event(item["session_id"])
|
|
172
|
+
digest = _stable_hash({
|
|
173
|
+
"kind": kind,
|
|
174
|
+
"session_id": session_id,
|
|
175
|
+
"last_update": int(float(visible.get("last_update") or self.now_fn())),
|
|
176
|
+
"state": visible.get("raw_state"),
|
|
177
|
+
})[:24]
|
|
178
|
+
if kind == "turn_result":
|
|
179
|
+
minutes = max(3, int(round((duration_seconds or 0) / 60)))
|
|
180
|
+
result_summary = f"Turn finished after about {minutes} minutes"
|
|
181
|
+
required_action = None
|
|
182
|
+
risk_summary = None
|
|
183
|
+
else:
|
|
184
|
+
result_summary = None
|
|
185
|
+
required_action = "A session is waiting for your decision." if kind == "action_required" else "Open the failed turn."
|
|
186
|
+
risk_summary = "The session reported failure." if kind == "turn_failed" else None
|
|
187
|
+
provider, native_id = _parse_session_ref(session_id)
|
|
188
|
+
event = build_push_event(
|
|
189
|
+
event_id=f"push_auto_{kind}_{digest}",
|
|
190
|
+
kind=kind,
|
|
191
|
+
source="turn-state",
|
|
192
|
+
provider=provider,
|
|
193
|
+
session_id=session_id,
|
|
194
|
+
observed_at=float(visible.get("last_update") or self.now_fn()),
|
|
195
|
+
phase="done" if kind == "turn_result" else ("failed" if kind == "turn_failed" else "attention"),
|
|
196
|
+
current_step=visible.get("tool"),
|
|
197
|
+
result_summary=result_summary,
|
|
198
|
+
required_action=required_action,
|
|
199
|
+
risk_summary=risk_summary,
|
|
200
|
+
action_route="pairling://session/" + session_id,
|
|
201
|
+
)
|
|
202
|
+
payload = standard_alert_payload(event)
|
|
203
|
+
payload.update({
|
|
204
|
+
"result_summary": event.result_summary,
|
|
205
|
+
"required_action": event.required_action,
|
|
206
|
+
"risk_summary": event.risk_summary,
|
|
207
|
+
"phase": event.phase,
|
|
208
|
+
"collapse_id": event.collapse_id,
|
|
209
|
+
"dedupe_key": event.dedupe_key,
|
|
210
|
+
"kind": kind,
|
|
211
|
+
"session_id": native_id,
|
|
212
|
+
"provider": provider,
|
|
213
|
+
})
|
|
214
|
+
return payload
|
|
215
|
+
|
|
216
|
+
def _route_session_id_for_event(self, session_id: str) -> str:
|
|
217
|
+
provider, native_id = _parse_session_ref(session_id)
|
|
218
|
+
if provider == "claude" and native_id:
|
|
219
|
+
resolved = self.claude_session_resolver(native_id)
|
|
220
|
+
if resolved:
|
|
221
|
+
return f"claude:{resolved}"
|
|
222
|
+
if provider == "codex" and native_id:
|
|
223
|
+
return f"codex:{native_id}"
|
|
224
|
+
return session_id
|
|
225
|
+
|
|
226
|
+
def _standard_push_devices(self) -> list[dict[str, Any]]:
|
|
227
|
+
try:
|
|
228
|
+
status = self.push_dispatcher.status()
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
self._log(f"push status unavailable for standard turn publisher: {type(exc).__name__}: {exc}")
|
|
231
|
+
return []
|
|
232
|
+
if not _provider_can_deliver(status):
|
|
233
|
+
return []
|
|
234
|
+
devices = status.get("devices") if isinstance(status, dict) else []
|
|
235
|
+
out: list[dict[str, Any]] = []
|
|
236
|
+
for device in devices if isinstance(devices, list) else []:
|
|
237
|
+
if isinstance(device, dict) and device.get("standard_push_enabled") and device.get("device_id"):
|
|
238
|
+
out.append(device)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _device_wants_event(self, device: dict[str, Any], kind: str) -> bool:
|
|
242
|
+
if kind == "turn_result":
|
|
243
|
+
return bool(device.get("turn_done_enabled"))
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
def _publish(self, device_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
247
|
+
key = (device_id, payload["kind"], payload["event_id"])
|
|
248
|
+
now = float(self.now_fn())
|
|
249
|
+
previous = self._last_attempts.get(key)
|
|
250
|
+
if previous and previous.get("ok"):
|
|
251
|
+
return None
|
|
252
|
+
if previous and now - float(previous.get("attempted_at") or 0) < self.failed_retry_interval:
|
|
253
|
+
return None
|
|
254
|
+
delivery = self.push_dispatcher.record_event(device_id=device_id, payload=payload)
|
|
255
|
+
self._last_attempts[key] = {
|
|
256
|
+
"attempted_at": now,
|
|
257
|
+
"ok": bool(delivery.get("ok")),
|
|
258
|
+
}
|
|
259
|
+
return delivery
|
|
260
|
+
|
|
261
|
+
def _log(self, message: str) -> None:
|
|
262
|
+
if self.logger:
|
|
263
|
+
self.logger(message)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class MacHealthAlertPublisher:
|
|
267
|
+
"""Publish a bounded Mac-health alert when coordinator posture becomes unsafe."""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
push_dispatcher,
|
|
273
|
+
health_snapshot_fn: Callable[[], dict[str, Any]],
|
|
274
|
+
now_fn=time.time,
|
|
275
|
+
sleep_fn=time.sleep,
|
|
276
|
+
poll_interval: float = 30.0,
|
|
277
|
+
cooldown_seconds: float = 30 * 60,
|
|
278
|
+
logger: Callable[[str], None] | None = None,
|
|
279
|
+
) -> None:
|
|
280
|
+
self.push_dispatcher = push_dispatcher
|
|
281
|
+
self.health_snapshot_fn = health_snapshot_fn
|
|
282
|
+
self.now_fn = now_fn
|
|
283
|
+
self.sleep_fn = sleep_fn
|
|
284
|
+
self.poll_interval = poll_interval
|
|
285
|
+
self.cooldown_seconds = cooldown_seconds
|
|
286
|
+
self.logger = logger
|
|
287
|
+
self._stop = threading.Event()
|
|
288
|
+
self._thread: threading.Thread | None = None
|
|
289
|
+
self._last_signature: str | None = None
|
|
290
|
+
self._last_sent_at: dict[tuple[str, str], float] = {}
|
|
291
|
+
|
|
292
|
+
def start(self) -> None:
|
|
293
|
+
if self._thread and self._thread.is_alive():
|
|
294
|
+
return
|
|
295
|
+
self._stop.clear()
|
|
296
|
+
self._thread = threading.Thread(
|
|
297
|
+
target=self.run_forever,
|
|
298
|
+
name="pairling-mac-health-publisher",
|
|
299
|
+
daemon=True,
|
|
300
|
+
)
|
|
301
|
+
self._thread.start()
|
|
302
|
+
|
|
303
|
+
def stop(self) -> None:
|
|
304
|
+
self._stop.set()
|
|
305
|
+
|
|
306
|
+
def run_forever(self) -> None:
|
|
307
|
+
while not self._stop.is_set():
|
|
308
|
+
try:
|
|
309
|
+
self.scan_once()
|
|
310
|
+
except Exception as exc: # pragma: no cover - defensive daemon boundary.
|
|
311
|
+
self._log(f"mac health publisher scan failed: {type(exc).__name__}: {exc}")
|
|
312
|
+
self.sleep_fn(self.poll_interval)
|
|
313
|
+
|
|
314
|
+
def scan_once(self) -> list[dict[str, Any]]:
|
|
315
|
+
devices = self._standard_push_devices()
|
|
316
|
+
if not devices:
|
|
317
|
+
return []
|
|
318
|
+
health = self.health_snapshot_fn()
|
|
319
|
+
coordinator = health.get("coordinator") if isinstance(health.get("coordinator"), dict) else {}
|
|
320
|
+
posture = str(coordinator.get("posture") or "unknown")[:40]
|
|
321
|
+
severity = str(coordinator.get("severity") or posture)[:40]
|
|
322
|
+
summary = str(coordinator.get("summary") or "The paired Mac helper needs attention.")[:180]
|
|
323
|
+
unsafe = not bool(health.get("ok")) or posture not in {"ready", "warning"}
|
|
324
|
+
signature = _stable_hash({"posture": posture, "severity": severity, "summary": summary})
|
|
325
|
+
if not unsafe:
|
|
326
|
+
self._last_signature = signature
|
|
327
|
+
return []
|
|
328
|
+
if signature == self._last_signature:
|
|
329
|
+
return []
|
|
330
|
+
self._last_signature = signature
|
|
331
|
+
event_id = "push_auto_mac_health_" + signature[:24]
|
|
332
|
+
event = build_push_event(
|
|
333
|
+
event_id=event_id,
|
|
334
|
+
kind="mac_route_risk",
|
|
335
|
+
source="mac-health",
|
|
336
|
+
observed_at=float(self.now_fn()),
|
|
337
|
+
phase="stale",
|
|
338
|
+
risk_level="critical" if severity == "critical" else "warning",
|
|
339
|
+
risk_summary=summary,
|
|
340
|
+
route_health=posture,
|
|
341
|
+
action_route="pairling://health",
|
|
342
|
+
)
|
|
343
|
+
payload = standard_alert_payload(event)
|
|
344
|
+
payload.update({
|
|
345
|
+
"health_posture": posture,
|
|
346
|
+
"health_severity": severity,
|
|
347
|
+
"health_summary": summary,
|
|
348
|
+
"phase": "risk",
|
|
349
|
+
"route_health": event.route_health,
|
|
350
|
+
"collapse_id": event.collapse_id,
|
|
351
|
+
"dedupe_key": event.dedupe_key,
|
|
352
|
+
})
|
|
353
|
+
results: list[dict[str, Any]] = []
|
|
354
|
+
for device in devices:
|
|
355
|
+
key = (device["device_id"], signature)
|
|
356
|
+
now = float(self.now_fn())
|
|
357
|
+
if now - float(self._last_sent_at.get(key) or 0) < self.cooldown_seconds:
|
|
358
|
+
continue
|
|
359
|
+
delivery = self.push_dispatcher.record_event(device_id=device["device_id"], payload=payload)
|
|
360
|
+
self._last_sent_at[key] = now
|
|
361
|
+
results.append(delivery)
|
|
362
|
+
return results
|
|
363
|
+
|
|
364
|
+
def _standard_push_devices(self) -> list[dict[str, Any]]:
|
|
365
|
+
try:
|
|
366
|
+
status = self.push_dispatcher.status()
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
self._log(f"push status unavailable for mac health publisher: {type(exc).__name__}: {exc}")
|
|
369
|
+
return []
|
|
370
|
+
if not _provider_can_deliver(status):
|
|
371
|
+
return []
|
|
372
|
+
devices = status.get("devices") if isinstance(status, dict) else []
|
|
373
|
+
return [
|
|
374
|
+
item for item in devices if isinstance(item, dict)
|
|
375
|
+
and item.get("standard_push_enabled")
|
|
376
|
+
and item.get("device_id")
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
def _log(self, message: str) -> None:
|
|
380
|
+
if self.logger:
|
|
381
|
+
self.logger(message)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class SentinelBackgroundEvaluator:
|
|
385
|
+
"""Periodically evaluate worker/token sentinel state for opted-in devices."""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
*,
|
|
390
|
+
sentinel_center,
|
|
391
|
+
push_dispatcher,
|
|
392
|
+
worker_stats_fn: Callable[[], dict[str, Any]],
|
|
393
|
+
human_idle_minutes_fn: Callable[[], float | None] | None = None,
|
|
394
|
+
token_sessions_fn: Callable[[], list[dict[str, Any]]] | None = None,
|
|
395
|
+
now_fn=time.time,
|
|
396
|
+
sleep_fn=time.sleep,
|
|
397
|
+
poll_interval: float = 60.0,
|
|
398
|
+
logger: Callable[[str], None] | None = None,
|
|
399
|
+
) -> None:
|
|
400
|
+
self.sentinel_center = sentinel_center
|
|
401
|
+
self.push_dispatcher = push_dispatcher
|
|
402
|
+
self.worker_stats_fn = worker_stats_fn
|
|
403
|
+
self.human_idle_minutes_fn = human_idle_minutes_fn or (lambda: None)
|
|
404
|
+
self.token_sessions_fn = token_sessions_fn or (lambda: [])
|
|
405
|
+
self.now_fn = now_fn
|
|
406
|
+
self.sleep_fn = sleep_fn
|
|
407
|
+
self.poll_interval = poll_interval
|
|
408
|
+
self.logger = logger
|
|
409
|
+
self._stop = threading.Event()
|
|
410
|
+
self._thread: threading.Thread | None = None
|
|
411
|
+
|
|
412
|
+
def start(self) -> None:
|
|
413
|
+
if self._thread and self._thread.is_alive():
|
|
414
|
+
return
|
|
415
|
+
self._stop.clear()
|
|
416
|
+
self._thread = threading.Thread(
|
|
417
|
+
target=self.run_forever,
|
|
418
|
+
name="pairling-sentinel-publisher",
|
|
419
|
+
daemon=True,
|
|
420
|
+
)
|
|
421
|
+
self._thread.start()
|
|
422
|
+
|
|
423
|
+
def stop(self) -> None:
|
|
424
|
+
self._stop.set()
|
|
425
|
+
|
|
426
|
+
def run_forever(self) -> None:
|
|
427
|
+
while not self._stop.is_set():
|
|
428
|
+
try:
|
|
429
|
+
self.scan_once()
|
|
430
|
+
except Exception as exc: # pragma: no cover - defensive daemon boundary.
|
|
431
|
+
self._log(f"sentinel publisher scan failed: {type(exc).__name__}: {exc}")
|
|
432
|
+
self.sleep_fn(self.poll_interval)
|
|
433
|
+
|
|
434
|
+
def scan_once(self) -> list[dict[str, Any]]:
|
|
435
|
+
devices = self._sentinel_devices()
|
|
436
|
+
if not devices:
|
|
437
|
+
return []
|
|
438
|
+
worker_stats = self.worker_stats_fn()
|
|
439
|
+
token_sessions = self.token_sessions_fn()
|
|
440
|
+
human_idle = self.human_idle_minutes_fn()
|
|
441
|
+
results: list[dict[str, Any]] = []
|
|
442
|
+
for device in devices:
|
|
443
|
+
results.append(self.sentinel_center.evaluate_now(
|
|
444
|
+
worker_stats=worker_stats,
|
|
445
|
+
token_sessions=token_sessions,
|
|
446
|
+
human_idle_minutes=human_idle,
|
|
447
|
+
device_id=device["device_id"],
|
|
448
|
+
force=False,
|
|
449
|
+
))
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
def _sentinel_devices(self) -> list[dict[str, Any]]:
|
|
453
|
+
try:
|
|
454
|
+
status = self.push_dispatcher.status()
|
|
455
|
+
except Exception as exc:
|
|
456
|
+
self._log(f"push status unavailable for sentinel publisher: {type(exc).__name__}: {exc}")
|
|
457
|
+
return []
|
|
458
|
+
if not _provider_can_deliver(status):
|
|
459
|
+
return []
|
|
460
|
+
devices = status.get("devices") if isinstance(status, dict) else []
|
|
461
|
+
return [
|
|
462
|
+
item for item in devices if isinstance(item, dict)
|
|
463
|
+
and item.get("standard_push_enabled")
|
|
464
|
+
and item.get("worker_sentinel_enabled")
|
|
465
|
+
and item.get("device_id")
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
def _log(self, message: str) -> None:
|
|
469
|
+
if self.logger:
|
|
470
|
+
self.logger(message)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _parse_session_ref(session_id: str) -> tuple[str, str]:
|
|
474
|
+
text = str(session_id or "").strip()
|
|
475
|
+
if ":" in text:
|
|
476
|
+
provider, native_id = text.split(":", 1)
|
|
477
|
+
if provider in {"claude", "codex"} and _safe_stem(native_id):
|
|
478
|
+
return provider, native_id
|
|
479
|
+
if _safe_stem(text):
|
|
480
|
+
return "claude", text
|
|
481
|
+
return "claude", ""
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _provider_can_deliver(status: dict[str, Any]) -> bool:
|
|
485
|
+
provider = status.get("provider") if isinstance(status, dict) else None
|
|
486
|
+
return not isinstance(provider, dict) or bool(provider.get("configured"))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _safe_stem(value: str) -> bool:
|
|
490
|
+
return bool(re.fullmatch(r"[A-Za-z0-9_.:-]{1,180}", str(value or "")))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _is_done_state(raw_state: Any, raw_event: Any) -> bool:
|
|
494
|
+
state = str(raw_state or "").strip().lower()
|
|
495
|
+
event = str(raw_event or "").strip().lower()
|
|
496
|
+
return state in {"idle", "done", "completed"} or event in {"stop", "session_end"}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _optional_float(value: Any) -> float | None:
|
|
500
|
+
try:
|
|
501
|
+
if value is None or value == "":
|
|
502
|
+
return None
|
|
503
|
+
return float(value)
|
|
504
|
+
except (TypeError, ValueError):
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _bounded_optional(value: Any, limit: int) -> str | None:
|
|
509
|
+
text = str(value or "").strip()
|
|
510
|
+
if not text:
|
|
511
|
+
return None
|
|
512
|
+
return text[:limit]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _stable_hash(payload: dict[str, Any]) -> str:
|
|
516
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Read-only operational substrate status contract for the Pairling Mac daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_SINCE = "0000-01-01T00:00:00.000Z"
|
|
13
|
+
DEFAULT_LIMIT = 50
|
|
14
|
+
DEFAULT_MAX_LIMIT = 200
|
|
15
|
+
DEFAULT_ORGANIZER_BIN = str(Path.home() / "projects" / "metal-perception-memory-substrate" / "organizer")
|
|
16
|
+
DEFAULT_ORGANIZER_CONFIG = str(Path.home() / "projects" / "metal-perception-memory-substrate" / "organizer.yaml")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubstrateStatusError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
|
|
24
|
+
return os.environ if env is None else env
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def substrate_status_max_limit(env: Mapping[str, str] | None = None) -> int:
|
|
28
|
+
current_env = _env(env)
|
|
29
|
+
try:
|
|
30
|
+
value = int(current_env.get("COMPANION_SUBSTRATE_STATUS_MAX_LIMIT", str(DEFAULT_MAX_LIMIT)))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return DEFAULT_MAX_LIMIT
|
|
33
|
+
return max(1, min(value, 1000))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def organizer_bin(env: Mapping[str, str] | None = None) -> str:
|
|
37
|
+
return _env(env).get("SUBSTRATE_ORGANIZER_BIN") or DEFAULT_ORGANIZER_BIN
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def organizer_config(env: Mapping[str, str] | None = None) -> str:
|
|
41
|
+
return _env(env).get("SUBSTRATE_ORGANIZER_CONFIG") or DEFAULT_ORGANIZER_CONFIG
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_substrate_status_command(
|
|
45
|
+
run: str | Path,
|
|
46
|
+
since: str = DEFAULT_SINCE,
|
|
47
|
+
limit: int = DEFAULT_LIMIT,
|
|
48
|
+
*,
|
|
49
|
+
organizer: str | None = None,
|
|
50
|
+
config: str | None = None,
|
|
51
|
+
env: Mapping[str, str] | None = None,
|
|
52
|
+
) -> list[str]:
|
|
53
|
+
if not str(run).strip():
|
|
54
|
+
raise SubstrateStatusError("run is required")
|
|
55
|
+
if limit <= 0:
|
|
56
|
+
raise SubstrateStatusError("limit must be positive")
|
|
57
|
+
current_limit = min(limit, substrate_status_max_limit(env))
|
|
58
|
+
return [
|
|
59
|
+
organizer or organizer_bin(env),
|
|
60
|
+
"--config",
|
|
61
|
+
config or organizer_config(env),
|
|
62
|
+
"substrate",
|
|
63
|
+
"status",
|
|
64
|
+
"--run",
|
|
65
|
+
str(run),
|
|
66
|
+
"--since",
|
|
67
|
+
since,
|
|
68
|
+
"--limit",
|
|
69
|
+
str(current_limit),
|
|
70
|
+
"--json",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_substrate_feed_command(
|
|
75
|
+
run: str | Path,
|
|
76
|
+
since: str = DEFAULT_SINCE,
|
|
77
|
+
limit: int = DEFAULT_LIMIT,
|
|
78
|
+
event_types: list[str] | None = None,
|
|
79
|
+
*,
|
|
80
|
+
organizer: str | None = None,
|
|
81
|
+
config: str | None = None,
|
|
82
|
+
env: Mapping[str, str] | None = None,
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
command = build_substrate_status_command(run, since=since, limit=limit, organizer=organizer, config=config, env=env)
|
|
85
|
+
command[command.index("status")] = "feed"
|
|
86
|
+
for event_type in event_types or []:
|
|
87
|
+
if not event_type or len(event_type) > 120:
|
|
88
|
+
raise SubstrateStatusError("event type must be a non-empty bounded string")
|
|
89
|
+
command.extend(["--type", event_type])
|
|
90
|
+
return command
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_substrate_status(
|
|
94
|
+
run: str | Path,
|
|
95
|
+
since: str = DEFAULT_SINCE,
|
|
96
|
+
limit: int = DEFAULT_LIMIT,
|
|
97
|
+
*,
|
|
98
|
+
timeout_seconds: float = 5.0,
|
|
99
|
+
env: Mapping[str, str] | None = None,
|
|
100
|
+
) -> dict:
|
|
101
|
+
payload = _run_json(build_substrate_status_command(run, since=since, limit=limit, env=env), timeout_seconds)
|
|
102
|
+
if payload.get("feed") != "substrate.status.readonly.v1" or payload.get("read_only") is not True or payload.get("writes_performed") is not False:
|
|
103
|
+
raise SubstrateStatusError("substrate status adapter returned an unexpected contract")
|
|
104
|
+
payload["consumer"] = "pairling"
|
|
105
|
+
payload["adapter"] = "pairling.substrate_status"
|
|
106
|
+
return payload
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fetch_substrate_feed(
|
|
110
|
+
run: str | Path,
|
|
111
|
+
since: str = DEFAULT_SINCE,
|
|
112
|
+
limit: int = DEFAULT_LIMIT,
|
|
113
|
+
event_types: list[str] | None = None,
|
|
114
|
+
*,
|
|
115
|
+
timeout_seconds: float = 5.0,
|
|
116
|
+
env: Mapping[str, str] | None = None,
|
|
117
|
+
) -> dict:
|
|
118
|
+
payload = _run_json(build_substrate_feed_command(run, since=since, limit=limit, event_types=event_types, env=env), timeout_seconds)
|
|
119
|
+
if payload.get("feed") != "substrate.feed.readonly.v1" or payload.get("read_only") is not True or payload.get("writes_performed") is not False:
|
|
120
|
+
raise SubstrateStatusError("substrate feed adapter returned an unexpected contract")
|
|
121
|
+
payload["consumer"] = "pairling"
|
|
122
|
+
payload["adapter"] = "pairling.substrate_feed"
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_json(command: list[str], timeout_seconds: float) -> dict:
|
|
127
|
+
try:
|
|
128
|
+
completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout_seconds, check=False)
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
raise SubstrateStatusError(f"could not execute substrate adapter: {exc}") from exc
|
|
131
|
+
except subprocess.TimeoutExpired as exc:
|
|
132
|
+
raise SubstrateStatusError("substrate adapter timed out") from exc
|
|
133
|
+
if completed.returncode != 0:
|
|
134
|
+
detail = (completed.stderr or completed.stdout or "").strip()[:500]
|
|
135
|
+
raise SubstrateStatusError(f"substrate adapter failed: {detail}")
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(completed.stdout)
|
|
138
|
+
except json.JSONDecodeError as exc:
|
|
139
|
+
raise SubstrateStatusError("substrate adapter returned invalid JSON") from exc
|