patchground 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,344 @@
1
+ """Private frozen inputs and atomic, independently readable session closure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import stat
9
+ import tempfile
10
+ import time
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Literal
17
+ from uuid import uuid4
18
+
19
+ from pydantic import AwareDatetime, Field, ValidationError
20
+
21
+ from patchground import sandbox as engine
22
+ from patchground.sandbox import SandboxError
23
+ from patchground.sessions import store
24
+ from patchground.sessions.files import copy_tree, open_directory, open_entry
25
+ from patchground.sessions.models import CheckResult, Event, Model, SessionError, SessionRecord, now
26
+ from patchground.sessions.workflow import Sandbox, _root, _sanitized
27
+ from patchground.spec import ChallengeManifest
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SubmissionSnapshot:
32
+ session: Path
33
+ original: Path
34
+ final: Path
35
+ hidden: Path
36
+ record: SessionRecord
37
+ manifest: ChallengeManifest
38
+ events: tuple[Event, ...]
39
+ requested_at: datetime
40
+ saved_report: dict[str, object] | None
41
+
42
+
43
+ class SubmissionAudit(Model):
44
+ prefix_size: int = Field(ge=0)
45
+ prefix_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
46
+ event: str
47
+
48
+
49
+ class Receipt(Model):
50
+ schema_version: Literal[1]
51
+ requested_at: AwareDatetime
52
+ final_check: CheckResult
53
+ artifacts: dict[str, str]
54
+ audit: SubmissionAudit
55
+
56
+
57
+ def _inventory(path: Path) -> dict[str, str]:
58
+ result: dict[str, str] = {}
59
+
60
+ def visit(parent: int, prefix: str) -> None:
61
+ for name in sorted(os.listdir(parent)):
62
+ if not prefix and name == "receipt.json":
63
+ continue
64
+ with open_entry(parent, name) as descriptor:
65
+ info = os.fstat(descriptor)
66
+ key = prefix + name
67
+ if stat.S_ISDIR(info.st_mode):
68
+ result[key + "/"] = f"directory:{stat.S_IMODE(info.st_mode):o}"
69
+ visit(descriptor, key + "/")
70
+ else:
71
+ if info.st_nlink != 1:
72
+ raise SessionError("submission artifacts must not have hardlinks")
73
+ digest = hashlib.sha256(stat.S_IMODE(info.st_mode).to_bytes(2, "big"))
74
+ with os.fdopen(os.dup(descriptor), "rb") as stream:
75
+ for block in iter(lambda: stream.read(65536), b""):
76
+ digest.update(block)
77
+ result[key] = digest.hexdigest()
78
+
79
+ with open_directory(path) as parent:
80
+ visit(parent, "")
81
+ return result
82
+
83
+
84
+ def completed(meta: Path) -> tuple[dict[str, object], Receipt] | None:
85
+ """Validate committed artifacts without consulting live workspace files."""
86
+ with open_directory(meta) as parent:
87
+ if "submission" not in os.listdir(parent): # noqa: PTH208 -- pinned descriptor
88
+ return None
89
+ bundle = meta / "submission"
90
+ try:
91
+ with open_directory(bundle) as parent:
92
+ values: dict[str, str] = {}
93
+ for name in ("receipt.json", "report.json", "report.md"):
94
+ with (
95
+ store.metadata_file(parent, name) as descriptor,
96
+ os.fdopen(os.dup(descriptor), "r", encoding="utf-8") as stream,
97
+ ):
98
+ values[name] = stream.read()
99
+ if set(os.listdir(parent)) != { # noqa: PTH208 -- pinned descriptor
100
+ "receipt.json",
101
+ "report.json",
102
+ "report.md",
103
+ "final",
104
+ }:
105
+ raise ValueError("unexpected bundle entry")
106
+ with open_directory(bundle / "final"):
107
+ pass
108
+ receipt = Receipt.model_validate_json(values["receipt.json"])
109
+ event = Event.model_validate_json(receipt.audit.event)
110
+ if (
111
+ not receipt.audit.event.endswith("\n")
112
+ or len(receipt.audit.event.splitlines()) != 1
113
+ or event.type != "submitted"
114
+ or event.data != {"requested_at": receipt.requested_at.isoformat()}
115
+ ):
116
+ raise ValueError("inconsistent submission audit receipt")
117
+ if _inventory(bundle) != receipt.artifacts:
118
+ raise ValueError("artifact integrity mismatch")
119
+ report = json.loads(values["report.json"])
120
+ if not isinstance(report, dict):
121
+ raise ValueError("invalid report")
122
+ return report, receipt
123
+ except (OSError, UnicodeError, ValueError, ValidationError) as exc:
124
+ raise SessionError("corrupt or unsafe submission bundle") from exc
125
+
126
+
127
+ def require_active(meta: Path) -> None:
128
+ if completed(meta) is not None:
129
+ raise SessionError("session already submitted: start a new session to practice again")
130
+
131
+
132
+ def audit_contents(raw: bytes, receipt: Receipt) -> tuple[bytes, bytes]:
133
+ """Return parseable audit bytes and a proven missing append suffix; never discard bytes."""
134
+ audit = receipt.audit
135
+ prefix = raw[: audit.prefix_size]
136
+ if (
137
+ len(prefix) != audit.prefix_size
138
+ or hashlib.sha256(prefix).hexdigest() != audit.prefix_sha256
139
+ ):
140
+ raise SessionError("corrupt submission audit: history does not match receipt")
141
+ expected = audit.event.encode("utf-8")
142
+ suffix = raw[audit.prefix_size :]
143
+ if len(suffix) < len(expected) and expected.startswith(suffix):
144
+ return prefix, expected[len(suffix) :]
145
+ if not suffix.startswith(expected):
146
+ raise SessionError("corrupt submission audit: unexpected submission append")
147
+ if not raw.endswith(b"\n"):
148
+ raise SessionError("corrupt submission audit: incomplete unrelated event")
149
+ return raw, b""
150
+
151
+
152
+ def _journal(meta: Path) -> bytes:
153
+ with (
154
+ open_directory(meta) as parent,
155
+ store.metadata_file(parent, "events.jsonl") as descriptor,
156
+ os.fdopen(os.dup(descriptor), "rb") as stream,
157
+ ):
158
+ return stream.read()
159
+
160
+
161
+ def _reconcile(meta: Path, receipt: Receipt) -> None:
162
+ try:
163
+ raw = _journal(meta)
164
+ _, missing = audit_contents(raw, receipt)
165
+ if missing:
166
+ store.append_bytes(meta, missing)
167
+ except (OSError, UnicodeError, SessionError) as exc:
168
+ raise SessionError(
169
+ "submission bundle may already be published; retry submit to reconcile its audit"
170
+ ) from exc
171
+
172
+
173
+ @contextmanager
174
+ def prepare_submission(
175
+ session: Path | None = None, *, root: Path | None = None
176
+ ) -> Iterator[SubmissionSnapshot]:
177
+ requested_at = now()
178
+ try:
179
+ path = store.resolve(session, _root(root))
180
+ meta = path / ".session"
181
+ with store.locked(meta):
182
+ record, manifest, events = store.load(path)
183
+ saved = completed(meta)
184
+ if saved is not None:
185
+ report, receipt = saved
186
+ _reconcile(meta, receipt)
187
+ yield SubmissionSnapshot(
188
+ path,
189
+ meta / "original",
190
+ meta / "submission/final",
191
+ meta / "hidden",
192
+ record,
193
+ manifest,
194
+ tuple(events),
195
+ receipt.requested_at,
196
+ report,
197
+ )
198
+ return
199
+ with tempfile.TemporaryDirectory(
200
+ prefix="patchground-submit-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
201
+ ) as temporary:
202
+ captured = Path(temporary)
203
+ for name, source in (
204
+ ("original", meta / "original"),
205
+ ("final", path / "workspace"),
206
+ ("hidden", meta / "hidden"),
207
+ ):
208
+ (captured / name).mkdir(mode=0o700)
209
+ copy_tree(source, captured / name)
210
+ yield SubmissionSnapshot(
211
+ path,
212
+ captured / "original",
213
+ captured / "final",
214
+ captured / "hidden",
215
+ record,
216
+ manifest,
217
+ tuple(events),
218
+ requested_at,
219
+ None,
220
+ )
221
+ except (OSError, UnicodeError, SandboxError) as exc:
222
+ raise SessionError("submission unavailable: storage or frozen image unavailable") from exc
223
+
224
+
225
+ def check_submission(snapshot: SubmissionSnapshot, *, sandbox: Sandbox = engine) -> CheckResult:
226
+ if snapshot.saved_report is not None:
227
+ raise SessionError("session already submitted")
228
+ started = time.monotonic()
229
+ try:
230
+ with tempfile.TemporaryDirectory(
231
+ prefix="patchground-submit-check-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
232
+ ) as temporary:
233
+ clean = Path(temporary)
234
+ copy_tree(snapshot.final, clean)
235
+ copy_tree(snapshot.hidden, clean)
236
+ raw = sandbox.run_check(
237
+ snapshot.record.image_id, [clean], snapshot.manifest.environment.test_command
238
+ )
239
+ return _sanitized(raw, time.monotonic() - started)
240
+ except (OSError, SandboxError) as exc:
241
+ raise SessionError(
242
+ "submission check unavailable: storage or frozen image unavailable"
243
+ ) from exc
244
+
245
+
246
+ def _write(path: Path, text: str) -> None:
247
+ with open_directory(path.parent) as parent:
248
+ descriptor = os.open(
249
+ path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent
250
+ )
251
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
252
+ stream.write(text)
253
+ stream.flush()
254
+ os.fsync(stream.fileno())
255
+
256
+
257
+ def _sync_tree(path: Path) -> None:
258
+ with open_directory(path) as parent:
259
+ for name in os.listdir(parent): # noqa: PTH208 -- pinned descriptor
260
+ with open_entry(parent, name) as descriptor:
261
+ if stat.S_ISDIR(os.fstat(descriptor).st_mode):
262
+ _sync_tree(path / name)
263
+ else:
264
+ os.fsync(descriptor)
265
+ os.fsync(parent)
266
+
267
+
268
+ def commit_submission(
269
+ snapshot: SubmissionSnapshot, report: dict[str, object], markdown: str, final_check: CheckResult
270
+ ) -> None:
271
+ meta = snapshot.session / ".session"
272
+ existing = completed(meta)
273
+ if existing is not None:
274
+ store.load(snapshot.session)
275
+ _reconcile(meta, existing[1])
276
+ return
277
+ published = False
278
+ try:
279
+ # Failed/incomplete staging is deliberately left private and is never treated as closure.
280
+ # Never clean it via a pathname that an editor may have replaced.
281
+ name = ".submission-" + uuid4().hex
282
+ with open_directory(meta) as parent:
283
+ os.mkdir(name, mode=0o700, dir_fd=parent)
284
+ stage = meta / name
285
+ with open_directory(stage) as staged:
286
+ os.mkdir("final", mode=0o700, dir_fd=staged)
287
+ copy_tree(snapshot.final, stage / "final")
288
+ _write(
289
+ stage / "report.json",
290
+ json.dumps(report, ensure_ascii=False, allow_nan=False) + "\n",
291
+ )
292
+ _write(stage / "report.md", markdown)
293
+ store.load(snapshot.session)
294
+ prefix = _journal(meta)
295
+ if not prefix.endswith(b"\n"):
296
+ raise SessionError("corrupt session audit before submission")
297
+ audit_event = (
298
+ Event(
299
+ ts=now(),
300
+ type="submitted",
301
+ data={"requested_at": snapshot.requested_at.isoformat()},
302
+ ).model_dump_json()
303
+ + "\n"
304
+ )
305
+ receipt = Receipt(
306
+ schema_version=1,
307
+ requested_at=snapshot.requested_at,
308
+ final_check=final_check,
309
+ artifacts=_inventory(stage),
310
+ audit=SubmissionAudit(
311
+ prefix_size=len(prefix),
312
+ prefix_sha256=hashlib.sha256(prefix).hexdigest(),
313
+ event=audit_event,
314
+ ),
315
+ )
316
+ _write(
317
+ stage / "receipt.json",
318
+ receipt.model_dump_json(exclude={"final_check": {"passed", "counts"}}) + "\n",
319
+ )
320
+ _sync_tree(stage)
321
+ os.rename(name, "submission", src_dir_fd=parent, dst_dir_fd=parent)
322
+ published = True
323
+ # Closure has committed: any subsequent failure must never reopen the session.
324
+ os.fsync(parent)
325
+ store.load(snapshot.session)
326
+ _reconcile(meta, receipt)
327
+ except (OSError, UnicodeError, SessionError) as exc:
328
+ if published:
329
+ raise SessionError(
330
+ "submission bundle may already be published; retry submit to finish finalization"
331
+ ) from exc
332
+ raise
333
+
334
+
335
+ def read_submission(session: Path | None = None, *, root: Path | None = None) -> dict[str, object]:
336
+ try:
337
+ path = store.resolve(session, _root(root))
338
+ store.load(path)
339
+ saved = completed(path / ".session")
340
+ if saved is None:
341
+ raise SessionError("session not submitted: no final report exists")
342
+ return saved[0]
343
+ except (OSError, UnicodeError) as exc:
344
+ raise SessionError("submission unavailable: invalid session storage") from exc
@@ -0,0 +1,297 @@
1
+ """Start, check and status; sandbox is injected at the execution boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import time
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from patchground import sandbox as engine
12
+ from patchground.packs.models import ConsentRequired, PackError, PreparedChallenge
13
+ from patchground.sandbox import BuildResult, RunOutcome, RunResult, SandboxError, TestStatus
14
+ from patchground.sessions import git, store
15
+ from patchground.sessions.files import copy_tree, readonly, remove_partial, safe_path
16
+ from patchground.sessions.history import capture
17
+ from patchground.sessions.history_ignore import resolve as resolve_history_ignores
18
+ from patchground.sessions.models import (
19
+ CheckResult,
20
+ CheckTest,
21
+ Event,
22
+ SessionError,
23
+ SessionRecord,
24
+ SessionStatus,
25
+ StartedSession,
26
+ now,
27
+ )
28
+ from patchground.spec import ManifestError, load_challenge
29
+
30
+
31
+ class Sandbox(Protocol):
32
+ """Only execution capabilities sessions require; no engine SDK leaks upward."""
33
+
34
+ def build_image(self, dockerfile: Path, *, name: str) -> BuildResult: ...
35
+
36
+ def run_check(self, image_id: str, layers: Sequence[Path], command: str) -> RunResult: ...
37
+
38
+
39
+ def _root(root: Path | None) -> Path:
40
+ return (root if root is not None else store.default_root()).absolute()
41
+
42
+
43
+ def start_session(
44
+ path: Path | str,
45
+ *,
46
+ root: Path | None = None,
47
+ timer: bool = True,
48
+ history: bool = True,
49
+ sandbox: Sandbox = engine,
50
+ yes: bool = False,
51
+ expected_identity: str | None = None,
52
+ pack_root: Path | None = None,
53
+ ) -> StartedSession:
54
+ """Resolve and authorize a source before any build or session mutation."""
55
+ from patchground.packs.registry import prepare_execution
56
+
57
+ try:
58
+ with prepare_execution(
59
+ path, yes=yes, expected_identity=expected_identity, root=pack_root
60
+ ) as prepared:
61
+ return _start_session(
62
+ prepared.path,
63
+ root=root,
64
+ timer=timer,
65
+ history=history,
66
+ sandbox=sandbox,
67
+ prepared=prepared,
68
+ )
69
+ except ConsentRequired:
70
+ raise
71
+ except PackError as exc:
72
+ raise SessionError(str(exc)) from exc
73
+
74
+
75
+ def _start_session(
76
+ path: Path,
77
+ *,
78
+ root: Path | None = None,
79
+ timer: bool = True,
80
+ history: bool = True,
81
+ sandbox: Sandbox = engine,
82
+ prepared: PreparedChallenge,
83
+ ) -> StartedSession:
84
+ """Freeze an authorized source into an independent session."""
85
+ partial: Path | None = None
86
+ try:
87
+ try:
88
+ challenge = load_challenge(path)
89
+ except ManifestError as exc:
90
+ # Keep file/line diagnostics, but do not echo arbitrary manifest values.
91
+ locations = ", ".join(f"{issue.file.name}:{issue.line or 1}" for issue in exc.issues)
92
+ raise SessionError(f"invalid challenge manifest or unsafe paths ({locations})") from exc
93
+ manifest = challenge.manifest
94
+ if manifest.environment.services:
95
+ raise SessionError("services are not supported by local sessions yet")
96
+ partial = store.allocate(_root(root), manifest.id)
97
+ workspace = partial / "workspace"
98
+ meta = partial / ".session"
99
+ for folder in (workspace, meta, meta / "hidden", meta / "original"):
100
+ folder.mkdir(mode=0o700)
101
+ copy_tree(challenge.root / "workspace", workspace)
102
+ if manifest.checks.visible is not None:
103
+ copy_tree(challenge.root / manifest.checks.visible, workspace)
104
+ copy_tree(workspace, meta / "original")
105
+ readonly(meta / "original")
106
+ copy_tree(challenge.root / manifest.checks.hidden, meta / "hidden")
107
+ safe_path(challenge.root / "TICKET.md", directory=False)
108
+ ticket = (challenge.root / "TICKET.md").read_text(encoding="utf-8")
109
+ (meta / "TICKET.md").write_text(ticket, encoding="utf-8")
110
+ (meta / "TICKET.md").chmod(0o600)
111
+ store.write_json(meta / "manifest.json", manifest.model_dump(mode="json"))
112
+ if prepared.pack is None:
113
+ git.initialize(workspace, source=challenge.root)
114
+ else:
115
+ git.initialize(workspace, source=challenge.root, inputs=prepared.input_roots)
116
+ built = sandbox.build_image(
117
+ challenge.root / manifest.environment.dockerfile, name=manifest.id
118
+ )
119
+ record = SessionRecord(
120
+ challenge_id=manifest.id,
121
+ challenge_version=manifest.version,
122
+ source=str(prepared.source_path if prepared.pack else challenge.root),
123
+ trust="unofficial" if prepared.pack else "local-authored",
124
+ pack=prepared.pack,
125
+ image_id=built.image_id,
126
+ started_at=now(),
127
+ timer=timer,
128
+ history_enabled=history,
129
+ )
130
+ store.write_json(meta / "session.json", record.model_dump(mode="json"))
131
+ event = Event(
132
+ ts=record.started_at, type="session_started", data={"challenge_id": manifest.id}
133
+ )
134
+ (meta / "events.jsonl").write_text(event.model_dump_json() + "\n", encoding="utf-8")
135
+ (meta / "events.jsonl").chmod(0o600)
136
+ (meta / "lock").touch(mode=0o600)
137
+ checkpoint = capture(
138
+ meta,
139
+ meta / "original",
140
+ enabled=history,
141
+ kind="initial",
142
+ rules=resolve_history_ignores(
143
+ workspace, source=challenge.root, inputs=prepared.input_roots
144
+ )
145
+ if history
146
+ else None,
147
+ )
148
+ store.append_event(meta, "history_recorded", checkpoint.model_dump(mode="json"))
149
+ return StartedSession(
150
+ session=partial,
151
+ workspace=workspace,
152
+ challenge_id=manifest.id,
153
+ timer=timer,
154
+ ticket=ticket,
155
+ pack=prepared.pack,
156
+ history=checkpoint,
157
+ )
158
+ except BaseException as exc:
159
+ if partial is not None:
160
+ remove_partial(partial)
161
+ if isinstance(exc, OSError | UnicodeError | SandboxError):
162
+ raise SessionError(
163
+ "could not start session: filesystem, Git or image build unavailable"
164
+ ) from exc
165
+ raise
166
+
167
+
168
+ def _sanitized(result: RunResult, duration: float) -> CheckResult:
169
+ tests = tuple(
170
+ CheckTest(id="".join(c for c in test.id if c.isprintable())[:240], status=test.status)
171
+ for test in result.tests
172
+ )
173
+ outcome = result.outcome
174
+ if outcome == RunOutcome.PASSED:
175
+ if not tests or any(t.status == TestStatus.ERRORED for t in tests):
176
+ outcome = RunOutcome.ERRORED
177
+ elif any(t.status != TestStatus.PASSED for t in tests):
178
+ outcome = RunOutcome.FAILED
179
+ return CheckResult(
180
+ outcome=outcome,
181
+ tests=tests,
182
+ duration_seconds=duration,
183
+ limit=result.limit if result.limit in ("timeout", "memory") else None,
184
+ )
185
+
186
+
187
+ def check_session(
188
+ session: Path | None = None, *, root: Path | None = None, sandbox: Sandbox = engine
189
+ ) -> CheckResult:
190
+ """Check captured input; checkpoint only editable files in private storage."""
191
+ try:
192
+ path = store.resolve(session, _root(root))
193
+ store.load(path)
194
+ meta = path / ".session"
195
+ with store.locked(meta):
196
+ record, manifest, _events = store.load(path)
197
+ from patchground.sessions.submission import require_active
198
+
199
+ require_active(meta)
200
+ started = time.monotonic()
201
+ try:
202
+ # Canonicalize only the system temp parent (e.g. macOS /var),
203
+ # before creating our private directory; never resolve user trees.
204
+ with tempfile.TemporaryDirectory(
205
+ prefix="patchground-check-",
206
+ dir=Path(tempfile.gettempdir()).resolve(strict=True),
207
+ ) as temp:
208
+ editable = Path(temp) / "editable"
209
+ clean = Path(temp) / "check"
210
+ editable.mkdir()
211
+ clean.mkdir()
212
+ rules = (
213
+ resolve_history_ignores(
214
+ path / "workspace",
215
+ source=Path(record.source),
216
+ inputs=(Path(record.pack.source),)
217
+ if record.pack and record.pack.source_kind == "local"
218
+ else (),
219
+ )
220
+ if record.history_enabled
221
+ else None
222
+ )
223
+ copy_tree(path / "workspace", editable)
224
+ copy_tree(editable, clean)
225
+ copy_tree(meta / "hidden", clean)
226
+ raw = sandbox.run_check(
227
+ record.image_id, [clean], manifest.environment.test_command
228
+ )
229
+ result = _sanitized(raw, time.monotonic() - started)
230
+ checkpoint = capture(
231
+ meta,
232
+ editable,
233
+ enabled=record.history_enabled,
234
+ kind="check",
235
+ check_outcome=result.outcome.value,
236
+ rules=rules,
237
+ )
238
+ result = result.model_copy(update={"history": checkpoint})
239
+ except (SandboxError, OSError, SessionError, KeyboardInterrupt) as exc:
240
+ store.append_event(
241
+ meta,
242
+ "check_run",
243
+ {
244
+ "outcome": "interrupted"
245
+ if isinstance(exc, KeyboardInterrupt)
246
+ else "environment_error",
247
+ "duration_seconds": time.monotonic() - started,
248
+ },
249
+ )
250
+ raise
251
+ store.append_event(meta, "check_run", result.model_dump(mode="json"))
252
+ return result
253
+ except (OSError, UnicodeError, SandboxError) as exc:
254
+ raise SessionError(
255
+ "check unavailable: session storage or frozen image unavailable"
256
+ ) from exc
257
+
258
+
259
+ def session_status(session: Path | None = None, *, root: Path | None = None) -> SessionStatus:
260
+ """Read session state without Docker, lock creation or any filesystem writes."""
261
+ try:
262
+ path = store.resolve(session, _root(root))
263
+ record, manifest, events = store.load(path)
264
+ from patchground.sessions.submission import completed
265
+
266
+ saved = completed(path / ".session")
267
+ receipt = saved[1] if saved else None
268
+ last = next((event for event in reversed(events) if event.type == "check_run"), None)
269
+ return SessionStatus(
270
+ session=path,
271
+ workspace=path / "workspace",
272
+ challenge_id=record.challenge_id,
273
+ timer=record.timer,
274
+ state="submitted" if receipt else "active",
275
+ submitted_at=receipt.requested_at if receipt else None,
276
+ elapsed_seconds=max(
277
+ 0.0,
278
+ ((receipt.requested_at if receipt else now()) - record.started_at).total_seconds(),
279
+ )
280
+ if record.timer
281
+ else 0,
282
+ timebox_minutes=manifest.timebox_minutes,
283
+ last_check=receipt.final_check
284
+ if receipt
285
+ else store.check_from_event(last)
286
+ if last
287
+ else None,
288
+ last_outcome=receipt.final_check.outcome.value
289
+ if receipt
290
+ else str(last.data["outcome"])
291
+ if last
292
+ else None,
293
+ )
294
+ except (OSError, UnicodeError) as exc:
295
+ raise SessionError(
296
+ "status unavailable: invalid session storage or no session selected"
297
+ ) from exc
@@ -0,0 +1,35 @@
1
+ """patchground.spec — see SPEC.md in this folder."""
2
+
3
+ from patchground.spec._schema import export_schemas
4
+ from patchground.spec.loader import (
5
+ Challenge,
6
+ ManifestError,
7
+ ManifestIssue,
8
+ Pack,
9
+ load_challenge,
10
+ load_pack,
11
+ )
12
+ from patchground.spec.models import (
13
+ AIPolicy,
14
+ ChallengeManifest,
15
+ ChallengeType,
16
+ Difficulty,
17
+ PackManifest,
18
+ Provenance,
19
+ )
20
+
21
+ __all__ = [
22
+ "AIPolicy",
23
+ "Challenge",
24
+ "ChallengeManifest",
25
+ "ChallengeType",
26
+ "Difficulty",
27
+ "ManifestError",
28
+ "ManifestIssue",
29
+ "Pack",
30
+ "PackManifest",
31
+ "Provenance",
32
+ "export_schemas",
33
+ "load_challenge",
34
+ "load_pack",
35
+ ]