codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,89 @@
1
+ """
2
+ Usage routes: token usage summaries for repo/hub.
3
+
4
+ Moved out of the legacy docs routes during the workspace + file chat cutover.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+
13
+ from ....core.usage import (
14
+ UsageError,
15
+ default_codex_home,
16
+ get_repo_usage_series_cached,
17
+ get_repo_usage_summary_cached,
18
+ parse_iso_datetime,
19
+ )
20
+ from ..schemas import RepoUsageResponse, UsageSeriesResponse
21
+
22
+
23
+ def build_usage_routes() -> APIRouter:
24
+ router = APIRouter(prefix="/api", tags=["usage"])
25
+
26
+ @router.get("/usage", response_model=RepoUsageResponse)
27
+ def get_usage(
28
+ request: Request, since: Optional[str] = None, until: Optional[str] = None
29
+ ):
30
+ engine = request.app.state.engine
31
+ try:
32
+ since_dt = parse_iso_datetime(since)
33
+ until_dt = parse_iso_datetime(until)
34
+ except UsageError as exc:
35
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
36
+ summary, status = get_repo_usage_summary_cached(
37
+ engine.repo_root,
38
+ default_codex_home(),
39
+ config=engine.config,
40
+ since=since_dt,
41
+ until=until_dt,
42
+ )
43
+ return {
44
+ "mode": "repo",
45
+ "repo": str(engine.repo_root),
46
+ "codex_home": str(default_codex_home()),
47
+ "since": since,
48
+ "until": until,
49
+ "status": status,
50
+ **summary.to_dict(),
51
+ }
52
+
53
+ @router.get("/usage/series", response_model=UsageSeriesResponse)
54
+ def get_usage_series(
55
+ request: Request,
56
+ since: Optional[str] = None,
57
+ until: Optional[str] = None,
58
+ bucket: str = "day",
59
+ segment: str = "none",
60
+ ):
61
+ engine = request.app.state.engine
62
+ try:
63
+ since_dt = parse_iso_datetime(since)
64
+ until_dt = parse_iso_datetime(until)
65
+ except UsageError as exc:
66
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
67
+ try:
68
+ series, status = get_repo_usage_series_cached(
69
+ engine.repo_root,
70
+ default_codex_home(),
71
+ config=engine.config,
72
+ since=since_dt,
73
+ until=until_dt,
74
+ bucket=bucket,
75
+ segment=segment,
76
+ )
77
+ except UsageError as exc:
78
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
79
+ return {
80
+ "mode": "repo",
81
+ "repo": str(engine.repo_root),
82
+ "codex_home": str(default_codex_home()),
83
+ "since": since,
84
+ "until": until,
85
+ "status": status,
86
+ **series,
87
+ }
88
+
89
+ return router
@@ -0,0 +1,120 @@
1
+ """
2
+ Voice transcription and configuration routes.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
10
+
11
+ from ....voice import VoiceService, VoiceServiceError
12
+
13
+ logger = logging.getLogger("codex_autorunner.routes.voice")
14
+
15
+
16
+ def build_voice_routes() -> APIRouter:
17
+ """Build routes for voice transcription and config."""
18
+ router = APIRouter()
19
+
20
+ @router.get("/api/voice/config")
21
+ def get_voice_config(request: Request):
22
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
23
+ voice_config = request.app.state.voice_config
24
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
25
+ if missing_reason:
26
+ return {
27
+ "enabled": False,
28
+ "provider": voice_config.provider,
29
+ "latency_mode": voice_config.latency_mode,
30
+ "chunk_ms": voice_config.chunk_ms,
31
+ "sample_rate": voice_config.sample_rate,
32
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
33
+ "has_api_key": False,
34
+ "push_to_talk": {
35
+ "max_ms": voice_config.push_to_talk.max_ms,
36
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
37
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
38
+ },
39
+ "missing_extra": missing_reason,
40
+ }
41
+ if voice_service is None:
42
+ # Degrade gracefully: still return config to the UI even if service init failed.
43
+ try:
44
+ return VoiceService(
45
+ voice_config, logger=request.app.state.logger
46
+ ).config_payload()
47
+ except (ValueError, TypeError, OSError) as exc:
48
+ logger.debug("Failed to create VoiceService for config: %s", exc)
49
+ return {
50
+ "enabled": False,
51
+ "provider": voice_config.provider,
52
+ "latency_mode": voice_config.latency_mode,
53
+ "chunk_ms": voice_config.chunk_ms,
54
+ "sample_rate": voice_config.sample_rate,
55
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
56
+ "has_api_key": False,
57
+ "push_to_talk": {
58
+ "max_ms": voice_config.push_to_talk.max_ms,
59
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
60
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
61
+ },
62
+ }
63
+ return voice_service.config_payload()
64
+
65
+ @router.post("/api/voice/transcribe")
66
+ async def transcribe_voice(
67
+ request: Request,
68
+ file: Optional[UploadFile] = File(None),
69
+ language: Optional[str] = None,
70
+ ):
71
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
72
+ voice_config = request.app.state.voice_config
73
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
74
+ if missing_reason:
75
+ raise HTTPException(status_code=503, detail=missing_reason)
76
+ if not voice_service or not voice_config.enabled:
77
+ raise HTTPException(status_code=400, detail="Voice is disabled")
78
+
79
+ filename: Optional[str] = None
80
+ content_type: Optional[str] = None
81
+ if file is not None:
82
+ filename = file.filename
83
+ content_type = file.content_type
84
+ try:
85
+ audio_bytes = await file.read()
86
+ except Exception as exc:
87
+ raise HTTPException(
88
+ status_code=400, detail="Unable to read audio upload"
89
+ ) from exc
90
+ else:
91
+ audio_bytes = await request.body()
92
+ try:
93
+ result = await asyncio.to_thread(
94
+ voice_service.transcribe,
95
+ audio_bytes,
96
+ client="web",
97
+ user_agent=request.headers.get("user-agent"),
98
+ language=language,
99
+ filename=filename,
100
+ content_type=content_type,
101
+ )
102
+ except VoiceServiceError as exc:
103
+ if exc.reason == "unauthorized":
104
+ status = 401
105
+ elif exc.reason == "forbidden":
106
+ status = 403
107
+ elif exc.reason == "audio_too_large":
108
+ status = 413
109
+ elif exc.reason == "rate_limited":
110
+ status = 429
111
+ else:
112
+ status = (
113
+ 400
114
+ if exc.reason in ("disabled", "empty_audio", "invalid_audio")
115
+ else 502
116
+ )
117
+ raise HTTPException(status_code=status, detail=exc.detail) from exc
118
+ return {"status": "ok", **result}
119
+
120
+ return router
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import zipfile
5
+ from dataclasses import asdict
6
+
7
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
8
+ from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
9
+
10
+ from ....core import drafts as draft_utils
11
+ from ....tickets.spec_ingest import (
12
+ SpecIngestTicketsError,
13
+ ingest_workspace_spec_to_tickets,
14
+ )
15
+ from ....workspace.paths import (
16
+ PINNED_DOC_FILENAMES,
17
+ WORKSPACE_DOC_KINDS,
18
+ list_workspace_files,
19
+ list_workspace_tree,
20
+ normalize_workspace_rel_path,
21
+ read_workspace_doc,
22
+ read_workspace_file,
23
+ sanitize_workspace_filename,
24
+ workspace_dir,
25
+ workspace_doc_path,
26
+ write_workspace_doc,
27
+ write_workspace_file,
28
+ )
29
+ from ..schemas import (
30
+ SpecIngestTicketsResponse,
31
+ WorkspaceFileListResponse,
32
+ WorkspaceResponse,
33
+ WorkspaceTreeResponse,
34
+ WorkspaceUploadResponse,
35
+ WorkspaceWriteRequest,
36
+ )
37
+
38
+
39
+ def build_workspace_routes() -> APIRouter:
40
+ router = APIRouter(prefix="/api", tags=["workspace"])
41
+
42
+ @router.get("/workspace", response_model=WorkspaceResponse)
43
+ def get_workspace(request: Request):
44
+ repo_root = request.app.state.engine.repo_root
45
+ return {
46
+ "active_context": read_workspace_doc(repo_root, "active_context"),
47
+ "decisions": read_workspace_doc(repo_root, "decisions"),
48
+ "spec": read_workspace_doc(repo_root, "spec"),
49
+ }
50
+
51
+ @router.get("/workspace/file", response_class=PlainTextResponse)
52
+ def read_workspace(request: Request, path: str):
53
+ repo_root = request.app.state.engine.repo_root
54
+ try:
55
+ content = read_workspace_file(repo_root, path)
56
+ except ValueError as exc: # invalid path
57
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
58
+ return PlainTextResponse(content)
59
+
60
+ @router.put("/workspace/file", response_class=PlainTextResponse)
61
+ def write_workspace(request: Request, payload: WorkspaceWriteRequest, path: str):
62
+ repo_root = request.app.state.engine.repo_root
63
+ try:
64
+ # Normalize path the same way workspace helpers do to avoid traversal
65
+ safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
66
+ content = write_workspace_file(repo_root, path, payload.content)
67
+ try:
68
+ rel_repo_path = safe_path.relative_to(repo_root).as_posix()
69
+ draft_utils.invalidate_drafts_for_path(repo_root, rel_repo_path)
70
+ state_key = f"workspace_{rel_posix.replace('/', '_')}"
71
+ draft_utils.remove_draft(repo_root, state_key)
72
+ except Exception:
73
+ pass
74
+ except ValueError as exc:
75
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
76
+ return PlainTextResponse(content)
77
+
78
+ @router.put("/workspace/{kind}", response_model=WorkspaceResponse)
79
+ def put_workspace(kind: str, payload: WorkspaceWriteRequest, request: Request):
80
+ key = (kind or "").strip().lower()
81
+ if key not in WORKSPACE_DOC_KINDS:
82
+ raise HTTPException(status_code=400, detail="invalid workspace doc kind")
83
+ repo_root = request.app.state.engine.repo_root
84
+ write_workspace_doc(repo_root, key, payload.content)
85
+ try:
86
+ rel_path = workspace_doc_path(repo_root, key).relative_to(repo_root)
87
+ draft_utils.invalidate_drafts_for_path(repo_root, rel_path.as_posix())
88
+ state_key = f"workspace_{rel_path.name}"
89
+ draft_utils.remove_draft(repo_root, state_key)
90
+ except Exception:
91
+ # best-effort invalidation; avoid blocking writes
92
+ pass
93
+ return {
94
+ "active_context": read_workspace_doc(repo_root, "active_context"),
95
+ "decisions": read_workspace_doc(repo_root, "decisions"),
96
+ "spec": read_workspace_doc(repo_root, "spec"),
97
+ }
98
+
99
+ @router.get("/workspace/files", response_model=WorkspaceFileListResponse)
100
+ def list_files(request: Request):
101
+ repo_root = request.app.state.engine.repo_root
102
+ files = [asdict(item) for item in list_workspace_files(repo_root)]
103
+ return {"files": files}
104
+
105
+ @router.get("/workspace/tree", response_model=WorkspaceTreeResponse)
106
+ def get_workspace_tree(request: Request):
107
+ repo_root = request.app.state.engine.repo_root
108
+ tree = [asdict(item) for item in list_workspace_tree(repo_root)]
109
+ return {"tree": tree}
110
+
111
+ @router.post("/workspace/upload", response_model=WorkspaceUploadResponse)
112
+ async def upload_workspace_files(
113
+ request: Request,
114
+ files: list[UploadFile] = File(...), # noqa: B008
115
+ subdir: str = Form(""),
116
+ ):
117
+ if not files:
118
+ raise HTTPException(status_code=400, detail="no files provided")
119
+
120
+ repo_root = request.app.state.engine.repo_root
121
+ base = workspace_dir(repo_root)
122
+ target_dir = base
123
+ if subdir:
124
+ try:
125
+ target_dir, _ = normalize_workspace_rel_path(repo_root, subdir)
126
+ except ValueError as exc:
127
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
128
+
129
+ target_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ uploaded: list[dict[str, str | int]] = []
132
+ for upload in files:
133
+ filename = sanitize_workspace_filename(upload.filename or "")
134
+ try:
135
+ data = await upload.read()
136
+ except (
137
+ Exception
138
+ ) as exc: # pragma: no cover - handled by FastAPI for most cases
139
+ raise HTTPException(
140
+ status_code=400, detail="failed to read upload"
141
+ ) from exc
142
+
143
+ dest = target_dir / filename
144
+ dest.write_bytes(
145
+ data
146
+ ) # codeql[py/path-injection] dest sits under normalized workspace dir
147
+ rel_path = dest.relative_to(base).as_posix()
148
+ uploaded.append({"filename": filename, "path": rel_path, "size": len(data)})
149
+
150
+ return {"status": "ok", "uploaded": uploaded}
151
+
152
+ @router.get("/workspace/download")
153
+ async def download_workspace_file(request: Request, path: str):
154
+ repo_root = request.app.state.engine.repo_root
155
+ try:
156
+ safe_path, _ = normalize_workspace_rel_path(repo_root, path)
157
+ except ValueError as exc:
158
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
159
+
160
+ if not safe_path.exists() or safe_path.is_dir():
161
+ raise HTTPException(status_code=404, detail="file not found")
162
+
163
+ return FileResponse(
164
+ path=safe_path, filename=safe_path.name
165
+ ) # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
166
+
167
+ @router.get("/workspace/download-zip")
168
+ async def download_workspace_zip(request: Request, path: str = ""):
169
+ repo_root = request.app.state.engine.repo_root
170
+ base = workspace_dir(repo_root)
171
+ base.mkdir(parents=True, exist_ok=True)
172
+
173
+ target_dir = base
174
+ zip_name = "workspace.zip"
175
+ if path:
176
+ try:
177
+ target_dir, _ = normalize_workspace_rel_path(repo_root, path)
178
+ except ValueError as exc:
179
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
180
+ if not target_dir.exists() or not target_dir.is_dir():
181
+ raise HTTPException(status_code=404, detail="folder not found")
182
+ zip_name = f"{target_dir.name}.zip"
183
+
184
+ buffer = io.BytesIO()
185
+ base_real = base.resolve()
186
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
187
+ for file_path in target_dir.rglob("*"):
188
+ if file_path.is_dir():
189
+ continue
190
+ if file_path.is_symlink():
191
+ try:
192
+ file_path.resolve().relative_to(base_real)
193
+ except Exception:
194
+ continue
195
+ arc_name = file_path.relative_to(target_dir).as_posix()
196
+ zf.write(
197
+ file_path, arc_name
198
+ ) # codeql[py/path-injection] file_path constrained to workspace dir
199
+
200
+ buffer.seek(0)
201
+ return StreamingResponse(
202
+ buffer,
203
+ media_type="application/zip",
204
+ headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
205
+ )
206
+
207
+ @router.post("/workspace/folder")
208
+ async def create_workspace_folder(request: Request, path: str):
209
+ repo_root = request.app.state.engine.repo_root
210
+ try:
211
+ safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
212
+ except ValueError as exc:
213
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
214
+
215
+ if safe_path.exists():
216
+ raise HTTPException(status_code=400, detail="path already exists")
217
+
218
+ safe_path.mkdir(parents=True, exist_ok=True)
219
+ return {"status": "created", "path": rel_posix}
220
+
221
+ @router.delete("/workspace/folder")
222
+ async def delete_workspace_folder(request: Request, path: str):
223
+ repo_root = request.app.state.engine.repo_root
224
+ try:
225
+ safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
226
+ except ValueError as exc:
227
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
228
+
229
+ if not safe_path.exists():
230
+ raise HTTPException(status_code=404, detail="folder not found")
231
+ if not safe_path.is_dir():
232
+ raise HTTPException(status_code=400, detail="not a folder")
233
+ if any(safe_path.iterdir()):
234
+ raise HTTPException(status_code=400, detail="folder not empty")
235
+
236
+ safe_path.rmdir()
237
+ return {"status": "deleted", "path": rel_posix}
238
+
239
+ @router.delete("/workspace/file")
240
+ async def delete_workspace_file(request: Request, path: str):
241
+ repo_root = request.app.state.engine.repo_root
242
+ base = workspace_dir(repo_root)
243
+ try:
244
+ safe_path, rel_posix = normalize_workspace_rel_path(repo_root, path)
245
+ except ValueError as exc:
246
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
247
+
248
+ if safe_path.parent == base and safe_path.name in PINNED_DOC_FILENAMES:
249
+ raise HTTPException(status_code=400, detail="cannot delete pinned docs")
250
+ if not safe_path.exists():
251
+ raise HTTPException(status_code=404, detail="file not found")
252
+ if safe_path.is_dir():
253
+ raise HTTPException(status_code=400, detail="use folder delete endpoint")
254
+
255
+ safe_path.unlink() # codeql[py/path-injection] safe_path validated by normalize_workspace_rel_path
256
+ return {"status": "deleted", "path": rel_posix}
257
+
258
+ @router.post("/workspace/spec/ingest", response_model=SpecIngestTicketsResponse)
259
+ def ingest_workspace_spec(request: Request):
260
+ repo_root = request.app.state.engine.repo_root
261
+ try:
262
+ result = ingest_workspace_spec_to_tickets(repo_root)
263
+ except SpecIngestTicketsError as exc:
264
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
265
+ return {
266
+ "status": "ok",
267
+ "created": result.created,
268
+ "first_ticket_path": result.first_ticket_path,
269
+ }
270
+
271
+ return router
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from ...core.runner_controller import ProcessRunnerController
4
+ from ...core.runtime import RuntimeContext
5
+
6
+
7
+ class RunnerManager:
8
+ def __init__(self, engine: RuntimeContext):
9
+ self._controller = ProcessRunnerController(engine)
10
+
11
+ @property
12
+ def running(self) -> bool:
13
+ return self._controller.running
14
+
15
+ def start(self, once: bool = False) -> None:
16
+ self._controller.start(once=once)
17
+
18
+ def resume(self, once: bool = False) -> None:
19
+ self._controller.resume(once=once)
20
+
21
+ def stop(self) -> None:
22
+ self._controller.stop()
23
+
24
+ def kill(self) -> None:
25
+ self._controller.kill()