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.
Files changed (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. 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
+
@@ -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
+ ]