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