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,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