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,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()
|