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,126 @@
1
+ """Versioned storage and public, log-free session results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, computed_field, model_validator
10
+
11
+ from patchground.packs.models import PackIdentity
12
+ from patchground.sandbox import RunOutcome, TestStatus
13
+
14
+
15
+ class SessionError(Exception):
16
+ """Invalid input, unsafe session storage, or unavailable infrastructure."""
17
+
18
+
19
+ class Model(BaseModel):
20
+ model_config = ConfigDict(extra="forbid", frozen=True)
21
+
22
+
23
+ class SessionRecord(Model):
24
+ schema_version: Literal[1] = 1
25
+ challenge_id: str
26
+ challenge_version: str
27
+ source: str
28
+ trust: Literal["local-authored", "unofficial"] = "local-authored"
29
+ pack: PackIdentity | None = None
30
+ image_id: str = Field(min_length=1)
31
+ started_at: AwareDatetime
32
+ timer: bool
33
+ history_enabled: bool = False
34
+
35
+ @model_validator(mode="after")
36
+ def consistent_pack(self) -> SessionRecord:
37
+ if (self.pack is None) != (self.trust == "local-authored"):
38
+ raise ValueError("inconsistent pack provenance")
39
+ return self
40
+
41
+
42
+ class CheckTest(Model):
43
+ id: str
44
+ status: TestStatus
45
+
46
+
47
+ class SnapshotStatus(Model):
48
+ state: Literal["recorded", "unavailable", "disabled"]
49
+ ref: str | None = None
50
+ detail: Literal["Checkpoint capture unavailable"] | None = None
51
+
52
+
53
+ class HistoryEntry(Model):
54
+ ref: str
55
+ commit: str
56
+ kind: Literal["initial", "check"]
57
+ ts: AwareDatetime
58
+ check_outcome: str | None = None
59
+
60
+
61
+ class HistoryResult(Model):
62
+ session: Path
63
+ enabled: bool
64
+ availability: Literal["available", "disabled", "unavailable"]
65
+ snapshots: tuple[HistoryEntry, ...]
66
+
67
+
68
+ class CheckResult(Model):
69
+ outcome: RunOutcome
70
+ tests: tuple[CheckTest, ...]
71
+ duration_seconds: float = Field(ge=0, allow_inf_nan=False)
72
+ limit: Literal["timeout", "memory"] | None = None
73
+ history: SnapshotStatus | None = None
74
+
75
+ @computed_field # type: ignore[prop-decorator]
76
+ @property
77
+ def passed(self) -> bool:
78
+ return (
79
+ self.outcome == RunOutcome.PASSED
80
+ and bool(self.tests)
81
+ and all(test.status == TestStatus.PASSED for test in self.tests)
82
+ )
83
+
84
+ @computed_field # type: ignore[prop-decorator]
85
+ @property
86
+ def counts(self) -> dict[str, int]:
87
+ return {status.value: sum(t.status == status for t in self.tests) for status in TestStatus}
88
+
89
+
90
+ class Event(Model):
91
+ ts: AwareDatetime
92
+ type: str
93
+ data: dict[str, object]
94
+
95
+
96
+ class StartedSession(Model):
97
+ session: Path
98
+ workspace: Path
99
+ challenge_id: str
100
+ timer: bool
101
+ ticket: str
102
+ pack: PackIdentity | None = None
103
+ history: SnapshotStatus | None = None
104
+
105
+
106
+ class ResetResult(Model):
107
+ session: Path
108
+ workspace: Path
109
+ challenge_id: str
110
+
111
+
112
+ class SessionStatus(Model):
113
+ session: Path
114
+ workspace: Path
115
+ challenge_id: str
116
+ timer: bool
117
+ state: Literal["active", "submitted"] = "active"
118
+ submitted_at: AwareDatetime | None = None
119
+ elapsed_seconds: float
120
+ timebox_minutes: int
121
+ last_check: CheckResult | None
122
+ last_outcome: str | None
123
+
124
+
125
+ def now() -> datetime:
126
+ return datetime.now(UTC)
@@ -0,0 +1,202 @@
1
+ """Authored hints, local session browsing and metadata-only command orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import tempfile
8
+ import time
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Literal, Protocol
12
+
13
+ from pydantic import AwareDatetime, Field
14
+
15
+ from patchground import sandbox as engine
16
+ from patchground.sandbox import CommandResult, SandboxError
17
+ from patchground.sessions import store
18
+ from patchground.sessions.files import copy_tree, no_links, open_directory, safe_path
19
+ from patchground.sessions.models import Model, SessionError
20
+ from patchground.sessions.submission import require_active
21
+ from patchground.sessions.workflow import _root, session_status
22
+
23
+
24
+ class HintResult(Model):
25
+ session: Path
26
+ challenge_id: str
27
+ index: int | None
28
+ hint: str | None
29
+ remaining: int
30
+ exhausted: bool
31
+ total: int = 0
32
+
33
+
34
+ class SessionListingItem(Model):
35
+ session: Path
36
+ challenge_id: str
37
+ challenge_version: str
38
+ started_at: AwareDatetime
39
+ state: Literal["active", "submitted"]
40
+ last_outcome: str | None
41
+ timer: bool
42
+ elapsed_seconds: float
43
+ timebox_minutes: int
44
+
45
+
46
+ class InvalidSession(Model):
47
+ session: Path
48
+ reason: str
49
+
50
+
51
+ class SessionsList(Model):
52
+ sessions: tuple[SessionListingItem, ...]
53
+ invalid: tuple[InvalidSession, ...]
54
+ root: Path | None = None
55
+
56
+
57
+ class CommandRunResult(Model):
58
+ exit_code: int | None
59
+ limit: str | None
60
+ duration_seconds: float = Field(ge=0, allow_inf_nan=False)
61
+ output_tail: str
62
+ output_tail_only: Literal[True] = True
63
+
64
+
65
+ class CommandSandbox(Protocol):
66
+ def run_command(
67
+ self, image_id: str, layers: Sequence[Path], argv: Sequence[str]
68
+ ) -> CommandResult: ...
69
+
70
+
71
+ def reveal_hint(session: Path | None = None, *, root: Path | None = None) -> HintResult:
72
+ try:
73
+ path = store.resolve(session, _root(root))
74
+ meta = path / ".session"
75
+ with store.locked(meta):
76
+ record, manifest, events = store.load(path)
77
+ require_active(meta)
78
+ indices = [event.data.get("index") for event in events if event.type == "hint_revealed"]
79
+ if (
80
+ any(type(index) is not int for index in indices)
81
+ or indices != list(range(1, len(indices) + 1))
82
+ or len(indices) > len(manifest.hints)
83
+ ):
84
+ raise SessionError("corrupt hint history")
85
+ index = len(indices)
86
+ exhausted = index == len(manifest.hints)
87
+ if not exhausted:
88
+ store.append_event(meta, "hint_revealed", {"index": index + 1})
89
+ return HintResult(
90
+ session=path,
91
+ challenge_id=record.challenge_id,
92
+ index=None if exhausted else index + 1,
93
+ hint=None if exhausted else manifest.hints[index],
94
+ remaining=max(0, len(manifest.hints) - index - 1),
95
+ exhausted=exhausted,
96
+ total=len(manifest.hints),
97
+ )
98
+ except (OSError, UnicodeError) as exc:
99
+ raise SessionError("hint unavailable: invalid session storage") from exc
100
+
101
+
102
+ def list_sessions(*, root: Path | None = None) -> SessionsList:
103
+ base = _root(root)
104
+ items: list[SessionListingItem] = []
105
+ invalid: list[InvalidSession] = []
106
+ try:
107
+ no_links(base)
108
+ if not base.exists():
109
+ return SessionsList(sessions=(), invalid=(), root=base)
110
+ safe_path(base, directory=True)
111
+ with open_directory(base) as parent, os.scandir(parent) as entries:
112
+ names = sorted(entry.name for entry in entries)
113
+ for name in names:
114
+ path = base / name
115
+ try:
116
+ mode = path.lstat().st_mode
117
+ if stat.S_ISLNK(mode):
118
+ raise SessionError("unsafe session entry")
119
+ if not stat.S_ISDIR(mode):
120
+ continue
121
+ with open_directory(path) as parent, os.scandir(parent) as entries:
122
+ if not any(entry.name == ".session" for entry in entries):
123
+ continue
124
+ record, _, _ = store.load(path)
125
+ status = session_status(path, root=base)
126
+ items.append(
127
+ SessionListingItem(
128
+ session=path,
129
+ challenge_id=record.challenge_id,
130
+ challenge_version=record.challenge_version,
131
+ started_at=record.started_at,
132
+ state=status.state,
133
+ last_outcome=status.last_outcome,
134
+ timer=status.timer,
135
+ elapsed_seconds=status.elapsed_seconds,
136
+ timebox_minutes=status.timebox_minutes,
137
+ )
138
+ )
139
+ except (SessionError, OSError, UnicodeError):
140
+ invalid.append(InvalidSession(session=path, reason="unsafe or invalid session"))
141
+ except (OSError, UnicodeError) as exc:
142
+ raise SessionError("sessions unavailable: session directory cannot be read") from exc
143
+ items.sort(key=lambda item: (item.started_at, item.session.name), reverse=True)
144
+ return SessionsList(sessions=tuple(items), invalid=tuple(invalid), root=base)
145
+
146
+
147
+ def run_session_command(
148
+ argv: Sequence[str],
149
+ session: Path | None = None,
150
+ *,
151
+ root: Path | None = None,
152
+ sandbox: CommandSandbox = engine,
153
+ ) -> CommandRunResult:
154
+ if not argv or not argv[0] or any("\0" in argument for argument in argv):
155
+ raise SessionError("run requires a nonempty command after --")
156
+ try:
157
+ path = store.resolve(session, _root(root))
158
+ meta = path / ".session"
159
+ with store.locked(meta):
160
+ record, _, _ = store.load(path)
161
+ require_active(meta)
162
+ started = time.monotonic()
163
+ try:
164
+ with tempfile.TemporaryDirectory(
165
+ prefix="patchground-command-",
166
+ dir=Path(tempfile.gettempdir()).resolve(strict=True),
167
+ ) as temporary:
168
+ captured = Path(temporary)
169
+ copy_tree(path / "workspace", captured)
170
+ result = sandbox.run_command(record.image_id, [captured], tuple(argv))
171
+ response = CommandRunResult(
172
+ exit_code=result.exit_code,
173
+ limit=result.limit,
174
+ duration_seconds=time.monotonic() - started,
175
+ output_tail=result.output_tail,
176
+ )
177
+ except (SandboxError, SessionError, OSError, KeyboardInterrupt) as exc:
178
+ store.append_event(
179
+ meta,
180
+ "command_run",
181
+ {
182
+ "outcome": "interrupted"
183
+ if isinstance(exc, KeyboardInterrupt)
184
+ else "environment_error",
185
+ "duration_seconds": time.monotonic() - started,
186
+ },
187
+ )
188
+ raise
189
+ store.append_event(
190
+ meta,
191
+ "command_run",
192
+ {
193
+ "exit_code": response.exit_code,
194
+ "limit": response.limit,
195
+ "duration_seconds": response.duration_seconds,
196
+ },
197
+ )
198
+ return response
199
+ except (OSError, UnicodeError, SandboxError) as exc:
200
+ raise SessionError(
201
+ "command unavailable: session storage or frozen image unavailable"
202
+ ) from exc
@@ -0,0 +1,121 @@
1
+ """Restore ordinary workspace files without invoking Git or following links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import tempfile
9
+ from contextlib import suppress
10
+ from pathlib import Path
11
+
12
+ from patchground.sessions import store
13
+ from patchground.sessions.files import copy_tree, excluded, open_directory, open_entry
14
+ from patchground.sessions.models import ResetResult, SessionError
15
+
16
+
17
+ def _inspect(parent: int) -> dict[str, object]:
18
+ result: dict[str, object] = {}
19
+ for name in sorted(os.listdir(parent)):
20
+ if excluded(name):
21
+ result[name] = None
22
+ continue
23
+ with open_entry(parent, name) as entry:
24
+ result[name] = _inspect(entry) if stat.S_ISDIR(os.fstat(entry).st_mode) else False
25
+ return result
26
+
27
+
28
+ def _protected(node: object) -> bool:
29
+ return node is None or (isinstance(node, dict) and any(_protected(v) for v in node.values()))
30
+
31
+
32
+ def _preflight(original: dict[str, object], current: dict[str, object]) -> None:
33
+ for name, node in original.items():
34
+ if name not in current:
35
+ continue
36
+ target = current[name]
37
+ if isinstance(node, dict) and isinstance(target, dict):
38
+ _preflight(node, target)
39
+ elif _protected(target):
40
+ raise SessionError("reset blocked: starting files conflict with protected entries")
41
+
42
+
43
+ def _empty(parent: int) -> bool:
44
+ return not os.listdir(parent)
45
+
46
+
47
+ def _clear(parent: int) -> None:
48
+ for name in sorted(os.listdir(parent)):
49
+ if excluded(name):
50
+ continue
51
+ directory = False
52
+ with open_entry(parent, name) as entry:
53
+ directory = stat.S_ISDIR(os.fstat(entry).st_mode)
54
+ if directory:
55
+ _clear(entry)
56
+ empty = _empty(entry)
57
+ if directory:
58
+ if empty:
59
+ os.rmdir(name, dir_fd=parent)
60
+ else:
61
+ os.unlink(name, dir_fd=parent)
62
+
63
+
64
+ def _restore(source: int, target: int) -> None:
65
+ for name in sorted(os.listdir(source)):
66
+ with open_entry(source, name) as entry:
67
+ info = os.fstat(entry)
68
+ if stat.S_ISDIR(info.st_mode):
69
+ with suppress(FileExistsError):
70
+ os.mkdir(name, mode=0o700, dir_fd=target)
71
+ with open_entry(target, name, os.O_RDONLY | os.O_DIRECTORY) as folder:
72
+ _restore(entry, folder)
73
+ else:
74
+ output = os.open(
75
+ name,
76
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
77
+ 0o600,
78
+ dir_fd=target,
79
+ )
80
+ with os.fdopen(output, "wb") as writer:
81
+ with os.fdopen(os.dup(entry), "rb") as reader:
82
+ shutil.copyfileobj(reader, writer)
83
+ os.fchmod(writer.fileno(), (stat.S_IMODE(info.st_mode) & 0o777) | 0o600)
84
+
85
+
86
+ def reset_session(session: Path | None = None, *, root: Path | None = None) -> ResetResult:
87
+ """Reset a selected session; confirmation belongs to the CLI."""
88
+ mutated = False
89
+ try:
90
+ root = (root if root is not None else store.default_root()).absolute()
91
+ path = store.resolve(session, root)
92
+ store.load(path)
93
+ meta = path / ".session"
94
+ with store.locked(meta):
95
+ record, _, _ = store.load(path)
96
+ from patchground.sessions.submission import require_active
97
+
98
+ require_active(meta)
99
+ with tempfile.TemporaryDirectory(
100
+ prefix="patchground-reset-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
101
+ ) as temporary:
102
+ staged = Path(temporary)
103
+ copy_tree(meta / "original", staged)
104
+ with open_directory(staged) as original, open_directory(path / "workspace") as work:
105
+ _preflight(_inspect(original), _inspect(work))
106
+ mutated = True
107
+ _clear(work)
108
+ _restore(original, work)
109
+ store.append_event(meta, "reset", {})
110
+ return ResetResult(
111
+ session=path, workspace=path / "workspace", challenge_id=record.challenge_id
112
+ )
113
+ except (OSError, UnicodeError, SessionError) as exc:
114
+ if mutated:
115
+ raise SessionError(
116
+ "reset incomplete: workspace may be partially restored; "
117
+ "fix filesystem access and retry"
118
+ ) from exc
119
+ if isinstance(exc, SessionError):
120
+ raise
121
+ raise SessionError("reset unavailable: session storage could not be read") from exc
@@ -0,0 +1,203 @@
1
+ """Fixed session layout, typed reads, append-only events and POSIX check locking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import json
7
+ import os
8
+ import stat
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from patchground.sessions.files import no_links, open_directory, safe_path, tree
17
+ from patchground.sessions.models import CheckResult, Event, SessionError, SessionRecord, now
18
+ from patchground.spec import ChallengeManifest
19
+
20
+ SESSIONS_ENV = "PATCHGROUND_SESSIONS_DIR"
21
+
22
+
23
+ def default_root() -> Path:
24
+ """Where sessions live: ``$PATCHGROUND_SESSIONS_DIR`` if set, else ``~/patchground/sessions``.
25
+
26
+ A relative override would select different roots from different working directories, so it is
27
+ rejected rather than silently anchored to the current one. The explicitly chosen path is
28
+ canonicalized once (``/tmp`` is a symlink on macOS); sessions below it still refuse links.
29
+ """
30
+ configured = os.environ.get(SESSIONS_ENV, "").strip()
31
+ if not configured:
32
+ return Path.home() / "patchground" / "sessions"
33
+ path = Path(configured).expanduser()
34
+ if not path.is_absolute():
35
+ raise SessionError(f"{SESSIONS_ENV} must be an absolute path")
36
+ return path.resolve()
37
+
38
+
39
+ def allocate(root: Path, challenge_id: str) -> Path:
40
+ no_links(root)
41
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
42
+ safe_path(root, directory=True)
43
+ stem = f"{date.today().isoformat()}-{challenge_id}"
44
+ suffix = 0
45
+ while True:
46
+ candidate = root / (stem if suffix == 0 else f"{stem}-{suffix}")
47
+ try:
48
+ candidate.mkdir(mode=0o700)
49
+ return candidate
50
+ except FileExistsError:
51
+ suffix += 1
52
+
53
+
54
+ def resolve(session: Path | None, root: Path) -> Path:
55
+ no_links(root)
56
+ if not root.exists():
57
+ raise SessionError("no sessions found: start a challenge, then select it with --session")
58
+ safe_path(root, directory=True)
59
+ selected = (session if session is not None else Path.cwd()).absolute()
60
+ safe_path(selected, directory=True)
61
+ # Normalize '..' only after rejecting links: lexical ancestors alone can
62
+ # incorrectly select a session when the requested path actually escapes it.
63
+ selected = selected.resolve(strict=True)
64
+ for candidate in (selected, *selected.parents):
65
+ if candidate.parent == root.resolve(strict=True):
66
+ return candidate
67
+ raise SessionError(
68
+ "select a session with --session or run inside its workspace; "
69
+ "list your attempts with: pgnd sessions"
70
+ )
71
+
72
+
73
+ def load(path: Path) -> tuple[SessionRecord, ChallengeManifest, list[Event]]:
74
+ safe_path(path, directory=True)
75
+ meta = path / ".session"
76
+ safe_path(meta, directory=True)
77
+ from patchground.sessions.submission import audit_contents, completed
78
+
79
+ saved = completed(meta)
80
+ if saved is None:
81
+ safe_path(path / "workspace", directory=True)
82
+ contents: dict[str, bytes] = {}
83
+ with open_directory(meta) as parent:
84
+ for name in ("session.json", "manifest.json", "TICKET.md", "events.jsonl", "lock"):
85
+ with metadata_file(parent, name) as descriptor:
86
+ if name in ("session.json", "manifest.json", "events.jsonl"):
87
+ with os.fdopen(os.dup(descriptor), "rb") as stream:
88
+ contents[name] = stream.read()
89
+ if saved is None:
90
+ tree(meta / "hidden")
91
+ tree(meta / "original")
92
+ try:
93
+ record = SessionRecord.model_validate_json(contents["session.json"])
94
+ manifest = ChallengeManifest.model_validate_json(contents["manifest.json"])
95
+ raw = contents["events.jsonl"]
96
+ if saved is not None:
97
+ raw = audit_contents(raw, saved[1])[0]
98
+ elif not raw.endswith(b"\n"):
99
+ raise ValueError("truncated events")
100
+ events = [Event.model_validate_json(line) for line in raw.splitlines()]
101
+ if not events or events[0].type != "session_started":
102
+ raise ValueError("missing start event")
103
+ if events[0].ts != record.started_at or events[0].data != {
104
+ "challenge_id": record.challenge_id
105
+ }:
106
+ raise ValueError("inconsistent start event")
107
+ for event in events:
108
+ if event.type == "check_run":
109
+ check_from_event(event)
110
+ if (record.challenge_id, record.challenge_version) != (manifest.id, manifest.version):
111
+ raise ValueError("inconsistent manifest")
112
+ if manifest.environment.services:
113
+ raise ValueError("unsupported services")
114
+ submitted = [event for event in events if event.type == "submitted"]
115
+ if saved is not None:
116
+ receipt = saved[1]
117
+ if receipt.requested_at < record.started_at:
118
+ raise ValueError("submission predates session")
119
+ if submitted and (
120
+ len(submitted) != 1
121
+ or submitted[0].data != {"requested_at": receipt.requested_at.isoformat()}
122
+ or submitted[0] != Event.model_validate_json(receipt.audit.event)
123
+ ):
124
+ raise ValueError("inconsistent submitted event")
125
+ elif submitted:
126
+ raise ValueError("submission audit has no bundle")
127
+ except (ValidationError, ValueError, UnicodeError) as exc:
128
+ raise SessionError("corrupt session metadata or events") from exc
129
+ return record, manifest, events
130
+
131
+
132
+ def check_from_event(event: Event) -> CheckResult | None:
133
+ if event.data.get("outcome") in ("environment_error", "interrupted"):
134
+ if set(event.data) != {"outcome", "duration_seconds"}:
135
+ raise ValueError("invalid check event")
136
+ duration = event.data["duration_seconds"]
137
+ if not isinstance(duration, int | float) or not 0 <= duration < float("inf"):
138
+ raise ValueError("invalid check duration")
139
+ return None
140
+ data = dict(event.data)
141
+ counts = data.pop("counts", None)
142
+ passed = data.pop("passed", None)
143
+ result = CheckResult.model_validate(data)
144
+ if counts != result.counts or passed != result.passed:
145
+ raise ValueError("inconsistent check result")
146
+ return result
147
+
148
+
149
+ @contextmanager
150
+ def metadata_file(parent: int, name: str, flags: int = os.O_RDONLY) -> Iterator[int]:
151
+ """Mutable session metadata must be an unshared regular inode, even after a race."""
152
+ try:
153
+ descriptor = os.open(name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
154
+ except OSError as exc:
155
+ raise SessionError("session metadata contains a symlink or is unavailable") from exc
156
+ try:
157
+ info = os.fstat(descriptor)
158
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
159
+ raise SessionError("session metadata must be a regular file without hardlinks")
160
+ yield descriptor
161
+ finally:
162
+ os.close(descriptor)
163
+
164
+
165
+ def append_event(meta: Path, kind: str, data: dict[str, object]) -> None:
166
+ serialized = Event(ts=now(), type=kind, data=data).model_dump_json() + "\n"
167
+ append_bytes(meta, serialized.encode("utf-8"))
168
+
169
+
170
+ def append_bytes(meta: Path, serialized: bytes) -> None:
171
+ """Append exact session-owned event bytes, including deterministic audit recovery suffixes."""
172
+ # Validate the actual append descriptor after any long-running work.
173
+ with (
174
+ open_directory(meta) as parent,
175
+ metadata_file(parent, "events.jsonl", os.O_WRONLY | os.O_APPEND) as descriptor,
176
+ os.fdopen(os.dup(descriptor), "ab") as stream,
177
+ ):
178
+ stream.write(serialized)
179
+ stream.flush()
180
+ os.fsync(stream.fileno())
181
+
182
+
183
+ def write_json(path: Path, value: object) -> None:
184
+ with open_directory(path.parent) as parent:
185
+ descriptor = os.open(
186
+ path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent
187
+ )
188
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
189
+ stream.write(json.dumps(value, ensure_ascii=False) + "\n")
190
+
191
+
192
+ @contextmanager
193
+ def locked(meta: Path) -> Iterator[None]:
194
+ with (
195
+ open_directory(meta) as parent,
196
+ metadata_file(parent, "lock", os.O_RDWR) as descriptor,
197
+ ):
198
+ try:
199
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
200
+ except BlockingIOError as exc:
201
+ raise SessionError("session busy: another check or reset is running") from exc
202
+ # Release by closing the pinned descriptor, never by reopening/truncating its path.
203
+ yield