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.
Files changed (66) hide show
  1. ptsip/__init__.py +3 -0
  2. ptsip/__main__.py +4 -0
  3. ptsip/app/__init__.py +5 -0
  4. ptsip/app/client.py +53 -0
  5. ptsip/app/github_client.py +200 -0
  6. ptsip/app/server.py +130 -0
  7. ptsip/app/service.py +285 -0
  8. ptsip/app/store.py +233 -0
  9. ptsip/artifact_evidence.py +150 -0
  10. ptsip/build_resolution.py +298 -0
  11. ptsip/clarification/__init__.py +6 -0
  12. ptsip/clarification/generator.py +120 -0
  13. ptsip/clarification/generator_core.py +98 -0
  14. ptsip/clarification/i18n.py +106 -0
  15. ptsip/clarification/model.py +33 -0
  16. ptsip/clarification/render.py +78 -0
  17. ptsip/clarification/resolution/__init__.py +29 -0
  18. ptsip/clarification/resolution/model.py +33 -0
  19. ptsip/clarification/resolution/parser.py +56 -0
  20. ptsip/clarification/resolution/profile_projection.py +198 -0
  21. ptsip/clarification/resolution/resolver.py +30 -0
  22. ptsip/clarification/transports/__init__.py +1 -0
  23. ptsip/clarification/transports/github_issue.py +162 -0
  24. ptsip/cli.py +402 -0
  25. ptsip/conformance.py +531 -0
  26. ptsip/conformance_audit.py +143 -0
  27. ptsip/conformance_engine.py +396 -0
  28. ptsip/constants.py +9 -0
  29. ptsip/doctor.py +33 -0
  30. ptsip/inspection/__init__.py +1 -0
  31. ptsip/inspection/components.py +74 -0
  32. ptsip/inspection/dependencies.py +496 -0
  33. ptsip/inspection/dependencies_030.py +69 -0
  34. ptsip/inspection/dotnet.py +188 -0
  35. ptsip/inspection/go.py +157 -0
  36. ptsip/inspection/inventory.py +161 -0
  37. ptsip/inspection/javascript.py +376 -0
  38. ptsip/inspection/lexing.py +121 -0
  39. ptsip/inspection/source_adapters.py +60 -0
  40. ptsip/lifecycle_evidence.py +245 -0
  41. ptsip/model.py +105 -0
  42. ptsip/pilot/__init__.py +1 -0
  43. ptsip/pilot/runner.py +170 -0
  44. ptsip/repository/__init__.py +1 -0
  45. ptsip/repository/discover.py +68 -0
  46. ptsip/repository/remote.py +54 -0
  47. ptsip/repository/snapshot.py +173 -0
  48. ptsip/review_evidence.py +406 -0
  49. ptsip/spec_identity.py +22 -0
  50. ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
  51. ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
  52. ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
  53. ptsip/specdata/ptsip-profile.schema.json +310 -0
  54. ptsip/specdata/ptsip-registry.yaml +189 -0
  55. ptsip/storage/__init__.py +1 -0
  56. ptsip/storage/local_state.py +32 -0
  57. ptsip/validation/__init__.py +1 -0
  58. ptsip/validation/components.py +164 -0
  59. ptsip/validation/profile.py +172 -0
  60. ptsip/validation/rules.py +163 -0
  61. ptsip-0.3.1.dist-info/METADATA +338 -0
  62. ptsip-0.3.1.dist-info/RECORD +66 -0
  63. ptsip-0.3.1.dist-info/WHEEL +5 -0
  64. ptsip-0.3.1.dist-info/entry_points.txt +3 -0
  65. ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
  66. 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))