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,275 @@
1
+ """
2
+ Document management routes: read/write docs and chat functionality.
3
+ """
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from ..core.doc_chat import (
13
+ DocChatBusyError,
14
+ DocChatError,
15
+ DocChatValidationError,
16
+ _normalize_kind,
17
+ )
18
+ from ..core.snapshot import (
19
+ SnapshotError,
20
+ generate_snapshot,
21
+ load_snapshot,
22
+ load_snapshot_state,
23
+ )
24
+ from ..core.usage import (
25
+ UsageError,
26
+ default_codex_home,
27
+ get_repo_usage_series_cached,
28
+ get_repo_usage_summary_cached,
29
+ parse_iso_datetime,
30
+ )
31
+ from ..core.utils import atomic_write
32
+ from ..spec_ingest import (
33
+ SpecIngestError,
34
+ clear_work_docs,
35
+ generate_docs_from_spec,
36
+ write_ingested_docs,
37
+ )
38
+ from ..web.schemas import (
39
+ DocChatPayload,
40
+ DocContentRequest,
41
+ DocsResponse,
42
+ DocWriteResponse,
43
+ IngestSpecRequest,
44
+ RepoUsageResponse,
45
+ SnapshotCreateResponse,
46
+ SnapshotRequest,
47
+ SnapshotResponse,
48
+ UsageSeriesResponse,
49
+ )
50
+
51
+
52
+ def build_docs_routes() -> APIRouter:
53
+ """Build routes for document management and chat."""
54
+ router = APIRouter()
55
+
56
+ @router.get("/api/docs", response_model=DocsResponse)
57
+ def get_docs(request: Request):
58
+ engine = request.app.state.engine
59
+ return {
60
+ "todo": engine.docs.read_doc("todo"),
61
+ "progress": engine.docs.read_doc("progress"),
62
+ "opinions": engine.docs.read_doc("opinions"),
63
+ "spec": engine.docs.read_doc("spec"),
64
+ "summary": engine.docs.read_doc("summary"),
65
+ }
66
+
67
+ @router.put("/api/docs/{kind}", response_model=DocWriteResponse)
68
+ def put_doc(kind: str, payload: DocContentRequest, request: Request):
69
+ engine = request.app.state.engine
70
+ key = kind.lower()
71
+ if key not in ("todo", "progress", "opinions", "spec", "summary"):
72
+ raise HTTPException(status_code=400, detail="invalid doc kind")
73
+ content = payload.content
74
+ atomic_write(engine.config.doc_path(key), content)
75
+ return {"kind": key, "content": content}
76
+
77
+ @router.get("/api/snapshot", response_model=SnapshotResponse)
78
+ def get_snapshot(request: Request):
79
+ engine = request.app.state.engine
80
+ content = load_snapshot(engine)
81
+ state = load_snapshot_state(engine)
82
+ return {"exists": bool(content), "content": content or "", "state": state or {}}
83
+
84
+ @router.post("/api/snapshot", response_model=SnapshotCreateResponse)
85
+ async def post_snapshot(
86
+ request: Request, payload: Optional[SnapshotRequest] = None
87
+ ):
88
+ # Snapshot generation has a single default behavior now; we accept an
89
+ # optional JSON object for backwards compatibility, but ignore any fields.
90
+ engine = request.app.state.engine
91
+ try:
92
+ result = await asyncio.to_thread(
93
+ generate_snapshot,
94
+ engine,
95
+ )
96
+ except SnapshotError as exc:
97
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
98
+ except Exception as exc:
99
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
100
+
101
+ return {
102
+ "content": result.content,
103
+ "truncated": result.truncated,
104
+ "state": result.state,
105
+ }
106
+
107
+ @router.post("/api/docs/{kind}/chat")
108
+ async def chat_doc(
109
+ kind: str, request: Request, payload: Optional[DocChatPayload] = None
110
+ ):
111
+ doc_chat = request.app.state.doc_chat
112
+ try:
113
+ payload_dict = payload.model_dump(exclude_none=True) if payload else None
114
+ doc_req = doc_chat.parse_request(kind, payload_dict)
115
+ except DocChatValidationError as exc:
116
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
117
+
118
+ repo_blocked = doc_chat.repo_blocked_reason()
119
+ if repo_blocked:
120
+ raise HTTPException(status_code=409, detail=repo_blocked)
121
+
122
+ if doc_chat.doc_busy(doc_req.kind):
123
+ raise HTTPException(
124
+ status_code=409,
125
+ detail=f"Doc chat already running for {doc_req.kind}",
126
+ )
127
+
128
+ if doc_req.stream:
129
+ return StreamingResponse(
130
+ doc_chat.stream(doc_req), media_type="text/event-stream"
131
+ )
132
+
133
+ try:
134
+ async with doc_chat.doc_lock(doc_req.kind):
135
+ result = await doc_chat.execute(doc_req)
136
+ except DocChatBusyError as exc:
137
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
138
+
139
+ if result.get("status") != "ok":
140
+ detail = result.get("detail") or "Doc chat failed"
141
+ raise HTTPException(status_code=500, detail=detail)
142
+ return result
143
+
144
+ @router.post("/api/docs/{kind}/chat/apply")
145
+ async def apply_chat_patch(kind: str, request: Request):
146
+ doc_chat = request.app.state.doc_chat
147
+ key = _normalize_kind(kind)
148
+ repo_blocked = doc_chat.repo_blocked_reason()
149
+ if repo_blocked:
150
+ raise HTTPException(status_code=409, detail=repo_blocked)
151
+
152
+ try:
153
+ async with doc_chat.doc_lock(key):
154
+ content = doc_chat.apply_saved_patch(key)
155
+ except DocChatBusyError as exc:
156
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
157
+ except DocChatError as exc:
158
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
159
+ return {
160
+ "status": "ok",
161
+ "kind": key,
162
+ "content": content,
163
+ "agent_message": doc_chat.last_agent_message
164
+ or f"Updated {key.upper()} via doc chat.",
165
+ }
166
+
167
+ @router.post("/api/docs/{kind}/chat/discard")
168
+ async def discard_chat_patch(kind: str, request: Request):
169
+ doc_chat = request.app.state.doc_chat
170
+ key = _normalize_kind(kind)
171
+ try:
172
+ async with doc_chat.doc_lock(key):
173
+ content = doc_chat.discard_patch(key)
174
+ except DocChatError as exc:
175
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
176
+ return {"status": "ok", "kind": key, "content": content}
177
+
178
+ @router.get("/api/docs/{kind}/chat/pending")
179
+ async def pending_chat_patch(kind: str, request: Request):
180
+ doc_chat = request.app.state.doc_chat
181
+ key = _normalize_kind(kind)
182
+ pending = doc_chat.pending_patch(key)
183
+ if not pending:
184
+ raise HTTPException(status_code=404, detail="No pending patch")
185
+ return pending
186
+
187
+ @router.post("/api/ingest-spec", response_model=DocsResponse)
188
+ def ingest_spec(request: Request, payload: Optional[IngestSpecRequest] = None):
189
+ engine = request.app.state.engine
190
+ force = False
191
+ spec_override: Optional[Path] = None
192
+ if payload:
193
+ force = payload.force
194
+ if payload.spec_path:
195
+ spec_override = Path(str(payload.spec_path))
196
+ try:
197
+ docs = generate_docs_from_spec(engine, spec_path=spec_override)
198
+ write_ingested_docs(engine, docs, force=force)
199
+ except SpecIngestError as exc:
200
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
201
+ return docs
202
+
203
+ @router.post("/api/docs/clear", response_model=DocsResponse)
204
+ def clear_docs(request: Request):
205
+ engine = request.app.state.engine
206
+ try:
207
+ docs = clear_work_docs(engine)
208
+ docs["spec"] = engine.docs.read_doc("spec")
209
+ docs["summary"] = engine.docs.read_doc("summary")
210
+ except Exception as exc:
211
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
212
+ return docs
213
+
214
+ @router.get("/api/usage", response_model=RepoUsageResponse)
215
+ def get_usage(
216
+ request: Request, since: Optional[str] = None, until: Optional[str] = None
217
+ ):
218
+ engine = request.app.state.engine
219
+ try:
220
+ since_dt = parse_iso_datetime(since)
221
+ until_dt = parse_iso_datetime(until)
222
+ except UsageError as exc:
223
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
224
+ summary, status = get_repo_usage_summary_cached(
225
+ engine.repo_root,
226
+ default_codex_home(),
227
+ since=since_dt,
228
+ until=until_dt,
229
+ )
230
+ return {
231
+ "mode": "repo",
232
+ "repo": str(engine.repo_root),
233
+ "codex_home": str(default_codex_home()),
234
+ "since": since,
235
+ "until": until,
236
+ "status": status,
237
+ **summary.to_dict(),
238
+ }
239
+
240
+ @router.get("/api/usage/series", response_model=UsageSeriesResponse)
241
+ def get_usage_series(
242
+ request: Request,
243
+ since: Optional[str] = None,
244
+ until: Optional[str] = None,
245
+ bucket: str = "day",
246
+ segment: str = "none",
247
+ ):
248
+ engine = request.app.state.engine
249
+ try:
250
+ since_dt = parse_iso_datetime(since)
251
+ until_dt = parse_iso_datetime(until)
252
+ except UsageError as exc:
253
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
254
+ try:
255
+ series, status = get_repo_usage_series_cached(
256
+ engine.repo_root,
257
+ default_codex_home(),
258
+ since=since_dt,
259
+ until=until_dt,
260
+ bucket=bucket,
261
+ segment=segment,
262
+ )
263
+ except UsageError as exc:
264
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
265
+ return {
266
+ "mode": "repo",
267
+ "repo": str(engine.repo_root),
268
+ "codex_home": str(default_codex_home()),
269
+ "since": since,
270
+ "until": until,
271
+ "status": status,
272
+ **series,
273
+ }
274
+
275
+ return router
@@ -0,0 +1,197 @@
1
+ """
2
+ GitHub integration routes.
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Dict, Optional, Tuple
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+
11
+ from ..integrations.github.service import GitHubError, GitHubService
12
+ from ..web.schemas import GithubContextRequest, GithubIssueRequest, GithubPrSyncRequest
13
+
14
+ _GITHUB_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
15
+ _GITHUB_CACHE_LOCK = asyncio.Lock()
16
+ _GITHUB_STATUS_TTL_SECONDS = 20.0
17
+ _GITHUB_PR_TTL_SECONDS = 60.0
18
+
19
+
20
+ async def _get_cached_status_payload(
21
+ request: Request,
22
+ *,
23
+ kind: str,
24
+ ttl_seconds: float,
25
+ ) -> dict:
26
+ repo_root = request.app.state.engine.repo_root.resolve()
27
+ key = (str(repo_root), kind)
28
+ now = time.monotonic()
29
+ task: Optional[asyncio.Task] = None
30
+
31
+ async with _GITHUB_CACHE_LOCK:
32
+ entry = _GITHUB_CACHE.get(key) or {}
33
+ value = entry.get("value")
34
+ expires_at = float(entry.get("expires_at", 0) or 0)
35
+ task = entry.get("task")
36
+
37
+ if value is not None and expires_at > now:
38
+ return value
39
+ if task is None:
40
+ task = asyncio.create_task(
41
+ asyncio.to_thread(_github(request).status_payload)
42
+ )
43
+ _GITHUB_CACHE[key] = {
44
+ "value": value,
45
+ "expires_at": expires_at,
46
+ "task": task,
47
+ }
48
+
49
+ if task is None:
50
+ task = asyncio.create_task(asyncio.to_thread(_github(request).status_payload))
51
+ async with _GITHUB_CACHE_LOCK:
52
+ _GITHUB_CACHE[key] = {"task": task}
53
+
54
+ try:
55
+ value = await task
56
+ except Exception:
57
+ async with _GITHUB_CACHE_LOCK:
58
+ current = _GITHUB_CACHE.get(key) or {}
59
+ if current.get("task") is task:
60
+ _GITHUB_CACHE.pop(key, None)
61
+ raise
62
+
63
+ async with _GITHUB_CACHE_LOCK:
64
+ _GITHUB_CACHE[key] = {
65
+ "value": value,
66
+ "expires_at": now + ttl_seconds,
67
+ }
68
+ return value
69
+
70
+
71
+ def _github(request) -> GitHubService:
72
+ """Get a GitHubService instance from the request."""
73
+ engine = request.app.state.engine
74
+ return GitHubService(engine.repo_root, raw_config=engine.config.raw)
75
+
76
+
77
+ def build_github_routes() -> APIRouter:
78
+ """Build routes for GitHub integration."""
79
+ router = APIRouter()
80
+
81
+ @router.get("/api/github/status")
82
+ async def github_status(request: Request):
83
+ try:
84
+ return await _get_cached_status_payload(
85
+ request,
86
+ kind="status",
87
+ ttl_seconds=_GITHUB_STATUS_TTL_SECONDS,
88
+ )
89
+ except GitHubError as exc:
90
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
91
+ except Exception as exc:
92
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
93
+
94
+ @router.get("/api/github/pr")
95
+ async def github_pr(request: Request):
96
+ try:
97
+ status = await _get_cached_status_payload(
98
+ request,
99
+ kind="pr",
100
+ ttl_seconds=_GITHUB_PR_TTL_SECONDS,
101
+ )
102
+ return {
103
+ "status": "ok",
104
+ "git": status.get("git"),
105
+ "pr": status.get("pr"),
106
+ "links": status.get("pr_links"),
107
+ "link": status.get("link") or {},
108
+ }
109
+ except GitHubError as exc:
110
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
111
+ except Exception as exc:
112
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
113
+
114
+ @router.post("/api/github/link-issue")
115
+ async def github_link_issue(request: Request, payload: GithubIssueRequest):
116
+ issue = payload.issue
117
+ try:
118
+ state = await asyncio.to_thread(_github(request).link_issue, str(issue))
119
+ return {"status": "ok", "link": state}
120
+ except GitHubError as exc:
121
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
122
+ except Exception as exc:
123
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
124
+
125
+ @router.post("/api/github/spec/from-issue")
126
+ async def github_spec_from_issue(request: Request, payload: GithubIssueRequest):
127
+ issue = payload.issue
128
+
129
+ doc_chat = request.app.state.doc_chat
130
+ repo_blocked = doc_chat.repo_blocked_reason()
131
+ if repo_blocked:
132
+ raise HTTPException(status_code=409, detail=repo_blocked)
133
+ if doc_chat.doc_busy("spec"):
134
+ raise HTTPException(
135
+ status_code=409, detail="Doc chat already running for spec"
136
+ )
137
+
138
+ svc = _github(request)
139
+ try:
140
+ prompt, link_state = await asyncio.to_thread(
141
+ svc.build_spec_prompt_from_issue, str(issue)
142
+ )
143
+ doc_req = doc_chat.parse_request(
144
+ "spec", {"message": prompt, "stream": False}
145
+ )
146
+ async with doc_chat.doc_lock("spec"):
147
+ result = await doc_chat.execute(doc_req)
148
+ if result.get("status") != "ok":
149
+ detail = result.get("detail") or "SPEC generation failed"
150
+ raise HTTPException(status_code=500, detail=detail)
151
+ result["github"] = {"issue": link_state.get("issue")}
152
+ return result
153
+ except GitHubError as exc:
154
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
155
+ except HTTPException:
156
+ raise
157
+ except Exception as exc:
158
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
159
+
160
+ @router.post("/api/github/pr/sync")
161
+ async def github_pr_sync(request: Request, payload: GithubPrSyncRequest):
162
+ if payload.mode is not None:
163
+ raise HTTPException(
164
+ status_code=400,
165
+ detail="Repo mode does not support worktrees; create a hub worktree repo instead.",
166
+ )
167
+ draft = payload.draft
168
+ title = payload.title
169
+ body = payload.body
170
+ try:
171
+ return await asyncio.to_thread(
172
+ _github(request).sync_pr,
173
+ draft=draft,
174
+ title=str(title) if title else None,
175
+ body=str(body) if body else None,
176
+ )
177
+ except GitHubError as exc:
178
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
179
+ except Exception as exc:
180
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
181
+
182
+ @router.post("/api/github/context")
183
+ async def github_context(request: Request, payload: GithubContextRequest):
184
+ url = payload.url
185
+ try:
186
+ result = await asyncio.to_thread(
187
+ _github(request).build_context_file_from_url, str(url)
188
+ )
189
+ if not result:
190
+ return {"status": "ok", "injected": False}
191
+ return {"status": "ok", "injected": True, **result}
192
+ except GitHubError as exc:
193
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
194
+ except Exception as exc:
195
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
196
+
197
+ return router
@@ -0,0 +1,121 @@
1
+ """
2
+ Repository run control routes: start, stop, resume, reset, kill.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from ..core.engine import LockError, clear_stale_lock
10
+ from ..core.state import RunnerState, load_state, now_iso, save_state, state_lock
11
+ from ..web.schemas import (
12
+ RunControlRequest,
13
+ RunControlResponse,
14
+ RunResetResponse,
15
+ RunStatusResponse,
16
+ )
17
+
18
+
19
+ def build_repos_routes() -> APIRouter:
20
+ """Build routes for run control."""
21
+ router = APIRouter()
22
+
23
+ @router.post("/api/run/start", response_model=RunControlResponse)
24
+ def start_run(request: Request, payload: Optional[RunControlRequest] = None):
25
+ manager = request.app.state.manager
26
+ logger = request.app.state.logger
27
+ once = payload.once if payload else False
28
+ try:
29
+ logger.info("run/start once=%s", once)
30
+ except Exception:
31
+ pass
32
+ try:
33
+ manager.start(once=once)
34
+ except LockError as exc:
35
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
36
+ return {"running": manager.running, "once": once}
37
+
38
+ @router.post("/api/run/stop", response_model=RunStatusResponse)
39
+ def stop_run(request: Request):
40
+ manager = request.app.state.manager
41
+ logger = request.app.state.logger
42
+ try:
43
+ logger.info("run/stop requested")
44
+ except Exception:
45
+ pass
46
+ manager.stop()
47
+ return {"running": manager.running}
48
+
49
+ @router.post("/api/run/kill", response_model=RunStatusResponse)
50
+ def kill_run(request: Request):
51
+ engine = request.app.state.engine
52
+ manager = request.app.state.manager
53
+ logger = request.app.state.logger
54
+ try:
55
+ logger.info("run/kill requested")
56
+ except Exception:
57
+ pass
58
+ manager.kill()
59
+ with state_lock(engine.state_path):
60
+ state = load_state(engine.state_path)
61
+ new_state = RunnerState(
62
+ last_run_id=state.last_run_id,
63
+ status="error",
64
+ last_exit_code=137,
65
+ last_run_started_at=state.last_run_started_at,
66
+ last_run_finished_at=now_iso(),
67
+ runner_pid=None,
68
+ sessions=state.sessions,
69
+ repo_to_session=state.repo_to_session,
70
+ )
71
+ save_state(engine.state_path, new_state)
72
+ clear_stale_lock(engine.lock_path)
73
+ return {"running": manager.running}
74
+
75
+ @router.post("/api/run/resume", response_model=RunControlResponse)
76
+ def resume_run(request: Request, payload: Optional[RunControlRequest] = None):
77
+ manager = request.app.state.manager
78
+ logger = request.app.state.logger
79
+ once = payload.once if payload else False
80
+ try:
81
+ logger.info("run/resume once=%s", once)
82
+ except Exception:
83
+ pass
84
+ try:
85
+ manager.resume(once=once)
86
+ except LockError as exc:
87
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
88
+ return {"running": manager.running, "once": once}
89
+
90
+ @router.post("/api/run/reset", response_model=RunResetResponse)
91
+ def reset_runner(request: Request):
92
+ engine = request.app.state.engine
93
+ manager = request.app.state.manager
94
+ logger = request.app.state.logger
95
+ if manager.running:
96
+ raise HTTPException(
97
+ status_code=409, detail="Cannot reset while runner is active"
98
+ )
99
+ try:
100
+ logger.info("run/reset requested")
101
+ except Exception:
102
+ pass
103
+ with state_lock(engine.state_path):
104
+ current_state = load_state(engine.state_path)
105
+ engine.lock_path.unlink(missing_ok=True)
106
+ initial_state = RunnerState(
107
+ last_run_id=None,
108
+ status="idle",
109
+ last_exit_code=None,
110
+ last_run_started_at=None,
111
+ last_run_finished_at=None,
112
+ runner_pid=None,
113
+ sessions=current_state.sessions,
114
+ repo_to_session=current_state.repo_to_session,
115
+ )
116
+ save_state(engine.state_path, initial_state)
117
+ if engine.log_path.exists():
118
+ engine.log_path.unlink()
119
+ return {"status": "ok", "message": "Runner reset complete"}
120
+
121
+ return router