patchshuttle 0.1.0a2__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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/policy.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Workspace-relative path normalization and immutable local policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import ntpath
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from os import PathLike
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from patchshuttle.errors import PolicyError, PolicyErrorCode
|
|
17
|
+
from patchshuttle.workspace import Workspace
|
|
18
|
+
|
|
19
|
+
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PathKind(str, Enum):
|
|
23
|
+
"""Observable filesystem kind returned by a successful policy check."""
|
|
24
|
+
|
|
25
|
+
MISSING = "missing"
|
|
26
|
+
FILE = "file"
|
|
27
|
+
DIRECTORY = "directory"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class WorkspacePath:
|
|
32
|
+
"""One canonical workspace path and its inspected filesystem kind."""
|
|
33
|
+
|
|
34
|
+
relative: PurePosixPath
|
|
35
|
+
absolute: Path
|
|
36
|
+
kind: PathKind
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def exists(self) -> bool:
|
|
40
|
+
return self.kind is not PathKind.MISSING
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class _GlobPattern:
|
|
45
|
+
source: str
|
|
46
|
+
segments: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
def matches(self, path: PurePosixPath, *, case_sensitive: bool) -> bool:
|
|
49
|
+
patterns = self.segments
|
|
50
|
+
values = path.parts
|
|
51
|
+
if not case_sensitive:
|
|
52
|
+
patterns = tuple(part.casefold() for part in patterns)
|
|
53
|
+
values = tuple(part.casefold() for part in values)
|
|
54
|
+
|
|
55
|
+
@lru_cache(maxsize=None)
|
|
56
|
+
def match(pattern_index: int, value_index: int) -> bool:
|
|
57
|
+
if pattern_index == len(patterns):
|
|
58
|
+
return value_index == len(values)
|
|
59
|
+
|
|
60
|
+
pattern = patterns[pattern_index]
|
|
61
|
+
if pattern == "**":
|
|
62
|
+
return match(pattern_index + 1, value_index) or (
|
|
63
|
+
value_index < len(values) and match(pattern_index, value_index + 1)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
value_index < len(values)
|
|
68
|
+
and fnmatch.fnmatchcase(values[value_index], pattern)
|
|
69
|
+
and match(pattern_index + 1, value_index + 1)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return match(0, 0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _platform_case_sensitive() -> bool:
|
|
76
|
+
return os.path.normcase("A") != os.path.normcase("a")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Policy:
|
|
81
|
+
"""Immutable workspace path policy derived from local configuration."""
|
|
82
|
+
|
|
83
|
+
workspace: Workspace
|
|
84
|
+
case_sensitive: bool = field(default_factory=_platform_case_sensitive)
|
|
85
|
+
_protected_patterns: tuple[_GlobPattern, ...] = field(init=False, repr=False)
|
|
86
|
+
_exception_patterns: tuple[_GlobPattern, ...] = field(init=False, repr=False)
|
|
87
|
+
_ignored_patterns: tuple[_GlobPattern, ...] = field(init=False, repr=False)
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
project = self.workspace.config.project
|
|
91
|
+
object.__setattr__(
|
|
92
|
+
self,
|
|
93
|
+
"_protected_patterns",
|
|
94
|
+
_compile_patterns(project.protected_paths),
|
|
95
|
+
)
|
|
96
|
+
object.__setattr__(
|
|
97
|
+
self,
|
|
98
|
+
"_exception_patterns",
|
|
99
|
+
_compile_patterns(project.protected_path_exceptions),
|
|
100
|
+
)
|
|
101
|
+
object.__setattr__(
|
|
102
|
+
self,
|
|
103
|
+
"_ignored_patterns",
|
|
104
|
+
_compile_patterns(project.ignored_paths),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def root(self) -> Path:
|
|
109
|
+
return self.workspace.root
|
|
110
|
+
|
|
111
|
+
def normalize(self, value: str | PathLike[str]) -> PurePosixPath:
|
|
112
|
+
"""Return a canonical relative path without touching the filesystem."""
|
|
113
|
+
|
|
114
|
+
return _normalize_path(value)
|
|
115
|
+
|
|
116
|
+
def is_protected(self, value: str | PathLike[str]) -> bool:
|
|
117
|
+
"""Report whether a canonical path is blocked from AI job access."""
|
|
118
|
+
|
|
119
|
+
relative = self.normalize(value)
|
|
120
|
+
return self._is_protected(relative)
|
|
121
|
+
|
|
122
|
+
def is_ignored(self, value: str | PathLike[str]) -> bool:
|
|
123
|
+
"""Report whether inventory and audit traversal should skip a path."""
|
|
124
|
+
|
|
125
|
+
relative = self.normalize(value)
|
|
126
|
+
return any(
|
|
127
|
+
pattern.matches(prefix, case_sensitive=self.case_sensitive)
|
|
128
|
+
for prefix in _path_prefixes(relative)
|
|
129
|
+
for pattern in self._ignored_patterns
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def resolve(
|
|
133
|
+
self,
|
|
134
|
+
value: str | PathLike[str],
|
|
135
|
+
*,
|
|
136
|
+
allow_root: bool = False,
|
|
137
|
+
allow_missing: bool = False,
|
|
138
|
+
) -> WorkspacePath:
|
|
139
|
+
"""Validate, inspect, and resolve one path without changing anything."""
|
|
140
|
+
|
|
141
|
+
relative = self.normalize(value)
|
|
142
|
+
display_path = relative.as_posix()
|
|
143
|
+
if not relative.parts:
|
|
144
|
+
if not allow_root:
|
|
145
|
+
raise PolicyError(
|
|
146
|
+
PolicyErrorCode.PATH_ROOT_FORBIDDEN,
|
|
147
|
+
"workspace root requires explicit read-only permission",
|
|
148
|
+
path=display_path,
|
|
149
|
+
)
|
|
150
|
+
return WorkspacePath(
|
|
151
|
+
relative=relative,
|
|
152
|
+
absolute=self.root,
|
|
153
|
+
kind=PathKind.DIRECTORY,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if self._is_protected(relative):
|
|
157
|
+
raise PolicyError(
|
|
158
|
+
PolicyErrorCode.PATH_PROTECTED,
|
|
159
|
+
"path is protected by local policy",
|
|
160
|
+
path=display_path,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
candidate = self.root.joinpath(*relative.parts)
|
|
164
|
+
kind = self._inspect(relative)
|
|
165
|
+
resolved = self._resolve_candidate(candidate, display_path)
|
|
166
|
+
if not _is_within_root(
|
|
167
|
+
self.root,
|
|
168
|
+
resolved,
|
|
169
|
+
case_sensitive=self.case_sensitive,
|
|
170
|
+
):
|
|
171
|
+
raise PolicyError(
|
|
172
|
+
PolicyErrorCode.PATH_OUTSIDE_WORKSPACE,
|
|
173
|
+
"resolved path escapes the workspace root",
|
|
174
|
+
path=display_path,
|
|
175
|
+
)
|
|
176
|
+
if not _paths_equal(
|
|
177
|
+
candidate,
|
|
178
|
+
resolved,
|
|
179
|
+
case_sensitive=self.case_sensitive,
|
|
180
|
+
):
|
|
181
|
+
raise PolicyError(
|
|
182
|
+
PolicyErrorCode.PATH_SYMLINK,
|
|
183
|
+
"path changed or resolved through a symbolic link",
|
|
184
|
+
path=display_path,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if kind is PathKind.MISSING and not allow_missing:
|
|
188
|
+
raise PolicyError(
|
|
189
|
+
PolicyErrorCode.PATH_NOT_FOUND,
|
|
190
|
+
"path does not exist",
|
|
191
|
+
path=display_path,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return WorkspacePath(relative=relative, absolute=resolved, kind=kind)
|
|
195
|
+
|
|
196
|
+
def _is_protected(self, relative: PurePosixPath) -> bool:
|
|
197
|
+
if not relative.parts:
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
first = relative.parts[0]
|
|
201
|
+
patches_name = "patches" if self.case_sensitive else "patches".casefold()
|
|
202
|
+
compared_first = first if self.case_sensitive else first.casefold()
|
|
203
|
+
if compared_first == patches_name:
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
if any(
|
|
207
|
+
pattern.matches(relative, case_sensitive=self.case_sensitive)
|
|
208
|
+
for pattern in self._exception_patterns
|
|
209
|
+
):
|
|
210
|
+
return False
|
|
211
|
+
return any(
|
|
212
|
+
pattern.matches(prefix, case_sensitive=self.case_sensitive)
|
|
213
|
+
for prefix in _path_prefixes(relative)
|
|
214
|
+
for pattern in self._protected_patterns
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _inspect(self, relative: PurePosixPath) -> PathKind:
|
|
218
|
+
current = self.root
|
|
219
|
+
for index, part in enumerate(relative.parts):
|
|
220
|
+
current = current / part
|
|
221
|
+
current_relative = PurePosixPath(*relative.parts[: index + 1]).as_posix()
|
|
222
|
+
try:
|
|
223
|
+
mode = current.lstat().st_mode
|
|
224
|
+
except FileNotFoundError:
|
|
225
|
+
return PathKind.MISSING
|
|
226
|
+
except NotADirectoryError as exc:
|
|
227
|
+
raise PolicyError(
|
|
228
|
+
PolicyErrorCode.PATH_PARENT_NOT_DIRECTORY,
|
|
229
|
+
"an existing parent is not a directory",
|
|
230
|
+
path=PurePosixPath(*relative.parts[:index]).as_posix(),
|
|
231
|
+
) from exc
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
raise PolicyError(
|
|
234
|
+
PolicyErrorCode.PATH_INSPECTION_FAILED,
|
|
235
|
+
"path metadata could not be read",
|
|
236
|
+
path=current_relative,
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
if stat.S_ISLNK(mode):
|
|
240
|
+
raise PolicyError(
|
|
241
|
+
PolicyErrorCode.PATH_SYMLINK,
|
|
242
|
+
"symbolic-link targets and parents are not allowed",
|
|
243
|
+
path=current_relative,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
is_target = index == len(relative.parts) - 1
|
|
247
|
+
if not is_target:
|
|
248
|
+
if not stat.S_ISDIR(mode):
|
|
249
|
+
raise PolicyError(
|
|
250
|
+
PolicyErrorCode.PATH_PARENT_NOT_DIRECTORY,
|
|
251
|
+
"an existing parent is not a directory",
|
|
252
|
+
path=current_relative,
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if stat.S_ISREG(mode):
|
|
257
|
+
return PathKind.FILE
|
|
258
|
+
if stat.S_ISDIR(mode):
|
|
259
|
+
return PathKind.DIRECTORY
|
|
260
|
+
raise PolicyError(
|
|
261
|
+
PolicyErrorCode.PATH_SPECIAL_FILE,
|
|
262
|
+
"device files, sockets, and named pipes are not allowed",
|
|
263
|
+
path=current_relative,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return PathKind.MISSING # pragma: no cover - root handled by resolve
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _resolve_candidate(candidate: Path, display_path: str) -> Path:
|
|
270
|
+
try:
|
|
271
|
+
return candidate.resolve(strict=False)
|
|
272
|
+
except (OSError, RuntimeError) as exc:
|
|
273
|
+
raise PolicyError(
|
|
274
|
+
PolicyErrorCode.PATH_INSPECTION_FAILED,
|
|
275
|
+
"path could not be resolved",
|
|
276
|
+
path=display_path,
|
|
277
|
+
) from exc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _normalize_path(value: str | PathLike[str]) -> PurePosixPath:
|
|
281
|
+
try:
|
|
282
|
+
raw = os.fspath(value)
|
|
283
|
+
except TypeError as exc:
|
|
284
|
+
raise PolicyError(
|
|
285
|
+
PolicyErrorCode.PATH_INVALID,
|
|
286
|
+
"path must be text or a text path-like value",
|
|
287
|
+
) from exc
|
|
288
|
+
|
|
289
|
+
if not isinstance(raw, str) or not raw or "\0" in raw:
|
|
290
|
+
raise PolicyError(
|
|
291
|
+
PolicyErrorCode.PATH_INVALID,
|
|
292
|
+
"path must be non-empty text without null bytes",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
drive, _ = ntpath.splitdrive(raw)
|
|
296
|
+
if drive or raw.startswith(("/", "\\")):
|
|
297
|
+
raise PolicyError(
|
|
298
|
+
PolicyErrorCode.PATH_ABSOLUTE,
|
|
299
|
+
"absolute and drive-qualified paths are not allowed",
|
|
300
|
+
path=raw,
|
|
301
|
+
)
|
|
302
|
+
if _URL_SCHEME.match(raw):
|
|
303
|
+
raise PolicyError(
|
|
304
|
+
PolicyErrorCode.PATH_URL,
|
|
305
|
+
"URL-like paths are not allowed",
|
|
306
|
+
path=raw,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
parts: list[str] = []
|
|
310
|
+
for part in raw.replace("\\", "/").split("/"):
|
|
311
|
+
if part == "..":
|
|
312
|
+
raise PolicyError(
|
|
313
|
+
PolicyErrorCode.PATH_TRAVERSAL,
|
|
314
|
+
"parent traversal is not allowed",
|
|
315
|
+
path=raw,
|
|
316
|
+
)
|
|
317
|
+
if part not in ("", "."):
|
|
318
|
+
parts.append(part)
|
|
319
|
+
return PurePosixPath(*parts) if parts else PurePosixPath(".")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _compile_patterns(patterns: tuple[str, ...]) -> tuple[_GlobPattern, ...]:
|
|
323
|
+
compiled: list[_GlobPattern] = []
|
|
324
|
+
for pattern in patterns:
|
|
325
|
+
try:
|
|
326
|
+
normalized = _normalize_path(pattern)
|
|
327
|
+
except PolicyError as exc:
|
|
328
|
+
raise PolicyError(
|
|
329
|
+
PolicyErrorCode.POLICY_PATTERN_INVALID,
|
|
330
|
+
"local policy pattern must be a relative workspace glob",
|
|
331
|
+
path=pattern,
|
|
332
|
+
) from exc
|
|
333
|
+
compiled.append(_GlobPattern(source=pattern, segments=tuple(normalized.parts)))
|
|
334
|
+
return tuple(compiled)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _path_prefixes(path: PurePosixPath) -> tuple[PurePosixPath, ...]:
|
|
338
|
+
if not path.parts:
|
|
339
|
+
return (path,)
|
|
340
|
+
return tuple(
|
|
341
|
+
PurePosixPath(*path.parts[:index]) for index in range(1, len(path.parts) + 1)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_within_root(
|
|
346
|
+
root: Path,
|
|
347
|
+
candidate: Path,
|
|
348
|
+
*,
|
|
349
|
+
case_sensitive: bool,
|
|
350
|
+
) -> bool:
|
|
351
|
+
root_text = str(root)
|
|
352
|
+
candidate_text = str(candidate)
|
|
353
|
+
if not case_sensitive:
|
|
354
|
+
root_text = root_text.casefold()
|
|
355
|
+
candidate_text = candidate_text.casefold()
|
|
356
|
+
try:
|
|
357
|
+
common = os.path.commonpath((root_text, candidate_text))
|
|
358
|
+
except ValueError:
|
|
359
|
+
return False
|
|
360
|
+
return common == root_text
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _paths_equal(
|
|
364
|
+
left: Path,
|
|
365
|
+
right: Path,
|
|
366
|
+
*,
|
|
367
|
+
case_sensitive: bool,
|
|
368
|
+
) -> bool:
|
|
369
|
+
left_text = os.path.normpath(str(left))
|
|
370
|
+
right_text = os.path.normpath(str(right))
|
|
371
|
+
if not case_sensitive:
|
|
372
|
+
left_text = left_text.casefold()
|
|
373
|
+
right_text = right_text.casefold()
|
|
374
|
+
return left_text == right_text
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
__all__ = ["PathKind", "Policy", "WorkspacePath"]
|
patchshuttle/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
patchshuttle/registry.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Atomic project-local job identity and lifecycle registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode
|
|
15
|
+
from patchshuttle.models import JobKind
|
|
16
|
+
from patchshuttle.workspace import Workspace
|
|
17
|
+
|
|
18
|
+
_REGISTRY_RELATIVE_PATH = Path("patches/state/registry.json")
|
|
19
|
+
_MAX_REGISTRY_BYTES = 5_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RegistryDecision(str, Enum):
|
|
23
|
+
"""Identity decision made before an execution attempt."""
|
|
24
|
+
|
|
25
|
+
PROCEED = "PROCEED"
|
|
26
|
+
ALREADY_APPLIED = "ALREADY_APPLIED"
|
|
27
|
+
PATCH_ID_CONFLICT = "PATCH_ID_CONFLICT"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class RegistryJobRecord:
|
|
32
|
+
"""Validated latest project-local state for one stable job ID."""
|
|
33
|
+
|
|
34
|
+
job_id: str
|
|
35
|
+
job_hash: str
|
|
36
|
+
kind: str
|
|
37
|
+
first_run_at: str
|
|
38
|
+
latest_run_at: str
|
|
39
|
+
latest_result: str
|
|
40
|
+
backup_reference: str | None
|
|
41
|
+
rollback_state: str
|
|
42
|
+
archived_job_copy: str
|
|
43
|
+
completed: bool
|
|
44
|
+
run_count: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Registry:
|
|
49
|
+
"""Immutable validated view of ``patches/state/registry.json``."""
|
|
50
|
+
|
|
51
|
+
project_id: str
|
|
52
|
+
jobs: dict[str, RegistryJobRecord]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_registry(workspace: Workspace) -> Registry:
|
|
56
|
+
"""Read and validate an atomically published registry snapshot."""
|
|
57
|
+
|
|
58
|
+
path = workspace.root / _REGISTRY_RELATIVE_PATH
|
|
59
|
+
try:
|
|
60
|
+
metadata = path.lstat()
|
|
61
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > _MAX_REGISTRY_BYTES:
|
|
62
|
+
raise OSError("registry is not a bounded regular file")
|
|
63
|
+
raw = path.read_bytes()
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise _registry_error("workspace registry could not be read") from exc
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
69
|
+
registry = _parse_registry(payload)
|
|
70
|
+
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
71
|
+
raise _registry_error("workspace registry is invalid") from exc
|
|
72
|
+
|
|
73
|
+
if registry.project_id != workspace.project_id:
|
|
74
|
+
raise _registry_error("workspace registry project ID does not match config")
|
|
75
|
+
return registry
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decide_job(
|
|
79
|
+
registry: Registry,
|
|
80
|
+
*,
|
|
81
|
+
job_id: str,
|
|
82
|
+
job_hash: str,
|
|
83
|
+
) -> RegistryDecision:
|
|
84
|
+
"""Apply the protocol's stable-ID and normalized-hash rules."""
|
|
85
|
+
|
|
86
|
+
existing = registry.jobs.get(job_id)
|
|
87
|
+
if existing is None:
|
|
88
|
+
return RegistryDecision.PROCEED
|
|
89
|
+
if existing.job_hash != job_hash:
|
|
90
|
+
return RegistryDecision.PATCH_ID_CONFLICT
|
|
91
|
+
if existing.completed:
|
|
92
|
+
return RegistryDecision.ALREADY_APPLIED
|
|
93
|
+
return RegistryDecision.PROCEED
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def update_registry(
|
|
97
|
+
workspace: Workspace,
|
|
98
|
+
registry: Registry,
|
|
99
|
+
*,
|
|
100
|
+
job_id: str,
|
|
101
|
+
job_hash: str,
|
|
102
|
+
kind: JobKind,
|
|
103
|
+
occurred_at: str,
|
|
104
|
+
result: str,
|
|
105
|
+
backup_path: Path | None,
|
|
106
|
+
rollback_state: str,
|
|
107
|
+
archived_job_path: Path,
|
|
108
|
+
completed: bool,
|
|
109
|
+
reset_completed: bool = False,
|
|
110
|
+
) -> RegistryJobRecord:
|
|
111
|
+
"""Atomically retain the latest run state while preserving job identity.
|
|
112
|
+
|
|
113
|
+
The caller must hold ``patches/state/run.lock`` across its decision and
|
|
114
|
+
this write.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
existing = registry.jobs.get(job_id)
|
|
118
|
+
established_hash = existing.job_hash if existing is not None else job_hash
|
|
119
|
+
established_kind = existing.kind if existing is not None else kind.value
|
|
120
|
+
record = RegistryJobRecord(
|
|
121
|
+
job_id=job_id,
|
|
122
|
+
job_hash=established_hash,
|
|
123
|
+
kind=established_kind,
|
|
124
|
+
first_run_at=(existing.first_run_at if existing is not None else occurred_at),
|
|
125
|
+
latest_run_at=occurred_at,
|
|
126
|
+
latest_result=result,
|
|
127
|
+
backup_reference=(
|
|
128
|
+
_relative_path(workspace, backup_path)
|
|
129
|
+
if backup_path is not None
|
|
130
|
+
else (existing.backup_reference if existing is not None else None)
|
|
131
|
+
),
|
|
132
|
+
rollback_state=rollback_state,
|
|
133
|
+
archived_job_copy=_relative_path(workspace, archived_job_path),
|
|
134
|
+
completed=(
|
|
135
|
+
completed
|
|
136
|
+
if reset_completed
|
|
137
|
+
else completed or (existing.completed if existing is not None else False)
|
|
138
|
+
),
|
|
139
|
+
run_count=(existing.run_count + 1 if existing is not None else 1),
|
|
140
|
+
)
|
|
141
|
+
jobs = dict(registry.jobs)
|
|
142
|
+
jobs[job_id] = record
|
|
143
|
+
_write_registry(
|
|
144
|
+
workspace,
|
|
145
|
+
Registry(project_id=registry.project_id, jobs=jobs),
|
|
146
|
+
)
|
|
147
|
+
return record
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_job(registry: Registry, job_id: str) -> RegistryJobRecord:
|
|
151
|
+
"""Return one job or raise a stable read-command error."""
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return registry.jobs[job_id]
|
|
155
|
+
except KeyError as exc:
|
|
156
|
+
raise ExecutionError(
|
|
157
|
+
ExecutionErrorCode.JOB_NOT_FOUND,
|
|
158
|
+
"job ID is not present in the workspace registry",
|
|
159
|
+
item_id=job_id,
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_registry(payload: Any) -> Registry:
|
|
164
|
+
if not isinstance(payload, dict):
|
|
165
|
+
raise TypeError("registry root must be an object")
|
|
166
|
+
project_id = payload.get("project_id")
|
|
167
|
+
raw_jobs = payload.get("jobs")
|
|
168
|
+
if not isinstance(project_id, str) or not isinstance(raw_jobs, dict):
|
|
169
|
+
raise TypeError("registry project_id and jobs are required")
|
|
170
|
+
jobs: dict[str, RegistryJobRecord] = {}
|
|
171
|
+
for job_id, value in raw_jobs.items():
|
|
172
|
+
if not isinstance(job_id, str) or not isinstance(value, dict):
|
|
173
|
+
raise TypeError("registry jobs must be keyed objects")
|
|
174
|
+
record = RegistryJobRecord(
|
|
175
|
+
job_id=_required(value, "job_id", str),
|
|
176
|
+
job_hash=_required(value, "job_hash", str),
|
|
177
|
+
kind=_required(value, "kind", str),
|
|
178
|
+
first_run_at=_required(value, "first_run_at", str),
|
|
179
|
+
latest_run_at=_required(value, "latest_run_at", str),
|
|
180
|
+
latest_result=_required(value, "latest_result", str),
|
|
181
|
+
backup_reference=_optional_string(value, "backup_reference"),
|
|
182
|
+
rollback_state=_required(value, "rollback_state", str),
|
|
183
|
+
archived_job_copy=_required(value, "archived_job_copy", str),
|
|
184
|
+
completed=_required(value, "completed", bool),
|
|
185
|
+
run_count=_required(value, "run_count", int),
|
|
186
|
+
)
|
|
187
|
+
if record.job_id != job_id or record.run_count < 1:
|
|
188
|
+
raise ValueError("registry job identity or run count is invalid")
|
|
189
|
+
jobs[job_id] = record
|
|
190
|
+
return Registry(project_id=project_id, jobs=jobs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _required(value: dict[str, Any], key: str, expected: type):
|
|
194
|
+
item = value.get(key)
|
|
195
|
+
if type(item) is not expected:
|
|
196
|
+
raise TypeError(f"registry field {key} has an invalid type")
|
|
197
|
+
return item
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _optional_string(value: dict[str, Any], key: str) -> str | None:
|
|
201
|
+
item = value.get(key)
|
|
202
|
+
if item is not None and not isinstance(item, str):
|
|
203
|
+
raise TypeError(f"registry field {key} has an invalid type")
|
|
204
|
+
return item
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _write_registry(workspace: Workspace, registry: Registry) -> None:
|
|
208
|
+
path = workspace.root / _REGISTRY_RELATIVE_PATH
|
|
209
|
+
temporary = path.parent / f".registry-{uuid.uuid4().hex}.tmp"
|
|
210
|
+
payload = {
|
|
211
|
+
"jobs": {
|
|
212
|
+
job_id: _record_payload(record)
|
|
213
|
+
for job_id, record in sorted(registry.jobs.items())
|
|
214
|
+
},
|
|
215
|
+
"project_id": registry.project_id,
|
|
216
|
+
}
|
|
217
|
+
raw = (
|
|
218
|
+
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
219
|
+
).encode("utf-8")
|
|
220
|
+
try:
|
|
221
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
222
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
223
|
+
stream.write(raw)
|
|
224
|
+
stream.flush()
|
|
225
|
+
os.fsync(stream.fileno())
|
|
226
|
+
os.replace(temporary, path)
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise _registry_error("workspace registry could not be written") from exc
|
|
229
|
+
finally:
|
|
230
|
+
try:
|
|
231
|
+
temporary.unlink()
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _record_payload(record: RegistryJobRecord) -> dict[str, object]:
|
|
237
|
+
return {
|
|
238
|
+
"archived_job_copy": record.archived_job_copy,
|
|
239
|
+
"backup_reference": record.backup_reference,
|
|
240
|
+
"completed": record.completed,
|
|
241
|
+
"first_run_at": record.first_run_at,
|
|
242
|
+
"job_hash": record.job_hash,
|
|
243
|
+
"job_id": record.job_id,
|
|
244
|
+
"kind": record.kind,
|
|
245
|
+
"latest_result": record.latest_result,
|
|
246
|
+
"latest_run_at": record.latest_run_at,
|
|
247
|
+
"rollback_state": record.rollback_state,
|
|
248
|
+
"run_count": record.run_count,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _relative_path(workspace: Workspace, path: Path) -> str:
|
|
253
|
+
try:
|
|
254
|
+
return path.relative_to(workspace.root).as_posix()
|
|
255
|
+
except ValueError as exc: # pragma: no cover - internal path invariant
|
|
256
|
+
raise _registry_error("operational path is outside the workspace") from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _registry_error(message: str) -> ExecutionError:
|
|
260
|
+
return ExecutionError(
|
|
261
|
+
ExecutionErrorCode.OPERATIONAL_RECORD_FAILED,
|
|
262
|
+
message,
|
|
263
|
+
path=_REGISTRY_RELATIVE_PATH.as_posix(),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
__all__ = [
|
|
268
|
+
"Registry",
|
|
269
|
+
"RegistryDecision",
|
|
270
|
+
"RegistryJobRecord",
|
|
271
|
+
"decide_job",
|
|
272
|
+
"get_job",
|
|
273
|
+
"load_registry",
|
|
274
|
+
"update_registry",
|
|
275
|
+
]
|