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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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())
|