codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +17 -7
- codex_autorunner/bootstrap.py +219 -1
- codex_autorunner/core/__init__.py +17 -1
- codex_autorunner/core/about_car.py +124 -11
- codex_autorunner/core/app_server_threads.py +6 -0
- codex_autorunner/core/config.py +238 -3
- codex_autorunner/core/context_awareness.py +39 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +71 -1
- codex_autorunner/core/flows/reconciler.py +4 -1
- codex_autorunner/core/flows/runtime.py +22 -0
- codex_autorunner/core/flows/store.py +61 -9
- codex_autorunner/core/flows/transition.py +23 -16
- codex_autorunner/core/flows/ux_helpers.py +18 -3
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/hub.py +198 -41
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +683 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/agent_backend.py +2 -5
- codex_autorunner/core/ports/run_event.py +1 -4
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +5 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/ticket_linter_cli.py +17 -0
- codex_autorunner/core/ticket_manager_cli.py +154 -92
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/utils.py +34 -6
- codex_autorunner/flows/review/service.py +23 -25
- codex_autorunner/flows/ticket_flow/definition.py +43 -1
- codex_autorunner/integrations/agents/__init__.py +2 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
- codex_autorunner/integrations/agents/codex_backend.py +19 -8
- codex_autorunner/integrations/agents/runner.py +3 -8
- codex_autorunner/integrations/agents/wiring.py +8 -0
- codex_autorunner/integrations/telegram/adapter.py +1 -1
- codex_autorunner/integrations/telegram/config.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
- codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
- codex_autorunner/integrations/telegram/helpers.py +1 -3
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +30 -0
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
- codex_autorunner/integrations/telegram/transport.py +10 -3
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +21 -5
- codex_autorunner/static/app.js +115 -11
- codex_autorunner/static/archive.js +274 -81
- codex_autorunner/static/archiveApi.js +21 -0
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/constants.js +1 -1
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +46 -81
- codex_autorunner/static/index.html +303 -24
- codex_autorunner/static/messages.js +82 -4
- codex_autorunner/static/notifications.js +288 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/settings.js +3 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9141 -6742
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminalManager.js +22 -3
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +41 -13
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +69 -19
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +28 -0
- codex_autorunner/static/workspace.js +258 -44
- codex_autorunner/static/workspaceFileBrowser.js +6 -4
- codex_autorunner/surfaces/cli/cli.py +1465 -155
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/web/app.py +253 -49
- codex_autorunner/surfaces/web/routes/__init__.py +4 -0
- codex_autorunner/surfaces/web/routes/analytics.py +29 -22
- codex_autorunner/surfaces/web/routes/archive.py +197 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +219 -29
- codex_autorunner/surfaces/web/routes/messages.py +70 -39
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +1 -1
- codex_autorunner/surfaces/web/routes/shared.py +0 -3
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/runner_manager.py +2 -2
- codex_autorunner/surfaces/web/schemas.py +81 -18
- codex_autorunner/tickets/agent_pool.py +27 -0
- codex_autorunner/tickets/files.py +33 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +3 -0
- codex_autorunner/tickets/outbox.py +41 -5
- codex_autorunner/tickets/runner.py +350 -69
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -3302
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.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.
|
|
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:
|
|
8
|
+
def __init__(self, engine: RuntimeContext):
|
|
9
9
|
self._controller = ProcessRunnerController(engine)
|
|
10
10
|
|
|
11
11
|
@property
|