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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|