codex-autorunner 1.1.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ from typing import Optional
6
6
 
7
7
  from fastapi import APIRouter, HTTPException, Request
8
8
 
9
- from ....core.engine import LockError, clear_stale_lock
9
+ from ....core.runtime import LockError, clear_stale_lock
10
10
  from ....core.state import RunnerState, load_state, now_iso, save_state, state_lock
11
11
  from ..schemas import (
12
12
  RunControlRequest,
@@ -240,7 +240,6 @@ async def state_stream(
240
240
  emitted = False
241
241
  try:
242
242
  state = await asyncio.to_thread(load_state, engine.state_path)
243
- outstanding, done = await asyncio.to_thread(engine.docs.todos)
244
243
  status, runner_pid, running = resolve_runner_status(engine, state)
245
244
  lock_payload = resolve_lock_payload(engine)
246
245
  payload = {
@@ -249,8 +248,6 @@ async def state_stream(
249
248
  "last_exit_code": state.last_exit_code,
250
249
  "last_run_started_at": state.last_run_started_at,
251
250
  "last_run_finished_at": state.last_run_finished_at,
252
- "outstanding_count": len(outstanding),
253
- "done_count": len(done),
254
251
  "running": running,
255
252
  "runner_pid": runner_pid,
256
253
  **lock_payload,
@@ -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
@@ -1,11 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from ...core.engine import Engine
4
3
  from ...core.runner_controller import ProcessRunnerController
4
+ from ...core.runtime import RuntimeContext
5
5
 
6
6
 
7
7
  class RunnerManager:
8
- def __init__(self, engine: Engine):
8
+ def __init__(self, engine: RuntimeContext):
9
9
  self._controller = ProcessRunnerController(engine)
10
10
 
11
11
  @property