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,634 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import yaml
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from ....agents.registry import validate_agent_id
10
+ from ....core.config import (
11
+ ConfigError,
12
+ RepoConfig,
13
+ load_hub_config,
14
+ load_repo_config,
15
+ update_override_templates,
16
+ )
17
+ from ....core.git_utils import GitError
18
+ from ....core.templates import (
19
+ FetchedTemplate,
20
+ NetworkUnavailableError,
21
+ RefNotFoundError,
22
+ RepoNotConfiguredError,
23
+ TemplateNotFoundError,
24
+ fetch_template,
25
+ inject_provenance,
26
+ parse_template_ref,
27
+ )
28
+ from ....core.templates.scan_cache import TemplateScanRecord, get_scan_record, scan_lock
29
+ from ....integrations.templates import (
30
+ TemplateScanError,
31
+ TemplateScanRejectedError,
32
+ format_template_scan_rejection,
33
+ run_template_scan,
34
+ )
35
+ from ....tickets.files import normalize_ticket_dir, safe_relpath
36
+ from ....tickets.frontmatter import split_markdown_frontmatter
37
+ from ....tickets.lint import parse_ticket_index
38
+ from ..schemas import (
39
+ TemplateApplyRequest,
40
+ TemplateApplyResponse,
41
+ TemplateFetchRequest,
42
+ TemplateFetchResponse,
43
+ TemplateRepoCreateRequest,
44
+ TemplateReposResponse,
45
+ TemplateRepoUpdateRequest,
46
+ )
47
+
48
+
49
+ def _error_detail(code: str, message: str, meta: Optional[dict] = None) -> dict:
50
+ payload = {"code": code, "message": message}
51
+ if meta:
52
+ payload["meta"] = meta
53
+ return payload
54
+
55
+
56
+ def _require_templates_enabled(config: RepoConfig) -> None:
57
+ if not config.templates.enabled:
58
+ raise HTTPException(
59
+ status_code=403,
60
+ detail=_error_detail(
61
+ "templates_disabled",
62
+ "Templates are disabled. Set templates.enabled=true in the hub config to enable.",
63
+ ),
64
+ )
65
+
66
+
67
+ def _find_template_repo(config: RepoConfig, repo_id: str):
68
+ for repo in config.templates.repos:
69
+ if repo.id == repo_id:
70
+ return repo
71
+ return None
72
+
73
+
74
+ def _resolve_hub_root(repo_root: Path) -> Path:
75
+ try:
76
+ hub_config = load_hub_config(repo_root)
77
+ except ConfigError as exc:
78
+ raise HTTPException(
79
+ status_code=500,
80
+ detail=_error_detail(
81
+ "hub_config_error",
82
+ str(exc),
83
+ ),
84
+ ) from exc
85
+ return hub_config.root
86
+
87
+
88
+ def _reload_repo_config(request: Request) -> RepoConfig:
89
+ engine = request.app.state.engine
90
+ try:
91
+ new_config = load_repo_config(engine.repo_root)
92
+ except ConfigError as exc:
93
+ raise HTTPException(
94
+ status_code=500,
95
+ detail=_error_detail("config_reload_failed", str(exc)),
96
+ ) from exc
97
+ # RuntimeContext stores config on a private attribute.
98
+ engine._config = new_config
99
+ request.app.state.config = new_config
100
+ return new_config
101
+
102
+
103
+ def _normalize_required_string(value: object, field: str) -> str:
104
+ if not isinstance(value, str):
105
+ raise HTTPException(
106
+ status_code=400,
107
+ detail=_error_detail("validation_error", f"{field} must be a string"),
108
+ )
109
+ cleaned = value.strip()
110
+ if not cleaned:
111
+ raise HTTPException(
112
+ status_code=400,
113
+ detail=_error_detail("validation_error", f"{field} must not be empty"),
114
+ )
115
+ if "\n" in cleaned or "\r" in cleaned:
116
+ raise HTTPException(
117
+ status_code=400,
118
+ detail=_error_detail("validation_error", f"{field} must be single-line"),
119
+ )
120
+ return cleaned
121
+
122
+
123
+ def _normalize_optional_string(value: object, field: str) -> Optional[str]:
124
+ if value is None:
125
+ return None
126
+ if not isinstance(value, str):
127
+ raise HTTPException(
128
+ status_code=400,
129
+ detail=_error_detail("validation_error", f"{field} must be a string"),
130
+ )
131
+ cleaned = value.strip()
132
+ if not cleaned:
133
+ raise HTTPException(
134
+ status_code=400,
135
+ detail=_error_detail("validation_error", f"{field} must not be empty"),
136
+ )
137
+ if "\n" in cleaned or "\r" in cleaned:
138
+ raise HTTPException(
139
+ status_code=400,
140
+ detail=_error_detail("validation_error", f"{field} must be single-line"),
141
+ )
142
+ return cleaned
143
+
144
+
145
+ def _validate_repo_url(url: str) -> None:
146
+ if any(ch.isspace() for ch in url):
147
+ raise HTTPException(
148
+ status_code=400,
149
+ detail=_error_detail("validation_error", "url must not contain whitespace"),
150
+ )
151
+ # Keep this intentionally permissive: https://, ssh://, git@host:org/repo.git, etc.
152
+ if "://" not in url and not url.startswith("git@"):
153
+ raise HTTPException(
154
+ status_code=400,
155
+ detail=_error_detail(
156
+ "validation_error",
157
+ "url must look like a git remote (expected '://...' or 'git@...')",
158
+ ),
159
+ )
160
+
161
+
162
+ def _repos_to_dicts(repos) -> list[dict]:
163
+ return [
164
+ {
165
+ "id": repo.id,
166
+ "url": repo.url,
167
+ "trusted": bool(repo.trusted),
168
+ "default_ref": repo.default_ref,
169
+ }
170
+ for repo in repos
171
+ ]
172
+
173
+
174
+ async def _fetch_template_with_scan(
175
+ template: str, request: Request
176
+ ) -> tuple[FetchedTemplate, Optional[TemplateScanRecord], Path]:
177
+ try:
178
+ parsed = parse_template_ref(template)
179
+ except ValueError as exc:
180
+ raise HTTPException(
181
+ status_code=400,
182
+ detail=_error_detail("template_ref_invalid", str(exc)),
183
+ ) from exc
184
+
185
+ config: RepoConfig = request.app.state.config
186
+ repo_cfg = _find_template_repo(config, parsed.repo_id)
187
+ if repo_cfg is None:
188
+ raise HTTPException(
189
+ status_code=404,
190
+ detail=_error_detail(
191
+ "template_repo_missing",
192
+ f"Template repo not configured: {parsed.repo_id}",
193
+ ),
194
+ )
195
+
196
+ hub_root = _resolve_hub_root(request.app.state.engine.repo_root)
197
+ try:
198
+ fetched = fetch_template(
199
+ repo=repo_cfg,
200
+ hub_root=hub_root,
201
+ template_ref=template,
202
+ )
203
+ except NetworkUnavailableError as exc:
204
+ raise HTTPException(
205
+ status_code=503,
206
+ detail=_error_detail(
207
+ "template_network_unavailable",
208
+ str(exc),
209
+ ),
210
+ ) from exc
211
+ except (RepoNotConfiguredError, RefNotFoundError, TemplateNotFoundError) as exc:
212
+ raise HTTPException(
213
+ status_code=404,
214
+ detail=_error_detail("template_not_found", str(exc)),
215
+ ) from exc
216
+ except GitError as exc:
217
+ raise HTTPException(
218
+ status_code=500,
219
+ detail=_error_detail("template_git_error", str(exc)),
220
+ ) from exc
221
+
222
+ scan_record: Optional[TemplateScanRecord] = None
223
+ if not fetched.trusted:
224
+ with scan_lock(hub_root, fetched.blob_sha):
225
+ scan_record = get_scan_record(hub_root, fetched.blob_sha)
226
+ if scan_record is None:
227
+ try:
228
+ scan_record = await run_template_scan(
229
+ ctx=request.app.state.engine, template=fetched
230
+ )
231
+ except TemplateScanRejectedError as exc:
232
+ raise HTTPException(
233
+ status_code=403,
234
+ detail=_error_detail("template_scan_rejected", str(exc)),
235
+ ) from exc
236
+ except TemplateScanError as exc:
237
+ raise HTTPException(
238
+ status_code=502,
239
+ detail=_error_detail("template_scan_failed", str(exc)),
240
+ ) from exc
241
+ elif scan_record.decision != "approve":
242
+ raise HTTPException(
243
+ status_code=403,
244
+ detail=_error_detail(
245
+ "template_scan_rejected",
246
+ format_template_scan_rejection(scan_record),
247
+ ),
248
+ )
249
+
250
+ return fetched, scan_record, hub_root
251
+
252
+
253
+ def _resolve_ticket_dir(repo_root: Path, ticket_dir: Optional[str]) -> Path:
254
+ try:
255
+ return normalize_ticket_dir(repo_root, ticket_dir)
256
+ except ValueError as exc:
257
+ raise HTTPException(
258
+ status_code=400,
259
+ detail=_error_detail("ticket_dir_invalid", str(exc)),
260
+ ) from exc
261
+
262
+
263
+ def _collect_ticket_indices(ticket_dir: Path) -> list[int]:
264
+ indices: list[int] = []
265
+ if not ticket_dir.exists() or not ticket_dir.is_dir():
266
+ return indices
267
+ for (
268
+ path
269
+ ) in (
270
+ ticket_dir.iterdir()
271
+ ): # codeql[py/path-injection] validated by normalize_ticket_dir
272
+ if not path.is_file():
273
+ continue
274
+ idx = parse_ticket_index(path.name)
275
+ if idx is None:
276
+ continue
277
+ indices.append(idx)
278
+ return indices
279
+
280
+
281
+ def _next_available_ticket_index(existing: list[int]) -> int:
282
+ if not existing:
283
+ return 1
284
+ seen = set(existing)
285
+ candidate = 1
286
+ while candidate in seen:
287
+ candidate += 1
288
+ return candidate
289
+
290
+
291
+ def _ticket_filename(index: int, *, suffix: str, width: int) -> str:
292
+ return f"TICKET-{index:0{width}d}{suffix}.md"
293
+
294
+
295
+ def _normalize_ticket_suffix(suffix: Optional[str]) -> str:
296
+ if not suffix:
297
+ return ""
298
+ cleaned = suffix.strip()
299
+ if not cleaned:
300
+ return ""
301
+ if "/" in cleaned or "\\" in cleaned:
302
+ raise HTTPException(
303
+ status_code=400,
304
+ detail=_error_detail(
305
+ "ticket_suffix_invalid",
306
+ "Ticket suffix may not include path separators.",
307
+ ),
308
+ )
309
+ if not cleaned.startswith("-"):
310
+ return f"-{cleaned}"
311
+ return cleaned
312
+
313
+
314
+ def _apply_agent_override(content: str, agent: str) -> str:
315
+ fm_yaml, body = split_markdown_frontmatter(content)
316
+ if fm_yaml is None:
317
+ raise HTTPException(
318
+ status_code=400,
319
+ detail=_error_detail(
320
+ "template_frontmatter_missing",
321
+ "Template is missing YAML frontmatter; cannot set agent.",
322
+ ),
323
+ )
324
+ try:
325
+ data = yaml.safe_load(fm_yaml)
326
+ except yaml.YAMLError as exc:
327
+ raise HTTPException(
328
+ status_code=400,
329
+ detail=_error_detail(
330
+ "template_frontmatter_invalid",
331
+ f"Template frontmatter is invalid YAML: {exc}",
332
+ ),
333
+ ) from exc
334
+ if not isinstance(data, dict):
335
+ raise HTTPException(
336
+ status_code=400,
337
+ detail=_error_detail(
338
+ "template_frontmatter_invalid",
339
+ "Template frontmatter must be a YAML mapping to set agent.",
340
+ ),
341
+ )
342
+ data["agent"] = agent
343
+ rendered = yaml.safe_dump(data, sort_keys=False).rstrip()
344
+ return f"---\n{rendered}\n---{body}"
345
+
346
+
347
+ def _format_fetch_response(
348
+ fetched: FetchedTemplate, scan_record: Optional[TemplateScanRecord]
349
+ ) -> TemplateFetchResponse:
350
+ return TemplateFetchResponse(
351
+ content=fetched.content,
352
+ repo_id=fetched.repo_id,
353
+ path=fetched.path,
354
+ ref=fetched.ref,
355
+ commit_sha=fetched.commit_sha,
356
+ blob_sha=fetched.blob_sha,
357
+ trusted=fetched.trusted,
358
+ scan_decision=(
359
+ scan_record.to_dict(include_evidence=False) if scan_record else None
360
+ ),
361
+ )
362
+
363
+
364
+ def build_templates_routes() -> APIRouter:
365
+ router = APIRouter(prefix="/api/templates", tags=["templates"])
366
+
367
+ @router.get("/repos", response_model=TemplateReposResponse)
368
+ def list_template_repos(request: Request):
369
+ config: RepoConfig = request.app.state.config
370
+ return TemplateReposResponse(
371
+ enabled=config.templates.enabled,
372
+ repos=[
373
+ {
374
+ "id": repo.id,
375
+ "url": repo.url,
376
+ "trusted": repo.trusted,
377
+ "default_ref": repo.default_ref,
378
+ }
379
+ for repo in config.templates.repos
380
+ ],
381
+ )
382
+
383
+ @router.post("/repos", response_model=TemplateReposResponse)
384
+ def add_template_repo(request: Request, payload: TemplateRepoCreateRequest):
385
+ config: RepoConfig = request.app.state.config
386
+ repo_id = _normalize_required_string(payload.id, "id")
387
+ url = _normalize_required_string(payload.url, "url")
388
+ _validate_repo_url(url)
389
+ default_ref = _normalize_required_string(payload.default_ref, "default_ref")
390
+ trusted = bool(payload.trusted)
391
+
392
+ if any(repo.id == repo_id for repo in config.templates.repos):
393
+ raise HTTPException(
394
+ status_code=409,
395
+ detail=_error_detail(
396
+ "template_repo_conflict", f"Template repo already exists: {repo_id}"
397
+ ),
398
+ )
399
+
400
+ updated = _repos_to_dicts(config.templates.repos)
401
+ updated.append(
402
+ {
403
+ "id": repo_id,
404
+ "url": url,
405
+ "trusted": trusted,
406
+ "default_ref": default_ref,
407
+ }
408
+ )
409
+ hub_root = _resolve_hub_root(request.app.state.engine.repo_root)
410
+ update_override_templates(hub_root, updated)
411
+ new_config = _reload_repo_config(request)
412
+ return TemplateReposResponse(
413
+ enabled=new_config.templates.enabled,
414
+ repos=_repos_to_dicts(new_config.templates.repos),
415
+ )
416
+
417
+ @router.put("/repos/{repo_id}", response_model=TemplateReposResponse)
418
+ def update_template_repo(
419
+ request: Request, repo_id: str, payload: TemplateRepoUpdateRequest
420
+ ):
421
+ config: RepoConfig = request.app.state.config
422
+ existing = _find_template_repo(config, repo_id)
423
+ if existing is None:
424
+ raise HTTPException(
425
+ status_code=404,
426
+ detail=_error_detail(
427
+ "template_repo_missing", f"Template repo not configured: {repo_id}"
428
+ ),
429
+ )
430
+ updates = payload.model_dump(exclude_unset=True)
431
+ url = (
432
+ _normalize_optional_string(updates.get("url"), "url")
433
+ if "url" in updates
434
+ else None
435
+ )
436
+ if url is not None:
437
+ _validate_repo_url(url)
438
+ default_ref = (
439
+ _normalize_optional_string(updates.get("default_ref"), "default_ref")
440
+ if "default_ref" in updates
441
+ else None
442
+ )
443
+ trusted_val = updates.get("trusted") if "trusted" in updates else None
444
+ if trusted_val is not None and not isinstance(trusted_val, bool):
445
+ raise HTTPException(
446
+ status_code=400,
447
+ detail=_error_detail("validation_error", "trusted must be boolean"),
448
+ )
449
+ trusted = bool(trusted_val) if trusted_val is not None else None
450
+
451
+ updated: list[dict] = []
452
+ for repo in config.templates.repos:
453
+ if repo.id != repo_id:
454
+ updated.append(
455
+ {
456
+ "id": repo.id,
457
+ "url": repo.url,
458
+ "trusted": bool(repo.trusted),
459
+ "default_ref": repo.default_ref,
460
+ }
461
+ )
462
+ continue
463
+ updated.append(
464
+ {
465
+ "id": repo.id,
466
+ "url": url if url is not None else repo.url,
467
+ "trusted": trusted if trusted is not None else bool(repo.trusted),
468
+ "default_ref": (
469
+ default_ref if default_ref is not None else repo.default_ref
470
+ ),
471
+ }
472
+ )
473
+
474
+ hub_root = _resolve_hub_root(request.app.state.engine.repo_root)
475
+ update_override_templates(hub_root, updated)
476
+ new_config = _reload_repo_config(request)
477
+ return TemplateReposResponse(
478
+ enabled=new_config.templates.enabled,
479
+ repos=_repos_to_dicts(new_config.templates.repos),
480
+ )
481
+
482
+ @router.delete("/repos/{repo_id}", response_model=TemplateReposResponse)
483
+ def delete_template_repo(request: Request, repo_id: str):
484
+ config: RepoConfig = request.app.state.config
485
+ if _find_template_repo(config, repo_id) is None:
486
+ raise HTTPException(
487
+ status_code=404,
488
+ detail=_error_detail(
489
+ "template_repo_missing", f"Template repo not configured: {repo_id}"
490
+ ),
491
+ )
492
+ updated = [
493
+ {
494
+ "id": repo.id,
495
+ "url": repo.url,
496
+ "trusted": bool(repo.trusted),
497
+ "default_ref": repo.default_ref,
498
+ }
499
+ for repo in config.templates.repos
500
+ if repo.id != repo_id
501
+ ]
502
+ hub_root = _resolve_hub_root(request.app.state.engine.repo_root)
503
+ update_override_templates(hub_root, updated)
504
+ new_config = _reload_repo_config(request)
505
+ return TemplateReposResponse(
506
+ enabled=new_config.templates.enabled,
507
+ repos=_repos_to_dicts(new_config.templates.repos),
508
+ )
509
+
510
+ @router.post("/fetch", response_model=TemplateFetchResponse)
511
+ async def fetch_template_route(request: Request, payload: TemplateFetchRequest):
512
+ config: RepoConfig = request.app.state.config
513
+ _require_templates_enabled(config)
514
+ fetched, scan_record, _hub_root = await _fetch_template_with_scan(
515
+ payload.template, request
516
+ )
517
+ return _format_fetch_response(fetched, scan_record)
518
+
519
+ @router.post("/apply", response_model=TemplateApplyResponse)
520
+ async def apply_template_route(request: Request, payload: TemplateApplyRequest):
521
+ config: RepoConfig = request.app.state.config
522
+ _require_templates_enabled(config)
523
+ fetched, scan_record, _hub_root = await _fetch_template_with_scan(
524
+ payload.template, request
525
+ )
526
+
527
+ resolved_dir = _resolve_ticket_dir(
528
+ request.app.state.engine.repo_root, payload.ticket_dir
529
+ )
530
+ if resolved_dir.exists() and not resolved_dir.is_dir():
531
+ raise HTTPException(
532
+ status_code=400,
533
+ detail=_error_detail(
534
+ "ticket_dir_invalid",
535
+ f"Ticket dir is not a directory: {resolved_dir}",
536
+ ),
537
+ )
538
+ try:
539
+ resolved_dir.mkdir(
540
+ parents=True, exist_ok=True
541
+ ) # codeql[py/path-injection] validated by normalize_ticket_dir
542
+ except OSError as exc:
543
+ raise HTTPException(
544
+ status_code=500,
545
+ detail=_error_detail("ticket_dir_error", str(exc)),
546
+ ) from exc
547
+
548
+ if payload.at is None and not payload.next_index:
549
+ raise HTTPException(
550
+ status_code=400,
551
+ detail=_error_detail(
552
+ "ticket_index_missing",
553
+ "Specify at or leave next_index enabled to pick an index.",
554
+ ),
555
+ )
556
+ if payload.at is not None and payload.at < 1:
557
+ raise HTTPException(
558
+ status_code=400,
559
+ detail=_error_detail(
560
+ "ticket_index_invalid", "Ticket index must be >= 1."
561
+ ),
562
+ )
563
+
564
+ existing_indices = _collect_ticket_indices(resolved_dir)
565
+ if payload.at is None:
566
+ index = _next_available_ticket_index(existing_indices)
567
+ else:
568
+ index = payload.at
569
+ if index in existing_indices:
570
+ raise HTTPException(
571
+ status_code=409,
572
+ detail=_error_detail(
573
+ "ticket_index_conflict",
574
+ f"Ticket index {index} already exists.",
575
+ ),
576
+ )
577
+
578
+ normalized_suffix = _normalize_ticket_suffix(payload.suffix)
579
+ width = max(3, max([len(str(i)) for i in existing_indices + [index]]))
580
+ filename = _ticket_filename(index, suffix=normalized_suffix, width=width)
581
+ path = resolved_dir / filename
582
+ if path.exists():
583
+ raise HTTPException(
584
+ status_code=409,
585
+ detail=_error_detail(
586
+ "ticket_exists",
587
+ f"Ticket already exists: {path}",
588
+ ),
589
+ )
590
+
591
+ content = fetched.content
592
+ if payload.set_agent:
593
+ if payload.set_agent != "user":
594
+ try:
595
+ validate_agent_id(payload.set_agent)
596
+ except ValueError as exc:
597
+ raise HTTPException(
598
+ status_code=400,
599
+ detail=_error_detail("agent_invalid", str(exc)),
600
+ ) from exc
601
+ content = _apply_agent_override(content, payload.set_agent)
602
+
603
+ if payload.include_provenance:
604
+ content = inject_provenance(content, fetched, scan_record)
605
+
606
+ try:
607
+ path.write_text(
608
+ content, encoding="utf-8"
609
+ ) # codeql[py/path-injection] validated by normalize_ticket_dir
610
+ except OSError as exc:
611
+ raise HTTPException(
612
+ status_code=500,
613
+ detail=_error_detail("ticket_write_failed", str(exc)),
614
+ ) from exc
615
+
616
+ metadata = {
617
+ "repo_id": fetched.repo_id,
618
+ "path": fetched.path,
619
+ "ref": fetched.ref,
620
+ "commit_sha": fetched.commit_sha,
621
+ "blob_sha": fetched.blob_sha,
622
+ "trusted": fetched.trusted,
623
+ "scan_decision": (
624
+ scan_record.to_dict(include_evidence=False) if scan_record else None
625
+ ),
626
+ }
627
+ return TemplateApplyResponse(
628
+ created_path=safe_relpath(path, request.app.state.engine.repo_root),
629
+ index=index,
630
+ filename=filename,
631
+ metadata=metadata,
632
+ )
633
+
634
+ return router