worktree-controller 1.30.4__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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
"""Pure validation and serialization of versioned Controller metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .models import (
|
|
16
|
+
INTEGRATION_EVENT_TYPES,
|
|
17
|
+
INTEGRATION_INTENT_STATES,
|
|
18
|
+
LIFECYCLE_STATES,
|
|
19
|
+
METADATA_SCHEMA_VERSION,
|
|
20
|
+
RELEASE_INTENT_STATES,
|
|
21
|
+
SUPPORTED_METADATA_SCHEMA_VERSIONS,
|
|
22
|
+
WORKTREE_ROLES,
|
|
23
|
+
ControllerError,
|
|
24
|
+
IntegrationIntent,
|
|
25
|
+
IntegrationReconciliationReceipt,
|
|
26
|
+
LifecycleEvent,
|
|
27
|
+
MetadataRecord,
|
|
28
|
+
PreparedCandidateReceipt,
|
|
29
|
+
PublicationLease,
|
|
30
|
+
PublicationReceipt,
|
|
31
|
+
ReconciliationReceipt,
|
|
32
|
+
ReleaseIntent,
|
|
33
|
+
ReleasePlan,
|
|
34
|
+
ReleaseReceipt,
|
|
35
|
+
WriterHolder,
|
|
36
|
+
WriterLease,
|
|
37
|
+
WriterReconciliationReceipt,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
TEARDOWN_RECEIPT_SCHEMA_VERSION = 1
|
|
41
|
+
TEARDOWN_RECEIPT_RESULTS = frozenset({"prepared", "removal_started", "removed", "released"})
|
|
42
|
+
INTEGRATION_INTENT_SCHEMA_VERSION = 3
|
|
43
|
+
INTEGRATION_INTENT_CONTRACT_VERSION = 2
|
|
44
|
+
PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION = 1
|
|
45
|
+
LIFECYCLE_EVENT_SCHEMA_VERSION = 1
|
|
46
|
+
INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION = 1
|
|
47
|
+
PUBLICATION_LEASE_SCHEMA_VERSION = 2
|
|
48
|
+
PUBLICATION_RECEIPT_SCHEMA_VERSION = 2
|
|
49
|
+
PUBLICATION_RECEIPT_STATES = frozenset({"REMOTE_VERIFIED", "RELEASED"})
|
|
50
|
+
RELEASE_INTENT_SCHEMA_VERSION = 1
|
|
51
|
+
RELEASE_PLAN_SCHEMA_VERSION = 1
|
|
52
|
+
RELEASE_RECEIPT_SCHEMA_VERSION = 1
|
|
53
|
+
RECONCILIATION_RECEIPT_SCHEMA_VERSION = 1
|
|
54
|
+
_LOWERCASE_HEX = re.compile(r"[0-9a-f]+")
|
|
55
|
+
_CANONICAL_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _nonempty_string(value: Any, field: str) -> str:
|
|
59
|
+
if not isinstance(value, str) or not (text := value.strip()):
|
|
60
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a non-empty string")
|
|
61
|
+
if "\x00" in value:
|
|
62
|
+
raise ControllerError("INVALID_METADATA", f"{field} contains a NUL byte")
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _optional_string(value: Any, field: str) -> str | None:
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
return _nonempty_string(value, field)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _optional_oid(value: Any, field: str) -> str | None:
|
|
73
|
+
text = _optional_string(value, field)
|
|
74
|
+
if text is not None and (
|
|
75
|
+
len(text) != 40 or _LOWERCASE_HEX.fullmatch(text) is None
|
|
76
|
+
):
|
|
77
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a full SHA-1 commit OID")
|
|
78
|
+
return text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _required_oid(value: Any, field: str) -> str:
|
|
82
|
+
oid = _optional_oid(value, field)
|
|
83
|
+
if oid is None:
|
|
84
|
+
raise ControllerError("INVALID_METADATA", f"{field} is required")
|
|
85
|
+
return oid
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _string_list(value: Any, field: str) -> tuple[str, ...]:
|
|
89
|
+
if not isinstance(value, (list, tuple)) or not all(
|
|
90
|
+
isinstance(item, str) and item.strip() and "\x00" not in item for item in value
|
|
91
|
+
):
|
|
92
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a string list")
|
|
93
|
+
return tuple(item.strip() for item in value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _scope_paths(value: Any) -> tuple[str, ...]:
|
|
97
|
+
scopes = _string_list(value, "scope_paths")
|
|
98
|
+
for scope in scopes:
|
|
99
|
+
path = PurePosixPath(scope)
|
|
100
|
+
if (
|
|
101
|
+
"\\" in scope
|
|
102
|
+
or path.is_absolute()
|
|
103
|
+
or not path.parts
|
|
104
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
105
|
+
or path.as_posix() != scope
|
|
106
|
+
or scope == ".git"
|
|
107
|
+
or scope.startswith(".git/")
|
|
108
|
+
):
|
|
109
|
+
raise ControllerError("INVALID_METADATA", f"scope path is unsafe: {scope}")
|
|
110
|
+
if scopes != tuple(sorted(set(scopes))):
|
|
111
|
+
raise ControllerError("INVALID_METADATA", "scope_paths must be sorted and unique")
|
|
112
|
+
return scopes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _source_allocations(value: Any) -> tuple[str, ...]:
|
|
116
|
+
allocations = _string_list(value, "source_allocations")
|
|
117
|
+
if allocations != tuple(dict.fromkeys(allocations)):
|
|
118
|
+
raise ControllerError("INVALID_METADATA", "source_allocations must be unique")
|
|
119
|
+
for allocation_id in allocations:
|
|
120
|
+
try:
|
|
121
|
+
uuid.UUID(allocation_id)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
raise ControllerError("INVALID_METADATA", "source allocation must be a UUID") from exc
|
|
124
|
+
return allocations
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _optional_uuid(value: Any, field: str) -> str | None:
|
|
128
|
+
text = _optional_string(value, field)
|
|
129
|
+
if text is None:
|
|
130
|
+
return None
|
|
131
|
+
if len(text) == 36 and _CANONICAL_UUID.fullmatch(text) is not None:
|
|
132
|
+
return text
|
|
133
|
+
try:
|
|
134
|
+
uuid.UUID(text)
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a UUID") from exc
|
|
137
|
+
return text
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _optional_recovery_ref(value: Any) -> str | None:
|
|
141
|
+
ref = _optional_string(value, "recovery_ref")
|
|
142
|
+
if ref is None:
|
|
143
|
+
return None
|
|
144
|
+
branch = ref.removeprefix("refs/heads/")
|
|
145
|
+
if (
|
|
146
|
+
not ref.startswith("refs/heads/")
|
|
147
|
+
or not branch
|
|
148
|
+
or branch.startswith("-")
|
|
149
|
+
or branch.endswith("/")
|
|
150
|
+
or any(token in branch for token in ("..", "//", "@{", "\\"))
|
|
151
|
+
):
|
|
152
|
+
raise ControllerError("INVALID_METADATA", "recovery_ref must be a safe branch ref")
|
|
153
|
+
return ref
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _disposable_resources(value: Any, *, schema_version: int) -> tuple[dict[str, Any], ...]:
|
|
157
|
+
if not isinstance(value, (list, tuple)):
|
|
158
|
+
raise ControllerError("INVALID_METADATA", "disposable_resources must be an object list")
|
|
159
|
+
resources: list[dict[str, Any]] = []
|
|
160
|
+
for item in value:
|
|
161
|
+
legacy_fields = {"resource_id", "path", "kind", "state"}
|
|
162
|
+
current_fields = legacy_fields | {
|
|
163
|
+
"reproducible",
|
|
164
|
+
"creator",
|
|
165
|
+
"teardown_policy",
|
|
166
|
+
}
|
|
167
|
+
expected = legacy_fields if schema_version == 2 else current_fields
|
|
168
|
+
if not isinstance(item, dict) or set(item) != expected:
|
|
169
|
+
raise ControllerError("INVALID_METADATA", "disposable resource fields are invalid")
|
|
170
|
+
resource_id = _optional_uuid(item.get("resource_id"), "resource_id")
|
|
171
|
+
if resource_id is None:
|
|
172
|
+
raise ControllerError("INVALID_METADATA", "resource_id is required")
|
|
173
|
+
kind = _nonempty_string(item.get("kind"), "resource kind")
|
|
174
|
+
if kind != "python-venv":
|
|
175
|
+
raise ControllerError("INVALID_METADATA", f"unsupported disposable resource: {kind}")
|
|
176
|
+
state = _nonempty_string(item.get("state"), "resource state")
|
|
177
|
+
if state not in {"registered", "cleaned"}:
|
|
178
|
+
raise ControllerError("INVALID_METADATA", f"unsupported resource state: {state}")
|
|
179
|
+
reproducible = item.get("reproducible", False)
|
|
180
|
+
if not isinstance(reproducible, bool):
|
|
181
|
+
raise ControllerError("INVALID_METADATA", "resource reproducible must be boolean")
|
|
182
|
+
creator = (
|
|
183
|
+
_nonempty_string(item.get("creator"), "resource creator")
|
|
184
|
+
if schema_version >= 3
|
|
185
|
+
else "controller-v2-migration"
|
|
186
|
+
)
|
|
187
|
+
teardown_policy = (
|
|
188
|
+
_nonempty_string(item.get("teardown_policy"), "resource teardown policy")
|
|
189
|
+
if schema_version >= 3
|
|
190
|
+
else "manual"
|
|
191
|
+
)
|
|
192
|
+
if teardown_policy not in {"manual", "on-release"}:
|
|
193
|
+
raise ControllerError(
|
|
194
|
+
"INVALID_METADATA", f"unsupported resource teardown policy: {teardown_policy}"
|
|
195
|
+
)
|
|
196
|
+
resources.append(
|
|
197
|
+
{
|
|
198
|
+
"resource_id": resource_id,
|
|
199
|
+
"path": _nonempty_string(item.get("path"), "resource path"),
|
|
200
|
+
"kind": kind,
|
|
201
|
+
"reproducible": reproducible,
|
|
202
|
+
"creator": creator,
|
|
203
|
+
"teardown_policy": teardown_policy,
|
|
204
|
+
"state": state,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
if resources[-1]["path"] != ".venv":
|
|
208
|
+
raise ControllerError("INVALID_METADATA", "python-venv path must be .venv")
|
|
209
|
+
if not reproducible and teardown_policy == "on-release":
|
|
210
|
+
raise ControllerError(
|
|
211
|
+
"INVALID_METADATA", "non-reproducible resources cannot use on-release teardown"
|
|
212
|
+
)
|
|
213
|
+
paths = [str(resource["path"]) for resource in resources]
|
|
214
|
+
if len(paths) != len(set(paths)):
|
|
215
|
+
raise ControllerError("INVALID_METADATA", "disposable resource paths must be unique")
|
|
216
|
+
return tuple(resources)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _validate_timestamp(value: Any, field: str) -> str:
|
|
220
|
+
text = _nonempty_string(value, field)
|
|
221
|
+
try:
|
|
222
|
+
parsed = datetime.fromisoformat(text)
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
raise ControllerError("INVALID_METADATA", f"{field} is not ISO-8601") from exc
|
|
225
|
+
if parsed.tzinfo is None:
|
|
226
|
+
raise ControllerError("INVALID_METADATA", f"{field} must include a timezone")
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _required_sha256(value: Any, field: str) -> str:
|
|
231
|
+
text = _nonempty_string(value, field)
|
|
232
|
+
if len(text) != 64 or _LOWERCASE_HEX.fullmatch(text) is None:
|
|
233
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a SHA-256 digest")
|
|
234
|
+
return text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _optional_sha256(value: Any, field: str) -> str | None:
|
|
238
|
+
if value is None:
|
|
239
|
+
return None
|
|
240
|
+
return _required_sha256(value, field)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _scope_digest_value(scopes: tuple[str, ...]) -> str:
|
|
244
|
+
payload = json.dumps(list(scopes), ensure_ascii=True, separators=(",", ":"))
|
|
245
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _nonnegative_int(value: Any, field: str) -> int:
|
|
249
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
250
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be a non-negative integer")
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _positive_int(value: Any, field: str) -> int:
|
|
255
|
+
result = _nonnegative_int(value, field)
|
|
256
|
+
if result < 1:
|
|
257
|
+
raise ControllerError("INVALID_METADATA", f"{field} must be positive")
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_teardown_receipt(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
262
|
+
expected = {
|
|
263
|
+
"schema_version",
|
|
264
|
+
"receipt_id",
|
|
265
|
+
"transaction_id",
|
|
266
|
+
"repository_id",
|
|
267
|
+
"allocation_id",
|
|
268
|
+
"owner",
|
|
269
|
+
"authority_key",
|
|
270
|
+
"worktree_path",
|
|
271
|
+
"worktree_head",
|
|
272
|
+
"environment_path",
|
|
273
|
+
"pyvenv_cfg_sha256",
|
|
274
|
+
"cachedir_tag_sha256",
|
|
275
|
+
"lock_path",
|
|
276
|
+
"lock_sha256",
|
|
277
|
+
"project_path",
|
|
278
|
+
"project_sha256",
|
|
279
|
+
"python_version",
|
|
280
|
+
"python_implementation",
|
|
281
|
+
"python_identity_sha256",
|
|
282
|
+
"environment_identity_sha256",
|
|
283
|
+
"environment_device",
|
|
284
|
+
"environment_inode",
|
|
285
|
+
"bytes_before",
|
|
286
|
+
"entries_before",
|
|
287
|
+
"result",
|
|
288
|
+
"created_at",
|
|
289
|
+
"updated_at",
|
|
290
|
+
}
|
|
291
|
+
if set(data) != expected or data.get("schema_version") != TEARDOWN_RECEIPT_SCHEMA_VERSION:
|
|
292
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt schema is invalid")
|
|
293
|
+
receipt_id = _optional_uuid(data.get("receipt_id"), "receipt_id")
|
|
294
|
+
transaction_id = _optional_uuid(data.get("transaction_id"), "transaction_id")
|
|
295
|
+
repository_id = _optional_uuid(data.get("repository_id"), "repository_id")
|
|
296
|
+
allocation_id = _optional_uuid(data.get("allocation_id"), "allocation_id")
|
|
297
|
+
if None in {receipt_id, transaction_id, repository_id, allocation_id}:
|
|
298
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt identifiers are required")
|
|
299
|
+
if receipt_id != transaction_id:
|
|
300
|
+
raise ControllerError("INVALID_METADATA", "receipt and transaction identifiers differ")
|
|
301
|
+
worktree_path = Path(_nonempty_string(data.get("worktree_path"), "worktree_path"))
|
|
302
|
+
if not worktree_path.is_absolute():
|
|
303
|
+
raise ControllerError("INVALID_METADATA", "teardown worktree path must be absolute")
|
|
304
|
+
if data.get("environment_path") != ".venv":
|
|
305
|
+
raise ControllerError("INVALID_METADATA", "teardown environment path must be .venv")
|
|
306
|
+
lock_path = data.get("lock_path")
|
|
307
|
+
project_path = data.get("project_path")
|
|
308
|
+
if (
|
|
309
|
+
not isinstance(lock_path, str)
|
|
310
|
+
or not isinstance(project_path, str)
|
|
311
|
+
or not lock_path
|
|
312
|
+
or not project_path
|
|
313
|
+
):
|
|
314
|
+
raise ControllerError("INVALID_METADATA", "teardown project identity paths are invalid")
|
|
315
|
+
lock_p = PurePosixPath(lock_path)
|
|
316
|
+
project_p = PurePosixPath(project_path)
|
|
317
|
+
if (
|
|
318
|
+
lock_p.is_absolute()
|
|
319
|
+
or project_p.is_absolute()
|
|
320
|
+
or any(part in {"..", ".", ""} for part in lock_p.parts)
|
|
321
|
+
or any(part in {"..", ".", ""} for part in project_p.parts)
|
|
322
|
+
or lock_p.name != "uv.lock"
|
|
323
|
+
or project_p.name != "pyproject.toml"
|
|
324
|
+
or lock_p.parent != project_p.parent
|
|
325
|
+
):
|
|
326
|
+
raise ControllerError("INVALID_METADATA", "teardown project identity paths are invalid")
|
|
327
|
+
bytes_before = data.get("bytes_before")
|
|
328
|
+
entries_before = data.get("entries_before")
|
|
329
|
+
environment_device = data.get("environment_device")
|
|
330
|
+
environment_inode = data.get("environment_inode")
|
|
331
|
+
if any(
|
|
332
|
+
not isinstance(value, int) or isinstance(value, bool) or value < 0
|
|
333
|
+
for value in (bytes_before, entries_before, environment_device, environment_inode)
|
|
334
|
+
):
|
|
335
|
+
raise ControllerError(
|
|
336
|
+
"INVALID_METADATA", "teardown filesystem and size evidence must be non-negative"
|
|
337
|
+
)
|
|
338
|
+
result = _nonempty_string(data.get("result"), "teardown result")
|
|
339
|
+
if result not in TEARDOWN_RECEIPT_RESULTS:
|
|
340
|
+
raise ControllerError("INVALID_METADATA", "teardown receipt result is invalid")
|
|
341
|
+
validated = dict(data)
|
|
342
|
+
for field in (
|
|
343
|
+
"pyvenv_cfg_sha256",
|
|
344
|
+
"cachedir_tag_sha256",
|
|
345
|
+
"lock_sha256",
|
|
346
|
+
"project_sha256",
|
|
347
|
+
"python_identity_sha256",
|
|
348
|
+
"environment_identity_sha256",
|
|
349
|
+
):
|
|
350
|
+
validated[field] = _required_sha256(data.get(field), field)
|
|
351
|
+
validated["worktree_head"] = _required_oid(data.get("worktree_head"), "worktree_head")
|
|
352
|
+
validated["owner"] = _nonempty_string(data.get("owner"), "owner")
|
|
353
|
+
validated["authority_key"] = _nonempty_string(data.get("authority_key"), "authority_key")
|
|
354
|
+
validated["python_version"] = _nonempty_string(data.get("python_version"), "python_version")
|
|
355
|
+
validated["python_implementation"] = _optional_string(
|
|
356
|
+
data.get("python_implementation"), "python_implementation"
|
|
357
|
+
)
|
|
358
|
+
validated["created_at"] = _validate_timestamp(data.get("created_at"), "created_at")
|
|
359
|
+
validated["updated_at"] = _validate_timestamp(data.get("updated_at"), "updated_at")
|
|
360
|
+
return validated
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def record_from_dict(data: Mapping[str, Any]) -> MetadataRecord:
|
|
364
|
+
expected_v1 = {
|
|
365
|
+
"schema_version",
|
|
366
|
+
"record_version",
|
|
367
|
+
"allocation_id",
|
|
368
|
+
"task_key",
|
|
369
|
+
"authority_key",
|
|
370
|
+
"module",
|
|
371
|
+
"role",
|
|
372
|
+
"owner",
|
|
373
|
+
"lifecycle",
|
|
374
|
+
"created_at",
|
|
375
|
+
"last_reviewed_at",
|
|
376
|
+
"handoff_commit",
|
|
377
|
+
"finalizer_record_id",
|
|
378
|
+
"external_bindings",
|
|
379
|
+
"history",
|
|
380
|
+
}
|
|
381
|
+
expected_v2 = expected_v1 | {
|
|
382
|
+
"base_commit",
|
|
383
|
+
"task_commit",
|
|
384
|
+
"finalizer_commit",
|
|
385
|
+
"scope_paths",
|
|
386
|
+
"source_allocations",
|
|
387
|
+
"quarantine_expected_commit",
|
|
388
|
+
"quarantine_observed_commit",
|
|
389
|
+
"quarantine_reason",
|
|
390
|
+
"recovery_allocation_id",
|
|
391
|
+
"recovery_ref",
|
|
392
|
+
"disposable_resources",
|
|
393
|
+
}
|
|
394
|
+
schema_version = data.get("schema_version")
|
|
395
|
+
expected = expected_v1 if schema_version == 1 else expected_v2
|
|
396
|
+
if schema_version not in SUPPORTED_METADATA_SCHEMA_VERSIONS or set(data) != expected:
|
|
397
|
+
raise ControllerError("INVALID_METADATA", "record schema fields are invalid")
|
|
398
|
+
record_version = data.get("record_version")
|
|
399
|
+
if (
|
|
400
|
+
not isinstance(record_version, int)
|
|
401
|
+
or isinstance(record_version, bool)
|
|
402
|
+
or record_version < 1
|
|
403
|
+
):
|
|
404
|
+
raise ControllerError("INVALID_METADATA", "record_version must be positive")
|
|
405
|
+
allocation_id = _nonempty_string(data.get("allocation_id"), "allocation_id")
|
|
406
|
+
try:
|
|
407
|
+
uuid.UUID(allocation_id)
|
|
408
|
+
except ValueError as exc:
|
|
409
|
+
raise ControllerError("INVALID_METADATA", "allocation_id must be a UUID") from exc
|
|
410
|
+
role = _nonempty_string(data.get("role"), "role")
|
|
411
|
+
if role not in WORKTREE_ROLES:
|
|
412
|
+
raise ControllerError("INVALID_METADATA", f"unsupported role: {role}")
|
|
413
|
+
lifecycle = _nonempty_string(data.get("lifecycle"), "lifecycle")
|
|
414
|
+
if lifecycle not in LIFECYCLE_STATES:
|
|
415
|
+
raise ControllerError("INVALID_METADATA", f"unsupported lifecycle: {lifecycle}")
|
|
416
|
+
external = _string_list(data.get("external_bindings"), "external_bindings")
|
|
417
|
+
history = data.get("history")
|
|
418
|
+
if not isinstance(history, list) or not all(isinstance(item, dict) for item in history):
|
|
419
|
+
raise ControllerError("INVALID_METADATA", "history must be an object list")
|
|
420
|
+
return MetadataRecord(
|
|
421
|
+
schema_version=schema_version,
|
|
422
|
+
record_version=record_version,
|
|
423
|
+
allocation_id=allocation_id,
|
|
424
|
+
task_key=_nonempty_string(data.get("task_key"), "task_key"),
|
|
425
|
+
authority_key=_nonempty_string(data.get("authority_key"), "authority_key"),
|
|
426
|
+
module=_nonempty_string(data.get("module"), "module"),
|
|
427
|
+
role=role,
|
|
428
|
+
owner=_nonempty_string(data.get("owner"), "owner"),
|
|
429
|
+
lifecycle=lifecycle,
|
|
430
|
+
created_at=_validate_timestamp(data.get("created_at"), "created_at"),
|
|
431
|
+
last_reviewed_at=_validate_timestamp(data.get("last_reviewed_at"), "last_reviewed_at"),
|
|
432
|
+
handoff_commit=_optional_string(data.get("handoff_commit"), "handoff_commit"),
|
|
433
|
+
finalizer_record_id=_optional_string(
|
|
434
|
+
data.get("finalizer_record_id"), "finalizer_record_id"
|
|
435
|
+
),
|
|
436
|
+
base_commit=(
|
|
437
|
+
_optional_oid(data.get("base_commit"), "base_commit") if schema_version >= 2 else None
|
|
438
|
+
),
|
|
439
|
+
task_commit=(
|
|
440
|
+
_optional_oid(data.get("task_commit"), "task_commit")
|
|
441
|
+
if schema_version >= 2
|
|
442
|
+
else _optional_oid(data.get("handoff_commit"), "handoff_commit")
|
|
443
|
+
),
|
|
444
|
+
finalizer_commit=(
|
|
445
|
+
_optional_oid(data.get("finalizer_commit"), "finalizer_commit")
|
|
446
|
+
if schema_version >= 2
|
|
447
|
+
else (
|
|
448
|
+
_optional_oid(data.get("handoff_commit"), "handoff_commit")
|
|
449
|
+
if data.get("finalizer_record_id")
|
|
450
|
+
else None
|
|
451
|
+
)
|
|
452
|
+
),
|
|
453
|
+
scope_paths=(_scope_paths(data.get("scope_paths")) if schema_version >= 2 else ()),
|
|
454
|
+
source_allocations=(
|
|
455
|
+
_source_allocations(data.get("source_allocations")) if schema_version >= 2 else ()
|
|
456
|
+
),
|
|
457
|
+
quarantine_expected_commit=(
|
|
458
|
+
_optional_oid(data.get("quarantine_expected_commit"), "quarantine_expected_commit")
|
|
459
|
+
if schema_version >= 2
|
|
460
|
+
else None
|
|
461
|
+
),
|
|
462
|
+
quarantine_observed_commit=(
|
|
463
|
+
_optional_oid(data.get("quarantine_observed_commit"), "quarantine_observed_commit")
|
|
464
|
+
if schema_version >= 2
|
|
465
|
+
else None
|
|
466
|
+
),
|
|
467
|
+
quarantine_reason=(
|
|
468
|
+
_optional_string(data.get("quarantine_reason"), "quarantine_reason")
|
|
469
|
+
if schema_version >= 2
|
|
470
|
+
else None
|
|
471
|
+
),
|
|
472
|
+
recovery_allocation_id=(
|
|
473
|
+
_optional_uuid(data.get("recovery_allocation_id"), "recovery_allocation_id")
|
|
474
|
+
if schema_version >= 2
|
|
475
|
+
else None
|
|
476
|
+
),
|
|
477
|
+
recovery_ref=(
|
|
478
|
+
_optional_recovery_ref(data.get("recovery_ref")) if schema_version >= 2 else None
|
|
479
|
+
),
|
|
480
|
+
disposable_resources=(
|
|
481
|
+
_disposable_resources(data.get("disposable_resources"), schema_version=schema_version)
|
|
482
|
+
if schema_version >= 2
|
|
483
|
+
else ()
|
|
484
|
+
),
|
|
485
|
+
external_bindings=external,
|
|
486
|
+
history=tuple(dict(item) for item in history),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def record_to_dict(record: MetadataRecord) -> dict[str, Any]:
|
|
491
|
+
payload = asdict(record)
|
|
492
|
+
payload["schema_version"] = METADATA_SCHEMA_VERSION
|
|
493
|
+
payload["scope_paths"] = list(record.scope_paths)
|
|
494
|
+
payload["source_allocations"] = list(record.source_allocations)
|
|
495
|
+
payload["disposable_resources"] = [dict(item) for item in record.disposable_resources]
|
|
496
|
+
payload["external_bindings"] = list(record.external_bindings)
|
|
497
|
+
payload["history"] = [dict(item) for item in record.history]
|
|
498
|
+
return payload
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def integration_intent_from_dict(data: Mapping[str, Any]) -> IntegrationIntent:
|
|
502
|
+
expected_v1 = {
|
|
503
|
+
"schema_version",
|
|
504
|
+
"intent_id",
|
|
505
|
+
"task_key",
|
|
506
|
+
"authority_key",
|
|
507
|
+
"module",
|
|
508
|
+
"owner",
|
|
509
|
+
"source_allocations",
|
|
510
|
+
"source_handoffs",
|
|
511
|
+
"scope_paths",
|
|
512
|
+
"state",
|
|
513
|
+
"allocation_id",
|
|
514
|
+
"base_main_oid",
|
|
515
|
+
"candidate_oid",
|
|
516
|
+
"revision",
|
|
517
|
+
"validation_evidence_id",
|
|
518
|
+
"validated_oid",
|
|
519
|
+
"blocked_reason",
|
|
520
|
+
"enqueued_at",
|
|
521
|
+
"claimed_at",
|
|
522
|
+
"validated_at",
|
|
523
|
+
"published_at",
|
|
524
|
+
"released_at",
|
|
525
|
+
"updated_at",
|
|
526
|
+
"history",
|
|
527
|
+
}
|
|
528
|
+
expected_v2 = expected_v1 | {"equivalent_replays"}
|
|
529
|
+
expected_v3 = expected_v2 | {
|
|
530
|
+
"contract_version",
|
|
531
|
+
"source_branches",
|
|
532
|
+
"source_finalizer_record_ids",
|
|
533
|
+
"source_snapshot_ids",
|
|
534
|
+
"scope_digest",
|
|
535
|
+
"queue_sequence",
|
|
536
|
+
"attempt",
|
|
537
|
+
"prepare_identity",
|
|
538
|
+
"publication_identity",
|
|
539
|
+
"recovery_identity",
|
|
540
|
+
"prepared_receipt_id",
|
|
541
|
+
"prepared_base_origin_main_oid",
|
|
542
|
+
"prepared_commit_oid",
|
|
543
|
+
"prepared_tree_oid",
|
|
544
|
+
"prepared_scope_digest",
|
|
545
|
+
"tests_receipt_id",
|
|
546
|
+
"snapshot_receipt_id",
|
|
547
|
+
}
|
|
548
|
+
schema_version = data.get("schema_version")
|
|
549
|
+
expected = {1: expected_v1, 2: expected_v2, 3: expected_v3}.get(schema_version)
|
|
550
|
+
if expected is None or set(data) != expected:
|
|
551
|
+
raise ControllerError("INVALID_METADATA", "integration intent schema is invalid")
|
|
552
|
+
intent_id = _optional_uuid(data.get("intent_id"), "intent_id")
|
|
553
|
+
if intent_id is None:
|
|
554
|
+
raise ControllerError("INVALID_METADATA", "intent_id is required")
|
|
555
|
+
state = _nonempty_string(data.get("state"), "intent state")
|
|
556
|
+
if state not in INTEGRATION_INTENT_STATES:
|
|
557
|
+
raise ControllerError("INVALID_METADATA", "integration intent state is invalid")
|
|
558
|
+
if schema_version < 3:
|
|
559
|
+
state = {
|
|
560
|
+
"CLAIMED": "PREPARING_INTEGRATION",
|
|
561
|
+
"VALIDATED": "READY_TO_PUBLISH",
|
|
562
|
+
"BLOCKED": "ATTENTION_REQUIRED",
|
|
563
|
+
"PUBLISHED": "REMOTE_VERIFIED",
|
|
564
|
+
}.get(state, state)
|
|
565
|
+
source_allocations = _source_allocations(data.get("source_allocations"))
|
|
566
|
+
source_handoffs = _string_list(data.get("source_handoffs"), "source_handoffs")
|
|
567
|
+
if len(source_allocations) != len(source_handoffs) or not source_handoffs:
|
|
568
|
+
raise ControllerError(
|
|
569
|
+
"INVALID_METADATA", "integration intent sources and frozen handoffs must align"
|
|
570
|
+
)
|
|
571
|
+
for index, oid in enumerate(source_handoffs):
|
|
572
|
+
_required_oid(oid, f"source_handoffs[{index}]")
|
|
573
|
+
source_count = len(source_allocations)
|
|
574
|
+
source_branches = (
|
|
575
|
+
_string_list(data.get("source_branches"), "source_branches")
|
|
576
|
+
if schema_version >= 3
|
|
577
|
+
else tuple("legacy-unrecorded" for _ in range(source_count))
|
|
578
|
+
)
|
|
579
|
+
source_finalizers = (
|
|
580
|
+
_string_list(data.get("source_finalizer_record_ids"), "source_finalizer_record_ids")
|
|
581
|
+
if schema_version >= 3
|
|
582
|
+
else tuple("legacy-unrecorded" for _ in range(source_count))
|
|
583
|
+
)
|
|
584
|
+
source_snapshots = (
|
|
585
|
+
_string_list(data.get("source_snapshot_ids"), "source_snapshot_ids")
|
|
586
|
+
if schema_version >= 3
|
|
587
|
+
else tuple("legacy-unrecorded" for _ in range(source_count))
|
|
588
|
+
)
|
|
589
|
+
if any(
|
|
590
|
+
len(values) != source_count
|
|
591
|
+
for values in (source_branches, source_finalizers, source_snapshots)
|
|
592
|
+
):
|
|
593
|
+
raise ControllerError("INVALID_METADATA", "integration source evidence must align")
|
|
594
|
+
scopes = _scope_paths(data.get("scope_paths"))
|
|
595
|
+
scope_digest = (
|
|
596
|
+
_required_sha256(data.get("scope_digest"), "scope_digest")
|
|
597
|
+
if schema_version >= 3
|
|
598
|
+
else _scope_digest_value(scopes)
|
|
599
|
+
)
|
|
600
|
+
if scope_digest != _scope_digest_value(scopes):
|
|
601
|
+
raise ControllerError("INVALID_METADATA", "integration scope digest differs")
|
|
602
|
+
history = data.get("history")
|
|
603
|
+
if (
|
|
604
|
+
not isinstance(history, list)
|
|
605
|
+
or not history
|
|
606
|
+
or not all(isinstance(item, dict) for item in history)
|
|
607
|
+
):
|
|
608
|
+
raise ControllerError("INVALID_METADATA", "integration intent history is invalid")
|
|
609
|
+
allocation_id = _optional_uuid(data.get("allocation_id"), "allocation_id")
|
|
610
|
+
candidate_oid = _optional_oid(data.get("candidate_oid"), "candidate_oid")
|
|
611
|
+
validated_oid = _optional_oid(data.get("validated_oid"), "validated_oid")
|
|
612
|
+
validation_evidence_id = _optional_string(
|
|
613
|
+
data.get("validation_evidence_id"), "validation_evidence_id"
|
|
614
|
+
)
|
|
615
|
+
if state in {"QUEUED", "WAITING_SCOPE"} and any(
|
|
616
|
+
value is not None
|
|
617
|
+
for value in (allocation_id, candidate_oid, validated_oid, validation_evidence_id)
|
|
618
|
+
):
|
|
619
|
+
raise ControllerError("INVALID_METADATA", "queued intent cannot bind candidate evidence")
|
|
620
|
+
if state in {
|
|
621
|
+
"PREPARING_INTEGRATION",
|
|
622
|
+
"READY_TO_PUBLISH",
|
|
623
|
+
"NEEDS_REPREPARE",
|
|
624
|
+
"PUBLISHING",
|
|
625
|
+
"RECOVERY_REQUIRED",
|
|
626
|
+
"REMOTE_VERIFIED",
|
|
627
|
+
"RELEASED",
|
|
628
|
+
} and (allocation_id is None or candidate_oid is None):
|
|
629
|
+
raise ControllerError("INVALID_METADATA", "claimed intent lacks candidate identity")
|
|
630
|
+
if state in {"READY_TO_PUBLISH", "PUBLISHING", "REMOTE_VERIFIED", "RELEASED"} and (
|
|
631
|
+
validated_oid != candidate_oid or validation_evidence_id is None
|
|
632
|
+
):
|
|
633
|
+
raise ControllerError("INVALID_METADATA", "validated intent evidence is incomplete")
|
|
634
|
+
raw_replays = data.get("equivalent_replays", [])
|
|
635
|
+
if not isinstance(raw_replays, list):
|
|
636
|
+
raise ControllerError("INVALID_METADATA", "equivalent_replays must be an object list")
|
|
637
|
+
equivalent_replays: list[dict[str, str]] = []
|
|
638
|
+
for replay in raw_replays:
|
|
639
|
+
fields = {
|
|
640
|
+
"source_allocation_id",
|
|
641
|
+
"source_handoff_oid",
|
|
642
|
+
"replay_oid",
|
|
643
|
+
"evidence_id",
|
|
644
|
+
}
|
|
645
|
+
if not isinstance(replay, dict) or set(replay) != fields:
|
|
646
|
+
raise ControllerError("INVALID_METADATA", "equivalent replay fields are invalid")
|
|
647
|
+
source_allocation_id = _optional_uuid(
|
|
648
|
+
replay.get("source_allocation_id"), "source_allocation_id"
|
|
649
|
+
)
|
|
650
|
+
if source_allocation_id is None or source_allocation_id not in source_allocations:
|
|
651
|
+
raise ControllerError(
|
|
652
|
+
"INVALID_METADATA", "equivalent replay source allocation is invalid"
|
|
653
|
+
)
|
|
654
|
+
source_index = source_allocations.index(source_allocation_id)
|
|
655
|
+
source_handoff_oid = _required_oid(replay.get("source_handoff_oid"), "source_handoff_oid")
|
|
656
|
+
if source_handoff_oid != source_handoffs[source_index]:
|
|
657
|
+
raise ControllerError("INVALID_METADATA", "equivalent replay source handoff differs")
|
|
658
|
+
equivalent_replays.append(
|
|
659
|
+
{
|
|
660
|
+
"source_allocation_id": source_allocation_id,
|
|
661
|
+
"source_handoff_oid": source_handoff_oid,
|
|
662
|
+
"replay_oid": _required_oid(replay.get("replay_oid"), "replay_oid"),
|
|
663
|
+
"evidence_id": _nonempty_string(replay.get("evidence_id"), "evidence_id"),
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
replay_sources = [item["source_allocation_id"] for item in equivalent_replays]
|
|
667
|
+
if len(replay_sources) != len(set(replay_sources)):
|
|
668
|
+
raise ControllerError("INVALID_METADATA", "equivalent replay sources must be unique")
|
|
669
|
+
prepared_receipt_id = (
|
|
670
|
+
_optional_uuid(data.get("prepared_receipt_id"), "prepared_receipt_id")
|
|
671
|
+
if schema_version >= 3
|
|
672
|
+
else None
|
|
673
|
+
)
|
|
674
|
+
prepared_base = (
|
|
675
|
+
_optional_oid(data.get("prepared_base_origin_main_oid"), "prepared_base_origin_main_oid")
|
|
676
|
+
if schema_version >= 3
|
|
677
|
+
else (
|
|
678
|
+
_optional_oid(data.get("base_main_oid"), "base_main_oid")
|
|
679
|
+
if state == "READY_TO_PUBLISH"
|
|
680
|
+
else None
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
prepared_commit = (
|
|
684
|
+
_optional_oid(data.get("prepared_commit_oid"), "prepared_commit_oid")
|
|
685
|
+
if schema_version >= 3
|
|
686
|
+
else (candidate_oid if state == "READY_TO_PUBLISH" else None)
|
|
687
|
+
)
|
|
688
|
+
prepared_tree = (
|
|
689
|
+
_optional_oid(data.get("prepared_tree_oid"), "prepared_tree_oid")
|
|
690
|
+
if schema_version >= 3
|
|
691
|
+
else None
|
|
692
|
+
)
|
|
693
|
+
prepared_scope = (
|
|
694
|
+
_optional_sha256(data.get("prepared_scope_digest"), "prepared_scope_digest")
|
|
695
|
+
if schema_version >= 3
|
|
696
|
+
else (scope_digest if state == "READY_TO_PUBLISH" else None)
|
|
697
|
+
)
|
|
698
|
+
prepare_identity = (
|
|
699
|
+
_optional_sha256(data.get("prepare_identity"), "prepare_identity")
|
|
700
|
+
if schema_version >= 3
|
|
701
|
+
else None
|
|
702
|
+
)
|
|
703
|
+
tests_receipt_id = (
|
|
704
|
+
_optional_string(data.get("tests_receipt_id"), "tests_receipt_id")
|
|
705
|
+
if schema_version >= 3
|
|
706
|
+
else None
|
|
707
|
+
)
|
|
708
|
+
snapshot_receipt_id = (
|
|
709
|
+
_optional_string(data.get("snapshot_receipt_id"), "snapshot_receipt_id")
|
|
710
|
+
if schema_version >= 3
|
|
711
|
+
else None
|
|
712
|
+
)
|
|
713
|
+
if (
|
|
714
|
+
schema_version >= 3
|
|
715
|
+
and state
|
|
716
|
+
in {
|
|
717
|
+
"READY_TO_PUBLISH",
|
|
718
|
+
"PUBLISHING",
|
|
719
|
+
"REMOTE_VERIFIED",
|
|
720
|
+
"RELEASED",
|
|
721
|
+
}
|
|
722
|
+
and any(
|
|
723
|
+
value is None
|
|
724
|
+
for value in (
|
|
725
|
+
prepared_receipt_id,
|
|
726
|
+
prepared_base,
|
|
727
|
+
prepared_commit,
|
|
728
|
+
prepared_tree,
|
|
729
|
+
prepared_scope,
|
|
730
|
+
prepare_identity,
|
|
731
|
+
tests_receipt_id,
|
|
732
|
+
snapshot_receipt_id,
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
):
|
|
736
|
+
raise ControllerError("INVALID_METADATA", "prepared candidate contract is incomplete")
|
|
737
|
+
contract_version = (
|
|
738
|
+
_positive_int(data.get("contract_version"), "contract_version")
|
|
739
|
+
if schema_version >= 3
|
|
740
|
+
else 1
|
|
741
|
+
)
|
|
742
|
+
if schema_version >= 3 and contract_version != INTEGRATION_INTENT_CONTRACT_VERSION:
|
|
743
|
+
raise ControllerError("INVALID_METADATA", "integration intent contract version is invalid")
|
|
744
|
+
return IntegrationIntent(
|
|
745
|
+
schema_version=int(schema_version),
|
|
746
|
+
contract_version=contract_version,
|
|
747
|
+
intent_id=intent_id,
|
|
748
|
+
task_key=_nonempty_string(data.get("task_key"), "task_key"),
|
|
749
|
+
authority_key=_nonempty_string(data.get("authority_key"), "authority_key"),
|
|
750
|
+
module=_nonempty_string(data.get("module"), "module"),
|
|
751
|
+
owner=_nonempty_string(data.get("owner"), "owner"),
|
|
752
|
+
source_allocations=source_allocations,
|
|
753
|
+
source_handoffs=tuple(source_handoffs),
|
|
754
|
+
source_branches=tuple(source_branches),
|
|
755
|
+
source_finalizer_record_ids=tuple(source_finalizers),
|
|
756
|
+
source_snapshot_ids=tuple(source_snapshots),
|
|
757
|
+
scope_paths=scopes,
|
|
758
|
+
scope_digest=scope_digest,
|
|
759
|
+
queue_sequence=(
|
|
760
|
+
_nonnegative_int(data.get("queue_sequence"), "queue_sequence")
|
|
761
|
+
if schema_version >= 3
|
|
762
|
+
else 0
|
|
763
|
+
),
|
|
764
|
+
state=state,
|
|
765
|
+
allocation_id=allocation_id,
|
|
766
|
+
base_main_oid=_optional_oid(data.get("base_main_oid"), "base_main_oid"),
|
|
767
|
+
candidate_oid=candidate_oid,
|
|
768
|
+
revision=_nonnegative_int(data.get("revision"), "revision"),
|
|
769
|
+
attempt=(
|
|
770
|
+
_positive_int(data.get("attempt"), "attempt")
|
|
771
|
+
if schema_version >= 3
|
|
772
|
+
else max(_nonnegative_int(data.get("revision"), "revision"), 1)
|
|
773
|
+
),
|
|
774
|
+
prepare_identity=prepare_identity,
|
|
775
|
+
publication_identity=(
|
|
776
|
+
_optional_uuid(data.get("publication_identity"), "publication_identity")
|
|
777
|
+
if schema_version >= 3
|
|
778
|
+
else None
|
|
779
|
+
),
|
|
780
|
+
recovery_identity=(
|
|
781
|
+
_optional_string(data.get("recovery_identity"), "recovery_identity")
|
|
782
|
+
if schema_version >= 3
|
|
783
|
+
else None
|
|
784
|
+
),
|
|
785
|
+
prepared_receipt_id=prepared_receipt_id,
|
|
786
|
+
prepared_base_origin_main_oid=prepared_base,
|
|
787
|
+
prepared_commit_oid=prepared_commit,
|
|
788
|
+
prepared_tree_oid=prepared_tree,
|
|
789
|
+
prepared_scope_digest=prepared_scope,
|
|
790
|
+
tests_receipt_id=tests_receipt_id,
|
|
791
|
+
snapshot_receipt_id=snapshot_receipt_id,
|
|
792
|
+
validation_evidence_id=validation_evidence_id,
|
|
793
|
+
validated_oid=validated_oid,
|
|
794
|
+
blocked_reason=_optional_string(data.get("blocked_reason"), "blocked_reason"),
|
|
795
|
+
enqueued_at=_validate_timestamp(data.get("enqueued_at"), "enqueued_at"),
|
|
796
|
+
claimed_at=(
|
|
797
|
+
_validate_timestamp(data.get("claimed_at"), "claimed_at")
|
|
798
|
+
if data.get("claimed_at") is not None
|
|
799
|
+
else None
|
|
800
|
+
),
|
|
801
|
+
validated_at=(
|
|
802
|
+
_validate_timestamp(data.get("validated_at"), "validated_at")
|
|
803
|
+
if data.get("validated_at") is not None
|
|
804
|
+
else None
|
|
805
|
+
),
|
|
806
|
+
published_at=(
|
|
807
|
+
_validate_timestamp(data.get("published_at"), "published_at")
|
|
808
|
+
if data.get("published_at") is not None
|
|
809
|
+
else None
|
|
810
|
+
),
|
|
811
|
+
released_at=(
|
|
812
|
+
_validate_timestamp(data.get("released_at"), "released_at")
|
|
813
|
+
if data.get("released_at") is not None
|
|
814
|
+
else None
|
|
815
|
+
),
|
|
816
|
+
updated_at=_validate_timestamp(data.get("updated_at"), "updated_at"),
|
|
817
|
+
equivalent_replays=tuple(equivalent_replays),
|
|
818
|
+
history=tuple(dict(item) for item in history),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def integration_intent_to_dict(intent: IntegrationIntent) -> dict[str, Any]:
|
|
823
|
+
payload = asdict(intent)
|
|
824
|
+
payload["source_allocations"] = list(intent.source_allocations)
|
|
825
|
+
payload["source_handoffs"] = list(intent.source_handoffs)
|
|
826
|
+
payload["source_branches"] = list(intent.source_branches)
|
|
827
|
+
payload["source_finalizer_record_ids"] = list(intent.source_finalizer_record_ids)
|
|
828
|
+
payload["source_snapshot_ids"] = list(intent.source_snapshot_ids)
|
|
829
|
+
payload["scope_paths"] = list(intent.scope_paths)
|
|
830
|
+
payload["schema_version"] = INTEGRATION_INTENT_SCHEMA_VERSION
|
|
831
|
+
payload["contract_version"] = INTEGRATION_INTENT_CONTRACT_VERSION
|
|
832
|
+
payload["equivalent_replays"] = [dict(item) for item in intent.equivalent_replays]
|
|
833
|
+
payload["history"] = [dict(item) for item in intent.history]
|
|
834
|
+
return payload
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def prepared_candidate_receipt_from_dict(
|
|
838
|
+
data: Mapping[str, Any],
|
|
839
|
+
) -> PreparedCandidateReceipt:
|
|
840
|
+
expected = {
|
|
841
|
+
"schema_version",
|
|
842
|
+
"receipt_id",
|
|
843
|
+
"repository_id",
|
|
844
|
+
"intent_id",
|
|
845
|
+
"allocation_id",
|
|
846
|
+
"attempt",
|
|
847
|
+
"base_origin_main_oid",
|
|
848
|
+
"candidate_oid",
|
|
849
|
+
"tree_oid",
|
|
850
|
+
"scope_digest",
|
|
851
|
+
"prepare_identity",
|
|
852
|
+
"validation_evidence_id",
|
|
853
|
+
"tests_receipt_id",
|
|
854
|
+
"snapshot_receipt_id",
|
|
855
|
+
"created_at",
|
|
856
|
+
}
|
|
857
|
+
if (
|
|
858
|
+
set(data) != expected
|
|
859
|
+
or data.get("schema_version") != PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION
|
|
860
|
+
):
|
|
861
|
+
raise ControllerError("INVALID_METADATA", "prepared candidate receipt schema is invalid")
|
|
862
|
+
identifiers = {
|
|
863
|
+
field: _optional_uuid(data.get(field), field)
|
|
864
|
+
for field in ("receipt_id", "repository_id", "intent_id", "allocation_id")
|
|
865
|
+
}
|
|
866
|
+
if any(value is None for value in identifiers.values()):
|
|
867
|
+
raise ControllerError("INVALID_METADATA", "prepared receipt identifiers are required")
|
|
868
|
+
return PreparedCandidateReceipt(
|
|
869
|
+
schema_version=PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION,
|
|
870
|
+
receipt_id=str(identifiers["receipt_id"]),
|
|
871
|
+
repository_id=str(identifiers["repository_id"]),
|
|
872
|
+
intent_id=str(identifiers["intent_id"]),
|
|
873
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
874
|
+
attempt=_positive_int(data.get("attempt"), "attempt"),
|
|
875
|
+
base_origin_main_oid=_required_oid(
|
|
876
|
+
data.get("base_origin_main_oid"), "base_origin_main_oid"
|
|
877
|
+
),
|
|
878
|
+
candidate_oid=_required_oid(data.get("candidate_oid"), "candidate_oid"),
|
|
879
|
+
tree_oid=_required_oid(data.get("tree_oid"), "tree_oid"),
|
|
880
|
+
scope_digest=_required_sha256(data.get("scope_digest"), "scope_digest"),
|
|
881
|
+
prepare_identity=_required_sha256(data.get("prepare_identity"), "prepare_identity"),
|
|
882
|
+
validation_evidence_id=_nonempty_string(
|
|
883
|
+
data.get("validation_evidence_id"), "validation_evidence_id"
|
|
884
|
+
),
|
|
885
|
+
tests_receipt_id=_nonempty_string(data.get("tests_receipt_id"), "tests_receipt_id"),
|
|
886
|
+
snapshot_receipt_id=_nonempty_string(
|
|
887
|
+
data.get("snapshot_receipt_id"), "snapshot_receipt_id"
|
|
888
|
+
),
|
|
889
|
+
created_at=_validate_timestamp(data.get("created_at"), "created_at"),
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def prepared_candidate_receipt_to_dict(receipt: PreparedCandidateReceipt) -> dict[str, Any]:
|
|
894
|
+
return asdict(receipt)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def lifecycle_event_from_dict(data: Mapping[str, Any]) -> LifecycleEvent:
|
|
898
|
+
expected = {
|
|
899
|
+
"schema_version",
|
|
900
|
+
"event_id",
|
|
901
|
+
"repository_id",
|
|
902
|
+
"intent_id",
|
|
903
|
+
"allocation_id",
|
|
904
|
+
"event_type",
|
|
905
|
+
"state",
|
|
906
|
+
"sequence",
|
|
907
|
+
"identity",
|
|
908
|
+
"created_at",
|
|
909
|
+
}
|
|
910
|
+
if set(data) != expected or data.get("schema_version") != LIFECYCLE_EVENT_SCHEMA_VERSION:
|
|
911
|
+
raise ControllerError("INVALID_METADATA", "lifecycle event schema is invalid")
|
|
912
|
+
identifiers = {
|
|
913
|
+
field: _optional_uuid(data.get(field), field)
|
|
914
|
+
for field in ("event_id", "repository_id", "intent_id")
|
|
915
|
+
}
|
|
916
|
+
if any(value is None for value in identifiers.values()):
|
|
917
|
+
raise ControllerError("INVALID_METADATA", "lifecycle event identifiers are required")
|
|
918
|
+
event_type = _nonempty_string(data.get("event_type"), "event_type")
|
|
919
|
+
if event_type not in INTEGRATION_EVENT_TYPES:
|
|
920
|
+
raise ControllerError("INVALID_METADATA", "lifecycle event type is invalid")
|
|
921
|
+
state = _nonempty_string(data.get("state"), "event state")
|
|
922
|
+
if state not in INTEGRATION_INTENT_STATES:
|
|
923
|
+
raise ControllerError("INVALID_METADATA", "lifecycle event state is invalid")
|
|
924
|
+
return LifecycleEvent(
|
|
925
|
+
schema_version=LIFECYCLE_EVENT_SCHEMA_VERSION,
|
|
926
|
+
event_id=str(identifiers["event_id"]),
|
|
927
|
+
repository_id=str(identifiers["repository_id"]),
|
|
928
|
+
intent_id=str(identifiers["intent_id"]),
|
|
929
|
+
allocation_id=_optional_uuid(data.get("allocation_id"), "allocation_id"),
|
|
930
|
+
event_type=event_type,
|
|
931
|
+
state=state,
|
|
932
|
+
sequence=_nonnegative_int(data.get("sequence"), "sequence"),
|
|
933
|
+
identity=_required_sha256(data.get("identity"), "identity"),
|
|
934
|
+
created_at=_validate_timestamp(data.get("created_at"), "created_at"),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def lifecycle_event_to_dict(event: LifecycleEvent) -> dict[str, Any]:
|
|
939
|
+
return asdict(event)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def integration_reconciliation_receipt_from_dict(
|
|
943
|
+
data: Mapping[str, Any],
|
|
944
|
+
) -> IntegrationReconciliationReceipt:
|
|
945
|
+
expected = {
|
|
946
|
+
"schema_version",
|
|
947
|
+
"receipt_id",
|
|
948
|
+
"repository_id",
|
|
949
|
+
"intent_id",
|
|
950
|
+
"observed_intent_schema_version",
|
|
951
|
+
"observed_intent_state",
|
|
952
|
+
"observed_allocation_id",
|
|
953
|
+
"observed_record_version",
|
|
954
|
+
"observed_record_lifecycle",
|
|
955
|
+
"effective_terminal_state",
|
|
956
|
+
"result",
|
|
957
|
+
"actor",
|
|
958
|
+
"reconciled_at",
|
|
959
|
+
}
|
|
960
|
+
if (
|
|
961
|
+
set(data) != expected
|
|
962
|
+
or data.get("schema_version") != INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION
|
|
963
|
+
):
|
|
964
|
+
raise ControllerError(
|
|
965
|
+
"INVALID_METADATA", "integration reconciliation receipt schema is invalid"
|
|
966
|
+
)
|
|
967
|
+
identifiers = {
|
|
968
|
+
field: _optional_uuid(data.get(field), field)
|
|
969
|
+
for field in ("receipt_id", "repository_id", "intent_id")
|
|
970
|
+
}
|
|
971
|
+
if any(value is None for value in identifiers.values()):
|
|
972
|
+
raise ControllerError("INVALID_METADATA", "integration reconciliation ids are required")
|
|
973
|
+
record_version = data.get("observed_record_version")
|
|
974
|
+
if record_version is not None:
|
|
975
|
+
record_version = _positive_int(record_version, "observed_record_version")
|
|
976
|
+
lifecycle = _optional_string(data.get("observed_record_lifecycle"), "observed_record_lifecycle")
|
|
977
|
+
if lifecycle is not None and lifecycle not in LIFECYCLE_STATES:
|
|
978
|
+
raise ControllerError("INVALID_METADATA", "observed record lifecycle is invalid")
|
|
979
|
+
terminal = _nonempty_string(data.get("effective_terminal_state"), "terminal state")
|
|
980
|
+
if terminal != "RELEASED":
|
|
981
|
+
raise ControllerError("INVALID_METADATA", "reconciled terminal state must be RELEASED")
|
|
982
|
+
result = _nonempty_string(data.get("result"), "reconciliation result")
|
|
983
|
+
if result != "STALE_INTENT_TERMINAL_ACKNOWLEDGED":
|
|
984
|
+
raise ControllerError("INVALID_METADATA", "integration reconciliation result is invalid")
|
|
985
|
+
return IntegrationReconciliationReceipt(
|
|
986
|
+
schema_version=INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION,
|
|
987
|
+
receipt_id=str(identifiers["receipt_id"]),
|
|
988
|
+
repository_id=str(identifiers["repository_id"]),
|
|
989
|
+
intent_id=str(identifiers["intent_id"]),
|
|
990
|
+
observed_intent_schema_version=_positive_int(
|
|
991
|
+
data.get("observed_intent_schema_version"), "observed_intent_schema_version"
|
|
992
|
+
),
|
|
993
|
+
observed_intent_state=_nonempty_string(
|
|
994
|
+
data.get("observed_intent_state"), "observed_intent_state"
|
|
995
|
+
),
|
|
996
|
+
observed_allocation_id=_optional_uuid(
|
|
997
|
+
data.get("observed_allocation_id"), "observed_allocation_id"
|
|
998
|
+
),
|
|
999
|
+
observed_record_version=record_version,
|
|
1000
|
+
observed_record_lifecycle=lifecycle,
|
|
1001
|
+
effective_terminal_state=terminal,
|
|
1002
|
+
result=result,
|
|
1003
|
+
actor=_nonempty_string(data.get("actor"), "actor"),
|
|
1004
|
+
reconciled_at=_validate_timestamp(data.get("reconciled_at"), "reconciled_at"),
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def integration_reconciliation_receipt_to_dict(
|
|
1009
|
+
receipt: IntegrationReconciliationReceipt,
|
|
1010
|
+
) -> dict[str, Any]:
|
|
1011
|
+
return asdict(receipt)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
WRITER_LEASE_SCHEMA_VERSION = 2
|
|
1015
|
+
WRITER_RECEIPT_SCHEMA_VERSION = 1
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def writer_lease_from_dict(data: Mapping[str, Any]) -> WriterLease:
|
|
1019
|
+
expected = {
|
|
1020
|
+
"schema_version",
|
|
1021
|
+
"lease_id",
|
|
1022
|
+
"repository_id",
|
|
1023
|
+
"allocation_id",
|
|
1024
|
+
"worktree_path",
|
|
1025
|
+
"owner",
|
|
1026
|
+
"holder",
|
|
1027
|
+
"acquired_at",
|
|
1028
|
+
"renewed_at",
|
|
1029
|
+
"expires_at",
|
|
1030
|
+
"record_version",
|
|
1031
|
+
}
|
|
1032
|
+
if data.get("schema_version") != WRITER_LEASE_SCHEMA_VERSION or set(data) != expected:
|
|
1033
|
+
raise ControllerError("INVALID_METADATA", "writer lease schema is invalid")
|
|
1034
|
+
lease_id = _optional_uuid(data.get("lease_id"), "lease_id")
|
|
1035
|
+
allocation_id = _optional_uuid(data.get("allocation_id"), "allocation_id")
|
|
1036
|
+
if lease_id is None or allocation_id is None:
|
|
1037
|
+
raise ControllerError("INVALID_METADATA", "writer lease identifiers are required")
|
|
1038
|
+
holder = data.get("holder")
|
|
1039
|
+
if not isinstance(holder, Mapping) or set(holder) != {
|
|
1040
|
+
"client_id",
|
|
1041
|
+
"pid",
|
|
1042
|
+
"boot_id",
|
|
1043
|
+
"start_ticks",
|
|
1044
|
+
"session_id",
|
|
1045
|
+
"run_id",
|
|
1046
|
+
}:
|
|
1047
|
+
raise ControllerError("INVALID_METADATA", "writer lease holder is invalid")
|
|
1048
|
+
session_id = holder.get("session_id")
|
|
1049
|
+
run_id = holder.get("run_id")
|
|
1050
|
+
if session_id is not None and not isinstance(session_id, str):
|
|
1051
|
+
raise ControllerError("INVALID_METADATA", "writer lease session is invalid")
|
|
1052
|
+
if run_id is not None and not isinstance(run_id, str):
|
|
1053
|
+
raise ControllerError("INVALID_METADATA", "writer lease run is invalid")
|
|
1054
|
+
raw_expiry = data.get("expires_at")
|
|
1055
|
+
return WriterLease(
|
|
1056
|
+
schema_version=WRITER_LEASE_SCHEMA_VERSION,
|
|
1057
|
+
lease_id=lease_id,
|
|
1058
|
+
repository_id=_nonempty_string(data.get("repository_id"), "repository_id"),
|
|
1059
|
+
allocation_id=allocation_id,
|
|
1060
|
+
worktree_path=_nonempty_string(data.get("worktree_path"), "worktree_path"),
|
|
1061
|
+
owner=_nonempty_string(data.get("owner"), "owner"),
|
|
1062
|
+
holder=WriterHolder(
|
|
1063
|
+
client_id=_nonempty_string(holder.get("client_id"), "client_id"),
|
|
1064
|
+
pid=_positive_int(holder.get("pid"), "pid"),
|
|
1065
|
+
boot_id=_nonempty_string(holder.get("boot_id"), "boot_id"),
|
|
1066
|
+
start_ticks=_positive_int(holder.get("start_ticks"), "start_ticks"),
|
|
1067
|
+
session_id=session_id,
|
|
1068
|
+
run_id=run_id,
|
|
1069
|
+
),
|
|
1070
|
+
acquired_at=_validate_timestamp(data.get("acquired_at"), "acquired_at"),
|
|
1071
|
+
renewed_at=_validate_timestamp(data.get("renewed_at"), "renewed_at"),
|
|
1072
|
+
expires_at=(None if raw_expiry is None else _validate_timestamp(raw_expiry, "expires_at")),
|
|
1073
|
+
record_version=_positive_int(data.get("record_version"), "record_version"),
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def writer_lease_to_dict(lease: WriterLease) -> dict[str, Any]:
|
|
1078
|
+
return {
|
|
1079
|
+
"schema_version": lease.schema_version,
|
|
1080
|
+
"lease_id": lease.lease_id,
|
|
1081
|
+
"repository_id": lease.repository_id,
|
|
1082
|
+
"allocation_id": lease.allocation_id,
|
|
1083
|
+
"worktree_path": lease.worktree_path,
|
|
1084
|
+
"owner": lease.owner,
|
|
1085
|
+
"holder": {
|
|
1086
|
+
"client_id": lease.holder.client_id,
|
|
1087
|
+
"pid": lease.holder.pid,
|
|
1088
|
+
"boot_id": lease.holder.boot_id,
|
|
1089
|
+
"start_ticks": lease.holder.start_ticks,
|
|
1090
|
+
"session_id": lease.holder.session_id,
|
|
1091
|
+
"run_id": lease.holder.run_id,
|
|
1092
|
+
},
|
|
1093
|
+
"acquired_at": lease.acquired_at,
|
|
1094
|
+
"renewed_at": lease.renewed_at,
|
|
1095
|
+
"expires_at": lease.expires_at,
|
|
1096
|
+
"record_version": lease.record_version,
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def writer_receipt_to_dict(receipt: WriterReconciliationReceipt) -> dict[str, Any]:
|
|
1101
|
+
return {
|
|
1102
|
+
"schema_version": receipt.schema_version,
|
|
1103
|
+
"receipt_id": receipt.receipt_id,
|
|
1104
|
+
"repository_id": receipt.repository_id,
|
|
1105
|
+
"allocation_id": receipt.allocation_id,
|
|
1106
|
+
"actor": receipt.actor,
|
|
1107
|
+
"owner": receipt.owner,
|
|
1108
|
+
"trigger": receipt.trigger,
|
|
1109
|
+
"outcome": receipt.outcome,
|
|
1110
|
+
"reason": receipt.reason,
|
|
1111
|
+
"evidence": list(receipt.evidence),
|
|
1112
|
+
"previous_lifecycle": receipt.previous_lifecycle,
|
|
1113
|
+
"new_lifecycle": receipt.new_lifecycle,
|
|
1114
|
+
"reconciled_at": receipt.reconciled_at,
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def publication_lease_from_dict(data: Mapping[str, Any]) -> PublicationLease:
|
|
1119
|
+
legacy = {
|
|
1120
|
+
"schema_version",
|
|
1121
|
+
"lease_id",
|
|
1122
|
+
"allocation_id",
|
|
1123
|
+
"owner",
|
|
1124
|
+
"remote",
|
|
1125
|
+
"branch",
|
|
1126
|
+
"candidate_head",
|
|
1127
|
+
"remote_head",
|
|
1128
|
+
"finalizer_record_id",
|
|
1129
|
+
"acquired_at",
|
|
1130
|
+
}
|
|
1131
|
+
expected = {
|
|
1132
|
+
"schema_version",
|
|
1133
|
+
"lease_id",
|
|
1134
|
+
"repository_id",
|
|
1135
|
+
"allocation_id",
|
|
1136
|
+
"holder",
|
|
1137
|
+
"run_id",
|
|
1138
|
+
"remote",
|
|
1139
|
+
"branch",
|
|
1140
|
+
"target_ref",
|
|
1141
|
+
"candidate_oid",
|
|
1142
|
+
"expected_main_oid",
|
|
1143
|
+
"validation_evidence_id",
|
|
1144
|
+
"acquired_at",
|
|
1145
|
+
"expires_at",
|
|
1146
|
+
}
|
|
1147
|
+
if data.get("schema_version") != PUBLICATION_LEASE_SCHEMA_VERSION or frozenset(data) not in {
|
|
1148
|
+
frozenset(legacy),
|
|
1149
|
+
frozenset(expected),
|
|
1150
|
+
}:
|
|
1151
|
+
raise ControllerError("INVALID_METADATA", "publication lease schema is invalid")
|
|
1152
|
+
lease_id = _optional_uuid(data.get("lease_id"), "lease_id")
|
|
1153
|
+
allocation_id = _optional_uuid(data.get("allocation_id"), "allocation_id")
|
|
1154
|
+
if lease_id is None or allocation_id is None:
|
|
1155
|
+
raise ControllerError("INVALID_METADATA", "publication lease identifiers are required")
|
|
1156
|
+
if set(data) == legacy:
|
|
1157
|
+
acquired_at = _validate_timestamp(data.get("acquired_at"), "acquired_at")
|
|
1158
|
+
return PublicationLease(
|
|
1159
|
+
schema_version=PUBLICATION_LEASE_SCHEMA_VERSION,
|
|
1160
|
+
lease_id=lease_id,
|
|
1161
|
+
repository_id="00000000-0000-0000-0000-000000000000",
|
|
1162
|
+
allocation_id=allocation_id,
|
|
1163
|
+
holder=_nonempty_string(data.get("owner"), "lease owner"),
|
|
1164
|
+
run_id=f"legacy-{lease_id}",
|
|
1165
|
+
remote=_nonempty_string(data.get("remote"), "lease remote"),
|
|
1166
|
+
branch=_nonempty_string(data.get("branch"), "lease branch"),
|
|
1167
|
+
target_ref=f"refs/heads/{_nonempty_string(data.get('branch'), 'lease branch')}",
|
|
1168
|
+
candidate_oid=_required_oid(data.get("candidate_head"), "candidate_head"),
|
|
1169
|
+
expected_main_oid=_required_oid(data.get("remote_head"), "remote_head"),
|
|
1170
|
+
validation_evidence_id=_nonempty_string(
|
|
1171
|
+
data.get("finalizer_record_id"), "finalizer_record_id"
|
|
1172
|
+
),
|
|
1173
|
+
acquired_at=acquired_at,
|
|
1174
|
+
expires_at="9999-12-31T23:59:59+00:00",
|
|
1175
|
+
)
|
|
1176
|
+
repository_id = _optional_uuid(data.get("repository_id"), "repository_id")
|
|
1177
|
+
if repository_id is None:
|
|
1178
|
+
raise ControllerError("INVALID_METADATA", "lease repository_id is required")
|
|
1179
|
+
branch = _nonempty_string(data.get("branch"), "lease branch")
|
|
1180
|
+
target_ref = _nonempty_string(data.get("target_ref"), "target_ref")
|
|
1181
|
+
if target_ref != f"refs/heads/{branch}":
|
|
1182
|
+
raise ControllerError("INVALID_METADATA", "lease target_ref and branch differ")
|
|
1183
|
+
return PublicationLease(
|
|
1184
|
+
schema_version=PUBLICATION_LEASE_SCHEMA_VERSION,
|
|
1185
|
+
lease_id=lease_id,
|
|
1186
|
+
repository_id=repository_id,
|
|
1187
|
+
allocation_id=allocation_id,
|
|
1188
|
+
holder=_nonempty_string(data.get("holder"), "lease holder"),
|
|
1189
|
+
run_id=_nonempty_string(data.get("run_id"), "lease run_id"),
|
|
1190
|
+
remote=_nonempty_string(data.get("remote"), "lease remote"),
|
|
1191
|
+
branch=branch,
|
|
1192
|
+
target_ref=target_ref,
|
|
1193
|
+
candidate_oid=_required_oid(data.get("candidate_oid"), "candidate_oid"),
|
|
1194
|
+
expected_main_oid=_required_oid(data.get("expected_main_oid"), "expected_main_oid"),
|
|
1195
|
+
validation_evidence_id=_nonempty_string(
|
|
1196
|
+
data.get("validation_evidence_id"), "validation_evidence_id"
|
|
1197
|
+
),
|
|
1198
|
+
acquired_at=_validate_timestamp(data.get("acquired_at"), "acquired_at"),
|
|
1199
|
+
expires_at=_validate_timestamp(data.get("expires_at"), "expires_at"),
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def publication_lease_to_dict(lease: PublicationLease) -> dict[str, Any]:
|
|
1204
|
+
return asdict(lease)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def publication_receipt_from_dict(data: Mapping[str, Any]) -> PublicationReceipt:
|
|
1208
|
+
expected_v1 = {
|
|
1209
|
+
"schema_version",
|
|
1210
|
+
"receipt_id",
|
|
1211
|
+
"repository_id",
|
|
1212
|
+
"allocation_id",
|
|
1213
|
+
"intent_id",
|
|
1214
|
+
"target_ref",
|
|
1215
|
+
"holder",
|
|
1216
|
+
"run_id",
|
|
1217
|
+
"expected_main_oid",
|
|
1218
|
+
"candidate_oid",
|
|
1219
|
+
"validation_evidence_id",
|
|
1220
|
+
"finalizer_record_id",
|
|
1221
|
+
"status",
|
|
1222
|
+
"acquired_at",
|
|
1223
|
+
"remote_verified_at",
|
|
1224
|
+
"lease_released_at",
|
|
1225
|
+
"released_at",
|
|
1226
|
+
"lease_duration_ms",
|
|
1227
|
+
"remote_verified_to_released_ms",
|
|
1228
|
+
}
|
|
1229
|
+
expected_v2 = expected_v1 | {"source_results"}
|
|
1230
|
+
schema_version = data.get("schema_version")
|
|
1231
|
+
expected = expected_v1 if schema_version == 1 else expected_v2
|
|
1232
|
+
if schema_version not in {1, PUBLICATION_RECEIPT_SCHEMA_VERSION} or set(data) != expected:
|
|
1233
|
+
raise ControllerError("INVALID_METADATA", "publication receipt schema is invalid")
|
|
1234
|
+
identifiers = {
|
|
1235
|
+
field: _optional_uuid(data.get(field), field)
|
|
1236
|
+
for field in ("receipt_id", "repository_id", "allocation_id")
|
|
1237
|
+
}
|
|
1238
|
+
if any(value is None for value in identifiers.values()):
|
|
1239
|
+
raise ControllerError("INVALID_METADATA", "publication receipt identifiers are required")
|
|
1240
|
+
intent_id = _optional_uuid(data.get("intent_id"), "intent_id")
|
|
1241
|
+
status = _nonempty_string(data.get("status"), "publication receipt status")
|
|
1242
|
+
if status not in PUBLICATION_RECEIPT_STATES:
|
|
1243
|
+
raise ControllerError("INVALID_METADATA", "publication receipt status is invalid")
|
|
1244
|
+
released_at = (
|
|
1245
|
+
_validate_timestamp(data.get("released_at"), "released_at")
|
|
1246
|
+
if data.get("released_at") is not None
|
|
1247
|
+
else None
|
|
1248
|
+
)
|
|
1249
|
+
released_duration = data.get("remote_verified_to_released_ms")
|
|
1250
|
+
if status == "RELEASED" and (
|
|
1251
|
+
released_at is None or released_duration is None or data.get("lease_released_at") is None
|
|
1252
|
+
):
|
|
1253
|
+
raise ControllerError("INVALID_METADATA", "released publication receipt is incomplete")
|
|
1254
|
+
if released_duration is not None:
|
|
1255
|
+
released_duration = _nonnegative_int(released_duration, "remote_verified_to_released_ms")
|
|
1256
|
+
raw_source_results = data.get("source_results", [])
|
|
1257
|
+
if not isinstance(raw_source_results, list):
|
|
1258
|
+
raise ControllerError("INVALID_METADATA", "source_results must be an object list")
|
|
1259
|
+
source_results: list[dict[str, str]] = []
|
|
1260
|
+
for source_result in raw_source_results:
|
|
1261
|
+
fields = {
|
|
1262
|
+
"source_allocation_id",
|
|
1263
|
+
"source_handoff_oid",
|
|
1264
|
+
"mode",
|
|
1265
|
+
"absorbed_oid",
|
|
1266
|
+
"evidence_id",
|
|
1267
|
+
}
|
|
1268
|
+
if not isinstance(source_result, dict) or set(source_result) != fields:
|
|
1269
|
+
raise ControllerError("INVALID_METADATA", "publication source result is invalid")
|
|
1270
|
+
mode = _nonempty_string(source_result.get("mode"), "source result mode")
|
|
1271
|
+
if mode not in {"ancestry", "equivalent-replay"}:
|
|
1272
|
+
raise ControllerError("INVALID_METADATA", "publication source result mode is invalid")
|
|
1273
|
+
source_allocation_id = _optional_uuid(
|
|
1274
|
+
source_result.get("source_allocation_id"), "source_allocation_id"
|
|
1275
|
+
)
|
|
1276
|
+
if source_allocation_id is None:
|
|
1277
|
+
raise ControllerError("INVALID_METADATA", "source result allocation is required")
|
|
1278
|
+
source_results.append(
|
|
1279
|
+
{
|
|
1280
|
+
"source_allocation_id": source_allocation_id,
|
|
1281
|
+
"source_handoff_oid": _required_oid(
|
|
1282
|
+
source_result.get("source_handoff_oid"), "source_handoff_oid"
|
|
1283
|
+
),
|
|
1284
|
+
"mode": mode,
|
|
1285
|
+
"absorbed_oid": _required_oid(source_result.get("absorbed_oid"), "absorbed_oid"),
|
|
1286
|
+
"evidence_id": _nonempty_string(
|
|
1287
|
+
source_result.get("evidence_id"), "source result evidence_id"
|
|
1288
|
+
),
|
|
1289
|
+
}
|
|
1290
|
+
)
|
|
1291
|
+
source_allocations = [item["source_allocation_id"] for item in source_results]
|
|
1292
|
+
if len(source_allocations) != len(set(source_allocations)):
|
|
1293
|
+
raise ControllerError("INVALID_METADATA", "publication source results must be unique")
|
|
1294
|
+
return PublicationReceipt(
|
|
1295
|
+
schema_version=PUBLICATION_RECEIPT_SCHEMA_VERSION,
|
|
1296
|
+
receipt_id=str(identifiers["receipt_id"]),
|
|
1297
|
+
repository_id=str(identifiers["repository_id"]),
|
|
1298
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
1299
|
+
intent_id=intent_id,
|
|
1300
|
+
target_ref=_nonempty_string(data.get("target_ref"), "target_ref"),
|
|
1301
|
+
holder=_nonempty_string(data.get("holder"), "holder"),
|
|
1302
|
+
run_id=_nonempty_string(data.get("run_id"), "run_id"),
|
|
1303
|
+
expected_main_oid=_required_oid(data.get("expected_main_oid"), "expected_main_oid"),
|
|
1304
|
+
candidate_oid=_required_oid(data.get("candidate_oid"), "candidate_oid"),
|
|
1305
|
+
validation_evidence_id=_nonempty_string(
|
|
1306
|
+
data.get("validation_evidence_id"), "validation_evidence_id"
|
|
1307
|
+
),
|
|
1308
|
+
finalizer_record_id=_nonempty_string(
|
|
1309
|
+
data.get("finalizer_record_id"), "finalizer_record_id"
|
|
1310
|
+
),
|
|
1311
|
+
status=status,
|
|
1312
|
+
acquired_at=_validate_timestamp(data.get("acquired_at"), "acquired_at"),
|
|
1313
|
+
remote_verified_at=_validate_timestamp(
|
|
1314
|
+
data.get("remote_verified_at"), "remote_verified_at"
|
|
1315
|
+
),
|
|
1316
|
+
lease_released_at=(
|
|
1317
|
+
_validate_timestamp(data.get("lease_released_at"), "lease_released_at")
|
|
1318
|
+
if data.get("lease_released_at") is not None
|
|
1319
|
+
else None
|
|
1320
|
+
),
|
|
1321
|
+
released_at=released_at,
|
|
1322
|
+
lease_duration_ms=_nonnegative_int(data.get("lease_duration_ms"), "lease_duration_ms"),
|
|
1323
|
+
remote_verified_to_released_ms=released_duration,
|
|
1324
|
+
source_results=tuple(source_results),
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def publication_receipt_to_dict(receipt: PublicationReceipt) -> dict[str, Any]:
|
|
1329
|
+
payload = asdict(receipt)
|
|
1330
|
+
payload["schema_version"] = PUBLICATION_RECEIPT_SCHEMA_VERSION
|
|
1331
|
+
payload["source_results"] = [dict(item) for item in receipt.source_results]
|
|
1332
|
+
return payload
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def release_intent_from_dict(data: Mapping[str, Any]) -> ReleaseIntent:
|
|
1336
|
+
expected = {
|
|
1337
|
+
"schema_version",
|
|
1338
|
+
"intent_id",
|
|
1339
|
+
"repository_id",
|
|
1340
|
+
"allocation_id",
|
|
1341
|
+
"task_key",
|
|
1342
|
+
"authority_key",
|
|
1343
|
+
"owner",
|
|
1344
|
+
"role",
|
|
1345
|
+
"expected_head",
|
|
1346
|
+
"expected_record_version",
|
|
1347
|
+
"task_commit",
|
|
1348
|
+
"handoff_commit",
|
|
1349
|
+
"finalizer_commit",
|
|
1350
|
+
"finalizer_record_id",
|
|
1351
|
+
"integration_intent_id",
|
|
1352
|
+
"integration_allocation_id",
|
|
1353
|
+
"publication_receipt_id",
|
|
1354
|
+
"integration_candidate_oid",
|
|
1355
|
+
"integration_mode",
|
|
1356
|
+
"integration_evidence_id",
|
|
1357
|
+
"trigger",
|
|
1358
|
+
"status",
|
|
1359
|
+
"blocker",
|
|
1360
|
+
"created_at",
|
|
1361
|
+
"updated_at",
|
|
1362
|
+
"expires_at",
|
|
1363
|
+
"history",
|
|
1364
|
+
}
|
|
1365
|
+
if set(data) != expected or data.get("schema_version") != RELEASE_INTENT_SCHEMA_VERSION:
|
|
1366
|
+
raise ControllerError("INVALID_METADATA", "release intent schema is invalid")
|
|
1367
|
+
identifiers = {
|
|
1368
|
+
field: _optional_uuid(data.get(field), field)
|
|
1369
|
+
for field in ("intent_id", "repository_id", "allocation_id")
|
|
1370
|
+
}
|
|
1371
|
+
if any(value is None for value in identifiers.values()):
|
|
1372
|
+
raise ControllerError("INVALID_METADATA", "release intent identifiers are required")
|
|
1373
|
+
role = _nonempty_string(data.get("role"), "release intent role")
|
|
1374
|
+
if role not in {"feature", "audit"}:
|
|
1375
|
+
raise ControllerError("INVALID_METADATA", "release intent role must be feature or audit")
|
|
1376
|
+
status = _nonempty_string(data.get("status"), "release intent status")
|
|
1377
|
+
if status not in RELEASE_INTENT_STATES:
|
|
1378
|
+
raise ControllerError("INVALID_METADATA", "release intent status is invalid")
|
|
1379
|
+
integration_mode = _optional_string(data.get("integration_mode"), "integration_mode")
|
|
1380
|
+
if integration_mode not in {None, "ancestry", "equivalent-replay"}:
|
|
1381
|
+
raise ControllerError("INVALID_METADATA", "release intent integration mode is invalid")
|
|
1382
|
+
integration_intent_id = _optional_uuid(
|
|
1383
|
+
data.get("integration_intent_id"), "integration_intent_id"
|
|
1384
|
+
)
|
|
1385
|
+
integration_allocation_id = _optional_uuid(
|
|
1386
|
+
data.get("integration_allocation_id"), "integration_allocation_id"
|
|
1387
|
+
)
|
|
1388
|
+
publication_receipt_id = _optional_uuid(
|
|
1389
|
+
data.get("publication_receipt_id"), "publication_receipt_id"
|
|
1390
|
+
)
|
|
1391
|
+
integration_candidate_oid = _optional_oid(
|
|
1392
|
+
data.get("integration_candidate_oid"), "integration_candidate_oid"
|
|
1393
|
+
)
|
|
1394
|
+
integration_evidence_id = _optional_string(
|
|
1395
|
+
data.get("integration_evidence_id"), "integration_evidence_id"
|
|
1396
|
+
)
|
|
1397
|
+
integration_values = (
|
|
1398
|
+
integration_intent_id,
|
|
1399
|
+
integration_allocation_id,
|
|
1400
|
+
publication_receipt_id,
|
|
1401
|
+
integration_candidate_oid,
|
|
1402
|
+
integration_mode,
|
|
1403
|
+
integration_evidence_id,
|
|
1404
|
+
)
|
|
1405
|
+
if any(value is not None for value in integration_values) and any(
|
|
1406
|
+
value is None for value in integration_values
|
|
1407
|
+
):
|
|
1408
|
+
raise ControllerError("INVALID_METADATA", "release intent integration identity is partial")
|
|
1409
|
+
history = data.get("history")
|
|
1410
|
+
if (
|
|
1411
|
+
not isinstance(history, list)
|
|
1412
|
+
or not history
|
|
1413
|
+
or not all(isinstance(item, dict) for item in history)
|
|
1414
|
+
):
|
|
1415
|
+
raise ControllerError("INVALID_METADATA", "release intent history is invalid")
|
|
1416
|
+
return ReleaseIntent(
|
|
1417
|
+
schema_version=RELEASE_INTENT_SCHEMA_VERSION,
|
|
1418
|
+
intent_id=str(identifiers["intent_id"]),
|
|
1419
|
+
repository_id=str(identifiers["repository_id"]),
|
|
1420
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
1421
|
+
task_key=_nonempty_string(data.get("task_key"), "release intent task_key"),
|
|
1422
|
+
authority_key=_nonempty_string(data.get("authority_key"), "release intent authority_key"),
|
|
1423
|
+
owner=_nonempty_string(data.get("owner"), "release intent owner"),
|
|
1424
|
+
role=role,
|
|
1425
|
+
expected_head=_required_oid(data.get("expected_head"), "expected_head"),
|
|
1426
|
+
expected_record_version=_positive_int(
|
|
1427
|
+
data.get("expected_record_version"), "expected_record_version"
|
|
1428
|
+
),
|
|
1429
|
+
task_commit=_required_oid(data.get("task_commit"), "task_commit"),
|
|
1430
|
+
handoff_commit=_required_oid(data.get("handoff_commit"), "handoff_commit"),
|
|
1431
|
+
finalizer_commit=_required_oid(data.get("finalizer_commit"), "finalizer_commit"),
|
|
1432
|
+
finalizer_record_id=_nonempty_string(
|
|
1433
|
+
data.get("finalizer_record_id"), "finalizer_record_id"
|
|
1434
|
+
),
|
|
1435
|
+
integration_intent_id=integration_intent_id,
|
|
1436
|
+
integration_allocation_id=integration_allocation_id,
|
|
1437
|
+
publication_receipt_id=publication_receipt_id,
|
|
1438
|
+
integration_candidate_oid=integration_candidate_oid,
|
|
1439
|
+
integration_mode=integration_mode,
|
|
1440
|
+
integration_evidence_id=integration_evidence_id,
|
|
1441
|
+
trigger=_nonempty_string(data.get("trigger"), "release intent trigger"),
|
|
1442
|
+
status=status,
|
|
1443
|
+
blocker=_optional_string(data.get("blocker"), "release intent blocker"),
|
|
1444
|
+
created_at=_validate_timestamp(data.get("created_at"), "created_at"),
|
|
1445
|
+
updated_at=_validate_timestamp(data.get("updated_at"), "updated_at"),
|
|
1446
|
+
expires_at=_validate_timestamp(data.get("expires_at"), "expires_at"),
|
|
1447
|
+
history=tuple(dict(item) for item in history),
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def release_intent_to_dict(intent: ReleaseIntent) -> dict[str, Any]:
|
|
1452
|
+
payload = asdict(intent)
|
|
1453
|
+
payload["history"] = [dict(item) for item in intent.history]
|
|
1454
|
+
return payload
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def release_plan_from_dict(data: Mapping[str, Any]) -> ReleasePlan:
|
|
1458
|
+
expected = {
|
|
1459
|
+
"schema_version",
|
|
1460
|
+
"plan_id",
|
|
1461
|
+
"repository_id",
|
|
1462
|
+
"allocation_id",
|
|
1463
|
+
"release_intent_id",
|
|
1464
|
+
"target_path",
|
|
1465
|
+
"branch",
|
|
1466
|
+
"expected_head",
|
|
1467
|
+
"expected_record_version",
|
|
1468
|
+
"owner",
|
|
1469
|
+
"authority_key",
|
|
1470
|
+
"role",
|
|
1471
|
+
"lifecycle",
|
|
1472
|
+
"trigger",
|
|
1473
|
+
"publication_receipt_id",
|
|
1474
|
+
"resource_actions",
|
|
1475
|
+
"created_at",
|
|
1476
|
+
}
|
|
1477
|
+
if set(data) != expected or data.get("schema_version") != RELEASE_PLAN_SCHEMA_VERSION:
|
|
1478
|
+
raise ControllerError("INVALID_METADATA", "release plan schema is invalid")
|
|
1479
|
+
plan_id = _required_sha256(data.get("plan_id"), "plan_id")
|
|
1480
|
+
identity = {field: data[field] for field in expected if field != "plan_id"}
|
|
1481
|
+
encoded = json.dumps(identity, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
1482
|
+
observed_plan_id = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
1483
|
+
if observed_plan_id != plan_id:
|
|
1484
|
+
raise ControllerError("INVALID_METADATA", "release plan content hash is invalid")
|
|
1485
|
+
identifiers = {
|
|
1486
|
+
field: _optional_uuid(data.get(field), field)
|
|
1487
|
+
for field in ("repository_id", "allocation_id")
|
|
1488
|
+
}
|
|
1489
|
+
if any(value is None for value in identifiers.values()):
|
|
1490
|
+
raise ControllerError("INVALID_METADATA", "release plan identifiers are required")
|
|
1491
|
+
target_path = Path(_nonempty_string(data.get("target_path"), "target_path"))
|
|
1492
|
+
if not target_path.is_absolute():
|
|
1493
|
+
raise ControllerError("INVALID_METADATA", "release plan target path must be absolute")
|
|
1494
|
+
role = _nonempty_string(data.get("role"), "release plan role")
|
|
1495
|
+
if role not in WORKTREE_ROLES:
|
|
1496
|
+
raise ControllerError("INVALID_METADATA", "release plan role is invalid")
|
|
1497
|
+
lifecycle = _nonempty_string(data.get("lifecycle"), "release plan lifecycle")
|
|
1498
|
+
if lifecycle not in LIFECYCLE_STATES:
|
|
1499
|
+
raise ControllerError("INVALID_METADATA", "release plan lifecycle is invalid")
|
|
1500
|
+
raw_actions = data.get("resource_actions")
|
|
1501
|
+
if not isinstance(raw_actions, list) or not all(
|
|
1502
|
+
isinstance(action, dict) for action in raw_actions
|
|
1503
|
+
):
|
|
1504
|
+
raise ControllerError("INVALID_METADATA", "release plan resource actions are invalid")
|
|
1505
|
+
actions: list[dict[str, Any]] = []
|
|
1506
|
+
for action in raw_actions:
|
|
1507
|
+
expected_action = {
|
|
1508
|
+
"resource_id",
|
|
1509
|
+
"path",
|
|
1510
|
+
"kind",
|
|
1511
|
+
"creator",
|
|
1512
|
+
"teardown_policy",
|
|
1513
|
+
"action",
|
|
1514
|
+
"evidence",
|
|
1515
|
+
}
|
|
1516
|
+
if set(action) != expected_action or action.get("action") != "teardown":
|
|
1517
|
+
raise ControllerError("INVALID_METADATA", "release plan resource action is invalid")
|
|
1518
|
+
resource_id = _optional_uuid(action.get("resource_id"), "resource_id")
|
|
1519
|
+
if resource_id is None:
|
|
1520
|
+
raise ControllerError("INVALID_METADATA", "release plan resource id is required")
|
|
1521
|
+
evidence = action.get("evidence")
|
|
1522
|
+
if not isinstance(evidence, dict):
|
|
1523
|
+
raise ControllerError("INVALID_METADATA", "release plan resource evidence is invalid")
|
|
1524
|
+
actions.append(
|
|
1525
|
+
{
|
|
1526
|
+
"resource_id": resource_id,
|
|
1527
|
+
"path": _nonempty_string(action.get("path"), "resource path"),
|
|
1528
|
+
"kind": _nonempty_string(action.get("kind"), "resource kind"),
|
|
1529
|
+
"creator": _nonempty_string(action.get("creator"), "resource creator"),
|
|
1530
|
+
"teardown_policy": _nonempty_string(
|
|
1531
|
+
action.get("teardown_policy"), "resource teardown policy"
|
|
1532
|
+
),
|
|
1533
|
+
"action": "teardown",
|
|
1534
|
+
"evidence": dict(evidence),
|
|
1535
|
+
}
|
|
1536
|
+
)
|
|
1537
|
+
return ReleasePlan(
|
|
1538
|
+
schema_version=RELEASE_PLAN_SCHEMA_VERSION,
|
|
1539
|
+
plan_id=plan_id,
|
|
1540
|
+
repository_id=str(identifiers["repository_id"]),
|
|
1541
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
1542
|
+
release_intent_id=_optional_uuid(data.get("release_intent_id"), "release_intent_id"),
|
|
1543
|
+
target_path=str(target_path),
|
|
1544
|
+
branch=_nonempty_string(data.get("branch"), "release plan branch"),
|
|
1545
|
+
expected_head=_required_oid(data.get("expected_head"), "expected_head"),
|
|
1546
|
+
expected_record_version=_positive_int(
|
|
1547
|
+
data.get("expected_record_version"), "expected_record_version"
|
|
1548
|
+
),
|
|
1549
|
+
owner=_nonempty_string(data.get("owner"), "release plan owner"),
|
|
1550
|
+
authority_key=_nonempty_string(data.get("authority_key"), "release plan authority"),
|
|
1551
|
+
role=role,
|
|
1552
|
+
lifecycle=lifecycle,
|
|
1553
|
+
trigger=_nonempty_string(data.get("trigger"), "release plan trigger"),
|
|
1554
|
+
publication_receipt_id=_optional_uuid(
|
|
1555
|
+
data.get("publication_receipt_id"), "publication_receipt_id"
|
|
1556
|
+
),
|
|
1557
|
+
resource_actions=tuple(actions),
|
|
1558
|
+
created_at=_validate_timestamp(data.get("created_at"), "created_at"),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def release_plan_to_dict(plan: ReleasePlan) -> dict[str, Any]:
|
|
1563
|
+
payload = asdict(plan)
|
|
1564
|
+
payload["resource_actions"] = [dict(action) for action in plan.resource_actions]
|
|
1565
|
+
return payload
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def release_receipt_from_dict(data: Mapping[str, Any]) -> ReleaseReceipt:
|
|
1569
|
+
expected = {
|
|
1570
|
+
"schema_version",
|
|
1571
|
+
"receipt_id",
|
|
1572
|
+
"plan_id",
|
|
1573
|
+
"repository_id",
|
|
1574
|
+
"allocation_id",
|
|
1575
|
+
"release_intent_id",
|
|
1576
|
+
"target_path",
|
|
1577
|
+
"branch",
|
|
1578
|
+
"released_head",
|
|
1579
|
+
"expected_record_version",
|
|
1580
|
+
"released_record_version",
|
|
1581
|
+
"trigger",
|
|
1582
|
+
"resource_receipt_ids",
|
|
1583
|
+
"result",
|
|
1584
|
+
"released_at",
|
|
1585
|
+
}
|
|
1586
|
+
if set(data) != expected or data.get("schema_version") != RELEASE_RECEIPT_SCHEMA_VERSION:
|
|
1587
|
+
raise ControllerError("INVALID_METADATA", "release receipt schema is invalid")
|
|
1588
|
+
identifiers = {
|
|
1589
|
+
field: _optional_uuid(data.get(field), field)
|
|
1590
|
+
for field in ("receipt_id", "repository_id", "allocation_id")
|
|
1591
|
+
}
|
|
1592
|
+
if any(value is None for value in identifiers.values()):
|
|
1593
|
+
raise ControllerError("INVALID_METADATA", "release receipt identifiers are required")
|
|
1594
|
+
plan_id = _required_sha256(data.get("plan_id"), "plan_id")
|
|
1595
|
+
target_path = Path(_nonempty_string(data.get("target_path"), "target_path"))
|
|
1596
|
+
if not target_path.is_absolute():
|
|
1597
|
+
raise ControllerError("INVALID_METADATA", "release receipt target path must be absolute")
|
|
1598
|
+
result = _nonempty_string(data.get("result"), "release receipt result")
|
|
1599
|
+
if result != "RELEASED":
|
|
1600
|
+
raise ControllerError("INVALID_METADATA", "release receipt result is invalid")
|
|
1601
|
+
resource_receipt_ids = _string_list(data.get("resource_receipt_ids"), "resource_receipt_ids")
|
|
1602
|
+
for resource_receipt_id in resource_receipt_ids:
|
|
1603
|
+
if _optional_uuid(resource_receipt_id, "resource_receipt_id") is None:
|
|
1604
|
+
raise ControllerError("INVALID_METADATA", "resource receipt id is required")
|
|
1605
|
+
return ReleaseReceipt(
|
|
1606
|
+
schema_version=RELEASE_RECEIPT_SCHEMA_VERSION,
|
|
1607
|
+
receipt_id=str(identifiers["receipt_id"]),
|
|
1608
|
+
plan_id=plan_id,
|
|
1609
|
+
repository_id=str(identifiers["repository_id"]),
|
|
1610
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
1611
|
+
release_intent_id=_optional_uuid(data.get("release_intent_id"), "release_intent_id"),
|
|
1612
|
+
target_path=str(target_path),
|
|
1613
|
+
branch=_nonempty_string(data.get("branch"), "release receipt branch"),
|
|
1614
|
+
released_head=_required_oid(data.get("released_head"), "released_head"),
|
|
1615
|
+
expected_record_version=_positive_int(
|
|
1616
|
+
data.get("expected_record_version"), "expected_record_version"
|
|
1617
|
+
),
|
|
1618
|
+
released_record_version=_positive_int(
|
|
1619
|
+
data.get("released_record_version"), "released_record_version"
|
|
1620
|
+
),
|
|
1621
|
+
trigger=_nonempty_string(data.get("trigger"), "release receipt trigger"),
|
|
1622
|
+
resource_receipt_ids=resource_receipt_ids,
|
|
1623
|
+
result=result,
|
|
1624
|
+
released_at=_validate_timestamp(data.get("released_at"), "released_at"),
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def release_receipt_to_dict(receipt: ReleaseReceipt) -> dict[str, Any]:
|
|
1629
|
+
payload = asdict(receipt)
|
|
1630
|
+
payload["resource_receipt_ids"] = list(receipt.resource_receipt_ids)
|
|
1631
|
+
return payload
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def reconciliation_receipt_from_dict(data: Mapping[str, Any]) -> ReconciliationReceipt:
|
|
1635
|
+
expected = {
|
|
1636
|
+
"schema_version",
|
|
1637
|
+
"receipt_id",
|
|
1638
|
+
"repository_id",
|
|
1639
|
+
"source_receipt_id",
|
|
1640
|
+
"allocation_id",
|
|
1641
|
+
"observed_record_version",
|
|
1642
|
+
"observed_record_lifecycle",
|
|
1643
|
+
"observed_receipt_status",
|
|
1644
|
+
"differences",
|
|
1645
|
+
"recovered_intent_id",
|
|
1646
|
+
"recovered_intent_state",
|
|
1647
|
+
"result",
|
|
1648
|
+
"actor",
|
|
1649
|
+
"reconciled_at",
|
|
1650
|
+
}
|
|
1651
|
+
if set(data) != expected or data.get("schema_version") != RECONCILIATION_RECEIPT_SCHEMA_VERSION:
|
|
1652
|
+
raise ControllerError("INVALID_METADATA", "reconciliation receipt schema is invalid")
|
|
1653
|
+
identifiers = {
|
|
1654
|
+
field: _optional_uuid(data.get(field), field)
|
|
1655
|
+
for field in ("receipt_id", "repository_id", "source_receipt_id", "allocation_id")
|
|
1656
|
+
}
|
|
1657
|
+
if any(value is None for value in identifiers.values()):
|
|
1658
|
+
raise ControllerError("INVALID_METADATA", "reconciliation receipt identifiers are required")
|
|
1659
|
+
result = _nonempty_string(data.get("result"), "reconciliation result")
|
|
1660
|
+
if result not in {"RELEASE_STATE_ACKNOWLEDGED", "LEASE_RELEASE_ACKNOWLEDGED"}:
|
|
1661
|
+
raise ControllerError("INVALID_METADATA", "reconciliation receipt result is invalid")
|
|
1662
|
+
differences = _string_list(data.get("differences"), "differences")
|
|
1663
|
+
if not differences:
|
|
1664
|
+
raise ControllerError("INVALID_METADATA", "reconciliation differences are required")
|
|
1665
|
+
return ReconciliationReceipt(
|
|
1666
|
+
schema_version=RECONCILIATION_RECEIPT_SCHEMA_VERSION,
|
|
1667
|
+
receipt_id=str(identifiers["receipt_id"]),
|
|
1668
|
+
repository_id=str(identifiers["repository_id"]),
|
|
1669
|
+
source_receipt_id=str(identifiers["source_receipt_id"]),
|
|
1670
|
+
allocation_id=str(identifiers["allocation_id"]),
|
|
1671
|
+
observed_record_version=_positive_int(
|
|
1672
|
+
data.get("observed_record_version"), "observed_record_version"
|
|
1673
|
+
),
|
|
1674
|
+
observed_record_lifecycle=_nonempty_string(
|
|
1675
|
+
data.get("observed_record_lifecycle"), "observed_record_lifecycle"
|
|
1676
|
+
),
|
|
1677
|
+
observed_receipt_status=_nonempty_string(
|
|
1678
|
+
data.get("observed_receipt_status"), "observed_receipt_status"
|
|
1679
|
+
),
|
|
1680
|
+
differences=differences,
|
|
1681
|
+
recovered_intent_id=_optional_uuid(data.get("recovered_intent_id"), "recovered_intent_id"),
|
|
1682
|
+
recovered_intent_state=_optional_string(
|
|
1683
|
+
data.get("recovered_intent_state"), "recovered_intent_state"
|
|
1684
|
+
),
|
|
1685
|
+
result=result,
|
|
1686
|
+
actor=_nonempty_string(data.get("actor"), "reconciliation actor"),
|
|
1687
|
+
reconciled_at=_validate_timestamp(data.get("reconciled_at"), "reconciled_at"),
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def reconciliation_receipt_to_dict(receipt: ReconciliationReceipt) -> dict[str, Any]:
|
|
1692
|
+
payload = asdict(receipt)
|
|
1693
|
+
payload["differences"] = list(receipt.differences)
|
|
1694
|
+
return payload
|