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,756 @@
1
+ import dataclasses
2
+ import enum
3
+ import logging
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from ..bootstrap import seed_repo_files
12
+ from ..discovery import DiscoveryRecord, discover_and_init
13
+ from ..manifest import Manifest, load_manifest, save_manifest
14
+ from .config import HubConfig, load_config
15
+ from .engine import Engine
16
+ from .git_utils import git_available, git_is_clean, git_upstream_status
17
+ from .locks import process_alive, read_lock_info
18
+ from .runner_controller import ProcessRunnerController, SpawnRunnerFn
19
+ from .state import RunnerState, load_state, now_iso
20
+ from .utils import atomic_write
21
+
22
+ logger = logging.getLogger("codex_autorunner.hub")
23
+
24
+
25
+ class RepoStatus(str, enum.Enum):
26
+ UNINITIALIZED = "uninitialized"
27
+ INITIALIZING = "initializing"
28
+ IDLE = "idle"
29
+ RUNNING = "running"
30
+ ERROR = "error"
31
+ LOCKED = "locked"
32
+ MISSING = "missing"
33
+ INIT_ERROR = "init_error"
34
+
35
+
36
+ class LockStatus(str, enum.Enum):
37
+ UNLOCKED = "unlocked"
38
+ LOCKED_ALIVE = "locked_alive"
39
+ LOCKED_STALE = "locked_stale"
40
+
41
+
42
+ @dataclasses.dataclass
43
+ class RepoSnapshot:
44
+ id: str
45
+ path: Path
46
+ display_name: str
47
+ enabled: bool
48
+ auto_run: bool
49
+ kind: str # base|worktree
50
+ worktree_of: Optional[str]
51
+ branch: Optional[str]
52
+ exists_on_disk: bool
53
+ initialized: bool
54
+ init_error: Optional[str]
55
+ status: RepoStatus
56
+ lock_status: LockStatus
57
+ last_run_id: Optional[int]
58
+ last_run_started_at: Optional[str]
59
+ last_run_finished_at: Optional[str]
60
+ last_exit_code: Optional[int]
61
+ runner_pid: Optional[int]
62
+
63
+ def to_dict(self, hub_root: Path) -> Dict[str, object]:
64
+ try:
65
+ rel_path = self.path.relative_to(hub_root)
66
+ except Exception:
67
+ rel_path = self.path
68
+ return {
69
+ "id": self.id,
70
+ "path": str(rel_path),
71
+ "display_name": self.display_name,
72
+ "enabled": self.enabled,
73
+ "auto_run": self.auto_run,
74
+ "kind": self.kind,
75
+ "worktree_of": self.worktree_of,
76
+ "branch": self.branch,
77
+ "exists_on_disk": self.exists_on_disk,
78
+ "initialized": self.initialized,
79
+ "init_error": self.init_error,
80
+ "status": self.status.value,
81
+ "lock_status": self.lock_status.value,
82
+ "last_run_id": self.last_run_id,
83
+ "last_run_started_at": self.last_run_started_at,
84
+ "last_run_finished_at": self.last_run_finished_at,
85
+ "last_exit_code": self.last_exit_code,
86
+ "runner_pid": self.runner_pid,
87
+ }
88
+
89
+
90
+ @dataclasses.dataclass
91
+ class HubState:
92
+ last_scan_at: Optional[str]
93
+ repos: List[RepoSnapshot]
94
+
95
+ def to_dict(self, hub_root: Path) -> Dict[str, object]:
96
+ return {
97
+ "last_scan_at": self.last_scan_at,
98
+ "repos": [repo.to_dict(hub_root) for repo in self.repos],
99
+ }
100
+
101
+
102
+ def read_lock_status(lock_path: Path) -> LockStatus:
103
+ if not lock_path.exists():
104
+ return LockStatus.UNLOCKED
105
+ info = read_lock_info(lock_path)
106
+ pid = info.pid
107
+ if pid and process_alive(pid):
108
+ return LockStatus.LOCKED_ALIVE
109
+ return LockStatus.LOCKED_STALE
110
+
111
+
112
+ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
113
+ if not state_path.exists():
114
+ return HubState(last_scan_at=None, repos=[])
115
+ data = state_path.read_text(encoding="utf-8")
116
+ try:
117
+ import json
118
+
119
+ payload = json.loads(data)
120
+ except Exception:
121
+ return HubState(last_scan_at=None, repos=[])
122
+ last_scan_at = payload.get("last_scan_at")
123
+ repos_payload = payload.get("repos") or []
124
+ repos: List[RepoSnapshot] = []
125
+ for entry in repos_payload:
126
+ try:
127
+ repo = RepoSnapshot(
128
+ id=str(entry.get("id")),
129
+ path=hub_root / entry.get("path", ""),
130
+ display_name=str(entry.get("display_name", "")),
131
+ enabled=bool(entry.get("enabled", True)),
132
+ auto_run=bool(entry.get("auto_run", False)),
133
+ kind=str(entry.get("kind", "base")),
134
+ worktree_of=entry.get("worktree_of"),
135
+ branch=entry.get("branch"),
136
+ exists_on_disk=bool(entry.get("exists_on_disk", False)),
137
+ initialized=bool(entry.get("initialized", False)),
138
+ init_error=entry.get("init_error"),
139
+ status=RepoStatus(entry.get("status", RepoStatus.UNINITIALIZED.value)),
140
+ lock_status=LockStatus(
141
+ entry.get("lock_status", LockStatus.UNLOCKED.value)
142
+ ),
143
+ last_run_id=entry.get("last_run_id"),
144
+ last_run_started_at=entry.get("last_run_started_at"),
145
+ last_run_finished_at=entry.get("last_run_finished_at"),
146
+ last_exit_code=entry.get("last_exit_code"),
147
+ runner_pid=entry.get("runner_pid"),
148
+ )
149
+ repos.append(repo)
150
+ except Exception:
151
+ continue
152
+ return HubState(last_scan_at=last_scan_at, repos=repos)
153
+
154
+
155
+ def save_hub_state(state_path: Path, state: HubState, hub_root: Path) -> None:
156
+ payload = state.to_dict(hub_root)
157
+ import json
158
+
159
+ atomic_write(state_path, json.dumps(payload, indent=2) + "\n")
160
+
161
+
162
+ class RepoRunner:
163
+ def __init__(
164
+ self,
165
+ repo_id: str,
166
+ repo_root: Path,
167
+ *,
168
+ spawn_fn: Optional[SpawnRunnerFn] = None,
169
+ ):
170
+ self.repo_id = repo_id
171
+ self._engine = Engine(repo_root)
172
+ self._controller = ProcessRunnerController(self._engine, spawn_fn=spawn_fn)
173
+
174
+ @property
175
+ def running(self) -> bool:
176
+ return self._controller.running
177
+
178
+ def start(self, once: bool = False) -> None:
179
+ self._controller.start(once=once)
180
+
181
+ def stop(self) -> None:
182
+ self._controller.stop()
183
+
184
+ def kill(self) -> Optional[int]:
185
+ return self._controller.kill()
186
+
187
+ def resume(self, once: bool = False) -> None:
188
+ self._controller.resume(once=once)
189
+
190
+
191
+ class HubSupervisor:
192
+ def __init__(
193
+ self, hub_config: HubConfig, *, spawn_fn: Optional[SpawnRunnerFn] = None
194
+ ):
195
+ self.hub_config = hub_config
196
+ self.state_path = hub_config.root / ".codex-autorunner" / "hub_state.json"
197
+ self._runners: Dict[str, RepoRunner] = {}
198
+ self._spawn_fn = spawn_fn
199
+ self.state = load_hub_state(self.state_path, self.hub_config.root)
200
+ self._list_cache_at: Optional[float] = None
201
+ self._list_cache: Optional[List[RepoSnapshot]] = None
202
+ self._reconcile_startup()
203
+
204
+ @classmethod
205
+ def from_path(cls, path: Path) -> "HubSupervisor":
206
+ config = load_config(path)
207
+ if not isinstance(config, HubConfig):
208
+ raise ValueError("HubSupervisor requires hub mode configuration")
209
+ return cls(config)
210
+
211
+ def scan(self) -> List[RepoSnapshot]:
212
+ self._invalidate_list_cache()
213
+ manifest, records = discover_and_init(self.hub_config)
214
+ snapshots = self._build_snapshots(records)
215
+ self.state = HubState(last_scan_at=now_iso(), repos=snapshots)
216
+ save_hub_state(self.state_path, self.state, self.hub_config.root)
217
+ return snapshots
218
+
219
+ def list_repos(self, *, use_cache: bool = True) -> List[RepoSnapshot]:
220
+ if use_cache and self._list_cache and self._list_cache_at is not None:
221
+ if time.monotonic() - self._list_cache_at < 2.0:
222
+ return self._list_cache
223
+ manifest, records = self._manifest_records(manifest_only=True)
224
+ snapshots = self._build_snapshots(records)
225
+ self.state = HubState(last_scan_at=self.state.last_scan_at, repos=snapshots)
226
+ save_hub_state(self.state_path, self.state, self.hub_config.root)
227
+ self._list_cache = snapshots
228
+ self._list_cache_at = time.monotonic()
229
+ return snapshots
230
+
231
+ def _reconcile_startup(self) -> None:
232
+ try:
233
+ _, records = self._manifest_records(manifest_only=True)
234
+ except Exception as exc:
235
+ logger.warning("Failed to load hub manifest for reconciliation: %s", exc)
236
+ return
237
+ for record in records:
238
+ if not record.initialized:
239
+ continue
240
+ try:
241
+ controller = ProcessRunnerController(Engine(record.absolute_path))
242
+ controller.reconcile()
243
+ except Exception as exc:
244
+ logger.warning(
245
+ "Failed to reconcile runner state for %s: %s",
246
+ record.absolute_path,
247
+ exc,
248
+ )
249
+
250
+ def run_repo(self, repo_id: str, once: bool = False) -> RepoSnapshot:
251
+ runner = self._ensure_runner(repo_id)
252
+ assert runner is not None
253
+ runner.start(once=once)
254
+ return self._snapshot_for_repo(repo_id)
255
+
256
+ def stop_repo(self, repo_id: str) -> RepoSnapshot:
257
+ runner = self._ensure_runner(repo_id, allow_uninitialized=True)
258
+ if runner:
259
+ runner.stop()
260
+ return self._snapshot_for_repo(repo_id)
261
+
262
+ def resume_repo(self, repo_id: str, once: bool = False) -> RepoSnapshot:
263
+ runner = self._ensure_runner(repo_id)
264
+ assert runner is not None
265
+ runner.resume(once=once)
266
+ return self._snapshot_for_repo(repo_id)
267
+
268
+ def kill_repo(self, repo_id: str) -> RepoSnapshot:
269
+ runner = self._ensure_runner(repo_id, allow_uninitialized=True)
270
+ if runner:
271
+ runner.kill()
272
+ return self._snapshot_for_repo(repo_id)
273
+
274
+ def init_repo(self, repo_id: str) -> RepoSnapshot:
275
+ self._invalidate_list_cache()
276
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
277
+ repo = manifest.get(repo_id)
278
+ if not repo:
279
+ raise ValueError(f"Repo {repo_id} not found in manifest")
280
+ repo_path = (self.hub_config.root / repo.path).resolve()
281
+ if not repo_path.exists():
282
+ raise ValueError(f"Repo {repo_id} missing on disk")
283
+ seed_repo_files(repo_path, force=False, git_required=False)
284
+ return self._snapshot_for_repo(repo_id)
285
+
286
+ def create_repo(
287
+ self,
288
+ repo_id: str,
289
+ repo_path: Optional[Path] = None,
290
+ git_init: bool = True,
291
+ force: bool = False,
292
+ ) -> RepoSnapshot:
293
+ self._invalidate_list_cache()
294
+ base_dir = self.hub_config.repos_root
295
+ target = repo_path if repo_path is not None else Path(repo_id)
296
+ if not target.is_absolute():
297
+ target = (base_dir / target).resolve()
298
+ else:
299
+ target = target.resolve()
300
+
301
+ try:
302
+ target.relative_to(base_dir)
303
+ except ValueError as exc:
304
+ raise ValueError(
305
+ f"Repo path must live under repos_root ({base_dir})"
306
+ ) from exc
307
+
308
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
309
+ existing = manifest.get(repo_id)
310
+ if existing:
311
+ existing_path = (self.hub_config.root / existing.path).resolve()
312
+ if existing_path != target:
313
+ raise ValueError(
314
+ f"Repo id {repo_id} already exists at {existing.path}; choose a different id"
315
+ )
316
+
317
+ if target.exists() and not force:
318
+ raise ValueError(f"Repo path already exists: {target}")
319
+
320
+ target.mkdir(parents=True, exist_ok=True)
321
+
322
+ if git_init and not (target / ".git").exists():
323
+ subprocess.run(["git", "init"], cwd=target, check=False)
324
+ if git_init and not (target / ".git").exists():
325
+ raise ValueError(f"git init failed for {target}")
326
+
327
+ seed_repo_files(target, force=force)
328
+ manifest.ensure_repo(self.hub_config.root, target, repo_id=repo_id, kind="base")
329
+ save_manifest(self.hub_config.manifest_path, manifest, self.hub_config.root)
330
+
331
+ return self._snapshot_for_repo(repo_id)
332
+
333
+ def clone_repo(
334
+ self,
335
+ *,
336
+ git_url: str,
337
+ repo_id: Optional[str] = None,
338
+ repo_path: Optional[Path] = None,
339
+ force: bool = False,
340
+ ) -> RepoSnapshot:
341
+ self._invalidate_list_cache()
342
+ git_url = (git_url or "").strip()
343
+ if not git_url:
344
+ raise ValueError("git_url is required")
345
+ inferred_id = (repo_id or "").strip() or _repo_id_from_url(git_url)
346
+ if not inferred_id:
347
+ raise ValueError("Unable to infer repo id from git_url")
348
+ base_dir = self.hub_config.repos_root
349
+ target = repo_path if repo_path is not None else Path(inferred_id)
350
+ if not target.is_absolute():
351
+ target = (base_dir / target).resolve()
352
+ else:
353
+ target = target.resolve()
354
+
355
+ try:
356
+ target.relative_to(base_dir)
357
+ except ValueError as exc:
358
+ raise ValueError(
359
+ f"Repo path must live under repos_root ({base_dir})"
360
+ ) from exc
361
+
362
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
363
+ existing = manifest.get(inferred_id)
364
+ if existing:
365
+ existing_path = (self.hub_config.root / existing.path).resolve()
366
+ if existing_path != target:
367
+ raise ValueError(
368
+ f"Repo id {inferred_id} already exists at {existing.path}; choose a different id"
369
+ )
370
+
371
+ if target.exists() and not force:
372
+ raise ValueError(f"Repo path already exists: {target}")
373
+
374
+ proc = subprocess.run(
375
+ ["git", "clone", git_url, str(target)],
376
+ check=False,
377
+ capture_output=True,
378
+ text=True,
379
+ )
380
+ if proc.returncode != 0:
381
+ detail = (
382
+ proc.stderr or proc.stdout or ""
383
+ ).strip() or f"exit {proc.returncode}"
384
+ raise ValueError(f"git clone failed: {detail}")
385
+
386
+ seed_repo_files(target, force=False, git_required=False)
387
+ manifest.ensure_repo(
388
+ self.hub_config.root, target, repo_id=inferred_id, kind="base"
389
+ )
390
+ save_manifest(self.hub_config.manifest_path, manifest, self.hub_config.root)
391
+ return self._snapshot_for_repo(inferred_id)
392
+
393
+ def create_worktree(
394
+ self,
395
+ *,
396
+ base_repo_id: str,
397
+ branch: str,
398
+ force: bool = False,
399
+ ) -> RepoSnapshot:
400
+ self._invalidate_list_cache()
401
+ """
402
+ Create a git worktree under hub.worktrees_root and register it as a hub repo entry.
403
+ Worktrees are treated as full repos (own .codex-autorunner docs/state).
404
+ """
405
+ branch = (branch or "").strip()
406
+ if not branch:
407
+ raise ValueError("branch is required")
408
+
409
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
410
+ base = manifest.get(base_repo_id)
411
+ if not base or base.kind != "base":
412
+ raise ValueError(f"Base repo not found: {base_repo_id}")
413
+ base_path = (self.hub_config.root / base.path).resolve()
414
+ if not base_path.exists():
415
+ raise ValueError(f"Base repo missing on disk: {base_repo_id}")
416
+
417
+ self.hub_config.worktrees_root.mkdir(parents=True, exist_ok=True)
418
+ safe_branch = re.sub(r"[^a-zA-Z0-9._/-]+", "-", branch).strip("-") or "work"
419
+ repo_id = f"{base_repo_id}--{safe_branch.replace('/', '-')}"
420
+ if manifest.get(repo_id) and not force:
421
+ raise ValueError(f"Worktree repo already exists: {repo_id}")
422
+ worktree_path = (self.hub_config.worktrees_root / repo_id).resolve()
423
+ if worktree_path.exists() and not force:
424
+ raise ValueError(f"Worktree path already exists: {worktree_path}")
425
+
426
+ # Create the worktree (branch may or may not exist locally).
427
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
428
+ exists = subprocess.run(
429
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
430
+ cwd=base_path,
431
+ check=False,
432
+ capture_output=True,
433
+ text=True,
434
+ )
435
+ if exists.returncode == 0:
436
+ proc = subprocess.run(
437
+ ["git", "worktree", "add", str(worktree_path), branch],
438
+ cwd=base_path,
439
+ check=False,
440
+ capture_output=True,
441
+ text=True,
442
+ )
443
+ else:
444
+ proc = subprocess.run(
445
+ ["git", "worktree", "add", "-b", branch, str(worktree_path)],
446
+ cwd=base_path,
447
+ check=False,
448
+ capture_output=True,
449
+ text=True,
450
+ )
451
+ if proc.returncode != 0:
452
+ detail = (
453
+ proc.stderr or proc.stdout or ""
454
+ ).strip() or f"exit {proc.returncode}"
455
+ raise ValueError(f"git worktree add failed: {detail}")
456
+
457
+ seed_repo_files(worktree_path, force=force, git_required=False)
458
+ manifest.ensure_repo(
459
+ self.hub_config.root,
460
+ worktree_path,
461
+ repo_id=repo_id,
462
+ kind="worktree",
463
+ worktree_of=base_repo_id,
464
+ branch=branch,
465
+ )
466
+ save_manifest(self.hub_config.manifest_path, manifest, self.hub_config.root)
467
+ return self._snapshot_for_repo(repo_id)
468
+
469
+ def cleanup_worktree(
470
+ self,
471
+ *,
472
+ worktree_repo_id: str,
473
+ delete_branch: bool = False,
474
+ delete_remote: bool = False,
475
+ ) -> None:
476
+ self._invalidate_list_cache()
477
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
478
+ entry = manifest.get(worktree_repo_id)
479
+ if not entry or entry.kind != "worktree":
480
+ raise ValueError(f"Worktree repo not found: {worktree_repo_id}")
481
+ if not entry.worktree_of:
482
+ raise ValueError("Worktree repo is missing worktree_of metadata")
483
+ base = manifest.get(entry.worktree_of)
484
+ if not base or base.kind != "base":
485
+ raise ValueError(f"Base repo not found: {entry.worktree_of}")
486
+
487
+ base_path = (self.hub_config.root / base.path).resolve()
488
+ worktree_path = (self.hub_config.root / entry.path).resolve()
489
+
490
+ # Stop any runner first.
491
+ runner = self._ensure_runner(worktree_repo_id, allow_uninitialized=True)
492
+ if runner:
493
+ runner.stop()
494
+
495
+ # Remove worktree from base repo.
496
+ proc = subprocess.run(
497
+ ["git", "worktree", "remove", "--force", str(worktree_path)],
498
+ cwd=base_path,
499
+ check=False,
500
+ capture_output=True,
501
+ text=True,
502
+ )
503
+ if proc.returncode != 0:
504
+ detail = (
505
+ proc.stderr or proc.stdout or ""
506
+ ).strip() or f"exit {proc.returncode}"
507
+ detail_lower = detail.lower()
508
+ # If the worktree is already gone (deleted via UI/Hub), continue cleanup.
509
+ if "not a working tree" not in detail_lower:
510
+ raise ValueError(f"git worktree remove failed: {detail}")
511
+ subprocess.run(
512
+ ["git", "worktree", "prune"],
513
+ cwd=base_path,
514
+ check=False,
515
+ capture_output=True,
516
+ text=True,
517
+ )
518
+
519
+ if delete_branch and entry.branch:
520
+ subprocess.run(
521
+ ["git", "branch", "-D", entry.branch],
522
+ cwd=base_path,
523
+ check=False,
524
+ capture_output=True,
525
+ text=True,
526
+ )
527
+ if delete_remote and entry.branch:
528
+ subprocess.run(
529
+ ["git", "push", "origin", "--delete", entry.branch],
530
+ cwd=base_path,
531
+ check=False,
532
+ capture_output=True,
533
+ text=True,
534
+ )
535
+
536
+ manifest.repos = [r for r in manifest.repos if r.id != worktree_repo_id]
537
+ save_manifest(self.hub_config.manifest_path, manifest, self.hub_config.root)
538
+
539
+ def check_repo_removal(self, repo_id: str) -> Dict[str, object]:
540
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
541
+ repo = manifest.get(repo_id)
542
+ if not repo:
543
+ raise ValueError(f"Repo {repo_id} not found in manifest")
544
+ repo_root = (self.hub_config.root / repo.path).resolve()
545
+ exists_on_disk = repo_root.exists()
546
+ clean: Optional[bool] = None
547
+ upstream = None
548
+ if exists_on_disk and git_available(repo_root):
549
+ clean = git_is_clean(repo_root)
550
+ upstream = git_upstream_status(repo_root)
551
+ worktrees = []
552
+ if repo.kind == "base":
553
+ worktrees = [
554
+ r.id
555
+ for r in manifest.repos
556
+ if r.kind == "worktree" and r.worktree_of == repo_id
557
+ ]
558
+ return {
559
+ "id": repo.id,
560
+ "path": str(repo_root),
561
+ "kind": repo.kind,
562
+ "exists_on_disk": exists_on_disk,
563
+ "is_clean": clean,
564
+ "upstream": upstream,
565
+ "worktrees": worktrees,
566
+ }
567
+
568
+ def remove_repo(
569
+ self,
570
+ repo_id: str,
571
+ *,
572
+ force: bool = False,
573
+ delete_dir: bool = True,
574
+ delete_worktrees: bool = False,
575
+ ) -> None:
576
+ self._invalidate_list_cache()
577
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
578
+ repo = manifest.get(repo_id)
579
+ if not repo:
580
+ raise ValueError(f"Repo {repo_id} not found in manifest")
581
+
582
+ if repo.kind == "worktree":
583
+ self.cleanup_worktree(worktree_repo_id=repo_id)
584
+ return
585
+
586
+ worktrees = [
587
+ r
588
+ for r in manifest.repos
589
+ if r.kind == "worktree" and r.worktree_of == repo_id
590
+ ]
591
+ if worktrees and not delete_worktrees:
592
+ ids = ", ".join(r.id for r in worktrees)
593
+ raise ValueError(f"Repo {repo_id} has worktrees: {ids}")
594
+ if worktrees and delete_worktrees:
595
+ for worktree in worktrees:
596
+ self.cleanup_worktree(worktree_repo_id=worktree.id)
597
+ manifest = load_manifest(
598
+ self.hub_config.manifest_path, self.hub_config.root
599
+ )
600
+ repo = manifest.get(repo_id)
601
+ if not repo:
602
+ raise ValueError(f"Repo {repo_id} missing after worktree cleanup")
603
+
604
+ repo_root = (self.hub_config.root / repo.path).resolve()
605
+ if repo_root.exists() and git_available(repo_root):
606
+ if not git_is_clean(repo_root) and not force:
607
+ raise ValueError("Repo has uncommitted changes; use force to remove")
608
+ upstream = git_upstream_status(repo_root)
609
+ if (
610
+ upstream
611
+ and upstream.get("has_upstream")
612
+ and upstream.get("ahead", 0) > 0
613
+ and not force
614
+ ):
615
+ raise ValueError("Repo has unpushed commits; use force to remove")
616
+
617
+ runner = self._ensure_runner(repo_id, allow_uninitialized=True)
618
+ if runner:
619
+ runner.stop()
620
+ self._runners.pop(repo_id, None)
621
+
622
+ if delete_dir and repo_root.exists():
623
+ shutil.rmtree(repo_root)
624
+
625
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
626
+ manifest.repos = [r for r in manifest.repos if r.id != repo_id]
627
+ save_manifest(self.hub_config.manifest_path, manifest, self.hub_config.root)
628
+ self.list_repos(use_cache=False)
629
+
630
+ def _ensure_runner(
631
+ self, repo_id: str, allow_uninitialized: bool = False
632
+ ) -> Optional[RepoRunner]:
633
+ if repo_id in self._runners:
634
+ return self._runners[repo_id]
635
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
636
+ repo = manifest.get(repo_id)
637
+ if not repo:
638
+ raise ValueError(f"Repo {repo_id} not found in manifest")
639
+ repo_root = (self.hub_config.root / repo.path).resolve()
640
+ config_path = repo_root / ".codex-autorunner" / "config.yml"
641
+ if not allow_uninitialized and not config_path.exists():
642
+ raise ValueError(f"Repo {repo_id} is not initialized")
643
+ if not config_path.exists():
644
+ return None
645
+ runner = RepoRunner(repo_id, repo_root, spawn_fn=self._spawn_fn)
646
+ self._runners[repo_id] = runner
647
+ return runner
648
+
649
+ def _manifest_records(
650
+ self, manifest_only: bool = False
651
+ ) -> Tuple[Manifest, List[DiscoveryRecord]]:
652
+ manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
653
+ records: List[DiscoveryRecord] = []
654
+ for entry in manifest.repos:
655
+ repo_path = (self.hub_config.root / entry.path).resolve()
656
+ initialized = (repo_path / ".codex-autorunner" / "config.yml").exists()
657
+ records.append(
658
+ DiscoveryRecord(
659
+ repo=entry,
660
+ absolute_path=repo_path,
661
+ added_to_manifest=False,
662
+ exists_on_disk=repo_path.exists(),
663
+ initialized=initialized,
664
+ init_error=None,
665
+ )
666
+ )
667
+ if manifest_only:
668
+ return manifest, records
669
+ return manifest, records
670
+
671
+ def _build_snapshots(self, records: List[DiscoveryRecord]) -> List[RepoSnapshot]:
672
+ snapshots: List[RepoSnapshot] = []
673
+ for record in records:
674
+ snapshots.append(self._snapshot_from_record(record))
675
+ return snapshots
676
+
677
+ def _snapshot_for_repo(self, repo_id: str) -> RepoSnapshot:
678
+ _, records = self._manifest_records(manifest_only=True)
679
+ record = next((r for r in records if r.repo.id == repo_id), None)
680
+ if not record:
681
+ raise ValueError(f"Repo {repo_id} not found in manifest")
682
+ snapshot = self._snapshot_from_record(record)
683
+ self.list_repos(use_cache=False)
684
+ return snapshot
685
+
686
+ def _invalidate_list_cache(self) -> None:
687
+ self._list_cache = None
688
+ self._list_cache_at = None
689
+
690
+ def _snapshot_from_record(self, record: DiscoveryRecord) -> RepoSnapshot:
691
+ repo_path = record.absolute_path
692
+ lock_path = repo_path / ".codex-autorunner" / "lock"
693
+ lock_status = read_lock_status(lock_path)
694
+
695
+ runner_state: Optional[RunnerState] = None
696
+ state_path = repo_path / ".codex-autorunner" / "state.json"
697
+ if record.initialized and state_path.exists():
698
+ runner_state = load_state(state_path)
699
+
700
+ status = self._derive_status(record, lock_status, runner_state)
701
+ last_run_id = runner_state.last_run_id if runner_state else None
702
+ return RepoSnapshot(
703
+ id=record.repo.id,
704
+ path=repo_path,
705
+ display_name=repo_path.name,
706
+ enabled=record.repo.enabled,
707
+ auto_run=record.repo.auto_run,
708
+ kind=record.repo.kind,
709
+ worktree_of=record.repo.worktree_of,
710
+ branch=record.repo.branch,
711
+ exists_on_disk=record.exists_on_disk,
712
+ initialized=record.initialized,
713
+ init_error=record.init_error,
714
+ status=status,
715
+ lock_status=lock_status,
716
+ last_run_id=last_run_id,
717
+ last_run_started_at=(
718
+ runner_state.last_run_started_at if runner_state else None
719
+ ),
720
+ last_run_finished_at=(
721
+ runner_state.last_run_finished_at if runner_state else None
722
+ ),
723
+ last_exit_code=runner_state.last_exit_code if runner_state else None,
724
+ runner_pid=runner_state.runner_pid if runner_state else None,
725
+ )
726
+
727
+ def _derive_status(
728
+ self,
729
+ record: DiscoveryRecord,
730
+ lock_status: LockStatus,
731
+ runner_state: Optional[RunnerState],
732
+ ) -> RepoStatus:
733
+ if not record.exists_on_disk:
734
+ return RepoStatus.MISSING
735
+ if record.init_error:
736
+ return RepoStatus.INIT_ERROR
737
+ if not record.initialized:
738
+ return RepoStatus.UNINITIALIZED
739
+ if runner_state and runner_state.status == "running":
740
+ if lock_status == LockStatus.LOCKED_ALIVE:
741
+ return RepoStatus.RUNNING
742
+ return RepoStatus.IDLE
743
+ if lock_status in (LockStatus.LOCKED_ALIVE, LockStatus.LOCKED_STALE):
744
+ return RepoStatus.LOCKED
745
+ if runner_state and runner_state.status == "error":
746
+ return RepoStatus.ERROR
747
+ return RepoStatus.IDLE
748
+
749
+
750
+ def _repo_id_from_url(url: str) -> str:
751
+ name = (url or "").rstrip("/").split("/")[-1]
752
+ if ":" in name:
753
+ name = name.split(":")[-1]
754
+ if name.endswith(".git"):
755
+ name = name[: -len(".git")]
756
+ return name.strip()