PTSIP 0.3.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.
- ptsip/__init__.py +3 -0
- ptsip/__main__.py +4 -0
- ptsip/app/__init__.py +5 -0
- ptsip/app/client.py +53 -0
- ptsip/app/github_client.py +200 -0
- ptsip/app/server.py +130 -0
- ptsip/app/service.py +285 -0
- ptsip/app/store.py +233 -0
- ptsip/artifact_evidence.py +150 -0
- ptsip/build_resolution.py +298 -0
- ptsip/clarification/__init__.py +6 -0
- ptsip/clarification/generator.py +120 -0
- ptsip/clarification/generator_core.py +98 -0
- ptsip/clarification/i18n.py +106 -0
- ptsip/clarification/model.py +33 -0
- ptsip/clarification/render.py +78 -0
- ptsip/clarification/resolution/__init__.py +29 -0
- ptsip/clarification/resolution/model.py +33 -0
- ptsip/clarification/resolution/parser.py +56 -0
- ptsip/clarification/resolution/profile_projection.py +198 -0
- ptsip/clarification/resolution/resolver.py +30 -0
- ptsip/clarification/transports/__init__.py +1 -0
- ptsip/clarification/transports/github_issue.py +162 -0
- ptsip/cli.py +402 -0
- ptsip/conformance.py +531 -0
- ptsip/conformance_audit.py +143 -0
- ptsip/conformance_engine.py +396 -0
- ptsip/constants.py +9 -0
- ptsip/doctor.py +33 -0
- ptsip/inspection/__init__.py +1 -0
- ptsip/inspection/components.py +74 -0
- ptsip/inspection/dependencies.py +496 -0
- ptsip/inspection/dependencies_030.py +69 -0
- ptsip/inspection/dotnet.py +188 -0
- ptsip/inspection/go.py +157 -0
- ptsip/inspection/inventory.py +161 -0
- ptsip/inspection/javascript.py +376 -0
- ptsip/inspection/lexing.py +121 -0
- ptsip/inspection/source_adapters.py +60 -0
- ptsip/lifecycle_evidence.py +245 -0
- ptsip/model.py +105 -0
- ptsip/pilot/__init__.py +1 -0
- ptsip/pilot/runner.py +170 -0
- ptsip/repository/__init__.py +1 -0
- ptsip/repository/discover.py +68 -0
- ptsip/repository/remote.py +54 -0
- ptsip/repository/snapshot.py +173 -0
- ptsip/review_evidence.py +406 -0
- ptsip/spec_identity.py +22 -0
- ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
- ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
- ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
- ptsip/specdata/ptsip-profile.schema.json +310 -0
- ptsip/specdata/ptsip-registry.yaml +189 -0
- ptsip/storage/__init__.py +1 -0
- ptsip/storage/local_state.py +32 -0
- ptsip/validation/__init__.py +1 -0
- ptsip/validation/components.py +164 -0
- ptsip/validation/profile.py +172 -0
- ptsip/validation/rules.py +163 -0
- ptsip-0.3.1.dist-info/METADATA +338 -0
- ptsip-0.3.1.dist-info/RECORD +66 -0
- ptsip-0.3.1.dist-info/WHEEL +5 -0
- ptsip-0.3.1.dist-info/entry_points.txt +3 -0
- ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
- ptsip-0.3.1.dist-info/top_level.txt +1 -0
ptsip/app/service.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..clarification.resolution import dump_payload, load_profile_text, parse_answer, project_payload, validate_answer, validate_projected_payload
|
|
6
|
+
from ..clarification.resolution.model import DecisionAnswer
|
|
7
|
+
from .github_client import GitHubAppClient, GitHubAPIError
|
|
8
|
+
from .store import DecisionRecord, DecisionStore
|
|
9
|
+
|
|
10
|
+
WRITE_PERMISSIONS = {"admin", "maintain", "write"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _answer_from_mapping(payload: dict[str, object]) -> DecisionAnswer:
|
|
14
|
+
return DecisionAnswer(
|
|
15
|
+
classification=str(payload["classification"]),
|
|
16
|
+
purpose=str(payload["purpose"]),
|
|
17
|
+
shipped=bool(payload["shipped"]),
|
|
18
|
+
runtime_required=bool(payload["runtime_required"]),
|
|
19
|
+
lifecycle_owner=str(payload["lifecycle_owner"]),
|
|
20
|
+
executable=bool(payload["executable"]),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _workflow_status(record: DecisionRecord) -> str:
|
|
25
|
+
if record.status == "PENDING":
|
|
26
|
+
return "DECISION_REQUIRED"
|
|
27
|
+
if record.status == "RESOLVED" and record.application_status not in {"APPLIED", "LOCAL_APPLIED"}:
|
|
28
|
+
return "RESOLVED_APPLICATION_REQUIRED"
|
|
29
|
+
return record.status
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DecisionService:
|
|
33
|
+
def __init__(self, store: DecisionStore, github: GitHubAppClient):
|
|
34
|
+
self.store = store
|
|
35
|
+
self.github = github
|
|
36
|
+
|
|
37
|
+
def _installation_for(self, repository: str) -> int:
|
|
38
|
+
installation = self.store.installation_for(repository)
|
|
39
|
+
if installation is not None:
|
|
40
|
+
return installation
|
|
41
|
+
installation = self.github.repository_installation(repository)
|
|
42
|
+
self.store.set_installation(repository, installation)
|
|
43
|
+
return installation
|
|
44
|
+
|
|
45
|
+
def gate(self, payload: dict[str, Any]) -> dict[str, object]:
|
|
46
|
+
for key in ("id", "repository", "branch", "subject_revision", "component_id", "request", "issue"):
|
|
47
|
+
if key not in payload:
|
|
48
|
+
raise ValueError(f"gate payload missing {key}")
|
|
49
|
+
record, stale = self.store.gate(payload)
|
|
50
|
+
installation = self._installation_for(record.repository)
|
|
51
|
+
for old in stale:
|
|
52
|
+
if old.issue_number:
|
|
53
|
+
try:
|
|
54
|
+
self.github.add_issue_comment(
|
|
55
|
+
old.repository,
|
|
56
|
+
installation,
|
|
57
|
+
old.issue_number,
|
|
58
|
+
"PTSIP marked this clarification as stale because a newer component decision request replaced it.",
|
|
59
|
+
)
|
|
60
|
+
self.github.update_issue_state(old.repository, installation, old.issue_number, "closed")
|
|
61
|
+
except GitHubAPIError:
|
|
62
|
+
pass
|
|
63
|
+
if record.status == "PENDING" and record.issue_number is None:
|
|
64
|
+
issue = payload["issue"]
|
|
65
|
+
if not isinstance(issue, dict):
|
|
66
|
+
raise ValueError("issue must be an object")
|
|
67
|
+
created = self.github.create_issue(
|
|
68
|
+
record.repository,
|
|
69
|
+
installation,
|
|
70
|
+
str(issue.get("title", f"[PTSIP clarification] {record.component_id}")),
|
|
71
|
+
str(issue.get("body", "")),
|
|
72
|
+
)
|
|
73
|
+
record = self.store.attach_issue(record.id, created.number, created.url)
|
|
74
|
+
elif record.status == "PENDING" and record.issue_number is not None:
|
|
75
|
+
# Issue open/closed state is only a UI surface. A manual close must
|
|
76
|
+
# not resolve the authoritative PENDING decision. Reopen it only
|
|
77
|
+
# when an active coding-agent gate actually needs the decision.
|
|
78
|
+
try:
|
|
79
|
+
self.github.update_issue_state(record.repository, installation, record.issue_number, "open")
|
|
80
|
+
except GitHubAPIError:
|
|
81
|
+
pass
|
|
82
|
+
return {"status": _workflow_status(record), "decision": record.as_dict()}
|
|
83
|
+
|
|
84
|
+
def decision(self, payload: dict[str, Any]) -> dict[str, object]:
|
|
85
|
+
decision_id = str(payload.get("decision_id", ""))
|
|
86
|
+
if not decision_id:
|
|
87
|
+
raise ValueError("decision_id is required")
|
|
88
|
+
record = self.store.get(decision_id)
|
|
89
|
+
if record is None:
|
|
90
|
+
raise KeyError(decision_id)
|
|
91
|
+
return {"status": _workflow_status(record), "decision": record.as_dict()}
|
|
92
|
+
|
|
93
|
+
def resolve_agent(self, payload: dict[str, Any]) -> dict[str, object]:
|
|
94
|
+
decision_id = str(payload.get("decision_id", ""))
|
|
95
|
+
raw_answer = payload.get("answer")
|
|
96
|
+
if not decision_id or not isinstance(raw_answer, dict):
|
|
97
|
+
raise ValueError("decision_id and answer are required")
|
|
98
|
+
answer = _answer_from_mapping(raw_answer)
|
|
99
|
+
validation = validate_answer(answer)
|
|
100
|
+
if not validation.valid:
|
|
101
|
+
return {"status": "CONFLICT", "validation": validation.as_dict()}
|
|
102
|
+
actor = str(payload.get("actor") or "coding-agent-session")
|
|
103
|
+
record, accepted = self.store.resolve(decision_id, answer.as_dict(), "AGENT_CHAT", actor)
|
|
104
|
+
return {
|
|
105
|
+
"status": "RESOLVED" if accepted else "ALREADY_RESOLVED",
|
|
106
|
+
"accepted": accepted,
|
|
107
|
+
"decision": record.as_dict(),
|
|
108
|
+
"validation": validation.as_dict(),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def application(self, payload: dict[str, Any]) -> dict[str, object]:
|
|
112
|
+
decision_id = str(payload.get("decision_id", ""))
|
|
113
|
+
status = str(payload.get("status", ""))
|
|
114
|
+
if status not in {"LOCAL_APPLIED", "FAILED", "STALE"}:
|
|
115
|
+
raise ValueError("unsupported agent application status")
|
|
116
|
+
existing = self.store.get(decision_id)
|
|
117
|
+
if existing is None:
|
|
118
|
+
raise KeyError(decision_id)
|
|
119
|
+
if existing.status != "RESOLVED":
|
|
120
|
+
raise ValueError("application state can be changed only for a resolved decision")
|
|
121
|
+
record = self.store.mark_application(
|
|
122
|
+
decision_id,
|
|
123
|
+
status,
|
|
124
|
+
str(payload.get("applied_revision") or "") or None,
|
|
125
|
+
)
|
|
126
|
+
if status == "LOCAL_APPLIED" and record.issue_number:
|
|
127
|
+
try:
|
|
128
|
+
installation = self._installation_for(record.repository)
|
|
129
|
+
self.github.add_issue_comment(
|
|
130
|
+
record.repository,
|
|
131
|
+
installation,
|
|
132
|
+
record.issue_number,
|
|
133
|
+
f"PTSIP decision `{record.id}` was resolved via `{record.resolution_source}` and applied by the active coding-agent workflow. Late replies are ignored.",
|
|
134
|
+
)
|
|
135
|
+
self.github.update_issue_state(record.repository, installation, record.issue_number, "closed")
|
|
136
|
+
except GitHubAPIError:
|
|
137
|
+
pass
|
|
138
|
+
return {"status": status, "decision": record.as_dict()}
|
|
139
|
+
|
|
140
|
+
def register_installation_event(self, payload: dict[str, Any]) -> None:
|
|
141
|
+
installation = payload.get("installation")
|
|
142
|
+
if not isinstance(installation, dict) or not isinstance(installation.get("id"), int):
|
|
143
|
+
return
|
|
144
|
+
installation_id = int(installation["id"])
|
|
145
|
+
repository = payload.get("repository")
|
|
146
|
+
if isinstance(repository, dict) and repository.get("full_name"):
|
|
147
|
+
self.store.set_installation(str(repository["full_name"]), installation_id)
|
|
148
|
+
for key in ("repositories", "repositories_added"):
|
|
149
|
+
repos = payload.get(key)
|
|
150
|
+
if isinstance(repos, list):
|
|
151
|
+
for item in repos:
|
|
152
|
+
if isinstance(item, dict) and item.get("full_name"):
|
|
153
|
+
self.store.set_installation(str(item["full_name"]), installation_id)
|
|
154
|
+
|
|
155
|
+
def issue_comment(self, payload: dict[str, Any]) -> dict[str, object]:
|
|
156
|
+
if payload.get("action") != "created":
|
|
157
|
+
return {"status": "IGNORED"}
|
|
158
|
+
self.register_installation_event(payload)
|
|
159
|
+
repository = payload.get("repository")
|
|
160
|
+
issue = payload.get("issue")
|
|
161
|
+
comment = payload.get("comment")
|
|
162
|
+
sender = payload.get("sender")
|
|
163
|
+
if not all(isinstance(item, dict) for item in (repository, issue, comment, sender)):
|
|
164
|
+
return {"status": "IGNORED"}
|
|
165
|
+
full_name = str(repository.get("full_name", ""))
|
|
166
|
+
issue_number = issue.get("number")
|
|
167
|
+
username = str(sender.get("login", ""))
|
|
168
|
+
body = str(comment.get("body", ""))
|
|
169
|
+
if not full_name or not isinstance(issue_number, int) or not username:
|
|
170
|
+
return {"status": "IGNORED"}
|
|
171
|
+
record = self.store.by_issue(full_name, issue_number)
|
|
172
|
+
if record is None:
|
|
173
|
+
return {"status": "IGNORED_NOT_PTSIP"}
|
|
174
|
+
if record.status != "PENDING":
|
|
175
|
+
return {"status": "IGNORED_TERMINAL_DECISION", "decision": record.as_dict()}
|
|
176
|
+
try:
|
|
177
|
+
installation = self._installation_for(full_name)
|
|
178
|
+
except GitHubAPIError:
|
|
179
|
+
return {"status": "IGNORED_NO_INSTALLATION"}
|
|
180
|
+
try:
|
|
181
|
+
permission = self.github.permission(full_name, installation, username)
|
|
182
|
+
except GitHubAPIError:
|
|
183
|
+
permission = "none"
|
|
184
|
+
if permission not in WRITE_PERMISSIONS:
|
|
185
|
+
return {"status": "IGNORED_UNAUTHORIZED"}
|
|
186
|
+
try:
|
|
187
|
+
answer = parse_answer(body)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
return {"status": "IGNORED_UNSTRUCTURED", "error": str(exc)}
|
|
190
|
+
validation = validate_answer(answer)
|
|
191
|
+
if not validation.valid:
|
|
192
|
+
self.github.add_issue_comment(
|
|
193
|
+
full_name,
|
|
194
|
+
installation,
|
|
195
|
+
issue_number,
|
|
196
|
+
"PTSIP could not accept this decision because it conflicts with the fixed resolution rules:\n\n- "
|
|
197
|
+
+ "\n- ".join(validation.errors),
|
|
198
|
+
)
|
|
199
|
+
return {"status": "CONFLICT", "validation": validation.as_dict()}
|
|
200
|
+
|
|
201
|
+
# Validate the answer against the profile at the exact gate snapshot
|
|
202
|
+
# before it is allowed to win the authoritative compare-and-set.
|
|
203
|
+
try:
|
|
204
|
+
projected_content = self._prepare_remote_projection(record, installation, answer)
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
self.github.add_issue_comment(
|
|
207
|
+
full_name,
|
|
208
|
+
installation,
|
|
209
|
+
issue_number,
|
|
210
|
+
"PTSIP could not accept this decision because it conflicts with the declared/projected profile:\n\n- "
|
|
211
|
+
+ str(exc),
|
|
212
|
+
)
|
|
213
|
+
return {"status": "CONFLICT", "error": str(exc)}
|
|
214
|
+
|
|
215
|
+
resolved, accepted = self.store.resolve(record.id, answer.as_dict(), "GITHUB_ISSUE", username)
|
|
216
|
+
if not accepted:
|
|
217
|
+
return {"status": "IGNORED_TERMINAL_DECISION", "decision": resolved.as_dict()}
|
|
218
|
+
try:
|
|
219
|
+
self._apply_remote(resolved, installation, projected_content)
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
self.store.mark_application(record.id, "FAILED")
|
|
222
|
+
try:
|
|
223
|
+
self.github.add_issue_comment(
|
|
224
|
+
record.repository,
|
|
225
|
+
installation,
|
|
226
|
+
issue_number,
|
|
227
|
+
"PTSIP accepted this decision but could not apply the profile automatically. "
|
|
228
|
+
f"A coding agent must reconcile the already resolved decision: `{exc}`",
|
|
229
|
+
)
|
|
230
|
+
self.github.update_issue_state(record.repository, installation, issue_number, "closed")
|
|
231
|
+
except GitHubAPIError:
|
|
232
|
+
pass
|
|
233
|
+
final = self.store.get(record.id)
|
|
234
|
+
assert final is not None
|
|
235
|
+
return {"status": "RESOLVED", "decision": final.as_dict()}
|
|
236
|
+
|
|
237
|
+
def _prepare_remote_projection(
|
|
238
|
+
self,
|
|
239
|
+
record: DecisionRecord,
|
|
240
|
+
installation: int,
|
|
241
|
+
answer: DecisionAnswer,
|
|
242
|
+
) -> str:
|
|
243
|
+
existing_text = self.github.file_text(record.repository, installation, "ptsip.yaml", record.subject_revision)
|
|
244
|
+
existing = load_profile_text(existing_text)
|
|
245
|
+
include = record.request.get("include", [])
|
|
246
|
+
if not isinstance(include, list):
|
|
247
|
+
raise ValueError("decision request include must be a list")
|
|
248
|
+
projected = project_payload(existing, record.component_id, [str(item) for item in include], answer)
|
|
249
|
+
projected_errors = validate_projected_payload(projected)
|
|
250
|
+
if projected_errors:
|
|
251
|
+
raise ValueError("Projected PTSIP profile is invalid: " + "; ".join(projected_errors))
|
|
252
|
+
return dump_payload(projected)
|
|
253
|
+
|
|
254
|
+
def _apply_remote(self, record: DecisionRecord, installation: int, projected_content: str) -> None:
|
|
255
|
+
current = self.github.branch_head(record.repository, installation, record.branch)
|
|
256
|
+
if current != record.subject_revision:
|
|
257
|
+
self.store.mark_application(record.id, "STALE")
|
|
258
|
+
if record.issue_number:
|
|
259
|
+
self.github.add_issue_comment(
|
|
260
|
+
record.repository,
|
|
261
|
+
installation,
|
|
262
|
+
record.issue_number,
|
|
263
|
+
"The decision was accepted, but the target branch changed after the last active decision gate. "
|
|
264
|
+
"PTSIP did not modify the profile. A coding agent must re-run the gate against the current revision and can reconcile the already authoritative answer without asking the user to decide again.",
|
|
265
|
+
)
|
|
266
|
+
self.github.update_issue_state(record.repository, installation, record.issue_number, "closed")
|
|
267
|
+
return
|
|
268
|
+
commit_sha = self.github.commit_file_at_parent(
|
|
269
|
+
record.repository,
|
|
270
|
+
installation,
|
|
271
|
+
record.branch,
|
|
272
|
+
record.subject_revision,
|
|
273
|
+
"ptsip.yaml",
|
|
274
|
+
projected_content,
|
|
275
|
+
f"ptsip: apply decision {record.id}",
|
|
276
|
+
)
|
|
277
|
+
self.store.mark_application(record.id, "APPLIED", commit_sha)
|
|
278
|
+
if record.issue_number:
|
|
279
|
+
self.github.add_issue_comment(
|
|
280
|
+
record.repository,
|
|
281
|
+
installation,
|
|
282
|
+
record.issue_number,
|
|
283
|
+
f"PTSIP applied decision `{record.id}` in commit `{commit_sha}`. Late replies are ignored.",
|
|
284
|
+
)
|
|
285
|
+
self.github.update_issue_state(record.repository, installation, record.issue_number, "closed")
|
ptsip/app/store.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DecisionRecord:
|
|
13
|
+
id: str
|
|
14
|
+
repository: str
|
|
15
|
+
branch: str
|
|
16
|
+
subject_revision: str
|
|
17
|
+
component_id: str
|
|
18
|
+
request: dict[str, object]
|
|
19
|
+
status: str
|
|
20
|
+
answer: dict[str, object] | None
|
|
21
|
+
resolution_source: str | None
|
|
22
|
+
resolved_by: str | None
|
|
23
|
+
application_status: str
|
|
24
|
+
applied_revision: str | None
|
|
25
|
+
issue_number: int | None
|
|
26
|
+
issue_url: str | None
|
|
27
|
+
|
|
28
|
+
def as_dict(self) -> dict[str, object]:
|
|
29
|
+
return {
|
|
30
|
+
"id": self.id,
|
|
31
|
+
"repository": self.repository,
|
|
32
|
+
"branch": self.branch,
|
|
33
|
+
"subject_revision": self.subject_revision,
|
|
34
|
+
"component_id": self.component_id,
|
|
35
|
+
"request": self.request,
|
|
36
|
+
"status": self.status,
|
|
37
|
+
"answer": self.answer,
|
|
38
|
+
"resolution_source": self.resolution_source,
|
|
39
|
+
"resolved_by": self.resolved_by,
|
|
40
|
+
"application_status": self.application_status,
|
|
41
|
+
"applied_revision": self.applied_revision,
|
|
42
|
+
"issue_number": self.issue_number,
|
|
43
|
+
"issue_url": self.issue_url,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DecisionStore:
|
|
48
|
+
def __init__(self, path: str | Path):
|
|
49
|
+
self.path = Path(path).expanduser().resolve()
|
|
50
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._lock = threading.RLock()
|
|
52
|
+
self._init()
|
|
53
|
+
|
|
54
|
+
def _connect(self) -> sqlite3.Connection:
|
|
55
|
+
conn = sqlite3.connect(self.path, timeout=30)
|
|
56
|
+
conn.row_factory = sqlite3.Row
|
|
57
|
+
return conn
|
|
58
|
+
|
|
59
|
+
def _init(self) -> None:
|
|
60
|
+
with self._connect() as conn:
|
|
61
|
+
conn.executescript(
|
|
62
|
+
"""
|
|
63
|
+
PRAGMA journal_mode=WAL;
|
|
64
|
+
CREATE TABLE IF NOT EXISTS installations (
|
|
65
|
+
repository TEXT PRIMARY KEY,
|
|
66
|
+
installation_id INTEGER NOT NULL
|
|
67
|
+
);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
repository TEXT NOT NULL,
|
|
71
|
+
branch TEXT NOT NULL,
|
|
72
|
+
subject_revision TEXT NOT NULL,
|
|
73
|
+
component_id TEXT NOT NULL,
|
|
74
|
+
request_json TEXT NOT NULL,
|
|
75
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
76
|
+
answer_json TEXT,
|
|
77
|
+
resolution_source TEXT,
|
|
78
|
+
resolved_by TEXT,
|
|
79
|
+
application_status TEXT NOT NULL DEFAULT 'NOT_APPLIED',
|
|
80
|
+
applied_revision TEXT,
|
|
81
|
+
issue_number INTEGER,
|
|
82
|
+
issue_url TEXT,
|
|
83
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
84
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_decision_issue
|
|
87
|
+
ON decisions(repository, issue_number);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_decision_component
|
|
89
|
+
ON decisions(repository, component_id, status);
|
|
90
|
+
"""
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _record(row: sqlite3.Row) -> DecisionRecord:
|
|
95
|
+
return DecisionRecord(
|
|
96
|
+
id=str(row["id"]),
|
|
97
|
+
repository=str(row["repository"]),
|
|
98
|
+
branch=str(row["branch"]),
|
|
99
|
+
subject_revision=str(row["subject_revision"]),
|
|
100
|
+
component_id=str(row["component_id"]),
|
|
101
|
+
request=json.loads(str(row["request_json"])),
|
|
102
|
+
status=str(row["status"]),
|
|
103
|
+
answer=json.loads(str(row["answer_json"])) if row["answer_json"] else None,
|
|
104
|
+
resolution_source=str(row["resolution_source"]) if row["resolution_source"] else None,
|
|
105
|
+
resolved_by=str(row["resolved_by"]) if row["resolved_by"] else None,
|
|
106
|
+
application_status=str(row["application_status"]),
|
|
107
|
+
applied_revision=str(row["applied_revision"]) if row["applied_revision"] else None,
|
|
108
|
+
issue_number=int(row["issue_number"]) if row["issue_number"] is not None else None,
|
|
109
|
+
issue_url=str(row["issue_url"]) if row["issue_url"] else None,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def set_installation(self, repository: str, installation_id: int) -> None:
|
|
113
|
+
with self._lock, self._connect() as conn:
|
|
114
|
+
conn.execute(
|
|
115
|
+
"INSERT INTO installations(repository, installation_id) VALUES (?, ?) "
|
|
116
|
+
"ON CONFLICT(repository) DO UPDATE SET installation_id=excluded.installation_id",
|
|
117
|
+
(repository, installation_id),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def installation_for(self, repository: str) -> int | None:
|
|
121
|
+
with self._connect() as conn:
|
|
122
|
+
row = conn.execute(
|
|
123
|
+
"SELECT installation_id FROM installations WHERE repository=?", (repository,)
|
|
124
|
+
).fetchone()
|
|
125
|
+
return int(row[0]) if row else None
|
|
126
|
+
|
|
127
|
+
def gate(self, payload: dict[str, Any]) -> tuple[DecisionRecord, tuple[DecisionRecord, ...]]:
|
|
128
|
+
decision_id = str(payload["id"])
|
|
129
|
+
repository = str(payload["repository"])
|
|
130
|
+
branch = str(payload["branch"])
|
|
131
|
+
revision = str(payload["subject_revision"])
|
|
132
|
+
component_id = str(payload["component_id"])
|
|
133
|
+
request = payload["request"]
|
|
134
|
+
if not isinstance(request, dict):
|
|
135
|
+
raise ValueError("request must be an object")
|
|
136
|
+
request_json = json.dumps(request, ensure_ascii=False)
|
|
137
|
+
stale: list[DecisionRecord] = []
|
|
138
|
+
with self._lock, self._connect() as conn:
|
|
139
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
140
|
+
rows = conn.execute(
|
|
141
|
+
"SELECT * FROM decisions WHERE repository=? AND component_id=? AND status='PENDING' AND id<>?",
|
|
142
|
+
(repository, component_id, decision_id),
|
|
143
|
+
).fetchall()
|
|
144
|
+
for row in rows:
|
|
145
|
+
stale.append(self._record(row))
|
|
146
|
+
conn.execute(
|
|
147
|
+
"UPDATE decisions SET status='STALE', updated_at=CURRENT_TIMESTAMP "
|
|
148
|
+
"WHERE repository=? AND component_id=? AND status='PENDING' AND id<>?",
|
|
149
|
+
(repository, component_id, decision_id),
|
|
150
|
+
)
|
|
151
|
+
conn.execute(
|
|
152
|
+
"INSERT OR IGNORE INTO decisions(id, repository, branch, subject_revision, component_id, request_json) "
|
|
153
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
154
|
+
(decision_id, repository, branch, revision, component_id, request_json),
|
|
155
|
+
)
|
|
156
|
+
# A gate call is an active-agent observation, not a timer. When the
|
|
157
|
+
# same semantic decision (same ID/component/selectors) is still
|
|
158
|
+
# pending or needs application retry, rebind its target snapshot to
|
|
159
|
+
# the revision the agent is actually working on. The human answer,
|
|
160
|
+
# resolution source and first-winner identity are never changed.
|
|
161
|
+
conn.execute(
|
|
162
|
+
"UPDATE decisions SET branch=?, subject_revision=?, request_json=?, updated_at=CURRENT_TIMESTAMP "
|
|
163
|
+
"WHERE id=? AND (status='PENDING' OR (status='RESOLVED' AND application_status IN ('NOT_APPLIED','FAILED','STALE')))",
|
|
164
|
+
(branch, revision, request_json, decision_id),
|
|
165
|
+
)
|
|
166
|
+
row = conn.execute("SELECT * FROM decisions WHERE id=?", (decision_id,)).fetchone()
|
|
167
|
+
assert row is not None
|
|
168
|
+
conn.commit()
|
|
169
|
+
return self._record(row), tuple(stale)
|
|
170
|
+
|
|
171
|
+
def get(self, decision_id: str) -> DecisionRecord | None:
|
|
172
|
+
with self._connect() as conn:
|
|
173
|
+
row = conn.execute("SELECT * FROM decisions WHERE id=?", (decision_id,)).fetchone()
|
|
174
|
+
return self._record(row) if row else None
|
|
175
|
+
|
|
176
|
+
def by_issue(self, repository: str, issue_number: int) -> DecisionRecord | None:
|
|
177
|
+
with self._connect() as conn:
|
|
178
|
+
row = conn.execute(
|
|
179
|
+
"SELECT * FROM decisions WHERE repository=? AND issue_number=? ORDER BY created_at DESC LIMIT 1",
|
|
180
|
+
(repository, issue_number),
|
|
181
|
+
).fetchone()
|
|
182
|
+
return self._record(row) if row else None
|
|
183
|
+
|
|
184
|
+
def attach_issue(self, decision_id: str, issue_number: int, issue_url: str) -> DecisionRecord:
|
|
185
|
+
with self._lock, self._connect() as conn:
|
|
186
|
+
conn.execute(
|
|
187
|
+
"UPDATE decisions SET issue_number=?, issue_url=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
188
|
+
(issue_number, issue_url, decision_id),
|
|
189
|
+
)
|
|
190
|
+
record = self.get(decision_id)
|
|
191
|
+
assert record is not None
|
|
192
|
+
return record
|
|
193
|
+
|
|
194
|
+
def resolve(
|
|
195
|
+
self,
|
|
196
|
+
decision_id: str,
|
|
197
|
+
answer: dict[str, object],
|
|
198
|
+
source: str,
|
|
199
|
+
actor: str,
|
|
200
|
+
) -> tuple[DecisionRecord, bool]:
|
|
201
|
+
answer_json = json.dumps(answer, ensure_ascii=False)
|
|
202
|
+
with self._lock, self._connect() as conn:
|
|
203
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
204
|
+
changed = conn.execute(
|
|
205
|
+
"UPDATE decisions SET status='RESOLVED', answer_json=?, resolution_source=?, resolved_by=?, "
|
|
206
|
+
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='PENDING'",
|
|
207
|
+
(answer_json, source, actor, decision_id),
|
|
208
|
+
).rowcount
|
|
209
|
+
row = conn.execute("SELECT * FROM decisions WHERE id=?", (decision_id,)).fetchone()
|
|
210
|
+
if row is None:
|
|
211
|
+
conn.rollback()
|
|
212
|
+
raise KeyError(decision_id)
|
|
213
|
+
retry_allowed = False
|
|
214
|
+
if not changed and source == "AGENT_CHAT":
|
|
215
|
+
existing_answer = json.loads(str(row["answer_json"])) if row["answer_json"] else None
|
|
216
|
+
retry_allowed = (
|
|
217
|
+
str(row["status"]) == "RESOLVED"
|
|
218
|
+
and str(row["application_status"]) in {"NOT_APPLIED", "FAILED", "STALE"}
|
|
219
|
+
and existing_answer == answer
|
|
220
|
+
)
|
|
221
|
+
conn.commit()
|
|
222
|
+
return self._record(row), bool(changed or retry_allowed)
|
|
223
|
+
|
|
224
|
+
def mark_application(self, decision_id: str, status: str, applied_revision: str | None = None) -> DecisionRecord:
|
|
225
|
+
with self._lock, self._connect() as conn:
|
|
226
|
+
conn.execute(
|
|
227
|
+
"UPDATE decisions SET application_status=?, applied_revision=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
228
|
+
(status, applied_revision, decision_id),
|
|
229
|
+
)
|
|
230
|
+
record = self.get(decision_id)
|
|
231
|
+
if record is None:
|
|
232
|
+
raise KeyError(decision_id)
|
|
233
|
+
return record
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from jsonschema import Draft202012Validator
|
|
11
|
+
|
|
12
|
+
from .repository.discover import RepositoryInfo
|
|
13
|
+
from .repository.snapshot import capture_snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ArtifactEvidenceDocument:
|
|
18
|
+
source_path: str
|
|
19
|
+
source_sha256: str
|
|
20
|
+
binding_path: str | None
|
|
21
|
+
binding_valid: bool
|
|
22
|
+
payload: dict[str, object]
|
|
23
|
+
|
|
24
|
+
def as_dict(self) -> dict[str, object]:
|
|
25
|
+
return {
|
|
26
|
+
"source_path": self.source_path,
|
|
27
|
+
"source_sha256": self.source_sha256,
|
|
28
|
+
"binding_path": self.binding_path,
|
|
29
|
+
"binding_valid": self.binding_valid,
|
|
30
|
+
"payload": self.payload,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ArtifactEvidenceIssue:
|
|
36
|
+
source_path: str
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, str]:
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ArtifactEvidenceLoad:
|
|
45
|
+
documents: tuple[ArtifactEvidenceDocument, ...]
|
|
46
|
+
issues: tuple[ArtifactEvidenceIssue, ...]
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict[str, object]:
|
|
49
|
+
return {
|
|
50
|
+
"documents": [item.as_dict() for item in self.documents],
|
|
51
|
+
"issues": [item.as_dict() for item in self.issues],
|
|
52
|
+
"document_count": len(self.documents),
|
|
53
|
+
"valid": not self.issues,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _schema() -> dict[str, object]:
|
|
58
|
+
path = files("ptsip").joinpath("specdata/ptsip-artifact-evidence.schema.json")
|
|
59
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _binding_issue(
|
|
63
|
+
artifact_path: Path,
|
|
64
|
+
digest: str,
|
|
65
|
+
repository: RepositoryInfo,
|
|
66
|
+
) -> tuple[str | None, str | None]:
|
|
67
|
+
binding_path = Path(str(artifact_path) + ".binding.json")
|
|
68
|
+
if not binding_path.is_file():
|
|
69
|
+
return str(binding_path), "Artifact evidence has no revision-binding sidecar."
|
|
70
|
+
try:
|
|
71
|
+
binding = yaml.safe_load(binding_path.read_text(encoding="utf-8-sig"))
|
|
72
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
73
|
+
return str(binding_path), f"Unable to parse artifact evidence binding: {exc}"
|
|
74
|
+
if not isinstance(binding, dict) or binding.get("format") != "ptsip-artifact-evidence-binding/v1":
|
|
75
|
+
return str(binding_path), "Artifact evidence binding format must be 'ptsip-artifact-evidence-binding/v1'."
|
|
76
|
+
subject = binding.get("subject")
|
|
77
|
+
if not isinstance(subject, dict):
|
|
78
|
+
return str(binding_path), "Artifact evidence binding requires a subject object."
|
|
79
|
+
expected_repositories = {repository.root}
|
|
80
|
+
if repository.remote and repository.remote.repository:
|
|
81
|
+
expected_repositories.add(repository.remote.repository)
|
|
82
|
+
if subject.get("repository") not in expected_repositories:
|
|
83
|
+
return str(binding_path), "Artifact evidence binding repository does not match the evaluated Consumer Repository."
|
|
84
|
+
if not repository.commit or subject.get("revision") != repository.commit:
|
|
85
|
+
return str(binding_path), "Artifact evidence binding revision does not match the evaluated repository revision."
|
|
86
|
+
if binding.get("artifact_sha256") != digest:
|
|
87
|
+
return str(binding_path), "Artifact evidence binding SHA-256 does not match the imported document."
|
|
88
|
+
snapshot_fingerprint = capture_snapshot(repository.root).tracked_content_fingerprint
|
|
89
|
+
if subject.get("tracked_content_sha256") != snapshot_fingerprint:
|
|
90
|
+
return str(binding_path), "Artifact evidence binding tracked-content fingerprint does not match the evaluated repository snapshot."
|
|
91
|
+
return str(binding_path), None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_artifact_evidence(
|
|
95
|
+
paths: list[str | Path] | tuple[str | Path, ...] | None,
|
|
96
|
+
repository: RepositoryInfo | None = None,
|
|
97
|
+
) -> ArtifactEvidenceLoad:
|
|
98
|
+
if not paths:
|
|
99
|
+
return ArtifactEvidenceLoad((), ())
|
|
100
|
+
|
|
101
|
+
validator = Draft202012Validator(_schema())
|
|
102
|
+
documents: list[ArtifactEvidenceDocument] = []
|
|
103
|
+
issues: list[ArtifactEvidenceIssue] = []
|
|
104
|
+
seen_ids: dict[str, str] = {}
|
|
105
|
+
|
|
106
|
+
for raw_path in paths:
|
|
107
|
+
path = Path(raw_path).expanduser().resolve()
|
|
108
|
+
source = str(path)
|
|
109
|
+
if not path.is_file():
|
|
110
|
+
issues.append(ArtifactEvidenceIssue(source, "Artifact evidence file does not exist or is not a file."))
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
raw = path.read_bytes()
|
|
114
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
115
|
+
payload = yaml.safe_load(raw.decode("utf-8-sig"))
|
|
116
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
117
|
+
issues.append(ArtifactEvidenceIssue(source, f"Unable to parse artifact evidence: {exc}"))
|
|
118
|
+
continue
|
|
119
|
+
if not isinstance(payload, dict):
|
|
120
|
+
issues.append(ArtifactEvidenceIssue(source, "Artifact evidence root must be a mapping/object."))
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
validation_errors = sorted(validator.iter_errors(payload), key=lambda item: list(item.absolute_path))
|
|
124
|
+
if validation_errors:
|
|
125
|
+
for error in validation_errors:
|
|
126
|
+
location = ".".join(str(item) for item in error.absolute_path) or "<root>"
|
|
127
|
+
issues.append(ArtifactEvidenceIssue(source, f"{location}: {error.message}"))
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
artifact_id = str(payload["artifact_id"])
|
|
131
|
+
previous = seen_ids.get(artifact_id)
|
|
132
|
+
if previous is not None:
|
|
133
|
+
issues.append(
|
|
134
|
+
ArtifactEvidenceIssue(
|
|
135
|
+
source,
|
|
136
|
+
f"Duplicate artifact_id {artifact_id!r}; already supplied by {previous}.",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
seen_ids[artifact_id] = source
|
|
141
|
+
binding_path: str | None = None
|
|
142
|
+
binding_valid = repository is None
|
|
143
|
+
if repository is not None:
|
|
144
|
+
binding_path, binding_error = _binding_issue(path, digest, repository)
|
|
145
|
+
binding_valid = binding_error is None
|
|
146
|
+
if binding_error is not None:
|
|
147
|
+
issues.append(ArtifactEvidenceIssue(binding_path or source, binding_error))
|
|
148
|
+
documents.append(ArtifactEvidenceDocument(source, digest, binding_path, binding_valid, payload))
|
|
149
|
+
|
|
150
|
+
return ArtifactEvidenceLoad(tuple(documents), tuple(issues))
|