nexo-brain 2.6.5 → 2.6.7

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.
@@ -0,0 +1,396 @@
1
+ from __future__ import annotations
2
+ """Public contribution preferences and GitHub PR workflow helpers.
3
+
4
+ This module manages the opt-in "public core evolution" mode:
5
+ - user consent and persisted config in schedule.json
6
+ - GitHub auth/fork detection
7
+ - active Draft PR pause/resume lifecycle
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import socket
16
+ import subprocess
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+
20
+ from runtime_power import load_schedule_config, save_schedule_config
21
+
22
+
23
+ UPSTREAM_REPO = "wazionapps/nexo"
24
+ CONFIG_KEY = "public_contribution"
25
+ CONSENT_VERSION = 1
26
+ MODE_UNSET = "unset"
27
+ MODE_OFF = "off"
28
+ MODE_DRAFT_PRS = "draft_prs"
29
+ MODE_PENDING_AUTH = "pending_auth"
30
+ STATUS_UNSET = "unset"
31
+ STATUS_ACTIVE = "active"
32
+ STATUS_PENDING_AUTH = "pending_auth"
33
+ STATUS_PAUSED_OPEN_PR = "paused_open_pr"
34
+ STATUS_COOLDOWN = "cooldown"
35
+ STATUS_OFF = "off"
36
+ COOLDOWN_HOURS_MERGED = 168
37
+ COOLDOWN_HOURS_CLOSED = 72
38
+ VALID_MODES = {MODE_UNSET, MODE_OFF, MODE_DRAFT_PRS, MODE_PENDING_AUTH}
39
+ VALID_STATUSES = {
40
+ STATUS_UNSET,
41
+ STATUS_ACTIVE,
42
+ STATUS_PENDING_AUTH,
43
+ STATUS_PAUSED_OPEN_PR,
44
+ STATUS_COOLDOWN,
45
+ STATUS_OFF,
46
+ }
47
+
48
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
49
+ CONTRIB_ROOT = NEXO_HOME / "contrib" / "public-core"
50
+ CONTRIB_REPO_DIR = CONTRIB_ROOT / "repo"
51
+ CONTRIB_WORKTREES_DIR = CONTRIB_ROOT / "worktrees"
52
+ CONTRIB_ARTIFACTS_DIR = NEXO_HOME / "operations" / "public-contrib"
53
+
54
+
55
+ def _utcnow() -> datetime:
56
+ return datetime.now(timezone.utc)
57
+
58
+
59
+ def _machine_id() -> str:
60
+ raw = socket.gethostname().strip().lower() or "nexo-machine"
61
+ return re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-") or "nexo-machine"
62
+
63
+
64
+ def _default_public_contribution() -> dict:
65
+ return {
66
+ "enabled": False,
67
+ "mode": MODE_UNSET,
68
+ "consent_version": CONSENT_VERSION,
69
+ "github_user": "",
70
+ "upstream_repo": UPSTREAM_REPO,
71
+ "fork_repo": "",
72
+ "machine_id": _machine_id(),
73
+ "active_pr_url": "",
74
+ "active_pr_number": None,
75
+ "active_branch": "",
76
+ "status": STATUS_UNSET,
77
+ "cooldown_until": "",
78
+ "last_run_at": "",
79
+ "last_result": "",
80
+ }
81
+
82
+
83
+ def normalize_public_contribution_config(config: dict | None) -> dict:
84
+ merged = dict(_default_public_contribution())
85
+ if isinstance(config, dict):
86
+ merged.update(config)
87
+ merged["mode"] = str(merged.get("mode") or MODE_UNSET).strip().lower()
88
+ if merged["mode"] not in VALID_MODES:
89
+ merged["mode"] = MODE_UNSET
90
+ merged["status"] = str(merged.get("status") or STATUS_UNSET).strip().lower()
91
+ if merged["status"] not in VALID_STATUSES:
92
+ merged["status"] = STATUS_UNSET
93
+ merged["enabled"] = bool(merged.get("enabled", False))
94
+ merged["consent_version"] = CONSENT_VERSION
95
+ merged["upstream_repo"] = str(merged.get("upstream_repo") or UPSTREAM_REPO)
96
+ merged["github_user"] = str(merged.get("github_user") or "").strip()
97
+ merged["fork_repo"] = str(merged.get("fork_repo") or "").strip()
98
+ merged["machine_id"] = str(merged.get("machine_id") or _machine_id()).strip() or _machine_id()
99
+ merged["active_pr_url"] = str(merged.get("active_pr_url") or "").strip()
100
+ merged["active_pr_number"] = merged.get("active_pr_number")
101
+ if merged["active_pr_number"] in {"", 0, "0"}:
102
+ merged["active_pr_number"] = None
103
+ merged["active_branch"] = str(merged.get("active_branch") or "").strip()
104
+ merged["cooldown_until"] = str(merged.get("cooldown_until") or "").strip()
105
+ merged["last_run_at"] = str(merged.get("last_run_at") or "").strip()
106
+ merged["last_result"] = str(merged.get("last_result") or "").strip()
107
+ return merged
108
+
109
+
110
+ def load_public_contribution_config(schedule: dict | None = None) -> dict:
111
+ schedule = schedule or load_schedule_config()
112
+ return normalize_public_contribution_config(schedule.get(CONFIG_KEY))
113
+
114
+
115
+ def save_public_contribution_config(config: dict) -> dict:
116
+ schedule = load_schedule_config()
117
+ schedule[CONFIG_KEY] = normalize_public_contribution_config(config)
118
+ save_schedule_config(schedule)
119
+ return schedule[CONFIG_KEY]
120
+
121
+
122
+ def _gh(*args: str, cwd: Path | None = None, timeout: int = 20) -> subprocess.CompletedProcess:
123
+ return subprocess.run(
124
+ ["gh", *args],
125
+ cwd=str(cwd) if cwd else None,
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=timeout,
129
+ )
130
+
131
+
132
+ def github_auth_status() -> dict:
133
+ if not shutil.which("gh"):
134
+ return {"ok": False, "message": "GitHub CLI not found.", "login": ""}
135
+ try:
136
+ result = _gh("api", "user", timeout=20)
137
+ except Exception as e:
138
+ return {"ok": False, "message": str(e), "login": ""}
139
+ if result.returncode != 0:
140
+ return {"ok": False, "message": (result.stderr or result.stdout).strip(), "login": ""}
141
+ try:
142
+ payload = json.loads(result.stdout or "{}")
143
+ login = str(payload.get("login") or "").strip()
144
+ except Exception:
145
+ login = ""
146
+ return {"ok": bool(login), "message": "", "login": login}
147
+
148
+
149
+ def ensure_fork(login: str) -> dict:
150
+ if not login:
151
+ return {"ok": False, "message": "Missing GitHub login.", "fork_repo": ""}
152
+ fork_repo = f"{login}/nexo"
153
+ if not shutil.which("gh"):
154
+ return {"ok": False, "message": "GitHub CLI not found.", "fork_repo": ""}
155
+ try:
156
+ check = _gh("repo", "view", fork_repo, "--json", "nameWithOwner", timeout=20)
157
+ if check.returncode == 0:
158
+ return {"ok": True, "message": "", "fork_repo": fork_repo}
159
+ create = _gh("repo", "fork", UPSTREAM_REPO, "--clone=false", "--remote=false", timeout=60)
160
+ if create.returncode == 0:
161
+ return {"ok": True, "message": "", "fork_repo": fork_repo}
162
+ return {
163
+ "ok": False,
164
+ "message": (create.stderr or create.stdout or check.stderr or check.stdout).strip(),
165
+ "fork_repo": "",
166
+ }
167
+ except Exception as e:
168
+ return {"ok": False, "message": str(e), "fork_repo": ""}
169
+
170
+
171
+ def _parse_iso(ts: str | None) -> datetime | None:
172
+ value = str(ts or "").strip()
173
+ if not value:
174
+ return None
175
+ try:
176
+ if value.endswith("Z"):
177
+ value = value[:-1] + "+00:00"
178
+ dt = datetime.fromisoformat(value)
179
+ if dt.tzinfo is None:
180
+ dt = dt.replace(tzinfo=timezone.utc)
181
+ return dt.astimezone(timezone.utc)
182
+ except Exception:
183
+ return None
184
+
185
+
186
+ def _future_iso(hours: int) -> str:
187
+ return (_utcnow() + timedelta(hours=hours)).isoformat()
188
+
189
+
190
+ def format_public_contribution_label(config: dict | None = None) -> str:
191
+ cfg = normalize_public_contribution_config(config)
192
+ if cfg["mode"] == MODE_DRAFT_PRS:
193
+ return f"draft_prs ({cfg['status']})"
194
+ return cfg["mode"]
195
+
196
+
197
+ def prompt_for_public_contribution(
198
+ *,
199
+ reason: str = "update",
200
+ input_fn=input,
201
+ output_fn=print,
202
+ ) -> dict:
203
+ output_fn("[NEXO] Public contribution mode is optional and opt-in.")
204
+ output_fn(
205
+ "[NEXO] If enabled, this machine may prepare core improvements in an isolated checkout "
206
+ "and open a Draft PR to the public NEXO repository."
207
+ )
208
+ output_fn("[NEXO] It never auto-merges, and it stays paused while that PR remains open.")
209
+ output_fn("[NEXO] It must never publish personal scripts, local runtime data, logs, prompts, or secrets.")
210
+
211
+ while True:
212
+ answer = str(
213
+ input_fn("[NEXO] Enable public contribution via Draft PRs on this machine? [y]es / [n]o / [l]ater: ")
214
+ ).strip().lower()
215
+ if answer in {"y", "yes"}:
216
+ auth = github_auth_status()
217
+ if not auth.get("ok"):
218
+ return {
219
+ "mode": MODE_PENDING_AUTH,
220
+ "status": STATUS_PENDING_AUTH,
221
+ "enabled": False,
222
+ "message": auth.get("message") or "GitHub authentication is missing.",
223
+ "github_user": "",
224
+ "fork_repo": "",
225
+ "prompted": True,
226
+ }
227
+ fork = ensure_fork(auth.get("login", ""))
228
+ if not fork.get("ok"):
229
+ return {
230
+ "mode": MODE_PENDING_AUTH,
231
+ "status": STATUS_PENDING_AUTH,
232
+ "enabled": False,
233
+ "message": fork.get("message") or "Could not ensure a GitHub fork.",
234
+ "github_user": auth.get("login", ""),
235
+ "fork_repo": "",
236
+ "prompted": True,
237
+ }
238
+ return {
239
+ "mode": MODE_DRAFT_PRS,
240
+ "status": STATUS_ACTIVE,
241
+ "enabled": True,
242
+ "message": "",
243
+ "github_user": auth.get("login", ""),
244
+ "fork_repo": fork.get("fork_repo", ""),
245
+ "prompted": True,
246
+ }
247
+ if answer in {"n", "no"}:
248
+ return {
249
+ "mode": MODE_OFF,
250
+ "status": STATUS_OFF,
251
+ "enabled": False,
252
+ "message": "",
253
+ "github_user": "",
254
+ "fork_repo": "",
255
+ "prompted": True,
256
+ }
257
+ if answer in {"l", "later", ""}:
258
+ return {
259
+ "mode": MODE_UNSET,
260
+ "status": STATUS_UNSET,
261
+ "enabled": False,
262
+ "message": "",
263
+ "github_user": "",
264
+ "fork_repo": "",
265
+ "prompted": True,
266
+ }
267
+ output_fn("[NEXO] Reply with yes, no, or later.")
268
+
269
+
270
+ def ensure_public_contribution_choice(
271
+ *,
272
+ interactive: bool,
273
+ reason: str = "update",
274
+ input_fn=input,
275
+ output_fn=print,
276
+ force_prompt: bool = False,
277
+ ) -> dict:
278
+ config = load_public_contribution_config()
279
+ prompted = False
280
+ if interactive and (force_prompt or config["mode"] == MODE_UNSET):
281
+ prompted = True
282
+ result = prompt_for_public_contribution(reason=reason, input_fn=input_fn, output_fn=output_fn)
283
+ config.update({
284
+ "enabled": result["enabled"],
285
+ "mode": result["mode"],
286
+ "status": result["status"],
287
+ "github_user": result["github_user"],
288
+ "fork_repo": result["fork_repo"],
289
+ "machine_id": config.get("machine_id") or _machine_id(),
290
+ })
291
+ if result["mode"] != MODE_DRAFT_PRS:
292
+ config["active_pr_url"] = ""
293
+ config["active_pr_number"] = None
294
+ config["active_branch"] = ""
295
+ save_public_contribution_config(config)
296
+ config = load_public_contribution_config()
297
+ config["message"] = result.get("message", "")
298
+ else:
299
+ config["message"] = ""
300
+ config["prompted"] = prompted
301
+ return config
302
+
303
+
304
+ def refresh_public_contribution_state(config: dict | None = None) -> dict:
305
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
306
+ if config["mode"] != MODE_DRAFT_PRS:
307
+ return config
308
+
309
+ if config.get("active_pr_number") and config.get("active_pr_url"):
310
+ try:
311
+ result = _gh(
312
+ "pr",
313
+ "view",
314
+ str(config["active_pr_number"]),
315
+ "--repo",
316
+ config["upstream_repo"],
317
+ "--json",
318
+ "state,isDraft,url,mergedAt,closed",
319
+ timeout=20,
320
+ )
321
+ except Exception as e:
322
+ config["last_result"] = f"pr_status_error:{e}"
323
+ save_public_contribution_config(config)
324
+ return config
325
+ if result.returncode == 0:
326
+ payload = json.loads(result.stdout or "{}")
327
+ if payload.get("state") == "OPEN" and payload.get("isDraft", False):
328
+ config["status"] = STATUS_PAUSED_OPEN_PR
329
+ save_public_contribution_config(config)
330
+ return config
331
+ config["active_pr_url"] = ""
332
+ config["active_pr_number"] = None
333
+ config["active_branch"] = ""
334
+ cooldown_hours = COOLDOWN_HOURS_MERGED if payload.get("mergedAt") else COOLDOWN_HOURS_CLOSED
335
+ config["cooldown_until"] = _future_iso(cooldown_hours)
336
+ config["status"] = STATUS_COOLDOWN
337
+ save_public_contribution_config(config)
338
+ return config
339
+
340
+ cooldown_until = _parse_iso(config.get("cooldown_until"))
341
+ if cooldown_until and cooldown_until > _utcnow():
342
+ config["status"] = STATUS_COOLDOWN
343
+ elif config["mode"] == MODE_PENDING_AUTH:
344
+ config["status"] = STATUS_PENDING_AUTH
345
+ elif config["mode"] == MODE_DRAFT_PRS:
346
+ config["status"] = STATUS_ACTIVE
347
+ save_public_contribution_config(config)
348
+ return config
349
+
350
+
351
+ def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str, dict]:
352
+ config = refresh_public_contribution_state(config)
353
+ if config["mode"] == MODE_PENDING_AUTH or config["status"] == STATUS_PENDING_AUTH:
354
+ return False, "github authentication or fork setup is pending", config
355
+ if config["mode"] != MODE_DRAFT_PRS or not config.get("enabled"):
356
+ return False, "public contribution is disabled", config
357
+ if config["status"] == STATUS_PAUSED_OPEN_PR:
358
+ return False, "an active Draft PR is already open for this machine", config
359
+ cooldown_until = _parse_iso(config.get("cooldown_until"))
360
+ if cooldown_until and cooldown_until > _utcnow():
361
+ return False, f"cooldown until {cooldown_until.isoformat()}", config
362
+ return True, "", config
363
+
364
+
365
+ def mark_public_contribution_result(*, result: str, config: dict | None = None) -> dict:
366
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
367
+ config["last_run_at"] = _utcnow().isoformat()
368
+ config["last_result"] = str(result or "")
369
+ save_public_contribution_config(config)
370
+ return config
371
+
372
+
373
+ def mark_active_pr(*, pr_url: str, pr_number: int | None, branch: str, config: dict | None = None) -> dict:
374
+ config = normalize_public_contribution_config(config or load_public_contribution_config())
375
+ config["active_pr_url"] = pr_url
376
+ config["active_pr_number"] = pr_number
377
+ config["active_branch"] = branch
378
+ config["status"] = STATUS_PAUSED_OPEN_PR
379
+ config["last_run_at"] = _utcnow().isoformat()
380
+ config["last_result"] = "draft_pr_created"
381
+ save_public_contribution_config(config)
382
+ return config
383
+
384
+
385
+ def disable_public_contribution() -> dict:
386
+ config = load_public_contribution_config()
387
+ config.update({
388
+ "enabled": False,
389
+ "mode": MODE_OFF,
390
+ "status": STATUS_OFF,
391
+ "active_pr_url": "",
392
+ "active_pr_number": None,
393
+ "active_branch": "",
394
+ })
395
+ save_public_contribution_config(config)
396
+ return config