codex-autorunner 0.1.0__py3-none-any.whl

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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,567 @@
1
+ import importlib.metadata
2
+ import json
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from urllib.parse import unquote, urlparse
12
+
13
+ from .git_utils import GitError, run_git
14
+
15
+
16
+ class UpdateInProgressError(RuntimeError):
17
+ """Raised when an update is already running."""
18
+
19
+
20
+ def _run_cmd(cmd: list[str], cwd: Path) -> None:
21
+ """Run a subprocess command, raising on failure."""
22
+ try:
23
+ subprocess.run(
24
+ cmd,
25
+ cwd=cwd,
26
+ check=True,
27
+ capture_output=True,
28
+ text=True,
29
+ timeout=300, # 5 mins should be enough for clone/install
30
+ )
31
+ except subprocess.CalledProcessError as e:
32
+ # Include stdout/stderr in the error message for debugging
33
+ detail = (
34
+ f"Command failed: {' '.join(cmd)}\nStdout: {e.stdout}\nStderr: {e.stderr}"
35
+ )
36
+ raise RuntimeError(detail) from e
37
+
38
+
39
+ def _normalize_update_target(raw: Optional[str]) -> str:
40
+ if raw is None:
41
+ return "both"
42
+ value = str(raw).strip().lower()
43
+ if value in ("", "both", "all"):
44
+ return "both"
45
+ if value in ("web", "hub", "server", "ui"):
46
+ return "web"
47
+ if value in ("telegram", "tg", "bot"):
48
+ return "telegram"
49
+ raise ValueError("Unsupported update target (use both, web, or telegram).")
50
+
51
+
52
+ def _normalize_update_ref(raw: Optional[str]) -> str:
53
+ value = str(raw or "").strip()
54
+ return value or "main"
55
+
56
+
57
+ def _update_status_path() -> Path:
58
+ return Path.home() / ".codex-autorunner" / "update_status.json"
59
+
60
+
61
+ def _write_update_status(status: str, message: str, **extra) -> None:
62
+ payload = {"status": status, "message": message, "at": time.time(), **extra}
63
+ path = _update_status_path()
64
+ existing = None
65
+ if path.exists():
66
+ try:
67
+ existing = json.loads(path.read_text(encoding="utf-8"))
68
+ except Exception:
69
+ existing = None
70
+ if isinstance(existing, dict):
71
+ for key in (
72
+ "notify_chat_id",
73
+ "notify_thread_id",
74
+ "notify_reply_to",
75
+ "notify_sent_at",
76
+ ):
77
+ if key not in payload and key in existing:
78
+ payload[key] = existing[key]
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ path.write_text(json.dumps(payload), encoding="utf-8")
81
+
82
+
83
+ def _is_valid_git_repo(path: Path) -> bool:
84
+ try:
85
+ result = subprocess.run(
86
+ ["git", "rev-parse", "--git-dir"],
87
+ cwd=path,
88
+ check=False,
89
+ capture_output=True,
90
+ text=True,
91
+ )
92
+ except Exception:
93
+ return False
94
+ return result.returncode == 0
95
+
96
+
97
+ def _read_update_status() -> Optional[dict[str, object]]:
98
+ path = _update_status_path()
99
+ if not path.exists():
100
+ return None
101
+ try:
102
+ payload = json.loads(path.read_text(encoding="utf-8"))
103
+ except Exception:
104
+ return None
105
+ if not isinstance(payload, dict):
106
+ return None
107
+ status = payload.get("status")
108
+ if status in ("running", "spawned") and _update_lock_active() is None:
109
+ _write_update_status(
110
+ "error",
111
+ "Update not running; last update may have crashed.",
112
+ previous_status=status,
113
+ )
114
+ try:
115
+ payload = json.loads(path.read_text(encoding="utf-8"))
116
+ except Exception:
117
+ return None
118
+ return payload if isinstance(payload, dict) else None
119
+ return payload
120
+
121
+
122
+ def _update_lock_path() -> Path:
123
+ return Path.home() / ".codex-autorunner" / "update.lock"
124
+
125
+
126
+ def _read_update_lock() -> Optional[dict[str, object]]:
127
+ path = _update_lock_path()
128
+ if not path.exists():
129
+ return None
130
+ try:
131
+ payload = json.loads(path.read_text(encoding="utf-8"))
132
+ except Exception:
133
+ return None
134
+ if isinstance(payload, dict):
135
+ return payload
136
+ return None
137
+
138
+
139
+ def _pid_is_running(pid: int) -> bool:
140
+ if pid <= 0:
141
+ return False
142
+ try:
143
+ os.kill(pid, 0)
144
+ except OSError:
145
+ return False
146
+ return True
147
+
148
+
149
+ def _update_lock_active() -> Optional[dict]:
150
+ lock = _read_update_lock()
151
+ if not lock:
152
+ try:
153
+ _update_lock_path().unlink()
154
+ except OSError:
155
+ pass
156
+ return None
157
+ pid = lock.get("pid")
158
+ if isinstance(pid, int) and _pid_is_running(pid):
159
+ return lock
160
+ try:
161
+ _update_lock_path().unlink()
162
+ except OSError:
163
+ pass
164
+ return None
165
+
166
+
167
+ def _acquire_update_lock(
168
+ *, repo_url: str, repo_ref: str, update_target: str, logger: logging.Logger
169
+ ) -> bool:
170
+ lock_path = _update_lock_path()
171
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
172
+ payload = {
173
+ "pid": os.getpid(),
174
+ "started_at": time.time(),
175
+ "repo_url": repo_url,
176
+ "repo_ref": repo_ref,
177
+ "update_target": update_target,
178
+ }
179
+ try:
180
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
181
+ except FileExistsError as exc:
182
+ existing = _update_lock_active()
183
+ if existing:
184
+ msg = f"Update already running (pid {existing.get('pid')})."
185
+ logger.info(msg)
186
+ raise UpdateInProgressError(msg) from exc
187
+ try:
188
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
189
+ except FileExistsError as exc:
190
+ msg = "Update already running."
191
+ logger.info(msg)
192
+ raise UpdateInProgressError(msg) from exc
193
+ with os.fdopen(fd, "w") as handle:
194
+ handle.write(json.dumps(payload))
195
+ return True
196
+
197
+
198
+ def _release_update_lock() -> None:
199
+ lock = _read_update_lock()
200
+ if not lock or lock.get("pid") != os.getpid():
201
+ return
202
+ try:
203
+ _update_lock_path().unlink()
204
+ except OSError:
205
+ pass
206
+
207
+
208
+ def _find_git_root(start: Path) -> Optional[Path]:
209
+ for candidate in (start, *start.parents):
210
+ if (candidate / ".git").exists():
211
+ return candidate
212
+ return None
213
+
214
+
215
+ def _find_git_root_from_install_metadata() -> Optional[Path]:
216
+ """
217
+ Best-effort: when installed from a local directory, pip may record a PEP 610
218
+ direct URL which can point back to a working tree that has a .git directory.
219
+ """
220
+ try:
221
+ dist = importlib.metadata.distribution("codex-autorunner")
222
+ except importlib.metadata.PackageNotFoundError:
223
+ return None
224
+
225
+ direct_url = dist.read_text("direct_url.json")
226
+ if not direct_url:
227
+ return None
228
+
229
+ try:
230
+ payload = json.loads(direct_url)
231
+ except Exception:
232
+ return None
233
+
234
+ raw_url = payload.get("url")
235
+ if not isinstance(raw_url, str) or not raw_url:
236
+ return None
237
+
238
+ parsed = urlparse(raw_url)
239
+ if parsed.scheme != "file":
240
+ return None
241
+
242
+ candidate = Path(unquote(parsed.path)).expanduser()
243
+ if not candidate.exists():
244
+ return None
245
+
246
+ return _find_git_root(candidate)
247
+
248
+
249
+ def _resolve_local_repo_root(
250
+ *, module_dir: Path, update_cache_dir: Path
251
+ ) -> Optional[Path]:
252
+ repo_root = _find_git_root(module_dir)
253
+ if repo_root is not None:
254
+ return repo_root
255
+
256
+ if (update_cache_dir / ".git").exists():
257
+ return update_cache_dir
258
+
259
+ return _find_git_root_from_install_metadata()
260
+
261
+
262
+ def _system_update_check(
263
+ *,
264
+ repo_url: str,
265
+ repo_ref: str,
266
+ module_dir: Optional[Path] = None,
267
+ update_cache_dir: Optional[Path] = None,
268
+ ) -> dict:
269
+ module_dir = module_dir or Path(__file__).resolve().parent
270
+ update_cache_dir = update_cache_dir or (
271
+ Path.home() / ".codex-autorunner" / "update_cache"
272
+ )
273
+ repo_ref = _normalize_update_ref(repo_ref)
274
+
275
+ repo_root = _resolve_local_repo_root(
276
+ module_dir=module_dir, update_cache_dir=update_cache_dir
277
+ )
278
+ if repo_root is None:
279
+ return {
280
+ "status": "ok",
281
+ "update_available": True,
282
+ "message": "No local git state found; update may be available.",
283
+ }
284
+
285
+ try:
286
+ local_sha = run_git(["rev-parse", "HEAD"], repo_root, check=True).stdout.strip()
287
+ except GitError as exc:
288
+ return {
289
+ "status": "ok",
290
+ "update_available": True,
291
+ "message": f"Unable to read local git state ({exc}); update may be available.",
292
+ }
293
+
294
+ try:
295
+ run_git(
296
+ ["fetch", "--quiet", repo_url, repo_ref],
297
+ repo_root,
298
+ timeout_seconds=60,
299
+ check=True,
300
+ )
301
+ remote_sha = run_git(
302
+ ["rev-parse", "FETCH_HEAD"], repo_root, check=True
303
+ ).stdout.strip()
304
+ except GitError as exc:
305
+ return {
306
+ "status": "ok",
307
+ "update_available": True,
308
+ "message": f"Unable to check remote updates ({exc}); you can try updating anyway.",
309
+ "local_commit": local_sha,
310
+ }
311
+
312
+ if not remote_sha or not local_sha:
313
+ return {
314
+ "status": "ok",
315
+ "update_available": True,
316
+ "message": "Unable to determine update status; you can try updating anyway.",
317
+ }
318
+
319
+ if remote_sha == local_sha:
320
+ return {
321
+ "status": "ok",
322
+ "update_available": False,
323
+ "message": "No update available (already up to date).",
324
+ "local_commit": local_sha,
325
+ "remote_commit": remote_sha,
326
+ }
327
+
328
+ local_is_ancestor = (
329
+ run_git(
330
+ ["merge-base", "--is-ancestor", local_sha, remote_sha], repo_root
331
+ ).returncode
332
+ == 0
333
+ )
334
+ remote_is_ancestor = (
335
+ run_git(
336
+ ["merge-base", "--is-ancestor", remote_sha, local_sha], repo_root
337
+ ).returncode
338
+ == 0
339
+ )
340
+
341
+ if local_is_ancestor:
342
+ message = "Update available."
343
+ update_available = True
344
+ elif remote_is_ancestor:
345
+ message = "No update available (local version is ahead of remote)."
346
+ update_available = False
347
+ else:
348
+ message = "Update available (local version diverged from remote)."
349
+ update_available = True
350
+
351
+ return {
352
+ "status": "ok",
353
+ "update_available": update_available,
354
+ "message": message,
355
+ "local_commit": local_sha,
356
+ "remote_commit": remote_sha,
357
+ }
358
+
359
+
360
+ def _system_update_worker(
361
+ *,
362
+ repo_url: str,
363
+ repo_ref: str,
364
+ update_dir: Path,
365
+ logger: logging.Logger,
366
+ update_target: str = "both",
367
+ ) -> None:
368
+ status_path = _update_status_path()
369
+ lock_acquired = False
370
+ try:
371
+ try:
372
+ update_target = _normalize_update_target(update_target)
373
+ except ValueError as exc:
374
+ msg = str(exc)
375
+ logger.error(msg)
376
+ _write_update_status("error", msg)
377
+ return
378
+ repo_ref = _normalize_update_ref(repo_ref)
379
+ try:
380
+ lock_acquired = _acquire_update_lock(
381
+ repo_url=repo_url,
382
+ repo_ref=repo_ref,
383
+ update_target=update_target,
384
+ logger=logger,
385
+ )
386
+ except UpdateInProgressError:
387
+ return
388
+
389
+ _write_update_status(
390
+ "running",
391
+ "Update started.",
392
+ repo_url=repo_url,
393
+ update_dir=str(update_dir),
394
+ repo_ref=repo_ref,
395
+ update_target=update_target,
396
+ )
397
+
398
+ missing = []
399
+ for cmd in ("git", "bash", "launchctl", "curl"):
400
+ if shutil.which(cmd) is None:
401
+ missing.append(cmd)
402
+ if missing:
403
+ msg = f"Missing required commands: {', '.join(missing)}"
404
+ logger.error(msg)
405
+ _write_update_status("error", msg)
406
+ return
407
+
408
+ update_dir.parent.mkdir(parents=True, exist_ok=True)
409
+
410
+ updated = False
411
+ if update_dir.exists() and (update_dir / ".git").exists():
412
+ if not _is_valid_git_repo(update_dir):
413
+ logger.warning(
414
+ "Update cache exists but is not a valid git repo; removing %s",
415
+ update_dir,
416
+ )
417
+ shutil.rmtree(update_dir)
418
+ else:
419
+ logger.info(
420
+ "Updating source in %s from %s (%s)",
421
+ update_dir,
422
+ repo_url,
423
+ repo_ref,
424
+ )
425
+ try:
426
+ _run_cmd(
427
+ ["git", "remote", "set-url", "origin", repo_url],
428
+ cwd=update_dir,
429
+ )
430
+ except Exception:
431
+ _run_cmd(
432
+ ["git", "remote", "add", "origin", repo_url],
433
+ cwd=update_dir,
434
+ )
435
+ _run_cmd(["git", "fetch", "origin", repo_ref], cwd=update_dir)
436
+ _run_cmd(["git", "reset", "--hard", "FETCH_HEAD"], cwd=update_dir)
437
+ updated = True
438
+ if not updated:
439
+ if update_dir.exists():
440
+ shutil.rmtree(update_dir)
441
+ logger.info("Cloning %s into %s", repo_url, update_dir)
442
+ _run_cmd(["git", "clone", repo_url, str(update_dir)], cwd=update_dir.parent)
443
+ _run_cmd(["git", "fetch", "origin", repo_ref], cwd=update_dir)
444
+ _run_cmd(["git", "reset", "--hard", "FETCH_HEAD"], cwd=update_dir)
445
+
446
+ if os.environ.get("CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS") == "1":
447
+ logger.info(
448
+ "Skipping update checks (CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS=1)."
449
+ )
450
+ else:
451
+ logger.info("Running checks...")
452
+ try:
453
+ _run_cmd(["./scripts/check.sh"], cwd=update_dir)
454
+ except Exception as exc:
455
+ logger.warning("Checks failed; continuing with refresh. %s", exc)
456
+
457
+ logger.info("Refreshing launchd service...")
458
+ refresh_script = update_dir / "scripts" / "safe-refresh-local-mac-hub.sh"
459
+ if not refresh_script.exists():
460
+ msg = f"Missing safe refresh script at {refresh_script}."
461
+ logger.error(msg)
462
+ _write_update_status("error", msg)
463
+ return
464
+
465
+ env = os.environ.copy()
466
+ env["PACKAGE_SRC"] = str(update_dir)
467
+ env["UPDATE_STATUS_PATH"] = str(status_path)
468
+ env["UPDATE_TARGET"] = update_target
469
+
470
+ proc = subprocess.Popen(
471
+ [str(refresh_script)],
472
+ cwd=update_dir,
473
+ env=env,
474
+ stdout=subprocess.PIPE,
475
+ stderr=subprocess.STDOUT,
476
+ text=True,
477
+ )
478
+ if proc.stdout:
479
+ for line in proc.stdout:
480
+ logger.info("[Updater] %s", line.rstrip("\n"))
481
+ proc.wait()
482
+ if proc.returncode != 0:
483
+ existing = _read_update_status()
484
+ if not existing or existing.get("status") not in ("rollback", "error"):
485
+ _write_update_status(
486
+ "rollback",
487
+ "Update failed; rollback attempted. Check hub logs for details.",
488
+ exit_code=proc.returncode,
489
+ )
490
+ return
491
+
492
+ existing = _read_update_status()
493
+ if not existing or existing.get("status") not in ("rollback", "error"):
494
+ _write_update_status(
495
+ "ok", "Update completed successfully.", update_target=update_target
496
+ )
497
+ except Exception:
498
+ logger.exception("System update failed")
499
+ _write_update_status(
500
+ "error",
501
+ "Update crashed; see hub logs for details.",
502
+ )
503
+ finally:
504
+ if lock_acquired:
505
+ _release_update_lock()
506
+
507
+
508
+ def _spawn_update_process(
509
+ *,
510
+ repo_url: str,
511
+ repo_ref: str,
512
+ update_dir: Path,
513
+ logger: logging.Logger,
514
+ update_target: str = "both",
515
+ notify_chat_id: Optional[int] = None,
516
+ notify_thread_id: Optional[int] = None,
517
+ notify_reply_to: Optional[int] = None,
518
+ ) -> None:
519
+ active = _update_lock_active()
520
+ if active:
521
+ raise UpdateInProgressError(
522
+ f"Update already running (pid {active.get('pid')})."
523
+ )
524
+ status_path = _update_status_path()
525
+ log_path = status_path.parent / "update-standalone.log"
526
+ _write_update_status(
527
+ "running",
528
+ "Update spawned.",
529
+ repo_url=repo_url,
530
+ update_dir=str(update_dir),
531
+ repo_ref=repo_ref,
532
+ update_target=update_target,
533
+ log_path=str(log_path),
534
+ notify_chat_id=notify_chat_id,
535
+ notify_thread_id=notify_thread_id,
536
+ notify_reply_to=notify_reply_to,
537
+ notify_sent_at=None,
538
+ )
539
+ cmd = [
540
+ sys.executable,
541
+ "-m",
542
+ "codex_autorunner.core.update_runner",
543
+ "--repo-url",
544
+ repo_url,
545
+ "--repo-ref",
546
+ repo_ref,
547
+ "--update-dir",
548
+ str(update_dir),
549
+ "--target",
550
+ update_target,
551
+ "--log-path",
552
+ str(log_path),
553
+ ]
554
+ try:
555
+ subprocess.Popen(
556
+ cmd,
557
+ cwd=str(update_dir.parent),
558
+ start_new_session=True,
559
+ stdout=subprocess.DEVNULL,
560
+ stderr=subprocess.DEVNULL,
561
+ )
562
+ except Exception:
563
+ logger.exception("Failed to spawn update worker")
564
+ _write_update_status(
565
+ "error",
566
+ "Failed to spawn update worker; see hub logs for details.",
567
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from .update import _system_update_worker
8
+
9
+
10
+ def _build_logger(log_path: Path) -> logging.Logger:
11
+ logger = logging.getLogger("codex_autorunner.system_update")
12
+ logger.setLevel(logging.INFO)
13
+ handler = logging.FileHandler(log_path, encoding="utf-8")
14
+ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
15
+ logger.addHandler(handler)
16
+ return logger
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(description="Run codex-autorunner update worker.")
21
+ parser.add_argument("--repo-url", required=True)
22
+ parser.add_argument("--repo-ref", default="main")
23
+ parser.add_argument("--update-dir", required=True)
24
+ parser.add_argument("--log-path", required=True)
25
+ parser.add_argument("--target", default="both")
26
+ args = parser.parse_args(argv)
27
+
28
+ update_dir = Path(args.update_dir).expanduser()
29
+ log_path = Path(args.log_path).expanduser()
30
+ log_path.parent.mkdir(parents=True, exist_ok=True)
31
+ logger = _build_logger(log_path)
32
+
33
+ _system_update_worker(
34
+ repo_url=args.repo_url,
35
+ repo_ref=args.repo_ref,
36
+ update_dir=update_dir,
37
+ logger=logger,
38
+ update_target=args.target,
39
+ )
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())