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,491 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Worker/token sentinel notification classifier and local event ledger.
|
|
3
|
+
|
|
4
|
+
This module is deliberately payload-small. It turns worker/token counters into
|
|
5
|
+
bounded notification events and never accepts transcript text, prompts, or raw
|
|
6
|
+
provider credentials as inputs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from push_event_catalog import build_push_event, standard_alert_payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CONTRACT_VERSION = "pairling-sentinel-notifications-v1"
|
|
23
|
+
|
|
24
|
+
DEFAULT_PREFERENCES: dict[str, Any] = {
|
|
25
|
+
"enabled": True,
|
|
26
|
+
"push_enabled": True,
|
|
27
|
+
"resolutions_enabled": False,
|
|
28
|
+
"worker_warning_active": 12,
|
|
29
|
+
"worker_critical_active": 25,
|
|
30
|
+
"worker_watch_active": 8,
|
|
31
|
+
"worker_watch_total": 15,
|
|
32
|
+
"human_idle_warning_minutes": 15,
|
|
33
|
+
"human_idle_critical_minutes": 30,
|
|
34
|
+
"stale_worker_minutes": 60,
|
|
35
|
+
"stale_cleanup_count": 5,
|
|
36
|
+
"token_pressure_ratio": 0.70,
|
|
37
|
+
"cooldown_warning_minutes": 30,
|
|
38
|
+
"cooldown_critical_minutes": 60,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_INT_PREFS = {
|
|
42
|
+
"worker_warning_active",
|
|
43
|
+
"worker_critical_active",
|
|
44
|
+
"worker_watch_active",
|
|
45
|
+
"worker_watch_total",
|
|
46
|
+
"human_idle_warning_minutes",
|
|
47
|
+
"human_idle_critical_minutes",
|
|
48
|
+
"stale_worker_minutes",
|
|
49
|
+
"stale_cleanup_count",
|
|
50
|
+
"cooldown_warning_minutes",
|
|
51
|
+
"cooldown_critical_minutes",
|
|
52
|
+
}
|
|
53
|
+
_FLOAT_PREFS = {"token_pressure_ratio"}
|
|
54
|
+
_BOOL_PREFS = {"enabled", "push_enabled", "resolutions_enabled"}
|
|
55
|
+
_LEVEL_RANK = {"info": 0, "watch": 1, "stale_cleanup": 2, "token_pressure": 3, "warning": 4, "critical": 5}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SentinelNotificationError(Exception):
|
|
59
|
+
def __init__(self, code: str, message: str, status: int = 400):
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.code = code
|
|
62
|
+
self.message = message
|
|
63
|
+
self.status = status
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SentinelNotificationCenter:
|
|
67
|
+
def __init__(self, root: Path, *, now_fn=time.time, push_dispatcher=None) -> None:
|
|
68
|
+
self.root = Path(root)
|
|
69
|
+
self.dir = self.root / "sentinel"
|
|
70
|
+
self.preferences_path = self.dir / "preferences.json"
|
|
71
|
+
self.state_path = self.dir / "state.json"
|
|
72
|
+
self.events_path = self.dir / "events.jsonl"
|
|
73
|
+
self.now_fn = now_fn
|
|
74
|
+
self.push_dispatcher = push_dispatcher
|
|
75
|
+
|
|
76
|
+
def preferences(self) -> dict[str, Any]:
|
|
77
|
+
prefs = dict(DEFAULT_PREFERENCES)
|
|
78
|
+
try:
|
|
79
|
+
loaded = json.loads(self.preferences_path.read_text(encoding="utf-8"))
|
|
80
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
81
|
+
loaded = {}
|
|
82
|
+
if isinstance(loaded, dict):
|
|
83
|
+
for key in DEFAULT_PREFERENCES:
|
|
84
|
+
if key in loaded:
|
|
85
|
+
prefs[key] = self._normalize_pref(key, loaded[key])
|
|
86
|
+
return {
|
|
87
|
+
"ok": True,
|
|
88
|
+
"contract_version": CONTRACT_VERSION,
|
|
89
|
+
"preferences": prefs,
|
|
90
|
+
"updated_at": self._state().get("updated_at"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def update_preferences(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
raise SentinelNotificationError("invalid_preferences", "preferences body must be an object")
|
|
96
|
+
prefs = self.preferences()["preferences"]
|
|
97
|
+
unknown = [key for key in payload if key not in DEFAULT_PREFERENCES]
|
|
98
|
+
if unknown:
|
|
99
|
+
raise SentinelNotificationError("unknown_preference", "unknown sentinel preference: " + unknown[0])
|
|
100
|
+
for key, value in payload.items():
|
|
101
|
+
prefs[key] = self._normalize_pref(key, value)
|
|
102
|
+
self._write_json(self.preferences_path, prefs)
|
|
103
|
+
state = self._state()
|
|
104
|
+
state["updated_at"] = self.now_fn()
|
|
105
|
+
self._write_json(self.state_path, state)
|
|
106
|
+
return self.preferences()
|
|
107
|
+
|
|
108
|
+
def status(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
worker_stats: dict[str, Any] | None = None,
|
|
112
|
+
token_sessions: list[dict[str, Any]] | None = None,
|
|
113
|
+
human_idle_minutes: float | None = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
prefs = self.preferences()["preferences"]
|
|
116
|
+
classification = self.classify(
|
|
117
|
+
worker_stats or {},
|
|
118
|
+
token_sessions=token_sessions or [],
|
|
119
|
+
human_idle_minutes=human_idle_minutes,
|
|
120
|
+
prefs=prefs,
|
|
121
|
+
)
|
|
122
|
+
state = self._state()
|
|
123
|
+
return {
|
|
124
|
+
"ok": True,
|
|
125
|
+
"contract_version": CONTRACT_VERSION,
|
|
126
|
+
**classification,
|
|
127
|
+
"snoozed_until": self._snoozed_until(classification["dedupe_key"], state),
|
|
128
|
+
"last_push_event_id": state.get("last_push_event_id"),
|
|
129
|
+
"preferences": prefs,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def evaluate_now(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
worker_stats: dict[str, Any],
|
|
136
|
+
token_sessions: list[dict[str, Any]] | None = None,
|
|
137
|
+
human_idle_minutes: float | None = None,
|
|
138
|
+
device_id: str | None = None,
|
|
139
|
+
force: bool = False,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
status = self.status(
|
|
142
|
+
worker_stats=worker_stats,
|
|
143
|
+
token_sessions=token_sessions or [],
|
|
144
|
+
human_idle_minutes=human_idle_minutes,
|
|
145
|
+
)
|
|
146
|
+
prefs = status["preferences"]
|
|
147
|
+
state = self._state()
|
|
148
|
+
event = self._event_from_status(status, device_id=device_id)
|
|
149
|
+
suppressed = self._suppression_reason(status, prefs, state, force=force, device_id=device_id)
|
|
150
|
+
delivery = None
|
|
151
|
+
if suppressed is None and prefs.get("push_enabled") and self.push_dispatcher is not None and device_id:
|
|
152
|
+
try:
|
|
153
|
+
push_event = build_push_event(
|
|
154
|
+
kind="worker_pressure",
|
|
155
|
+
event_id=event["event_id"],
|
|
156
|
+
source="sentinel",
|
|
157
|
+
provider=str(worker_stats.get("provider") or "all"),
|
|
158
|
+
project=_project_scope(worker_stats),
|
|
159
|
+
observed_at=event["created_at"],
|
|
160
|
+
phase="attention",
|
|
161
|
+
required_action="Open workers to review pressure.",
|
|
162
|
+
risk_level=status["severity"],
|
|
163
|
+
risk_summary=status["body"],
|
|
164
|
+
worker_summary=status["summary"],
|
|
165
|
+
action_route=status["route"],
|
|
166
|
+
dedupe_key=status["dedupe_key"],
|
|
167
|
+
)
|
|
168
|
+
push_payload = standard_alert_payload(push_event)
|
|
169
|
+
push_payload.update({
|
|
170
|
+
"sentinel_event_id": event["event_id"],
|
|
171
|
+
"sentinel_level": event["level"],
|
|
172
|
+
"sentinel_key": event["dedupe_key"],
|
|
173
|
+
"worker_summary": push_event.worker_summary,
|
|
174
|
+
"risk_summary": push_event.risk_summary,
|
|
175
|
+
"required_action": push_event.required_action,
|
|
176
|
+
"phase": push_event.phase,
|
|
177
|
+
"collapse_id": push_event.collapse_id,
|
|
178
|
+
"dedupe_key": push_event.dedupe_key,
|
|
179
|
+
})
|
|
180
|
+
delivery = self.push_dispatcher.record_event(
|
|
181
|
+
device_id=device_id,
|
|
182
|
+
payload=push_payload,
|
|
183
|
+
)
|
|
184
|
+
event["sent"] = bool(delivery.get("ok"))
|
|
185
|
+
event["delivery_outcome"] = (delivery.get("delivery") or {}).get("outcome")
|
|
186
|
+
except Exception as exc: # keep classifier useful even if push is down.
|
|
187
|
+
event["sent"] = False
|
|
188
|
+
event["delivery_outcome"] = f"{type(exc).__name__}: {exc}"[:180]
|
|
189
|
+
elif suppressed is None:
|
|
190
|
+
event["sent"] = False
|
|
191
|
+
event["delivery_outcome"] = "no_push_dispatcher_or_device"
|
|
192
|
+
else:
|
|
193
|
+
event["sent"] = False
|
|
194
|
+
event["suppressed_reason"] = suppressed
|
|
195
|
+
|
|
196
|
+
if suppressed is None:
|
|
197
|
+
sent_events = state.setdefault("sent_events", {})
|
|
198
|
+
sent_events[_sent_event_state_key(event["dedupe_key"], device_id)] = {
|
|
199
|
+
"last_sent_at": self.now_fn(),
|
|
200
|
+
"event_id": event["event_id"],
|
|
201
|
+
"level": event["level"],
|
|
202
|
+
"device_id": str(device_id or "")[:120] or None,
|
|
203
|
+
}
|
|
204
|
+
state["last_push_event_id"] = event["event_id"]
|
|
205
|
+
state["updated_at"] = self.now_fn()
|
|
206
|
+
self._write_json(self.state_path, state)
|
|
207
|
+
self._append_event(event)
|
|
208
|
+
return {
|
|
209
|
+
"ok": True,
|
|
210
|
+
"status": status,
|
|
211
|
+
"event": event,
|
|
212
|
+
"delivery": delivery,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def snooze(self, *, key: str | None = None, minutes: int = 60) -> dict[str, Any]:
|
|
216
|
+
minutes = max(1, min(int(minutes or 60), 60 * 24))
|
|
217
|
+
dedupe_key = str(key or "*").strip() or "*"
|
|
218
|
+
state = self._state()
|
|
219
|
+
snoozes = state.setdefault("snoozes", {})
|
|
220
|
+
snoozes[dedupe_key] = self.now_fn() + minutes * 60
|
|
221
|
+
state["updated_at"] = self.now_fn()
|
|
222
|
+
self._write_json(self.state_path, state)
|
|
223
|
+
return {
|
|
224
|
+
"ok": True,
|
|
225
|
+
"key": dedupe_key,
|
|
226
|
+
"snoozed_until": snoozes[dedupe_key],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def events(self, *, since: float | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
|
230
|
+
if not self.events_path.exists():
|
|
231
|
+
return []
|
|
232
|
+
since = float(since or 0)
|
|
233
|
+
limit = max(1, min(int(limit or 100), 300))
|
|
234
|
+
events: list[dict[str, Any]] = []
|
|
235
|
+
try:
|
|
236
|
+
for line in self.events_path.read_text(encoding="utf-8").splitlines():
|
|
237
|
+
if not line.strip():
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
event = json.loads(line)
|
|
241
|
+
except json.JSONDecodeError:
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(event, dict):
|
|
244
|
+
continue
|
|
245
|
+
if float(event.get("created_at") or 0) > since:
|
|
246
|
+
events.append(event)
|
|
247
|
+
except OSError:
|
|
248
|
+
return []
|
|
249
|
+
events.sort(key=lambda item: float(item.get("created_at") or 0), reverse=True)
|
|
250
|
+
return events[:limit]
|
|
251
|
+
|
|
252
|
+
def classify(
|
|
253
|
+
self,
|
|
254
|
+
worker_stats: dict[str, Any],
|
|
255
|
+
*,
|
|
256
|
+
token_sessions: list[dict[str, Any]],
|
|
257
|
+
human_idle_minutes: float | None,
|
|
258
|
+
prefs: dict[str, Any] | None = None,
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
prefs = prefs or self.preferences()["preferences"]
|
|
261
|
+
active = _int(worker_stats.get("automated_active"))
|
|
262
|
+
idle = _int(worker_stats.get("automated_idle"))
|
|
263
|
+
total = _int(worker_stats.get("total"), active + idle)
|
|
264
|
+
stale_ids = worker_stats.get("stale_session_ids") if isinstance(worker_stats.get("stale_session_ids"), list) else []
|
|
265
|
+
stale_count = len(stale_ids)
|
|
266
|
+
token_pressure_sessions = self._token_pressure_sessions(token_sessions, prefs)
|
|
267
|
+
human_idle = float(human_idle_minutes) if human_idle_minutes is not None else None
|
|
268
|
+
unattended_warning = human_idle is None or human_idle >= prefs["human_idle_warning_minutes"]
|
|
269
|
+
unattended_critical = human_idle is None or human_idle >= prefs["human_idle_critical_minutes"]
|
|
270
|
+
|
|
271
|
+
level = "info"
|
|
272
|
+
kind = "quiet"
|
|
273
|
+
severity = "info"
|
|
274
|
+
title = "Pairling sentinel quiet"
|
|
275
|
+
body = "No worker or token condition currently requires a push."
|
|
276
|
+
|
|
277
|
+
if active >= prefs["worker_critical_active"] and unattended_critical:
|
|
278
|
+
level = "critical"
|
|
279
|
+
kind = "worker_swarm"
|
|
280
|
+
severity = "critical"
|
|
281
|
+
title = "Pairling automation needs review"
|
|
282
|
+
body = f"{active} automated sessions are active. Review stale workers before token burn continues."
|
|
283
|
+
elif active >= prefs["worker_warning_active"] and unattended_warning:
|
|
284
|
+
level = "warning"
|
|
285
|
+
kind = "worker_swarm"
|
|
286
|
+
severity = "warning"
|
|
287
|
+
title = "Pairling worker warning"
|
|
288
|
+
idle_text = f" and you have been away for {int(human_idle)} minutes" if human_idle is not None else ""
|
|
289
|
+
body = f"{active} automated sessions are active{idle_text}."
|
|
290
|
+
elif stale_count >= prefs["stale_cleanup_count"]:
|
|
291
|
+
level = "stale_cleanup"
|
|
292
|
+
kind = "stale_cleanup"
|
|
293
|
+
severity = "warning"
|
|
294
|
+
title = "Pairling found stale workers"
|
|
295
|
+
body = f"{stale_count} workers have been idle for over an hour."
|
|
296
|
+
elif token_pressure_sessions and unattended_warning:
|
|
297
|
+
level = "token_pressure"
|
|
298
|
+
kind = "token_pressure"
|
|
299
|
+
severity = "warning"
|
|
300
|
+
title = "Pairling context pressure"
|
|
301
|
+
body = "This turn is near the context limit. Open the session to review."
|
|
302
|
+
elif active >= prefs["worker_watch_active"] or total >= prefs["worker_watch_total"]:
|
|
303
|
+
level = "watch"
|
|
304
|
+
kind = "worker_watch"
|
|
305
|
+
severity = "info"
|
|
306
|
+
title = "Pairling worker watch"
|
|
307
|
+
body = f"{active} active workers, {idle} idle workers."
|
|
308
|
+
|
|
309
|
+
project_scope = _project_scope(worker_stats)
|
|
310
|
+
provider = str(worker_stats.get("provider") or "all")[:40]
|
|
311
|
+
dedupe_key = f"sentinel:{kind}:{provider}:{project_scope}:{severity}"
|
|
312
|
+
return {
|
|
313
|
+
"level": level,
|
|
314
|
+
"kind": kind,
|
|
315
|
+
"severity": severity,
|
|
316
|
+
"summary": f"{active} active workers, {idle} idle, {stale_count} stale",
|
|
317
|
+
"active_workers": active,
|
|
318
|
+
"idle_workers": idle,
|
|
319
|
+
"total_workers": total,
|
|
320
|
+
"stale_workers": stale_count,
|
|
321
|
+
"stale_session_ids": [str(item)[:120] for item in stale_ids[:50]],
|
|
322
|
+
"token_pressure_sessions": token_pressure_sessions,
|
|
323
|
+
"human_idle_minutes": human_idle,
|
|
324
|
+
"dedupe_key": dedupe_key,
|
|
325
|
+
"title": title,
|
|
326
|
+
"body": body,
|
|
327
|
+
"route": "pairling://workers",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
def _token_pressure_sessions(self, token_sessions: list[dict[str, Any]], prefs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
331
|
+
rows: list[dict[str, Any]] = []
|
|
332
|
+
for item in token_sessions:
|
|
333
|
+
if not isinstance(item, dict):
|
|
334
|
+
continue
|
|
335
|
+
tokens = _int(item.get("tokens") or item.get("total_tokens"))
|
|
336
|
+
context_window = _int(item.get("context_window"))
|
|
337
|
+
if tokens <= 0 or context_window <= 0:
|
|
338
|
+
continue
|
|
339
|
+
ratio = tokens / context_window
|
|
340
|
+
if ratio >= float(prefs["token_pressure_ratio"]):
|
|
341
|
+
rows.append({
|
|
342
|
+
"session_id": str(item.get("session_id") or "")[:120],
|
|
343
|
+
"provider": str(item.get("provider") or "all")[:40],
|
|
344
|
+
"tokens": tokens,
|
|
345
|
+
"context_window": context_window,
|
|
346
|
+
"ratio": round(ratio, 4),
|
|
347
|
+
})
|
|
348
|
+
return rows[:20]
|
|
349
|
+
|
|
350
|
+
def _suppression_reason(
|
|
351
|
+
self,
|
|
352
|
+
status: dict[str, Any],
|
|
353
|
+
prefs: dict[str, Any],
|
|
354
|
+
state: dict[str, Any],
|
|
355
|
+
*,
|
|
356
|
+
force: bool,
|
|
357
|
+
device_id: str | None,
|
|
358
|
+
) -> str | None:
|
|
359
|
+
if force:
|
|
360
|
+
return None
|
|
361
|
+
if not prefs.get("enabled"):
|
|
362
|
+
return "disabled"
|
|
363
|
+
if status["level"] in {"info", "watch"}:
|
|
364
|
+
return "not_push_level"
|
|
365
|
+
key = status["dedupe_key"]
|
|
366
|
+
snoozed_until = self._snoozed_until(key, state)
|
|
367
|
+
if snoozed_until and snoozed_until > self.now_fn():
|
|
368
|
+
return "snoozed"
|
|
369
|
+
sent = state.get("sent_events", {}).get(_sent_event_state_key(key, device_id))
|
|
370
|
+
if isinstance(sent, dict):
|
|
371
|
+
last_sent_at = float(sent.get("last_sent_at") or 0)
|
|
372
|
+
cooldown = prefs["cooldown_critical_minutes"] if status["severity"] == "critical" else prefs["cooldown_warning_minutes"]
|
|
373
|
+
if self.now_fn() - last_sent_at < cooldown * 60:
|
|
374
|
+
return "cooldown"
|
|
375
|
+
human_idle = status.get("human_idle_minutes")
|
|
376
|
+
if human_idle is not None:
|
|
377
|
+
if status["severity"] == "critical" and human_idle < prefs["human_idle_critical_minutes"]:
|
|
378
|
+
return "human_recent"
|
|
379
|
+
if status["severity"] != "critical" and human_idle < prefs["human_idle_warning_minutes"]:
|
|
380
|
+
return "human_recent"
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def _snoozed_until(self, key: str, state: dict[str, Any]) -> float | None:
|
|
384
|
+
snoozes = state.get("snoozes") if isinstance(state.get("snoozes"), dict) else {}
|
|
385
|
+
until = max(float(snoozes.get("*") or 0), float(snoozes.get(key) or 0))
|
|
386
|
+
return until or None
|
|
387
|
+
|
|
388
|
+
def _event_from_status(self, status: dict[str, Any], *, device_id: str | None) -> dict[str, Any]:
|
|
389
|
+
created_at = self.now_fn()
|
|
390
|
+
digest = hashlib.sha256(
|
|
391
|
+
json.dumps({
|
|
392
|
+
"created_at": int(created_at),
|
|
393
|
+
"key": status["dedupe_key"],
|
|
394
|
+
"level": status["level"],
|
|
395
|
+
}, sort_keys=True).encode("utf-8")
|
|
396
|
+
).hexdigest()[:10]
|
|
397
|
+
return {
|
|
398
|
+
"event_id": f"sent_{int(created_at * 1000)}_{digest}",
|
|
399
|
+
"created_at": created_at,
|
|
400
|
+
"kind": status["kind"],
|
|
401
|
+
"level": status["level"],
|
|
402
|
+
"severity": status["severity"],
|
|
403
|
+
"dedupe_key": status["dedupe_key"],
|
|
404
|
+
"title": status["title"],
|
|
405
|
+
"body": status["body"],
|
|
406
|
+
"route": status["route"],
|
|
407
|
+
"device_id": str(device_id or "")[:120] or None,
|
|
408
|
+
"active_workers": status["active_workers"],
|
|
409
|
+
"idle_workers": status["idle_workers"],
|
|
410
|
+
"stale_workers": status["stale_workers"],
|
|
411
|
+
"token_pressure_sessions": status["token_pressure_sessions"],
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
def _state(self) -> dict[str, Any]:
|
|
415
|
+
try:
|
|
416
|
+
loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
417
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
418
|
+
loaded = {}
|
|
419
|
+
if not isinstance(loaded, dict):
|
|
420
|
+
loaded = {}
|
|
421
|
+
loaded.setdefault("sent_events", {})
|
|
422
|
+
loaded.setdefault("snoozes", {})
|
|
423
|
+
return loaded
|
|
424
|
+
|
|
425
|
+
def _append_event(self, event: dict[str, Any]) -> None:
|
|
426
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
try:
|
|
428
|
+
os.chmod(self.dir, stat.S_IRWXU)
|
|
429
|
+
except OSError:
|
|
430
|
+
pass
|
|
431
|
+
with self.events_path.open("a", encoding="utf-8") as handle:
|
|
432
|
+
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
|
433
|
+
try:
|
|
434
|
+
os.chmod(self.events_path, 0o600)
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
def _write_json(self, path: Path, payload: dict[str, Any]) -> None:
|
|
439
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
try:
|
|
441
|
+
os.chmod(path.parent, stat.S_IRWXU)
|
|
442
|
+
except OSError:
|
|
443
|
+
pass
|
|
444
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
445
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
446
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
447
|
+
handle.write("\n")
|
|
448
|
+
handle.flush()
|
|
449
|
+
os.fsync(handle.fileno())
|
|
450
|
+
os.replace(tmp, path)
|
|
451
|
+
try:
|
|
452
|
+
os.chmod(path, 0o600)
|
|
453
|
+
except OSError:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
def _normalize_pref(self, key: str, value: Any) -> Any:
|
|
457
|
+
if key in _BOOL_PREFS:
|
|
458
|
+
return bool(value)
|
|
459
|
+
if key in _INT_PREFS:
|
|
460
|
+
parsed = max(0, min(int(value), 10000))
|
|
461
|
+
if key.startswith("cooldown") or key.startswith("human_idle") or key == "stale_worker_minutes":
|
|
462
|
+
return max(1, parsed)
|
|
463
|
+
return parsed
|
|
464
|
+
if key in _FLOAT_PREFS:
|
|
465
|
+
return max(0.05, min(float(value), 0.99))
|
|
466
|
+
return value
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _sent_event_state_key(dedupe_key: str, device_id: str | None) -> str:
|
|
470
|
+
device = str(device_id or "").strip()
|
|
471
|
+
if not device:
|
|
472
|
+
return dedupe_key
|
|
473
|
+
return f"{dedupe_key}:device:{device[:120]}"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _int(value: Any, default: int = 0) -> int:
|
|
477
|
+
try:
|
|
478
|
+
return int(value)
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
return default
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _project_scope(worker_stats: dict[str, Any]) -> str:
|
|
484
|
+
projects = worker_stats.get("projects")
|
|
485
|
+
if not isinstance(projects, list) or not projects:
|
|
486
|
+
return "all"
|
|
487
|
+
first = projects[0] if isinstance(projects[0], dict) else {}
|
|
488
|
+
path = str(first.get("path") or first.get("project") or "all")
|
|
489
|
+
name = Path(path).name or "all"
|
|
490
|
+
safe = "".join(ch for ch in name if ch.isalnum() or ch in {"-", "_"})[:60]
|
|
491
|
+
return safe or "all"
|