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
|
@@ -13,19 +13,28 @@ SQLITE_PRAGMAS = (
|
|
|
13
13
|
"PRAGMA temp_store=MEMORY;",
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
SQLITE_PRAGMAS_DURABLE = (
|
|
17
|
+
"PRAGMA journal_mode=WAL;",
|
|
18
|
+
"PRAGMA synchronous=FULL;",
|
|
19
|
+
"PRAGMA foreign_keys=ON;",
|
|
20
|
+
"PRAGMA busy_timeout=5000;",
|
|
21
|
+
"PRAGMA temp_store=MEMORY;",
|
|
22
|
+
)
|
|
23
|
+
|
|
16
24
|
|
|
17
|
-
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
|
25
|
+
def connect_sqlite(path: Path, durable: bool = False) -> sqlite3.Connection:
|
|
18
26
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
27
|
conn = sqlite3.connect(path)
|
|
20
28
|
conn.row_factory = sqlite3.Row
|
|
21
|
-
|
|
29
|
+
pragmas = SQLITE_PRAGMAS_DURABLE if durable else SQLITE_PRAGMAS
|
|
30
|
+
for pragma in pragmas:
|
|
22
31
|
conn.execute(pragma)
|
|
23
32
|
return conn
|
|
24
33
|
|
|
25
34
|
|
|
26
35
|
@contextmanager
|
|
27
|
-
def open_sqlite(path: Path) -> Iterator[sqlite3.Connection]:
|
|
28
|
-
conn = connect_sqlite(path)
|
|
36
|
+
def open_sqlite(path: Path, durable: bool = False) -> Iterator[sqlite3.Connection]:
|
|
37
|
+
conn = connect_sqlite(path, durable=durable)
|
|
29
38
|
try:
|
|
30
39
|
yield conn
|
|
31
40
|
finally:
|
codex_autorunner/core/state.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
import json
|
|
3
3
|
from contextlib import contextmanager
|
|
4
|
-
from datetime import datetime, timezone
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from typing import Any, Iterator, Optional
|
|
7
6
|
|
|
8
7
|
from .locks import file_lock
|
|
9
8
|
from .sqlite_utils import open_sqlite
|
|
9
|
+
from .time_utils import now_iso
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@dataclasses.dataclass
|
|
@@ -93,10 +93,6 @@ class SessionRecord:
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def now_iso() -> str:
|
|
97
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
98
|
-
|
|
99
|
-
|
|
100
96
|
def _ensure_state_schema(conn) -> None:
|
|
101
97
|
with conn:
|
|
102
98
|
conn.execute(
|
|
@@ -197,8 +193,8 @@ def _apply_overrides(state: RunnerState, raw: Optional[str]) -> None:
|
|
|
197
193
|
state.runner_stop_after_runs = runner_stop_after_runs
|
|
198
194
|
|
|
199
195
|
|
|
200
|
-
def load_state(state_path: Path) -> RunnerState:
|
|
201
|
-
with open_sqlite(state_path) as conn:
|
|
196
|
+
def load_state(state_path: Path, durable: bool = False) -> RunnerState:
|
|
197
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
202
198
|
_ensure_state_schema(conn)
|
|
203
199
|
row = conn.execute(
|
|
204
200
|
"""
|
|
@@ -253,9 +249,9 @@ def load_state(state_path: Path) -> RunnerState:
|
|
|
253
249
|
return state
|
|
254
250
|
|
|
255
251
|
|
|
256
|
-
def save_state(state_path: Path, state: RunnerState) -> None:
|
|
252
|
+
def save_state(state_path: Path, state: RunnerState, durable: bool = False) -> None:
|
|
257
253
|
overrides_json = _encode_overrides(state)
|
|
258
|
-
with open_sqlite(state_path) as conn:
|
|
254
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
259
255
|
_ensure_state_schema(conn)
|
|
260
256
|
updated_at = now_iso()
|
|
261
257
|
with conn:
|
|
@@ -343,9 +339,10 @@ def persist_session_registry(
|
|
|
343
339
|
state_path: Path,
|
|
344
340
|
sessions: dict[str, SessionRecord],
|
|
345
341
|
repo_to_session: dict[str, str],
|
|
342
|
+
durable: bool = False,
|
|
346
343
|
) -> None:
|
|
347
344
|
with state_lock(state_path):
|
|
348
|
-
with open_sqlite(state_path) as conn:
|
|
345
|
+
with open_sqlite(state_path, durable=durable) as conn:
|
|
349
346
|
_ensure_state_schema(conn)
|
|
350
347
|
with conn:
|
|
351
348
|
conn.execute("DELETE FROM sessions")
|
|
@@ -55,3 +55,8 @@ def resolve_global_state_root(
|
|
|
55
55
|
def resolve_repo_state_root(repo_root: Path) -> Path:
|
|
56
56
|
"""Return the repo-local state root (.codex-autorunner)."""
|
|
57
57
|
return repo_root / ".codex-autorunner"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_hub_templates_root(hub_root: Path) -> Path:
|
|
61
|
+
"""Return the hub-scoped templates root."""
|
|
62
|
+
return hub_root / ".codex-autorunner" / "templates"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .git_mirror import (
|
|
2
|
+
FetchedTemplate,
|
|
3
|
+
NetworkUnavailableError,
|
|
4
|
+
RefNotFoundError,
|
|
5
|
+
RepoNotConfiguredError,
|
|
6
|
+
TemplateNotFoundError,
|
|
7
|
+
TemplateRef,
|
|
8
|
+
ensure_git_mirror,
|
|
9
|
+
fetch_template,
|
|
10
|
+
parse_template_ref,
|
|
11
|
+
)
|
|
12
|
+
from .provenance import inject_provenance
|
|
13
|
+
from .scan_cache import (
|
|
14
|
+
TemplateScanRecord,
|
|
15
|
+
get_scan_record,
|
|
16
|
+
scan_lock,
|
|
17
|
+
scan_lock_path,
|
|
18
|
+
scan_record_path,
|
|
19
|
+
write_scan_record,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"FetchedTemplate",
|
|
24
|
+
"NetworkUnavailableError",
|
|
25
|
+
"RefNotFoundError",
|
|
26
|
+
"RepoNotConfiguredError",
|
|
27
|
+
"TemplateNotFoundError",
|
|
28
|
+
"TemplateRef",
|
|
29
|
+
"ensure_git_mirror",
|
|
30
|
+
"fetch_template",
|
|
31
|
+
"parse_template_ref",
|
|
32
|
+
"inject_provenance",
|
|
33
|
+
"TemplateScanRecord",
|
|
34
|
+
"get_scan_record",
|
|
35
|
+
"scan_lock",
|
|
36
|
+
"scan_lock_path",
|
|
37
|
+
"scan_record_path",
|
|
38
|
+
"write_scan_record",
|
|
39
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..config import TemplateRepoConfig
|
|
8
|
+
from ..git_utils import GitError, run_git
|
|
9
|
+
from ..state_roots import resolve_hub_templates_root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RepoNotConfiguredError(Exception):
|
|
13
|
+
def __init__(self, repo_id: str, *, detail: Optional[str] = None) -> None:
|
|
14
|
+
message = f"Template repo not configured: {repo_id}"
|
|
15
|
+
if detail:
|
|
16
|
+
message = f"{message} ({detail})"
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.repo_id = repo_id
|
|
19
|
+
self.detail = detail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TemplateNotFoundError(Exception):
|
|
23
|
+
def __init__(self, repo_id: str, path: str, ref: str) -> None:
|
|
24
|
+
super().__init__(f"Template not found: repo_id={repo_id} path={path} ref={ref}")
|
|
25
|
+
self.repo_id = repo_id
|
|
26
|
+
self.path = path
|
|
27
|
+
self.ref = ref
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RefNotFoundError(Exception):
|
|
31
|
+
def __init__(self, repo_id: str, ref: str) -> None:
|
|
32
|
+
super().__init__(f"Ref not found: repo_id={repo_id} ref={ref}")
|
|
33
|
+
self.repo_id = repo_id
|
|
34
|
+
self.ref = ref
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkUnavailableError(Exception):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
repo_id: str,
|
|
41
|
+
ref: str,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
detail: Optional[str] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
message = (
|
|
47
|
+
"Template fetch failed and cache is unavailable: "
|
|
48
|
+
f"repo_id={repo_id} ref={ref} path={path}"
|
|
49
|
+
)
|
|
50
|
+
if detail:
|
|
51
|
+
message = f"{message} ({detail})"
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.repo_id = repo_id
|
|
54
|
+
self.ref = ref
|
|
55
|
+
self.path = path
|
|
56
|
+
self.detail = detail
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclasses.dataclass(frozen=True)
|
|
60
|
+
class TemplateRef:
|
|
61
|
+
repo_id: str
|
|
62
|
+
path: str
|
|
63
|
+
ref: Optional[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclasses.dataclass(frozen=True)
|
|
67
|
+
class FetchedTemplate:
|
|
68
|
+
repo_id: str
|
|
69
|
+
url: str
|
|
70
|
+
trusted: bool
|
|
71
|
+
path: str
|
|
72
|
+
ref: str
|
|
73
|
+
commit_sha: str
|
|
74
|
+
blob_sha: str
|
|
75
|
+
content: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_template_ref(raw: str) -> TemplateRef:
|
|
79
|
+
"""Parse canonical template reference strings: REPO_ID:PATH[@REF]."""
|
|
80
|
+
if ":" not in raw:
|
|
81
|
+
raise ValueError("template ref must be formatted as REPO_ID:PATH[@REF]")
|
|
82
|
+
repo_id, remainder = raw.split(":", 1)
|
|
83
|
+
if not repo_id:
|
|
84
|
+
raise ValueError("template ref missing repo_id")
|
|
85
|
+
if not remainder:
|
|
86
|
+
raise ValueError("template ref missing path")
|
|
87
|
+
|
|
88
|
+
path: str
|
|
89
|
+
ref: Optional[str]
|
|
90
|
+
if "@" in remainder:
|
|
91
|
+
path, ref = remainder.rsplit("@", 1)
|
|
92
|
+
if not ref:
|
|
93
|
+
raise ValueError("template ref missing ref after '@'")
|
|
94
|
+
else:
|
|
95
|
+
path, ref = remainder, None
|
|
96
|
+
|
|
97
|
+
if not path:
|
|
98
|
+
raise ValueError("template ref missing path")
|
|
99
|
+
|
|
100
|
+
return TemplateRef(repo_id=repo_id, path=path, ref=ref)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_git_mirror(repo: TemplateRepoConfig, hub_root: Path) -> Path:
|
|
104
|
+
templates_root = resolve_hub_templates_root(hub_root)
|
|
105
|
+
mirror_path = templates_root / "git" / f"{repo.id}.git"
|
|
106
|
+
if mirror_path.exists():
|
|
107
|
+
_ensure_origin_remote(mirror_path, repo.url)
|
|
108
|
+
return mirror_path
|
|
109
|
+
|
|
110
|
+
mirror_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
run_git(["init", "--bare", str(mirror_path)], mirror_path.parent, check=True)
|
|
112
|
+
_ensure_origin_remote(mirror_path, repo.url)
|
|
113
|
+
return mirror_path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_origin_remote(mirror_path: Path, url: str) -> None:
|
|
117
|
+
try:
|
|
118
|
+
proc = run_git(["remote", "get-url", "origin"], mirror_path, check=False)
|
|
119
|
+
except GitError:
|
|
120
|
+
proc = None
|
|
121
|
+
if proc and proc.returncode == 0:
|
|
122
|
+
current = (proc.stdout or "").strip()
|
|
123
|
+
if current and current != url:
|
|
124
|
+
run_git(["remote", "set-url", "origin", url], mirror_path, check=True)
|
|
125
|
+
else:
|
|
126
|
+
run_git(["remote", "add", "origin", url], mirror_path, check=True)
|
|
127
|
+
_configure_mirror_remote(mirror_path)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _configure_mirror_remote(mirror_path: Path) -> None:
|
|
131
|
+
run_git(
|
|
132
|
+
["config", "remote.origin.fetch", "+refs/*:refs/*"],
|
|
133
|
+
mirror_path,
|
|
134
|
+
check=True,
|
|
135
|
+
)
|
|
136
|
+
run_git(["config", "remote.origin.mirror", "true"], mirror_path, check=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_template(
|
|
140
|
+
*,
|
|
141
|
+
repo: TemplateRepoConfig,
|
|
142
|
+
hub_root: Path,
|
|
143
|
+
template_ref: str,
|
|
144
|
+
fetch_timeout_seconds: int = 30,
|
|
145
|
+
) -> FetchedTemplate:
|
|
146
|
+
parsed = parse_template_ref(template_ref)
|
|
147
|
+
if parsed.repo_id != repo.id:
|
|
148
|
+
raise RepoNotConfiguredError(
|
|
149
|
+
parsed.repo_id,
|
|
150
|
+
detail=f"expected repo_id {repo.id}",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
ref = parsed.ref or repo.default_ref
|
|
154
|
+
mirror_path = ensure_git_mirror(repo, hub_root)
|
|
155
|
+
|
|
156
|
+
fetch_error: Optional[str] = None
|
|
157
|
+
try:
|
|
158
|
+
run_git(
|
|
159
|
+
["fetch", "--prune", "origin"],
|
|
160
|
+
mirror_path,
|
|
161
|
+
timeout_seconds=fetch_timeout_seconds,
|
|
162
|
+
check=True,
|
|
163
|
+
)
|
|
164
|
+
except GitError as exc:
|
|
165
|
+
fetch_error = str(exc)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
commit_sha = _resolve_commit(mirror_path, repo.id, ref)
|
|
169
|
+
blob_sha = _resolve_blob(mirror_path, commit_sha, parsed.path, repo.id, ref)
|
|
170
|
+
content = _read_blob(mirror_path, blob_sha)
|
|
171
|
+
except (RefNotFoundError, TemplateNotFoundError) as exc:
|
|
172
|
+
if fetch_error:
|
|
173
|
+
raise NetworkUnavailableError(
|
|
174
|
+
repo.id,
|
|
175
|
+
ref,
|
|
176
|
+
parsed.path,
|
|
177
|
+
detail=fetch_error,
|
|
178
|
+
) from exc
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
return FetchedTemplate(
|
|
182
|
+
repo_id=repo.id,
|
|
183
|
+
url=repo.url,
|
|
184
|
+
trusted=repo.trusted,
|
|
185
|
+
path=parsed.path,
|
|
186
|
+
ref=ref,
|
|
187
|
+
commit_sha=commit_sha,
|
|
188
|
+
blob_sha=blob_sha,
|
|
189
|
+
content=content,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_commit(mirror_path: Path, repo_id: str, ref: str) -> str:
|
|
194
|
+
try:
|
|
195
|
+
proc = run_git(
|
|
196
|
+
["rev-parse", f"{ref}^{{commit}}"],
|
|
197
|
+
mirror_path,
|
|
198
|
+
check=True,
|
|
199
|
+
)
|
|
200
|
+
except GitError as exc:
|
|
201
|
+
raise RefNotFoundError(repo_id, ref) from exc
|
|
202
|
+
return (proc.stdout or "").strip()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve_blob(
|
|
206
|
+
mirror_path: Path,
|
|
207
|
+
commit_sha: str,
|
|
208
|
+
path: str,
|
|
209
|
+
repo_id: str,
|
|
210
|
+
ref: str,
|
|
211
|
+
) -> str:
|
|
212
|
+
try:
|
|
213
|
+
proc = run_git(
|
|
214
|
+
["ls-tree", commit_sha, "--", path],
|
|
215
|
+
mirror_path,
|
|
216
|
+
check=True,
|
|
217
|
+
)
|
|
218
|
+
except GitError as exc:
|
|
219
|
+
raise TemplateNotFoundError(repo_id, path, ref) from exc
|
|
220
|
+
|
|
221
|
+
raw = (proc.stdout or "").strip()
|
|
222
|
+
if not raw:
|
|
223
|
+
raise TemplateNotFoundError(repo_id, path, ref)
|
|
224
|
+
|
|
225
|
+
# Format: "<mode> <type> <sha>\t<path>"
|
|
226
|
+
parts = raw.split()
|
|
227
|
+
if len(parts) < 3:
|
|
228
|
+
raise TemplateNotFoundError(repo_id, path, ref)
|
|
229
|
+
return parts[2]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_blob(mirror_path: Path, blob_sha: str) -> str:
|
|
233
|
+
proc = run_git(["cat-file", "-p", blob_sha], mirror_path, check=True)
|
|
234
|
+
return proc.stdout or ""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from .git_mirror import FetchedTemplate
|
|
8
|
+
from .scan_cache import TemplateScanRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inject_provenance(
|
|
12
|
+
content: str,
|
|
13
|
+
fetched: FetchedTemplate,
|
|
14
|
+
scan_record: Optional[TemplateScanRecord],
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Inject deterministic provenance keys into ticket frontmatter.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
content: Ticket markdown content
|
|
20
|
+
fetched: The fetched template metadata
|
|
21
|
+
scan_record: Optional scan record for the template
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Updated markdown content with provenance keys in frontmatter
|
|
25
|
+
|
|
26
|
+
Notes:
|
|
27
|
+
- Does not embed scan evidence
|
|
28
|
+
- Does not add timestamps (avoids nondeterminism)
|
|
29
|
+
- Creates frontmatter if missing
|
|
30
|
+
- Merges with existing frontmatter without clobbering unrelated keys
|
|
31
|
+
"""
|
|
32
|
+
from ...tickets.frontmatter import split_markdown_frontmatter
|
|
33
|
+
|
|
34
|
+
fm_yaml, body = split_markdown_frontmatter(content)
|
|
35
|
+
|
|
36
|
+
data = {}
|
|
37
|
+
if fm_yaml is not None:
|
|
38
|
+
try:
|
|
39
|
+
parsed = yaml.safe_load(fm_yaml)
|
|
40
|
+
if isinstance(parsed, dict):
|
|
41
|
+
data = parsed
|
|
42
|
+
except yaml.YAMLError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
data["template"] = f"{fetched.repo_id}:{fetched.path}@{fetched.ref}"
|
|
46
|
+
data["template_commit"] = fetched.commit_sha
|
|
47
|
+
data["template_blob"] = fetched.blob_sha
|
|
48
|
+
data["template_trusted"] = fetched.trusted
|
|
49
|
+
|
|
50
|
+
if scan_record is not None:
|
|
51
|
+
data["template_scan"] = scan_record.decision
|
|
52
|
+
else:
|
|
53
|
+
data["template_scan"] = "skipped"
|
|
54
|
+
|
|
55
|
+
rendered = yaml.safe_dump(data, sort_keys=False).rstrip()
|
|
56
|
+
return f"---\n{rendered}\n---\n{body}"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator, Optional
|
|
8
|
+
|
|
9
|
+
from ..locks import FileLock
|
|
10
|
+
from ..state_roots import resolve_hub_templates_root
|
|
11
|
+
from ..utils import atomic_write
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True)
|
|
15
|
+
class TemplateScanRecord:
|
|
16
|
+
blob_sha: str
|
|
17
|
+
repo_id: str
|
|
18
|
+
path: str
|
|
19
|
+
ref: str
|
|
20
|
+
commit_sha: str
|
|
21
|
+
trusted: bool
|
|
22
|
+
decision: str
|
|
23
|
+
severity: str
|
|
24
|
+
reason: str
|
|
25
|
+
evidence: Optional[list[str]]
|
|
26
|
+
scanned_at: str
|
|
27
|
+
scanner: Optional[dict[str, str]]
|
|
28
|
+
|
|
29
|
+
def to_dict(self, *, include_evidence: bool = True) -> dict[str, Any]:
|
|
30
|
+
payload: dict[str, Any] = {
|
|
31
|
+
"blob_sha": self.blob_sha,
|
|
32
|
+
"repo_id": self.repo_id,
|
|
33
|
+
"path": self.path,
|
|
34
|
+
"ref": self.ref,
|
|
35
|
+
"commit_sha": self.commit_sha,
|
|
36
|
+
"trusted": self.trusted,
|
|
37
|
+
"decision": self.decision,
|
|
38
|
+
"severity": self.severity,
|
|
39
|
+
"reason": self.reason,
|
|
40
|
+
"scanned_at": self.scanned_at,
|
|
41
|
+
}
|
|
42
|
+
if include_evidence and self.evidence:
|
|
43
|
+
payload["evidence"] = list(self.evidence)
|
|
44
|
+
if self.scanner:
|
|
45
|
+
payload["scanner"] = dict(self.scanner)
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_dict(payload: dict[str, Any]) -> "TemplateScanRecord":
|
|
50
|
+
return TemplateScanRecord(
|
|
51
|
+
blob_sha=str(payload.get("blob_sha", "")),
|
|
52
|
+
repo_id=str(payload.get("repo_id", "")),
|
|
53
|
+
path=str(payload.get("path", "")),
|
|
54
|
+
ref=str(payload.get("ref", "")),
|
|
55
|
+
commit_sha=str(payload.get("commit_sha", "")),
|
|
56
|
+
trusted=bool(payload.get("trusted", False)),
|
|
57
|
+
decision=str(payload.get("decision", "")),
|
|
58
|
+
severity=str(payload.get("severity", "")),
|
|
59
|
+
reason=str(payload.get("reason", "")),
|
|
60
|
+
evidence=_coerce_evidence(payload.get("evidence")),
|
|
61
|
+
scanned_at=str(payload.get("scanned_at", "")),
|
|
62
|
+
scanner=_coerce_scanner(payload.get("scanner")),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _coerce_evidence(value: Any) -> Optional[list[str]]:
|
|
67
|
+
if not value:
|
|
68
|
+
return None
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [str(item) for item in value]
|
|
71
|
+
return [str(value)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _coerce_scanner(value: Any) -> Optional[dict[str, str]]:
|
|
75
|
+
if not value or not isinstance(value, dict):
|
|
76
|
+
return None
|
|
77
|
+
return {str(key): str(val) for key, val in value.items()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scan_root(hub_root: Path) -> Path:
|
|
81
|
+
return resolve_hub_templates_root(hub_root) / "scans"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan_record_path(hub_root: Path, blob_sha: str) -> Path:
|
|
85
|
+
return _scan_root(hub_root) / f"{blob_sha}.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_lock_path(hub_root: Path, blob_sha: str) -> Path:
|
|
89
|
+
return _scan_root(hub_root) / "locks" / f"{blob_sha}.lock"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_scan_record(hub_root: Path, blob_sha: str) -> Optional[TemplateScanRecord]:
|
|
93
|
+
path = scan_record_path(hub_root, blob_sha)
|
|
94
|
+
if not path.exists():
|
|
95
|
+
return None
|
|
96
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
97
|
+
payload = json.load(handle)
|
|
98
|
+
if not isinstance(payload, dict):
|
|
99
|
+
return None
|
|
100
|
+
return TemplateScanRecord.from_dict(payload)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_scan_record(record: TemplateScanRecord, hub_root: Path) -> None:
|
|
104
|
+
path = scan_record_path(hub_root, record.blob_sha)
|
|
105
|
+
payload = record.to_dict(include_evidence=False)
|
|
106
|
+
if record.evidence:
|
|
107
|
+
payload["evidence_redacted"] = True
|
|
108
|
+
atomic_write(path, json.dumps(payload, indent=2) + "\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def scan_lock(hub_root: Path, blob_sha: str) -> Iterator[None]:
|
|
113
|
+
path = scan_lock_path(hub_root, blob_sha)
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
lock = FileLock(path)
|
|
116
|
+
lock.acquire()
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
lock.release()
|
|
@@ -43,9 +43,12 @@ _SCRIPT = dedent(
|
|
|
43
43
|
|
|
44
44
|
tickets: List[tuple[int, Path]] = []
|
|
45
45
|
errors: List[str] = []
|
|
46
|
+
index_to_paths: dict[int, List[Path]] = {}
|
|
46
47
|
for path in sorted(tickets_dir.iterdir()):
|
|
47
48
|
if not path.is_file():
|
|
48
49
|
continue
|
|
50
|
+
if path.name == "AGENTS.md":
|
|
51
|
+
continue
|
|
49
52
|
match = _TICKET_NAME_RE.match(path.name)
|
|
50
53
|
if not match:
|
|
51
54
|
errors.append(
|
|
@@ -60,7 +63,21 @@ _SCRIPT = dedent(
|
|
|
60
63
|
)
|
|
61
64
|
continue
|
|
62
65
|
tickets.append((idx, path))
|
|
66
|
+
# Track paths by index to detect duplicates
|
|
67
|
+
if idx not in index_to_paths:
|
|
68
|
+
index_to_paths[idx] = []
|
|
69
|
+
index_to_paths[idx].append(path)
|
|
63
70
|
tickets.sort(key=lambda pair: pair[0])
|
|
71
|
+
|
|
72
|
+
# Check for duplicate indices
|
|
73
|
+
for idx, paths in index_to_paths.items():
|
|
74
|
+
if len(paths) > 1:
|
|
75
|
+
paths_str = ", ".join([str(p) for p in paths])
|
|
76
|
+
errors.append(
|
|
77
|
+
f\"Duplicate ticket index {idx:03d}: multiple files share the same index ({paths_str}). \"
|
|
78
|
+
\"Rename or remove duplicates to ensure deterministic ordering.\"
|
|
79
|
+
)
|
|
80
|
+
|
|
64
81
|
return [p for _, p in tickets], errors
|
|
65
82
|
|
|
66
83
|
|