@delorenj/pjangler 1.4.2 → 1.4.4

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 (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
@@ -0,0 +1,2340 @@
1
+ #!/usr/bin/env python3
2
+ """Commit one verified channel wiring as a crash-consistent transaction.
3
+
4
+ Raw credentials are never accepted. This helper owns the canonical lock
5
+ ordering (registry, then profile), journals every candidate before mutation,
6
+ and compare-and-swaps each target against the exact state it observed. A
7
+ crash is recovered on the next invocation; an unrelated edit is never erased.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import copy
14
+ import ctypes
15
+ import errno
16
+ import fcntl
17
+ import grp
18
+ import hashlib
19
+ import importlib.util
20
+ import json
21
+ import math
22
+ import os
23
+ import pathlib
24
+ import pwd
25
+ import re
26
+ import stat
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ import uuid
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+
35
+ import yaml
36
+
37
+
38
+ def load_profile_lock_module():
39
+ source = pathlib.Path(__file__).parent / "lib" / "profile-config-lock.py"
40
+ if source.is_symlink() or not source.is_file():
41
+ raise RuntimeError(f"trusted profile config lock helper is unavailable: {source}")
42
+ spec = importlib.util.spec_from_file_location(
43
+ "pjangler_profile_config_lock", source
44
+ )
45
+ if spec is None or spec.loader is None:
46
+ raise RuntimeError(f"cannot load profile config lock helper: {source}")
47
+ module = importlib.util.module_from_spec(spec)
48
+ spec.loader.exec_module(module)
49
+ return module
50
+
51
+
52
+ PROFILE_LOCK = load_profile_lock_module()
53
+
54
+
55
+ LIST_PATCH_KEY = "x-pjangler-merge"
56
+ CHANNEL_FIELDS = {
57
+ "telegram": ("provisioning_status", "bot_username", "bot_id"),
58
+ "slack": (
59
+ "provisioning_status",
60
+ "team_id",
61
+ "team_name",
62
+ "bot_user_id",
63
+ "bot_id",
64
+ "bot_username",
65
+ ),
66
+ }
67
+ CHANNEL_REFERENCE_KEYS = {
68
+ "telegram": ("TELEGRAM_BOT_TOKEN",),
69
+ "slack": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
70
+ }
71
+ CHANNEL_ALLOWED_KEYS = {
72
+ "telegram": "TELEGRAM_ALLOWED_USERS",
73
+ "slack": "SLACK_ALLOWED_USERS",
74
+ }
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class FileState:
79
+ existed: bool
80
+ mode: int
81
+ dev: int = 0
82
+ ino: int = 0
83
+ size: int = 0
84
+ mtime_ns: int = 0
85
+ nlink: int = 0
86
+ sha256: str = ""
87
+
88
+ def to_json(self) -> dict[str, Any]:
89
+ return {
90
+ "existed": self.existed,
91
+ "mode": self.mode,
92
+ "dev": self.dev,
93
+ "ino": self.ino,
94
+ "size": self.size,
95
+ "mtime_ns": self.mtime_ns,
96
+ "nlink": self.nlink,
97
+ "sha256": self.sha256,
98
+ }
99
+
100
+ @classmethod
101
+ def from_json(cls, value: object) -> "FileState":
102
+ if not isinstance(value, dict):
103
+ raise TransactionRecoveryError("journal file state is not a mapping")
104
+ expected = {
105
+ "existed",
106
+ "mode",
107
+ "dev",
108
+ "ino",
109
+ "size",
110
+ "mtime_ns",
111
+ "nlink",
112
+ "sha256",
113
+ }
114
+ if set(value) != expected:
115
+ raise TransactionRecoveryError("journal file state has invalid fields")
116
+ if not isinstance(value["existed"], bool):
117
+ raise TransactionRecoveryError("journal file state existence is invalid")
118
+ numeric = ("mode", "dev", "ino", "size", "mtime_ns", "nlink")
119
+ if any(not isinstance(value[key], int) or isinstance(value[key], bool) for key in numeric):
120
+ raise TransactionRecoveryError("journal file state numeric field is invalid")
121
+ digest = value["sha256"]
122
+ if not isinstance(digest, str) or (digest and not re.fullmatch(r"[0-9a-f]{64}", digest)):
123
+ raise TransactionRecoveryError("journal file state digest is invalid")
124
+ return cls(**value)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Snapshot:
129
+ state: FileState
130
+ content: bytes
131
+
132
+ @property
133
+ def existed(self) -> bool:
134
+ return self.state.existed
135
+
136
+ @property
137
+ def mode(self) -> int:
138
+ return self.state.mode
139
+
140
+
141
+ class TransactionConflict(RuntimeError):
142
+ """A target changed outside this transaction."""
143
+
144
+
145
+ class TransactionRecoveryError(RuntimeError):
146
+ """Protected recovery state is malformed or cannot be reconciled."""
147
+
148
+
149
+ class ExistingWiringUnavailable(RuntimeError):
150
+ """The role has no verified durable wiring to reconcile."""
151
+
152
+
153
+ class ExistingWiringValidationUnavailable(RuntimeError):
154
+ """Verified references exist but cannot currently be validated."""
155
+
156
+
157
+ class ExistingWiringAlreadyVerified(RuntimeError):
158
+ """Preparation found verified wiring and deliberately made no change."""
159
+
160
+
161
+ def fail(message: str) -> "None":
162
+ raise SystemExit(f"channel transaction failed: {message}")
163
+
164
+
165
+ def fsync_parent(path: pathlib.Path) -> None:
166
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
167
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
168
+ try:
169
+ directory_fd = os.open(path.parent, flags)
170
+ except OSError as exc:
171
+ if exc.errno in unsupported:
172
+ return
173
+ raise
174
+ try:
175
+ try:
176
+ os.fsync(directory_fd)
177
+ except OSError as exc:
178
+ if exc.errno not in unsupported:
179
+ raise
180
+ finally:
181
+ os.close(directory_fd)
182
+
183
+
184
+ def fsync_directory(path: pathlib.Path) -> None:
185
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
186
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
187
+ directory_fd = os.open(path, flags)
188
+ try:
189
+ try:
190
+ os.fsync(directory_fd)
191
+ except OSError as exc:
192
+ if exc.errno not in unsupported:
193
+ raise
194
+ finally:
195
+ os.close(directory_fd)
196
+
197
+
198
+ def finite_timeout(name: str, fallback: str) -> float:
199
+ raw = os.environ.get(name, fallback)
200
+ try:
201
+ value = float(raw)
202
+ except ValueError as exc:
203
+ raise TransactionRecoveryError(f"{name} must be a finite non-negative number") from exc
204
+ if not math.isfinite(value) or value < 0:
205
+ raise TransactionRecoveryError(f"{name} must be a finite non-negative number")
206
+ return value
207
+
208
+
209
+ def directory_is_private_to_user(info: os.stat_result) -> bool:
210
+ """Accept an owner-only directory or the account's private primary group."""
211
+
212
+ if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o002:
213
+ return False
214
+ if not stat.S_IMODE(info.st_mode) & 0o020:
215
+ return True
216
+ try:
217
+ username = pwd.getpwuid(os.geteuid()).pw_name
218
+ group = grp.getgrgid(info.st_gid)
219
+ primary_members = {
220
+ account.pw_name for account in pwd.getpwall() if account.pw_gid == info.st_gid
221
+ }
222
+ except (KeyError, OSError):
223
+ return False
224
+ return (set(group.gr_mem) | primary_members) <= {username}
225
+
226
+
227
+ class RegistryLock:
228
+ """Symlink-safe canonical registry lock, acquired before the profile lock.
229
+
230
+ During the caller migration this also recognizes the exact lock inode on an
231
+ inherited descriptor. It still calls ``flock`` itself, so direct helper
232
+ invocation cannot bypass locking and an inherited *unlocked* descriptor is
233
+ not trusted.
234
+ """
235
+
236
+ def __init__(self, registry: pathlib.Path):
237
+ self.registry = registry
238
+ self.path = registry.with_name(registry.name + ".lock")
239
+ self.fd: int | None = None
240
+ self.borrowed = False
241
+ self.timeout = finite_timeout(
242
+ "HERMES_REGISTRY_LOCK_TIMEOUT_SECONDS",
243
+ os.environ.get("FLEET_LOCK_TIMEOUT_SECONDS", "30"),
244
+ )
245
+
246
+ def _inherited_matching_fd(self, opened: os.stat_result) -> int | None:
247
+ try:
248
+ names = os.listdir("/proc/self/fd")
249
+ except OSError:
250
+ names = [str(number) for number in range(3, 256)]
251
+ for raw in names:
252
+ if not raw.isdigit():
253
+ continue
254
+ candidate = int(raw)
255
+ if candidate == self.fd:
256
+ continue
257
+ try:
258
+ current = os.fstat(candidate)
259
+ except OSError:
260
+ continue
261
+ if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
262
+ continue
263
+ if not stat.S_ISREG(current.st_mode):
264
+ continue
265
+ try:
266
+ fcntl.flock(candidate, fcntl.LOCK_EX | fcntl.LOCK_NB)
267
+ except BlockingIOError:
268
+ continue
269
+ return candidate
270
+ return None
271
+
272
+ def __enter__(self) -> "RegistryLock":
273
+ parent = self.path.parent
274
+ if parent.is_symlink() or not parent.is_dir():
275
+ raise TransactionRecoveryError(
276
+ f"registry parent must be a real directory before locking: {parent}"
277
+ )
278
+ if self.path.is_symlink():
279
+ raise TransactionRecoveryError(f"refusing registry lock symlink: {self.path}")
280
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
281
+ flags |= getattr(os, "O_NOFOLLOW", 0)
282
+ fd = os.open(self.path, flags, 0o600)
283
+ self.fd = fd
284
+ try:
285
+ opened = os.fstat(fd)
286
+ if not stat.S_ISREG(opened.st_mode):
287
+ raise TransactionRecoveryError(
288
+ f"registry lock is not a regular file: {self.path}"
289
+ )
290
+ os.fchmod(fd, 0o600)
291
+ deadline = time.monotonic() + self.timeout
292
+ while True:
293
+ try:
294
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
295
+ break
296
+ except BlockingIOError as exc:
297
+ inherited = self._inherited_matching_fd(opened)
298
+ if inherited is not None:
299
+ os.close(fd)
300
+ self.fd = inherited
301
+ self.borrowed = True
302
+ break
303
+ if time.monotonic() >= deadline:
304
+ raise TransactionRecoveryError(
305
+ f"timed out waiting for registry lock: {self.path}"
306
+ ) from exc
307
+ time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
308
+ locked = os.lstat(self.path)
309
+ held = os.fstat(self.fd)
310
+ if (locked.st_dev, locked.st_ino) != (held.st_dev, held.st_ino):
311
+ raise TransactionRecoveryError(
312
+ f"registry lock identity changed while acquiring: {self.path}"
313
+ )
314
+ if self.registry.is_symlink():
315
+ raise TransactionRecoveryError(
316
+ f"refusing registry symlink: {self.registry}"
317
+ )
318
+ if self.registry.exists() and not self.registry.is_file():
319
+ raise TransactionRecoveryError(
320
+ f"registry is not a regular file: {self.registry}"
321
+ )
322
+ return self
323
+ except BaseException:
324
+ if self.fd is not None and not self.borrowed:
325
+ os.close(self.fd)
326
+ self.fd = None
327
+ raise
328
+
329
+ def __exit__(self, *_args: object) -> None:
330
+ if self.fd is None:
331
+ return
332
+ fd, borrowed = self.fd, self.borrowed
333
+ self.fd = None
334
+ self.borrowed = False
335
+ if borrowed:
336
+ return
337
+ try:
338
+ fcntl.flock(fd, fcntl.LOCK_UN)
339
+ finally:
340
+ os.close(fd)
341
+
342
+
343
+ def snapshot(path: pathlib.Path, default_mode: int) -> Snapshot:
344
+ try:
345
+ listed = os.lstat(path)
346
+ except FileNotFoundError:
347
+ return Snapshot(FileState(False, default_mode), b"")
348
+ if stat.S_ISLNK(listed.st_mode):
349
+ fail(f"refusing symlinked transaction path: {path}")
350
+ if not stat.S_ISREG(listed.st_mode):
351
+ fail(f"transaction path is not a regular file: {path}")
352
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
353
+ fd = os.open(path, flags)
354
+ try:
355
+ opened = os.fstat(fd)
356
+ if not stat.S_ISREG(opened.st_mode):
357
+ fail(f"transaction path is not a regular file: {path}")
358
+ chunks: list[bytes] = []
359
+ while True:
360
+ chunk = os.read(fd, 1024 * 1024)
361
+ if not chunk:
362
+ break
363
+ chunks.append(chunk)
364
+ content = b"".join(chunks)
365
+ finished = os.fstat(fd)
366
+ finally:
367
+ os.close(fd)
368
+ relisted = os.lstat(path)
369
+ identities = {
370
+ (listed.st_dev, listed.st_ino),
371
+ (opened.st_dev, opened.st_ino),
372
+ (finished.st_dev, finished.st_ino),
373
+ (relisted.st_dev, relisted.st_ino),
374
+ }
375
+ signatures = {
376
+ (
377
+ value.st_size,
378
+ value.st_mtime_ns,
379
+ value.st_ctime_ns,
380
+ value.st_nlink,
381
+ stat.S_IMODE(value.st_mode),
382
+ )
383
+ for value in (listed, opened, finished, relisted)
384
+ }
385
+ if (
386
+ len(identities) != 1
387
+ or len(signatures) != 1
388
+ or finished.st_size != len(content)
389
+ ):
390
+ raise TransactionConflict(f"transaction path changed while snapshotting: {path}")
391
+ state = FileState(
392
+ True,
393
+ stat.S_IMODE(finished.st_mode),
394
+ finished.st_dev,
395
+ finished.st_ino,
396
+ finished.st_size,
397
+ finished.st_mtime_ns,
398
+ finished.st_nlink,
399
+ hashlib.sha256(content).hexdigest(),
400
+ )
401
+ return Snapshot(state, content)
402
+
403
+
404
+ def current_state(path: pathlib.Path, default_mode: int) -> FileState:
405
+ return snapshot(path, default_mode).state
406
+
407
+
408
+ def state_matches(path: pathlib.Path, expected: FileState) -> bool:
409
+ try:
410
+ return current_state(path, expected.mode) == expected
411
+ except (OSError, SystemExit, TransactionConflict):
412
+ return False
413
+
414
+
415
+ def state_same_inode_and_bytes(current: FileState, expected: FileState) -> bool:
416
+ """Compare durable identity/content while allowing known hard-link counts."""
417
+
418
+ return (
419
+ current.existed == expected.existed
420
+ and current.mode == expected.mode
421
+ and current.dev == expected.dev
422
+ and current.ino == expected.ino
423
+ and current.size == expected.size
424
+ and current.mtime_ns == expected.mtime_ns
425
+ and current.sha256 == expected.sha256
426
+ )
427
+
428
+
429
+ AT_FDCWD = -100
430
+ RENAME_NOREPLACE = 1
431
+ RENAME_EXCHANGE = 2
432
+ _LIBC = ctypes.CDLL(None, use_errno=True)
433
+ _RENAMEAT2 = getattr(_LIBC, "renameat2", None)
434
+ if _RENAMEAT2 is not None:
435
+ _RENAMEAT2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
436
+ _RENAMEAT2.restype = ctypes.c_int
437
+
438
+
439
+ def renameat2(source: pathlib.Path, target: pathlib.Path, flags: int) -> None:
440
+ """Linux atomic rename primitive used as the file-level CAS operation."""
441
+
442
+ if _RENAMEAT2 is None:
443
+ raise TransactionRecoveryError(
444
+ "renameat2 is required for crash-safe channel compare-and-swap"
445
+ )
446
+ result = _RENAMEAT2(
447
+ AT_FDCWD,
448
+ os.fsencode(source),
449
+ AT_FDCWD,
450
+ os.fsencode(target),
451
+ flags,
452
+ )
453
+ if result != 0:
454
+ error = ctypes.get_errno()
455
+ raise OSError(error, os.strerror(error), str(source), str(target))
456
+
457
+
458
+ def atomic_exchange(source: pathlib.Path, target: pathlib.Path) -> None:
459
+ renameat2(source, target, RENAME_EXCHANGE)
460
+
461
+
462
+ def atomic_move_noreplace(source: pathlib.Path, target: pathlib.Path) -> None:
463
+ renameat2(source, target, RENAME_NOREPLACE)
464
+
465
+
466
+ def fault_boundary(_label: str) -> None:
467
+ """No-op seam replaced only in copied test fixtures.
468
+
469
+ Production code has no path-writing pause hook. Process tests instrument a
470
+ private copy of this function to stop or kill the helper at exact durable
471
+ boundaries without widening the deployed interface.
472
+ """
473
+
474
+ # TEST_FIXTURE_FAULT_BOUNDARY
475
+
476
+
477
+ def strict_json_loads(content: str) -> dict[str, Any]:
478
+ def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
479
+ result: dict[str, Any] = {}
480
+ for key, value in pairs:
481
+ if key in result:
482
+ raise TransactionRecoveryError(f"duplicate journal key: {key}")
483
+ result[key] = value
484
+ return result
485
+
486
+ try:
487
+ value = json.loads(content, object_pairs_hook=unique_pairs)
488
+ except (json.JSONDecodeError, UnicodeError) as exc:
489
+ raise TransactionRecoveryError(
490
+ f"transaction journal is malformed: {type(exc).__name__}"
491
+ ) from exc
492
+ if not isinstance(value, dict):
493
+ raise TransactionRecoveryError("transaction journal root is not a mapping")
494
+ return value
495
+
496
+
497
+ class CrashConsistentTransaction:
498
+ """Ordered, journaled compare-and-swap across channel-owned files."""
499
+
500
+ SCHEMA_VERSION = 1
501
+ JOURNAL_NAME = "journal.json"
502
+ NEXT_JOURNAL_NAME = ".journal.next"
503
+
504
+ def __init__(
505
+ self,
506
+ *,
507
+ profile: pathlib.Path,
508
+ registry: pathlib.Path,
509
+ channel: str,
510
+ agent_id: str,
511
+ targets: dict[str, pathlib.Path],
512
+ modes: dict[str, int],
513
+ ):
514
+ self.profile = profile
515
+ self.registry = registry
516
+ self.channel = channel
517
+ self.agent_id = agent_id
518
+ self.targets = targets
519
+ self.modes = modes
520
+ self.directory = profile.parent / f".{profile.name}.channel-transaction"
521
+ self.journal_path = self.directory / self.JOURNAL_NAME
522
+ self.journal: dict[str, Any] = {}
523
+
524
+ def _validate_directory(self) -> None:
525
+ if self.directory.is_symlink() or not self.directory.is_dir():
526
+ raise TransactionRecoveryError(
527
+ f"protected transaction path is not a real directory: {self.directory}"
528
+ )
529
+ info = os.lstat(self.directory)
530
+ if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700:
531
+ raise TransactionRecoveryError(
532
+ f"protected transaction directory ownership/mode is unsafe: {self.directory}"
533
+ )
534
+
535
+ def _write_journal(self) -> None:
536
+ self._validate_directory()
537
+ payload = (json.dumps(self.journal, sort_keys=True, separators=(",", ":")) + "\n").encode(
538
+ "utf-8"
539
+ )
540
+ temporary = self.directory / self.NEXT_JOURNAL_NAME
541
+ if temporary.is_symlink():
542
+ raise TransactionRecoveryError(
543
+ f"refusing transaction journal symlink: {temporary}"
544
+ )
545
+ if temporary.exists():
546
+ if not temporary.is_file():
547
+ raise TransactionRecoveryError(
548
+ f"transaction journal staging path is unsafe: {temporary}"
549
+ )
550
+ temporary.unlink()
551
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
552
+ flags |= getattr(os, "O_NOFOLLOW", 0)
553
+ fd = os.open(temporary, flags, 0o600)
554
+ try:
555
+ os.fchmod(fd, 0o600)
556
+ view = memoryview(payload)
557
+ while view:
558
+ written = os.write(fd, view)
559
+ view = view[written:]
560
+ os.fsync(fd)
561
+ finally:
562
+ os.close(fd)
563
+ os.replace(temporary, self.journal_path)
564
+ fsync_directory(self.directory)
565
+
566
+ def _load_journal(self) -> dict[str, Any]:
567
+ self._validate_directory()
568
+ if self.journal_path.is_symlink() or not self.journal_path.is_file():
569
+ raise TransactionRecoveryError(
570
+ f"protected transaction journal is unavailable: {self.journal_path}"
571
+ )
572
+ if stat.S_IMODE(os.lstat(self.journal_path).st_mode) != 0o600:
573
+ raise TransactionRecoveryError(
574
+ f"protected transaction journal mode is unsafe: {self.journal_path}"
575
+ )
576
+ return strict_json_loads(self.journal_path.read_text(encoding="utf-8"))
577
+
578
+ def _validate_journal_identity(self, journal: dict[str, Any]) -> None:
579
+ if journal.get("schema_version") != self.SCHEMA_VERSION:
580
+ raise TransactionRecoveryError("unsupported transaction journal schema")
581
+ identity = journal.get("identity")
582
+ if not isinstance(identity, dict):
583
+ raise TransactionRecoveryError("transaction journal identity is invalid")
584
+ expected = {
585
+ "profile": str(self.profile),
586
+ "registry": str(self.registry),
587
+ "agent_id": self.agent_id,
588
+ }
589
+ for key, value in expected.items():
590
+ if identity.get(key) != value:
591
+ raise TransactionRecoveryError(
592
+ f"transaction journal {key} does not match this invocation"
593
+ )
594
+ targets = journal.get("targets")
595
+ if not isinstance(targets, dict) or set(targets) != set(self.targets):
596
+ raise TransactionRecoveryError("transaction journal target set is invalid")
597
+ for key, path in self.targets.items():
598
+ entry = targets[key]
599
+ if not isinstance(entry, dict) or entry.get("path") != str(path):
600
+ raise TransactionRecoveryError(
601
+ f"transaction journal path does not match for {key}"
602
+ )
603
+ FileState.from_json(entry.get("original"))
604
+
605
+ def _artifact_inventory(
606
+ self,
607
+ journal: dict[str, Any],
608
+ *,
609
+ directory: pathlib.Path | None = None,
610
+ ) -> tuple[dict[pathlib.Path, list[FileState]], dict[tuple[int, int], int]]:
611
+ """Return allowed artifact states and actual known link counts."""
612
+
613
+ artifact_directory = self.directory if directory is None else directory
614
+ allowed: dict[pathlib.Path, list[FileState]] = {}
615
+ per_target: dict[str, list[FileState]] = {}
616
+ for key, entry in journal["targets"].items():
617
+ states = [FileState.from_json(entry["original"])]
618
+ if entry.get("protected") is not None:
619
+ states.append(FileState.from_json(entry["protected"]))
620
+ per_target[key] = states
621
+ for operation in journal.get("operations", []):
622
+ key = operation["target"]
623
+ per_target[key].extend(
624
+ [
625
+ FileState.from_json(operation["expected"]),
626
+ FileState.from_json(operation["desired"]),
627
+ ]
628
+ )
629
+ staged = operation.get("staged")
630
+ if isinstance(staged, str):
631
+ allowed[artifact_directory / staged] = per_target[key]
632
+ for key, entry in journal["targets"].items():
633
+ recovery = entry.get("recovery")
634
+ if isinstance(recovery, str):
635
+ allowed[artifact_directory / recovery] = per_target[key]
636
+ rollback_capture = entry.get("rollback_capture")
637
+ if isinstance(rollback_capture, str):
638
+ allowed[artifact_directory / rollback_capture] = per_target[key]
639
+
640
+ counts: dict[tuple[int, int], int] = {}
641
+ candidates = [*self.targets.values(), *allowed]
642
+ for path in candidates:
643
+ try:
644
+ info = os.lstat(path)
645
+ except FileNotFoundError:
646
+ continue
647
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
648
+ raise TransactionConflict(f"unsafe transaction path appeared: {path}")
649
+ identity = (info.st_dev, info.st_ino)
650
+ counts[identity] = counts.get(identity, 0) + 1
651
+ return allowed, counts
652
+
653
+ def _validate_artifacts_no_discard(
654
+ self,
655
+ journal: dict[str, Any],
656
+ *,
657
+ directory: pathlib.Path | None = None,
658
+ ) -> None:
659
+ allowed, counts = self._artifact_inventory(journal, directory=directory)
660
+ for path, states in allowed.items():
661
+ if not path.exists():
662
+ if path.is_symlink():
663
+ raise TransactionConflict(f"transaction artifact became a symlink: {path}")
664
+ continue
665
+ current = current_state(path, 0o600)
666
+ if not any(state_same_inode_and_bytes(current, state) for state in states):
667
+ raise TransactionConflict(
668
+ f"unknown inode in protected transaction artifact; retained: {path}"
669
+ )
670
+ if current.nlink != counts[(current.dev, current.ino)]:
671
+ raise TransactionConflict(
672
+ f"unexpected hard-link alias for protected transaction artifact: {path}"
673
+ )
674
+
675
+ def _cleanup_directory(self, journal: dict[str, Any] | None = None) -> None:
676
+ """Atomically detach, validate, and remove only known artifacts.
677
+
678
+ A pathname writer racing cleanup is redirected into the empty directory
679
+ exchanged into the canonical location. The captured transaction tree
680
+ is validated only after that exchange, so cleanup never performs a
681
+ check-then-unlink against the live canonical pathname.
682
+ """
683
+
684
+ self._validate_directory()
685
+ allowed = {self.JOURNAL_NAME, self.NEXT_JOURNAL_NAME}
686
+ if journal is not None:
687
+ self._validate_artifacts_no_discard(journal)
688
+ targets = journal.get("targets", {})
689
+ if isinstance(targets, dict):
690
+ for entry in targets.values():
691
+ if isinstance(entry, dict) and isinstance(entry.get("recovery"), str):
692
+ allowed.add(entry["recovery"])
693
+ if isinstance(entry, dict) and isinstance(
694
+ entry.get("rollback_capture"), str
695
+ ):
696
+ allowed.add(entry["rollback_capture"])
697
+ operations = journal.get("operations", [])
698
+ if isinstance(operations, list):
699
+ for operation in operations:
700
+ if isinstance(operation, dict) and isinstance(operation.get("staged"), str):
701
+ allowed.add(operation["staged"])
702
+ for entry in tuple(self.directory.iterdir()):
703
+ if entry.name not in allowed:
704
+ raise TransactionRecoveryError(
705
+ f"unknown protected transaction artifact requires manual review: {entry}"
706
+ )
707
+ if entry.is_symlink() or (entry.exists() and not entry.is_file()):
708
+ raise TransactionRecoveryError(
709
+ f"unsafe protected transaction artifact requires manual review: {entry}"
710
+ )
711
+
712
+ captured_info = os.lstat(self.directory)
713
+ quarantine = self.directory.with_name(
714
+ f"{self.directory.name}.cleanup-{uuid.uuid4().hex}"
715
+ )
716
+ os.mkdir(quarantine, 0o700)
717
+ os.chmod(quarantine, 0o700)
718
+ placeholder_info = os.lstat(quarantine)
719
+ fsync_parent(quarantine)
720
+ fault_boundary("cleanup-before-exchange")
721
+ atomic_exchange(self.directory, quarantine)
722
+ fsync_parent(self.directory)
723
+ captured_after = os.lstat(quarantine)
724
+ placeholder_after = os.lstat(self.directory)
725
+ identities_ok = (
726
+ (captured_after.st_dev, captured_after.st_ino)
727
+ == (captured_info.st_dev, captured_info.st_ino)
728
+ and (placeholder_after.st_dev, placeholder_after.st_ino)
729
+ == (placeholder_info.st_dev, placeholder_info.st_ino)
730
+ )
731
+ try:
732
+ if not identities_ok:
733
+ raise TransactionConflict(
734
+ "transaction directory identity changed during atomic cleanup capture"
735
+ )
736
+ if journal is not None:
737
+ self._validate_artifacts_no_discard(journal, directory=quarantine)
738
+ for entry in tuple(quarantine.iterdir()):
739
+ if entry.name not in allowed:
740
+ raise TransactionConflict(
741
+ f"unknown cleanup artifact retained: {entry}"
742
+ )
743
+ if entry.is_symlink() or (entry.exists() and not entry.is_file()):
744
+ raise TransactionConflict(
745
+ f"unsafe cleanup artifact retained: {entry}"
746
+ )
747
+ except BaseException:
748
+ # Restore the captured tree with another atomic exchange. If a
749
+ # pathname writer populated the placeholder meanwhile, its files
750
+ # move to the quarantine name and are deliberately retained.
751
+ atomic_exchange(quarantine, self.directory)
752
+ fsync_parent(self.directory)
753
+ try:
754
+ quarantine.rmdir()
755
+ except OSError:
756
+ pass
757
+ raise
758
+
759
+ # The captured directory has an unguessable sibling name and the
760
+ # canonical pathname now designates a distinct empty directory. Check
761
+ # each inode again immediately before removal and remove the journal
762
+ # last, leaving recovery evidence intact on any discrepancy.
763
+ try:
764
+ artifact_states, _counts = (
765
+ self._artifact_inventory(journal, directory=quarantine)
766
+ if journal is not None
767
+ else ({}, {})
768
+ )
769
+ names = sorted(allowed - {self.JOURNAL_NAME, self.NEXT_JOURNAL_NAME})
770
+ names.extend([self.NEXT_JOURNAL_NAME, self.JOURNAL_NAME])
771
+ for name in names:
772
+ artifact = quarantine / name
773
+ try:
774
+ info = os.lstat(artifact)
775
+ except FileNotFoundError:
776
+ continue
777
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
778
+ raise TransactionConflict(
779
+ f"cleanup artifact changed and was retained: {artifact}"
780
+ )
781
+ states = artifact_states.get(artifact)
782
+ if states is not None:
783
+ current = current_state(artifact, 0o600)
784
+ if not any(
785
+ state_same_inode_and_bytes(current, expected)
786
+ for expected in states
787
+ ):
788
+ raise TransactionConflict(
789
+ f"cleanup artifact inode changed and was retained: {artifact}"
790
+ )
791
+ artifact.unlink()
792
+ fsync_directory(quarantine)
793
+ quarantine.rmdir()
794
+ except BaseException:
795
+ # Keep the journal (removed last above) and any remaining recovery
796
+ # artifacts at the canonical location if cleanup is interrupted.
797
+ if quarantine.exists() and self.directory.exists():
798
+ atomic_exchange(quarantine, self.directory)
799
+ fsync_parent(self.directory)
800
+ try:
801
+ quarantine.rmdir()
802
+ except OSError:
803
+ pass
804
+ raise
805
+ try:
806
+ self.directory.rmdir()
807
+ except OSError as exc:
808
+ raise TransactionConflict(
809
+ f"new state appeared during cleanup and was retained: {self.directory}"
810
+ ) from exc
811
+ fsync_parent(self.directory)
812
+
813
+ def _cleanup_incomplete_preparation(self) -> None:
814
+ """Remove only an owned 0700 directory created before journal publish.
815
+
816
+ Target mutation starts only after a complete ``ready`` journal exists,
817
+ so a journal-less directory cannot represent a committed file write.
818
+ """
819
+
820
+ self._validate_directory()
821
+ for entry in tuple(self.directory.iterdir()):
822
+ if entry.name != self.NEXT_JOURNAL_NAME:
823
+ raise TransactionRecoveryError(
824
+ f"journal-less transaction artifact requires manual review: {entry}"
825
+ )
826
+ if entry.is_symlink() or not entry.is_file():
827
+ raise TransactionRecoveryError(
828
+ f"unsafe journal-less transaction artifact: {entry}"
829
+ )
830
+ for entry in tuple(self.directory.iterdir()):
831
+ entry.unlink()
832
+ fsync_directory(self.directory)
833
+ self.directory.rmdir()
834
+ fsync_parent(self.directory)
835
+
836
+ @staticmethod
837
+ def _operation_states(journal: dict[str, Any]) -> dict[str, list[tuple[int, FileState]]]:
838
+ result: dict[str, list[tuple[int, FileState]]] = {}
839
+ operations = journal.get("operations", [])
840
+ if not isinstance(operations, list):
841
+ raise TransactionRecoveryError("transaction journal operations are invalid")
842
+ for index, operation in enumerate(operations):
843
+ if not isinstance(operation, dict) or operation.get("index") != index:
844
+ raise TransactionRecoveryError("transaction journal operation order is invalid")
845
+ key = operation.get("target")
846
+ if not isinstance(key, str):
847
+ raise TransactionRecoveryError("transaction journal operation target is invalid")
848
+ result.setdefault(key, []).append((index, FileState.from_json(operation.get("desired"))))
849
+ return result
850
+
851
+ def _record_conflicts(self, journal: dict[str, Any], conflicts: list[str]) -> None:
852
+ journal["status"] = "conflict"
853
+ journal["conflicts"] = sorted(set(conflicts))
854
+ self.journal = journal
855
+ self._write_journal()
856
+
857
+ @staticmethod
858
+ def _parse_intent(
859
+ journal: dict[str, Any], operations: list[dict[str, Any]]
860
+ ) -> tuple[int, str] | None:
861
+ value = journal.get("intent")
862
+ if value is None:
863
+ return None
864
+ if not isinstance(value, dict) or set(value) != {"index", "phase"}:
865
+ raise TransactionRecoveryError("transaction journal intent is invalid")
866
+ index = value.get("index")
867
+ phase = value.get("phase")
868
+ if (
869
+ not isinstance(index, int)
870
+ or isinstance(index, bool)
871
+ or not 0 <= index < len(operations)
872
+ or phase
873
+ not in {"before-syscall", "captured", "reversing", "reversed"}
874
+ ):
875
+ raise TransactionRecoveryError("transaction journal intent is invalid")
876
+ return index, phase
877
+
878
+ @staticmethod
879
+ def _same(current: FileState, expected: FileState) -> bool:
880
+ return current == expected or state_same_inode_and_bytes(current, expected)
881
+
882
+ def _operation_slot(
883
+ self, operation: dict[str, Any], *, required: bool = True
884
+ ) -> pathlib.Path | None:
885
+ raw = operation.get("staged")
886
+ if raw is None and not required:
887
+ return None
888
+ if not isinstance(raw, str) or pathlib.Path(raw).name != raw:
889
+ raise TransactionRecoveryError("transaction operation slot is invalid")
890
+ return self.directory / raw
891
+
892
+ def _target_artifacts(
893
+ self, journal: dict[str, Any], key: str
894
+ ) -> list[pathlib.Path]:
895
+ entry = journal["targets"][key]
896
+ result: list[pathlib.Path] = []
897
+ for name_key in ("recovery", "rollback_capture"):
898
+ raw = entry.get(name_key)
899
+ if isinstance(raw, str):
900
+ result.append(self.directory / raw)
901
+ for operation in journal.get("operations", []):
902
+ if operation.get("target") != key:
903
+ continue
904
+ slot = self._operation_slot(operation, required=False)
905
+ if slot is not None:
906
+ result.append(slot)
907
+ return list(dict.fromkeys(result))
908
+
909
+ def _find_original_artifact(
910
+ self, journal: dict[str, Any], key: str, original: FileState
911
+ ) -> tuple[pathlib.Path, FileState] | None:
912
+ for path in self._target_artifacts(journal, key):
913
+ state = current_state(path, original.mode)
914
+ if state.existed and state_same_inode_and_bytes(state, original):
915
+ return path, state
916
+ return None
917
+
918
+ def _forward_possible_states(
919
+ self,
920
+ journal: dict[str, Any],
921
+ operations: list[dict[str, Any]],
922
+ cursor: int,
923
+ intent: tuple[int, str] | None,
924
+ ) -> dict[str, list[FileState]]:
925
+ """States this transaction may legitimately have installed at targets."""
926
+
927
+ possible: dict[str, list[FileState]] = {
928
+ key: [FileState.from_json(entry["original"])]
929
+ for key, entry in journal["targets"].items()
930
+ }
931
+ for operation in operations[:cursor]:
932
+ possible[operation["target"]].append(
933
+ FileState.from_json(operation["desired"])
934
+ )
935
+ if intent is not None:
936
+ index, _phase = intent
937
+ operation = operations[index]
938
+ possible[operation["target"]].extend(
939
+ [
940
+ FileState.from_json(operation["expected"]),
941
+ FileState.from_json(operation["desired"]),
942
+ ]
943
+ )
944
+ return possible
945
+
946
+ def _reverse_external_forward_capture(
947
+ self,
948
+ journal: dict[str, Any],
949
+ operation: dict[str, Any],
950
+ target_state: FileState,
951
+ slot_state: FileState,
952
+ ) -> None:
953
+ """Atomically return a displaced external inode to the target."""
954
+
955
+ index = operation["index"]
956
+ label = operation["label"]
957
+ key = operation["target"]
958
+ path = self.targets[key]
959
+ kind = operation["kind"]
960
+ slot = self._operation_slot(operation)
961
+ assert slot is not None
962
+ journal["intent"] = {"index": index, "phase": "reversing"}
963
+ self.journal = journal
964
+ self._write_journal()
965
+ fault_boundary(f"reverse-intent:{index}:{label}")
966
+ reversed_ok = False
967
+ if kind == "replace":
968
+ atomic_exchange(slot, path)
969
+ fsync_parent(path)
970
+ fsync_directory(self.directory)
971
+ after_target = current_state(path, slot_state.mode)
972
+ after_slot = current_state(slot, target_state.mode)
973
+ reversed_ok = self._same(after_target, slot_state) and self._same(
974
+ after_slot, target_state
975
+ )
976
+ elif kind == "delete":
977
+ try:
978
+ atomic_move_noreplace(slot, path)
979
+ except OSError as exc:
980
+ if exc.errno != errno.EEXIST:
981
+ raise
982
+ else:
983
+ fsync_parent(path)
984
+ fsync_directory(self.directory)
985
+ after_target = current_state(path, slot_state.mode)
986
+ after_slot = current_state(slot, target_state.mode)
987
+ reversed_ok = self._same(after_target, slot_state) and not after_slot.existed
988
+ else:
989
+ raise TransactionRecoveryError(
990
+ f"cannot reverse transaction operation kind: {kind}"
991
+ )
992
+ journal["intent"] = {"index": index, "phase": "reversed"}
993
+ self._write_journal()
994
+ fault_boundary(f"reverse:{index}:{label}")
995
+ detail = (
996
+ f"{key}:external-capture-reversed"
997
+ if reversed_ok
998
+ else f"{key}:external-capture-raced"
999
+ )
1000
+ self._record_conflicts(journal, [detail])
1001
+ raise TransactionConflict(
1002
+ "external state preserved after atomic capture conflict; protected "
1003
+ f"journal retained at {self.journal_path}"
1004
+ )
1005
+
1006
+ def _resolve_inflight_capture(
1007
+ self,
1008
+ journal: dict[str, Any],
1009
+ operations: list[dict[str, Any]],
1010
+ intent: tuple[int, str] | None,
1011
+ ) -> None:
1012
+ """Resolve an interrupted or mismatched forward atomic syscall."""
1013
+
1014
+ if intent is None:
1015
+ return
1016
+ index, phase = intent
1017
+ operation = operations[index]
1018
+ kind = operation["kind"]
1019
+ if kind not in {"replace", "delete"}:
1020
+ return
1021
+ expected = FileState.from_json(operation["expected"])
1022
+ desired = FileState.from_json(operation["desired"])
1023
+ path = self.targets[operation["target"]]
1024
+ slot = self._operation_slot(operation)
1025
+ assert slot is not None
1026
+ target_state = current_state(path, desired.mode)
1027
+ slot_state = current_state(slot, expected.mode)
1028
+
1029
+ if kind == "replace":
1030
+ pre_syscall = self._same(target_state, expected) and self._same(
1031
+ slot_state, desired
1032
+ )
1033
+ captured_expected = self._same(target_state, desired) and self._same(
1034
+ slot_state, expected
1035
+ )
1036
+ reversed_external = self._same(slot_state, desired) and not (
1037
+ self._same(target_state, expected)
1038
+ or self._same(target_state, desired)
1039
+ )
1040
+ captured_external = self._same(target_state, desired) and not (
1041
+ self._same(slot_state, expected) or self._same(slot_state, desired)
1042
+ )
1043
+ if pre_syscall or captured_expected:
1044
+ return
1045
+ if captured_external:
1046
+ self._reverse_external_forward_capture(
1047
+ journal, operation, target_state, slot_state
1048
+ )
1049
+ if reversed_external or phase == "reversed":
1050
+ self._record_conflicts(
1051
+ journal, [f"{operation['target']}:external-capture-reversed"]
1052
+ )
1053
+ raise TransactionConflict(
1054
+ "external state preserved after interrupted atomic reversal; "
1055
+ f"journal retained at {self.journal_path}"
1056
+ )
1057
+ else:
1058
+ pre_syscall = self._same(target_state, expected) and not slot_state.existed
1059
+ captured_expected = not target_state.existed and self._same(
1060
+ slot_state, expected
1061
+ )
1062
+ reversed_external = (
1063
+ not slot_state.existed
1064
+ and target_state.existed
1065
+ and not self._same(target_state, expected)
1066
+ )
1067
+ captured_external = (
1068
+ not target_state.existed
1069
+ and slot_state.existed
1070
+ and not self._same(slot_state, expected)
1071
+ )
1072
+ if pre_syscall or captured_expected:
1073
+ return
1074
+ if captured_external:
1075
+ self._reverse_external_forward_capture(
1076
+ journal, operation, target_state, slot_state
1077
+ )
1078
+ if reversed_external or phase == "reversed":
1079
+ self._record_conflicts(
1080
+ journal, [f"{operation['target']}:external-capture-reversed"]
1081
+ )
1082
+ raise TransactionConflict(
1083
+ "external state preserved after interrupted atomic reversal; "
1084
+ f"journal retained at {self.journal_path}"
1085
+ )
1086
+
1087
+ self._record_conflicts(
1088
+ journal, [f"{operation['target']}:ambiguous-inflight-topology"]
1089
+ )
1090
+ raise TransactionConflict(
1091
+ "ambiguous atomic transaction topology retained for recovery at "
1092
+ f"{self.journal_path}"
1093
+ )
1094
+
1095
+ def _build_rollback(
1096
+ self,
1097
+ journal: dict[str, Any],
1098
+ possible: dict[str, list[FileState]],
1099
+ ) -> dict[str, Any]:
1100
+ """Classify all paths before the first rollback mutation."""
1101
+
1102
+ self._validate_artifacts_no_discard(journal)
1103
+ actions: list[dict[str, Any]] = []
1104
+ conflicts: list[str] = []
1105
+ for key, entry in journal["targets"].items():
1106
+ path = pathlib.Path(entry["path"])
1107
+ original = FileState.from_json(entry["original"])
1108
+ current = current_state(path, original.mode)
1109
+ if self._same(current, original):
1110
+ continue
1111
+ known_current = any(self._same(current, state) for state in possible[key])
1112
+ if original.existed:
1113
+ source = self._find_original_artifact(journal, key, original)
1114
+ if source is None:
1115
+ conflicts.append(f"{key}:missing-original-recovery-inode")
1116
+ continue
1117
+ source_path, source_state = source
1118
+ if current.existed and known_current:
1119
+ actions.append(
1120
+ {
1121
+ "key": key,
1122
+ "kind": "exchange",
1123
+ "target": str(path),
1124
+ "source": str(source_path),
1125
+ "expected_target": current.to_json(),
1126
+ "expected_source": source_state.to_json(),
1127
+ }
1128
+ )
1129
+ elif not current.existed and any(
1130
+ not state.existed for state in possible[key]
1131
+ ):
1132
+ actions.append(
1133
+ {
1134
+ "key": key,
1135
+ "kind": "restore-missing",
1136
+ "target": str(path),
1137
+ "source": str(source_path),
1138
+ "expected_target": current.to_json(),
1139
+ "expected_source": source_state.to_json(),
1140
+ }
1141
+ )
1142
+ else:
1143
+ conflicts.append(f"{key}:external-state")
1144
+ elif not current.existed:
1145
+ continue
1146
+ elif known_current:
1147
+ capture_name = entry.get("rollback_capture")
1148
+ if not isinstance(capture_name, str):
1149
+ conflicts.append(f"{key}:missing-rollback-capture")
1150
+ continue
1151
+ capture = self.directory / capture_name
1152
+ capture_state = current_state(capture, current.mode)
1153
+ if capture_state.existed:
1154
+ conflicts.append(f"{key}:occupied-rollback-capture")
1155
+ continue
1156
+ actions.append(
1157
+ {
1158
+ "key": key,
1159
+ "kind": "capture-created",
1160
+ "target": str(path),
1161
+ "source": str(capture),
1162
+ "expected_target": current.to_json(),
1163
+ "expected_source": capture_state.to_json(),
1164
+ }
1165
+ )
1166
+ else:
1167
+ conflicts.append(f"{key}:external-state")
1168
+ if conflicts:
1169
+ self._record_conflicts(journal, conflicts)
1170
+ raise TransactionConflict(
1171
+ "external state preserved; protected transaction journal retained at "
1172
+ f"{self.journal_path}"
1173
+ )
1174
+ rollback = {"cursor": 0, "intent": None, "actions": actions}
1175
+ journal["rollback"] = rollback
1176
+ journal["status"] = "rolling-back"
1177
+ journal["intent"] = None
1178
+ self.journal = journal
1179
+ self._write_journal()
1180
+ return rollback
1181
+
1182
+ def _parse_rollback(self, journal: dict[str, Any]) -> dict[str, Any]:
1183
+ rollback = journal.get("rollback")
1184
+ if not isinstance(rollback, dict) or set(rollback) != {
1185
+ "cursor",
1186
+ "intent",
1187
+ "actions",
1188
+ }:
1189
+ raise TransactionRecoveryError("transaction rollback journal is invalid")
1190
+ actions = rollback.get("actions")
1191
+ cursor = rollback.get("cursor")
1192
+ intent = rollback.get("intent")
1193
+ if (
1194
+ not isinstance(actions, list)
1195
+ or not isinstance(cursor, int)
1196
+ or isinstance(cursor, bool)
1197
+ or not 0 <= cursor <= len(actions)
1198
+ ):
1199
+ raise TransactionRecoveryError("transaction rollback cursor is invalid")
1200
+ if intent is not None and (
1201
+ not isinstance(intent, dict)
1202
+ or set(intent) != {"index", "phase"}
1203
+ or intent.get("index") != cursor
1204
+ or intent.get("phase")
1205
+ not in {"before-syscall", "captured", "reversing", "reversed"}
1206
+ ):
1207
+ raise TransactionRecoveryError("transaction rollback intent is invalid")
1208
+ for action in actions:
1209
+ if not isinstance(action, dict) or set(action) != {
1210
+ "key",
1211
+ "kind",
1212
+ "target",
1213
+ "source",
1214
+ "expected_target",
1215
+ "expected_source",
1216
+ }:
1217
+ raise TransactionRecoveryError("transaction rollback action is invalid")
1218
+ if action["kind"] not in {"exchange", "restore-missing", "capture-created"}:
1219
+ raise TransactionRecoveryError("transaction rollback action kind is invalid")
1220
+ FileState.from_json(action["expected_target"])
1221
+ FileState.from_json(action["expected_source"])
1222
+ return rollback
1223
+
1224
+ def _rollback_action_conflict(
1225
+ self,
1226
+ journal: dict[str, Any],
1227
+ action: dict[str, Any],
1228
+ detail: str,
1229
+ ) -> "None":
1230
+ self._record_conflicts(journal, [f"{action['key']}:{detail}"])
1231
+ raise TransactionConflict(
1232
+ "newer state appeared during atomic rollback and was preserved; "
1233
+ f"journal retained at {self.journal_path}"
1234
+ )
1235
+
1236
+ def _run_rollback_action(
1237
+ self,
1238
+ journal: dict[str, Any],
1239
+ rollback: dict[str, Any],
1240
+ index: int,
1241
+ ) -> None:
1242
+ action = rollback["actions"][index]
1243
+ key = action["key"]
1244
+ kind = action["kind"]
1245
+ target = pathlib.Path(action["target"])
1246
+ source = pathlib.Path(action["source"])
1247
+ expected_target = FileState.from_json(action["expected_target"])
1248
+ expected_source = FileState.from_json(action["expected_source"])
1249
+ target_state = current_state(target, expected_target.mode)
1250
+ source_state = current_state(source, expected_source.mode)
1251
+
1252
+ if kind == "exchange":
1253
+ post = self._same(target_state, expected_source) and self._same(
1254
+ source_state, expected_target
1255
+ )
1256
+ pre = self._same(target_state, expected_target) and self._same(
1257
+ source_state, expected_source
1258
+ )
1259
+ elif kind == "restore-missing":
1260
+ post = self._same(target_state, expected_source) and not source_state.existed
1261
+ pre = not target_state.existed and self._same(source_state, expected_source)
1262
+ else:
1263
+ post = not target_state.existed and self._same(source_state, expected_target)
1264
+ pre = self._same(target_state, expected_target) and not source_state.existed
1265
+
1266
+ if post:
1267
+ rollback["cursor"] = index + 1
1268
+ rollback["intent"] = None
1269
+ self._write_journal()
1270
+ fault_boundary(f"rollback-commit:{index}:{key}")
1271
+ return
1272
+ if not pre:
1273
+ self._rollback_action_conflict(journal, action, "rollback-topology-conflict")
1274
+
1275
+ rollback["intent"] = {"index": index, "phase": "before-syscall"}
1276
+ self._write_journal()
1277
+ fault_boundary(f"rollback-intent:{index}:{key}")
1278
+ if kind == "exchange":
1279
+ atomic_exchange(source, target)
1280
+ elif kind == "restore-missing":
1281
+ try:
1282
+ atomic_move_noreplace(source, target)
1283
+ except OSError as exc:
1284
+ if exc.errno == errno.EEXIST:
1285
+ self._rollback_action_conflict(
1286
+ journal, action, "rollback-target-created"
1287
+ )
1288
+ raise
1289
+ else:
1290
+ try:
1291
+ atomic_move_noreplace(target, source)
1292
+ except OSError as exc:
1293
+ if exc.errno == errno.EEXIST:
1294
+ self._rollback_action_conflict(
1295
+ journal, action, "rollback-capture-occupied"
1296
+ )
1297
+ raise
1298
+ fsync_parent(target)
1299
+ fsync_directory(self.directory)
1300
+ rollback["intent"] = {"index": index, "phase": "captured"}
1301
+ self._write_journal()
1302
+ fault_boundary(f"rollback-capture:{index}:{key}")
1303
+
1304
+ after_target = current_state(target, expected_target.mode)
1305
+ after_source = current_state(source, expected_source.mode)
1306
+ if kind == "exchange":
1307
+ valid = self._same(after_target, expected_source) and self._same(
1308
+ after_source, expected_target
1309
+ )
1310
+ elif kind == "restore-missing":
1311
+ valid = self._same(after_target, expected_source) and not after_source.existed
1312
+ else:
1313
+ valid = not after_target.existed and self._same(
1314
+ after_source, expected_target
1315
+ )
1316
+ if not valid:
1317
+ rollback["intent"] = {"index": index, "phase": "reversing"}
1318
+ self._write_journal()
1319
+ fault_boundary(f"rollback-reverse-intent:{index}:{key}")
1320
+ reverse_ok = False
1321
+ if kind == "exchange":
1322
+ atomic_exchange(source, target)
1323
+ reverse_target = current_state(target, after_source.mode)
1324
+ reverse_source = current_state(source, after_target.mode)
1325
+ reverse_ok = self._same(reverse_target, after_source) and self._same(
1326
+ reverse_source, after_target
1327
+ )
1328
+ elif kind == "restore-missing":
1329
+ try:
1330
+ atomic_move_noreplace(target, source)
1331
+ except OSError as exc:
1332
+ if exc.errno != errno.EEXIST:
1333
+ raise
1334
+ else:
1335
+ reverse_ok = not target.exists() and self._same(
1336
+ current_state(source, after_target.mode), after_target
1337
+ )
1338
+ else:
1339
+ try:
1340
+ atomic_move_noreplace(source, target)
1341
+ except OSError as exc:
1342
+ if exc.errno != errno.EEXIST:
1343
+ raise
1344
+ else:
1345
+ reverse_ok = not source.exists() and self._same(
1346
+ current_state(target, after_source.mode), after_source
1347
+ )
1348
+ fsync_parent(target)
1349
+ fsync_directory(self.directory)
1350
+ rollback["intent"] = {"index": index, "phase": "reversed"}
1351
+ self._write_journal()
1352
+ fault_boundary(f"rollback-reverse:{index}:{key}")
1353
+ self._rollback_action_conflict(
1354
+ journal,
1355
+ action,
1356
+ "rollback-cas-reversed" if reverse_ok else "rollback-cas-raced",
1357
+ )
1358
+
1359
+ rollback["cursor"] = index + 1
1360
+ rollback["intent"] = None
1361
+ self._write_journal()
1362
+ fault_boundary(f"rollback-commit:{index}:{key}")
1363
+
1364
+ def _rollback(self, journal: dict[str, Any]) -> None:
1365
+ cursor = journal.get("cursor")
1366
+ operations = journal.get("operations", [])
1367
+ if (
1368
+ not isinstance(operations, list)
1369
+ or not isinstance(cursor, int)
1370
+ or isinstance(cursor, bool)
1371
+ or not 0 <= cursor <= len(operations)
1372
+ ):
1373
+ raise TransactionRecoveryError("transaction journal cursor is invalid")
1374
+ intent = self._parse_intent(journal, operations)
1375
+ if journal.get("status") != "rolling-back":
1376
+ self._resolve_inflight_capture(journal, operations, intent)
1377
+ possible = self._forward_possible_states(
1378
+ journal, operations, cursor, intent
1379
+ )
1380
+ rollback = self._build_rollback(journal, possible)
1381
+ else:
1382
+ rollback = self._parse_rollback(journal)
1383
+
1384
+ self._validate_artifacts_no_discard(journal)
1385
+ for index in range(rollback["cursor"], len(rollback["actions"])):
1386
+ self._run_rollback_action(journal, rollback, index)
1387
+
1388
+ originals = {
1389
+ key: FileState.from_json(entry["original"])
1390
+ for key, entry in journal["targets"].items()
1391
+ }
1392
+ for key, original in originals.items():
1393
+ if not self._same(current_state(self.targets[key], original.mode), original):
1394
+ self._record_conflicts(journal, [f"{key}:restore-verification-failed"])
1395
+ raise TransactionConflict(
1396
+ f"rollback verification failed; journal retained at {self.journal_path}"
1397
+ )
1398
+ self._cleanup_directory(journal)
1399
+ for key, original in originals.items():
1400
+ if current_state(self.targets[key], original.mode) != original:
1401
+ raise TransactionRecoveryError(
1402
+ f"rollback did not restore exact inode/link state for {key}"
1403
+ )
1404
+
1405
+ def recover_if_needed(self) -> None:
1406
+ try:
1407
+ exists = self.directory.exists()
1408
+ except OSError as exc:
1409
+ raise TransactionRecoveryError(
1410
+ f"cannot inspect protected transaction directory: {type(exc).__name__}"
1411
+ ) from exc
1412
+ if not exists:
1413
+ if self.directory.is_symlink():
1414
+ raise TransactionRecoveryError(
1415
+ f"refusing protected transaction symlink: {self.directory}"
1416
+ )
1417
+ return
1418
+ if self.directory.is_symlink():
1419
+ raise TransactionRecoveryError(
1420
+ f"refusing protected transaction symlink: {self.directory}"
1421
+ )
1422
+ if not self.journal_path.exists():
1423
+ self._cleanup_incomplete_preparation()
1424
+ return
1425
+ journal = self._load_journal()
1426
+ self._validate_journal_identity(journal)
1427
+ status = journal.get("status")
1428
+ if status == "preparing":
1429
+ conflicts = []
1430
+ for key, entry in journal["targets"].items():
1431
+ original = FileState.from_json(entry["original"])
1432
+ current = current_state(pathlib.Path(entry["path"]), original.mode)
1433
+ if not (
1434
+ current == original
1435
+ or (
1436
+ original.existed
1437
+ and state_same_inode_and_bytes(current, original)
1438
+ )
1439
+ ):
1440
+ conflicts.append(f"{key}:changed-during-preparation")
1441
+ if conflicts:
1442
+ self._record_conflicts(journal, conflicts)
1443
+ raise TransactionConflict(
1444
+ f"incomplete preparation conflicts with live state; journal retained at {self.journal_path}"
1445
+ )
1446
+ try:
1447
+ self._cleanup_directory(journal)
1448
+ except TransactionConflict as exc:
1449
+ self._record_conflicts(journal, ["preparing:unsafe-recovery-artifact"])
1450
+ raise TransactionConflict(
1451
+ f"incomplete preparation recovery artifact was retained: {exc}"
1452
+ ) from exc
1453
+ return
1454
+ if status == "committed":
1455
+ final = {
1456
+ key: FileState.from_json(entry["original"])
1457
+ for key, entry in journal["targets"].items()
1458
+ }
1459
+ for operation in journal.get("operations", []):
1460
+ final[operation["target"]] = FileState.from_json(operation["desired"])
1461
+ conflicts = [
1462
+ f"{key}:post-commit-divergence"
1463
+ for key, state in final.items()
1464
+ if not state_matches(self.targets[key], state)
1465
+ ]
1466
+ if conflicts:
1467
+ self._record_conflicts(journal, conflicts)
1468
+ raise TransactionConflict(
1469
+ f"committed generation diverged; journal retained at {self.journal_path}"
1470
+ )
1471
+ self._cleanup_directory(journal)
1472
+ return
1473
+ if status not in {"ready", "running", "rolling-back", "conflict"}:
1474
+ raise TransactionRecoveryError(f"transaction journal status is invalid: {status}")
1475
+ self._operation_states(journal)
1476
+ self._rollback(journal)
1477
+
1478
+ def prepare(
1479
+ self,
1480
+ originals: dict[str, Snapshot],
1481
+ specifications: list[tuple[str, str, bytes | None, int]],
1482
+ ) -> None:
1483
+ if self.directory.exists() or self.directory.is_symlink():
1484
+ raise TransactionRecoveryError(
1485
+ f"protected transaction directory was not recovered: {self.directory}"
1486
+ )
1487
+ parent = self.directory.parent
1488
+ parent_info = os.lstat(parent)
1489
+ if not stat.S_ISDIR(parent_info.st_mode) or stat.S_ISLNK(parent_info.st_mode):
1490
+ raise TransactionRecoveryError(
1491
+ f"transaction parent must be a real directory: {parent}"
1492
+ )
1493
+ if not directory_is_private_to_user(parent_info):
1494
+ raise TransactionRecoveryError(
1495
+ f"transaction parent ownership/mode is unsafe: {parent}"
1496
+ )
1497
+ os.mkdir(self.directory, 0o700)
1498
+ os.chmod(self.directory, 0o700)
1499
+ fsync_parent(self.directory)
1500
+ transaction_id = uuid.uuid4().hex
1501
+ targets: dict[str, Any] = {}
1502
+ for key, path in self.targets.items():
1503
+ original = originals[key].state
1504
+ targets[key] = {
1505
+ "path": str(path),
1506
+ "original": original.to_json(),
1507
+ "recovery": f"recovery-{key}" if original.existed else None,
1508
+ "rollback_capture": f"rollback-{key}",
1509
+ "protected": None,
1510
+ }
1511
+ self.journal = {
1512
+ "schema_version": self.SCHEMA_VERSION,
1513
+ "transaction_id": transaction_id,
1514
+ "status": "preparing",
1515
+ "identity": {
1516
+ "profile": str(self.profile),
1517
+ "registry": str(self.registry),
1518
+ "agent_id": self.agent_id,
1519
+ "channel": self.channel,
1520
+ },
1521
+ "targets": targets,
1522
+ "operations": [],
1523
+ "cursor": 0,
1524
+ "intent": None,
1525
+ "conflicts": [],
1526
+ }
1527
+ self._write_journal()
1528
+
1529
+ transaction_dev = os.lstat(self.directory).st_dev
1530
+ for key, path in self.targets.items():
1531
+ parent_info = os.lstat(path.parent)
1532
+ if not stat.S_ISDIR(parent_info.st_mode) or stat.S_ISLNK(parent_info.st_mode):
1533
+ raise TransactionRecoveryError(
1534
+ f"transaction target parent is unsafe for {key}: {path.parent}"
1535
+ )
1536
+ if parent_info.st_dev != transaction_dev or (
1537
+ originals[key].existed and originals[key].state.dev != transaction_dev
1538
+ ):
1539
+ raise TransactionRecoveryError(
1540
+ f"transaction target is on a different filesystem: {path}"
1541
+ )
1542
+
1543
+ # Every original recovery inode is created and validated before any
1544
+ # target mutation. Recovery links live only in the protected profile
1545
+ # journal directory, never beside tracked role files.
1546
+ for key, path in self.targets.items():
1547
+ original = originals[key]
1548
+ if not original.existed:
1549
+ continue
1550
+ if original.state.nlink != 1:
1551
+ raise TransactionRecoveryError(
1552
+ f"transaction target already has a hard-link alias: {path}"
1553
+ )
1554
+ recovery = self.directory / targets[key]["recovery"]
1555
+ os.link(path, recovery, follow_symlinks=False)
1556
+ fault_boundary(f"recovery-link:{key}")
1557
+ protected_target = current_state(path, original.mode)
1558
+ protected_recovery = current_state(recovery, original.mode)
1559
+ if (
1560
+ not state_same_inode_and_bytes(protected_target, original.state)
1561
+ or protected_target.nlink != 2
1562
+ or protected_recovery != protected_target
1563
+ ):
1564
+ raise TransactionConflict(
1565
+ f"transaction target changed while preparing recovery: {path}"
1566
+ )
1567
+ targets[key]["protected"] = protected_target.to_json()
1568
+ fsync_directory(self.directory)
1569
+ self._write_journal()
1570
+
1571
+ expected = {
1572
+ key: (
1573
+ FileState.from_json(targets[key]["protected"])
1574
+ if original.existed
1575
+ else original.state
1576
+ )
1577
+ for key, original in originals.items()
1578
+ }
1579
+ operations: list[dict[str, Any]] = []
1580
+ for index, (label, key, content, mode) in enumerate(specifications):
1581
+ if key not in self.targets:
1582
+ raise TransactionRecoveryError(f"unknown transaction target: {key}")
1583
+ prior = expected[key]
1584
+ staged_name: str | None = None
1585
+ noop = False
1586
+ if content is None:
1587
+ desired = FileState(False, mode)
1588
+ noop = not prior.existed
1589
+ kind = "noop" if noop else "delete"
1590
+ if noop:
1591
+ desired = prior
1592
+ else:
1593
+ staged_name = f"capture-{index:02d}"
1594
+ else:
1595
+ digest = hashlib.sha256(content).hexdigest()
1596
+ if (
1597
+ prior.existed
1598
+ and prior.mode == mode
1599
+ and prior.size == len(content)
1600
+ and prior.sha256 == digest
1601
+ ):
1602
+ desired = prior
1603
+ noop = True
1604
+ kind = "noop"
1605
+ else:
1606
+ staged_name = f"candidate-{index:02d}"
1607
+ staged = self.directory / staged_name
1608
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
1609
+ flags |= getattr(os, "O_NOFOLLOW", 0)
1610
+ fd = os.open(staged, flags, mode)
1611
+ try:
1612
+ os.fchmod(fd, mode)
1613
+ view = memoryview(content)
1614
+ while view:
1615
+ written = os.write(fd, view)
1616
+ view = view[written:]
1617
+ os.fsync(fd)
1618
+ finally:
1619
+ os.close(fd)
1620
+ desired = current_state(staged, mode)
1621
+ if desired.dev != transaction_dev or desired.sha256 != digest:
1622
+ raise TransactionRecoveryError(
1623
+ f"staged transaction candidate validation failed: {label}"
1624
+ )
1625
+ kind = "create" if not prior.existed else "replace"
1626
+ operation = {
1627
+ "index": index,
1628
+ "label": label,
1629
+ "target": key,
1630
+ "expected": prior.to_json(),
1631
+ "desired": desired.to_json(),
1632
+ "staged": staged_name,
1633
+ "noop": noop,
1634
+ "kind": kind,
1635
+ }
1636
+ operations.append(operation)
1637
+ expected[key] = desired
1638
+ self.journal["operations"] = operations
1639
+ self.journal["status"] = "ready"
1640
+ self._write_journal()
1641
+ fault_boundary("prepared")
1642
+
1643
+ def execute(self) -> None:
1644
+ journal = self.journal
1645
+ operations = journal["operations"]
1646
+ try:
1647
+ journal["status"] = "running"
1648
+ self._write_journal()
1649
+ for index, operation in enumerate(operations):
1650
+ key = operation["target"]
1651
+ path = self.targets[key]
1652
+ expected = FileState.from_json(operation["expected"])
1653
+ desired = FileState.from_json(operation["desired"])
1654
+ kind = operation["kind"]
1655
+ journal["intent"] = {"index": index, "phase": "before-syscall"}
1656
+ self._write_journal()
1657
+ fault_boundary(f"intent:{index}:{operation['label']}")
1658
+ staged_name = operation.get("staged")
1659
+ staged = self.directory / staged_name if isinstance(staged_name, str) else None
1660
+ displaced: FileState | None = None
1661
+ if kind == "noop":
1662
+ if not state_matches(path, expected):
1663
+ raise TransactionConflict(
1664
+ f"compare-and-swap conflict at no-op {operation['label']}: {path}"
1665
+ )
1666
+ elif kind == "create":
1667
+ assert staged is not None
1668
+ if not state_matches(staged, desired):
1669
+ raise TransactionConflict(
1670
+ f"staged candidate changed before {operation['label']}"
1671
+ )
1672
+ try:
1673
+ atomic_move_noreplace(staged, path)
1674
+ except OSError as exc:
1675
+ if exc.errno == errno.EEXIST:
1676
+ raise TransactionConflict(
1677
+ f"compare-and-swap found a new external path at {operation['label']}: {path}"
1678
+ ) from exc
1679
+ raise
1680
+ elif kind == "replace":
1681
+ assert staged is not None
1682
+ if not state_matches(staged, desired):
1683
+ raise TransactionConflict(
1684
+ f"staged candidate changed before {operation['label']}"
1685
+ )
1686
+ atomic_exchange(staged, path)
1687
+ displaced = current_state(staged, expected.mode)
1688
+ elif kind == "delete":
1689
+ assert staged is not None
1690
+ try:
1691
+ atomic_move_noreplace(path, staged)
1692
+ except OSError as exc:
1693
+ if exc.errno == errno.ENOENT:
1694
+ raise TransactionConflict(
1695
+ f"compare-and-swap found a missing external path at {operation['label']}: {path}"
1696
+ ) from exc
1697
+ if exc.errno == errno.EEXIST:
1698
+ raise TransactionRecoveryError(
1699
+ f"protected capture slot already exists: {staged}"
1700
+ ) from exc
1701
+ raise
1702
+ displaced = current_state(staged, expected.mode)
1703
+ else:
1704
+ raise TransactionRecoveryError(
1705
+ f"transaction operation kind is invalid: {kind}"
1706
+ )
1707
+ if kind != "noop":
1708
+ fsync_parent(path)
1709
+ fsync_directory(self.directory)
1710
+ journal["intent"] = {"index": index, "phase": "captured"}
1711
+ self._write_journal()
1712
+ fault_boundary(f"capture:{index}:{operation['label']}")
1713
+
1714
+ if displaced is not None and displaced != expected:
1715
+ # The atomic syscall captured an out-of-band inode. Reverse
1716
+ # only through another atomic primitive; if a newer path
1717
+ # appeared meanwhile, retain both artifacts and fail.
1718
+ journal["intent"] = {"index": index, "phase": "reversing"}
1719
+ self._write_journal()
1720
+ fault_boundary(f"reverse-intent:{index}:{operation['label']}")
1721
+ if kind == "replace":
1722
+ atomic_exchange(staged, path)
1723
+ reversed_slot = current_state(staged, desired.mode)
1724
+ reversed_target = current_state(path, displaced.mode)
1725
+ reversed_ok = (
1726
+ reversed_slot == desired and reversed_target == displaced
1727
+ )
1728
+ else:
1729
+ try:
1730
+ atomic_move_noreplace(staged, path)
1731
+ except OSError as exc:
1732
+ if exc.errno == errno.EEXIST:
1733
+ reversed_ok = False
1734
+ else:
1735
+ raise
1736
+ else:
1737
+ reversed_ok = state_matches(path, displaced) and not staged.exists()
1738
+ fsync_parent(path)
1739
+ fsync_directory(self.directory)
1740
+ journal["intent"] = {"index": index, "phase": "reversed"}
1741
+ self._write_journal()
1742
+ fault_boundary(f"reverse:{index}:{operation['label']}")
1743
+ if not reversed_ok:
1744
+ raise TransactionConflict(
1745
+ f"external state changed during atomic CAS reversal at {operation['label']}; protected artifacts retained"
1746
+ )
1747
+ raise TransactionConflict(
1748
+ f"atomic compare-and-swap preserved external state at {operation['label']}: {path}"
1749
+ )
1750
+ fault_boundary(f"write:{index}:{operation['label']}")
1751
+ if not state_matches(path, desired):
1752
+ raise TransactionConflict(
1753
+ f"transaction write verification failed for {operation['label']}: {path}"
1754
+ )
1755
+ journal["cursor"] = index + 1
1756
+ journal["intent"] = None
1757
+ self._write_journal()
1758
+ fault_boundary(f"commit:{index}:{operation['label']}")
1759
+ journal["status"] = "committed"
1760
+ self._write_journal()
1761
+ fault_boundary("transaction-committed")
1762
+ self._cleanup_directory(journal)
1763
+ except BaseException:
1764
+ if journal.get("status") != "committed":
1765
+ self._rollback(journal)
1766
+ raise
1767
+
1768
+
1769
+ def load_mapping(path: pathlib.Path, *, required: bool = True) -> dict:
1770
+ if path.is_symlink() or (required and not path.is_file()):
1771
+ fail(f"required mapping is unavailable: {path}")
1772
+ if not path.exists():
1773
+ return {}
1774
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
1775
+ if not isinstance(data, dict):
1776
+ fail(f"mapping root required: {path}")
1777
+ return data
1778
+
1779
+
1780
+ def load_snapshot_mapping(
1781
+ path: pathlib.Path, original: Snapshot, *, required: bool = True
1782
+ ) -> dict:
1783
+ if not original.existed:
1784
+ if required:
1785
+ fail(f"required mapping is unavailable: {path}")
1786
+ return {}
1787
+ try:
1788
+ data = yaml.safe_load(original.content.decode("utf-8")) or {}
1789
+ except (UnicodeError, yaml.YAMLError) as exc:
1790
+ fail(f"invalid mapping snapshot {path}: {type(exc).__name__}")
1791
+ if not isinstance(data, dict):
1792
+ fail(f"mapping root required: {path}")
1793
+ return data
1794
+
1795
+
1796
+ def plain_merge(base: dict, override: dict) -> dict:
1797
+ result = copy.deepcopy(base)
1798
+ for key, value in override.items():
1799
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
1800
+ result[key] = plain_merge(result[key], value)
1801
+ elif key in result and isinstance(result[key], dict) and value is None:
1802
+ continue
1803
+ else:
1804
+ result[key] = copy.deepcopy(value)
1805
+ return result
1806
+
1807
+
1808
+ def apply_list_patches(result: dict, directive: object) -> None:
1809
+ if directive is None:
1810
+ return
1811
+ if not isinstance(directive, dict) or not isinstance(directive.get("list_patches", {}), dict):
1812
+ fail(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
1813
+ for dotted, rule in directive.get("list_patches", {}).items():
1814
+ if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
1815
+ fail("invalid list patch")
1816
+ additions = rule.get("add", []) or []
1817
+ removals = rule.get("remove", []) or []
1818
+ if not isinstance(additions, list) or not isinstance(removals, list) or not all(
1819
+ isinstance(item, str) for item in [*additions, *removals]
1820
+ ):
1821
+ fail(f"list patch for {dotted} must contain string lists")
1822
+ cursor = result
1823
+ parts = dotted.split(".")
1824
+ for part in parts[:-1]:
1825
+ child = cursor.setdefault(part, {})
1826
+ if not isinstance(child, dict):
1827
+ fail(f"list patch parent for {dotted} is not a mapping")
1828
+ cursor = child
1829
+ current = cursor.get(parts[-1], []) or []
1830
+ if not isinstance(current, list):
1831
+ fail(f"list patch target {dotted} is not a list")
1832
+ removed = set(removals)
1833
+ merged = [item for item in current if item not in removed]
1834
+ for item in additions:
1835
+ if item not in merged:
1836
+ merged.append(item)
1837
+ cursor[parts[-1]] = merged
1838
+
1839
+
1840
+ def merge(base: dict, delta: dict) -> dict:
1841
+ ordinary = {key: value for key, value in delta.items() if key != LIST_PATCH_KEY}
1842
+ result = plain_merge(base, ordinary)
1843
+ apply_list_patches(result, delta.get(LIST_PATCH_KEY))
1844
+ return result
1845
+
1846
+
1847
+ def delta_comments(original: bytes) -> list[str]:
1848
+ comments: list[str] = []
1849
+ for line in original.decode("utf-8").splitlines() if original else []:
1850
+ if line.lstrip().startswith("#") and line not in comments:
1851
+ comments.append(line)
1852
+ standard = [
1853
+ "# Override-only delta for this Hermes profile.",
1854
+ "# Contains configuration and secret references only; secret values remain in 1Password.",
1855
+ ]
1856
+ return [*standard, *(line for line in comments if line not in standard)]
1857
+
1858
+
1859
+ def render_delta(delta: dict, original: bytes) -> bytes:
1860
+ return (
1861
+ "\n".join(delta_comments(original))
1862
+ + "\n"
1863
+ + yaml.safe_dump(delta, sort_keys=False)
1864
+ ).encode("utf-8")
1865
+
1866
+
1867
+ def render_generated(base: dict, delta: dict) -> bytes:
1868
+ header = (
1869
+ "# GENERATED FILE -- DO NOT EDIT.\n"
1870
+ "# source: fleet config.yaml + profile config.delta.yaml\n"
1871
+ )
1872
+ return (header + yaml.safe_dump(merge(base, delta), sort_keys=False)).encode("utf-8")
1873
+
1874
+
1875
+ def update_role(original: bytes, channel: str, metadata: dict[str, str]) -> bytes:
1876
+ text = original.decode("utf-8")
1877
+ match = re.search(
1878
+ rf"(?ms)^{re.escape(channel)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
1879
+ )
1880
+ if not match:
1881
+ fail(f"{channel} metadata block missing from role.yaml")
1882
+ body = match.group("body")
1883
+ for key in CHANNEL_FIELDS[channel]:
1884
+ value = metadata[key]
1885
+ replacement = f" {key}: {json.dumps(value)}"
1886
+ body, count = re.subn(
1887
+ rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
1888
+ )
1889
+ if count == 0:
1890
+ if body and not body.endswith("\n"):
1891
+ body += "\n"
1892
+ body += replacement + "\n"
1893
+ return (text[: match.start("body")] + body + text[match.end("body") :]).encode(
1894
+ "utf-8"
1895
+ )
1896
+
1897
+
1898
+ def update_role_status(original: bytes, channel: str, status_value: str) -> bytes:
1899
+ text = original.decode("utf-8")
1900
+ match = re.search(
1901
+ rf"(?ms)^{re.escape(channel)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
1902
+ )
1903
+ if not match:
1904
+ fail(f"{channel} metadata block missing from role.yaml")
1905
+ body = match.group("body")
1906
+ replacement = f" provisioning_status: {json.dumps(status_value)}"
1907
+ body, count = re.subn(
1908
+ r"(?m)^\s+provisioning_status:\s*.*$", lambda _: replacement, body, count=1
1909
+ )
1910
+ if count == 0:
1911
+ if body and not body.endswith("\n"):
1912
+ body += "\n"
1913
+ body += replacement + "\n"
1914
+ return (text[: match.start("body")] + body + text[match.end("body") :]).encode(
1915
+ "utf-8"
1916
+ )
1917
+
1918
+
1919
+ def update_runtime_env(original: bytes, channel: str, allowed_value: str) -> bytes:
1920
+ text = original.decode("utf-8") if original else ""
1921
+ keys = [*CHANNEL_REFERENCE_KEYS[channel], CHANNEL_ALLOWED_KEYS[channel]]
1922
+ for key in keys:
1923
+ text = re.sub(
1924
+ rf"(?m)^\s*(?:export\s+)?#?\s*{re.escape(key)}\s*=.*(?:\n|$)",
1925
+ "",
1926
+ text,
1927
+ )
1928
+ text = text.rstrip("\n")
1929
+ if text:
1930
+ text += "\n"
1931
+ text += f"{CHANNEL_ALLOWED_KEYS[channel]}={json.dumps(allowed_value)}\n"
1932
+ return text.encode("utf-8")
1933
+
1934
+
1935
+ def snapshot_allowed_value(original: Snapshot, channel: str) -> str:
1936
+ """Read the active nonsecret policy from the same locked transaction snapshot."""
1937
+
1938
+ if not original.existed:
1939
+ return ""
1940
+ try:
1941
+ text = original.content.decode("utf-8")
1942
+ except UnicodeError as exc:
1943
+ fail(f"invalid {channel} runtime policy encoding: {type(exc).__name__}")
1944
+ key = CHANNEL_ALLOWED_KEYS[channel]
1945
+ match = re.search(
1946
+ rf"(?m)^\s*(?:export\s+)?{re.escape(key)}\s*=\s*(.*)$", text
1947
+ )
1948
+ if not match:
1949
+ return ""
1950
+ serialized = match.group(1).strip()
1951
+ try:
1952
+ value = json.loads(serialized)
1953
+ except json.JSONDecodeError:
1954
+ if len(serialized) >= 2 and serialized[0] == serialized[-1] == "'":
1955
+ value = serialized[1:-1]
1956
+ else:
1957
+ value = serialized
1958
+ if not isinstance(value, str) or any(character in value for character in "\r\n\0"):
1959
+ fail(f"invalid {channel} runtime allow-list policy")
1960
+ return value
1961
+
1962
+
1963
+ def merge_managed(current: dict, update: dict) -> dict:
1964
+ result = copy.deepcopy(current)
1965
+ for key, value in update.items():
1966
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
1967
+ result[key] = merge_managed(result[key], value)
1968
+ else:
1969
+ result[key] = copy.deepcopy(value)
1970
+ return result
1971
+
1972
+
1973
+ def update_registry(
1974
+ original: bytes,
1975
+ channel: str,
1976
+ agent_id: str,
1977
+ role_dir: str,
1978
+ profile_name: str,
1979
+ metadata: dict[str, str],
1980
+ ) -> bytes:
1981
+ try:
1982
+ data = yaml.safe_load(original.decode("utf-8")) if original else None
1983
+ except (UnicodeError, yaml.YAMLError) as exc:
1984
+ fail(f"cannot safely inspect {channel} ownership registry: {type(exc).__name__}")
1985
+ data = data or {"schema_version": 1, "agents": {}}
1986
+ if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
1987
+ fail(f"cannot safely inspect {channel} ownership registry: invalid agents mapping")
1988
+ agents = data.setdefault("agents", {})
1989
+ for other_id, entry in agents.items():
1990
+ if other_id == agent_id or not isinstance(entry, dict):
1991
+ continue
1992
+ claim = entry.get(channel) or {}
1993
+ if not isinstance(claim, dict):
1994
+ continue
1995
+ if channel == "telegram" and str(claim.get("bot_id") or "") == metadata["bot_id"]:
1996
+ fail(f"Telegram bot identity is already assigned to agent {other_id}")
1997
+ if channel == "slack":
1998
+ same_bot = claim.get("bot_id") == metadata["bot_id"]
1999
+ same_team_user = (
2000
+ claim.get("team_id") == metadata["team_id"]
2001
+ and claim.get("bot_user_id") == metadata["bot_user_id"]
2002
+ )
2003
+ if same_bot or same_team_user:
2004
+ fail(f"Slack bot identity is already assigned to agent {other_id}")
2005
+ existing = agents.get(agent_id, {})
2006
+ if not isinstance(existing, dict):
2007
+ fail(f"registry entry for {agent_id} is not a mapping")
2008
+ managed = {
2009
+ "role_dir": role_dir,
2010
+ "profile_name": profile_name,
2011
+ channel: metadata,
2012
+ }
2013
+ updated = merge_managed(existing, managed)
2014
+ if updated == existing:
2015
+ return original
2016
+ agents[agent_id] = updated
2017
+ return yaml.safe_dump(data, sort_keys=False).encode("utf-8")
2018
+
2019
+
2020
+ def parse_pairs(values: list[list[str]], expected: tuple[str, ...], label: str) -> dict[str, str]:
2021
+ pairs: dict[str, str] = {}
2022
+ for key, value in values:
2023
+ if key in pairs:
2024
+ fail(f"duplicate {label} key: {key}")
2025
+ pairs[key] = value
2026
+ if tuple(pairs) != expected:
2027
+ fail(f"{label} keys must be exactly: {', '.join(expected)}")
2028
+ return pairs
2029
+
2030
+
2031
+ def existing_wiring(
2032
+ args: argparse.Namespace,
2033
+ originals: dict[pathlib.Path, Snapshot],
2034
+ delta_path: pathlib.Path,
2035
+ role_path: pathlib.Path,
2036
+ ) -> tuple[dict[str, str], dict[str, str]]:
2037
+ channel = args.channel
2038
+ role = load_snapshot_mapping(role_path, originals[role_path])
2039
+ role_channel = role.get(channel)
2040
+ if not isinstance(role_channel, dict) or role_channel.get("provisioning_status") != "verified":
2041
+ raise ExistingWiringUnavailable
2042
+
2043
+ metadata: dict[str, str] = {}
2044
+ for key in CHANNEL_FIELDS[channel]:
2045
+ value = role_channel.get(key, "")
2046
+ if not isinstance(value, (str, int)) or isinstance(value, bool):
2047
+ fail(f"verified {channel} metadata field {key} is invalid")
2048
+ rendered = str(value)
2049
+ if key != "team_name" and not rendered:
2050
+ fail(f"verified {channel} metadata field {key} is missing")
2051
+ metadata[key] = rendered
2052
+
2053
+ delta = load_snapshot_mapping(delta_path, originals[delta_path])
2054
+ try:
2055
+ secret_env = delta["secrets"]["onepassword"]["env"]
2056
+ except (KeyError, TypeError):
2057
+ fail(f"verified {channel} wiring has no 1Password environment mapping")
2058
+ if not isinstance(secret_env, dict):
2059
+ fail(f"verified {channel} 1Password environment mapping is invalid")
2060
+ references: dict[str, str] = {}
2061
+ for name in CHANNEL_REFERENCE_KEYS[channel]:
2062
+ reference = secret_env.get(name, "")
2063
+ if (
2064
+ not isinstance(reference, str)
2065
+ or not reference.startswith("op://")
2066
+ or any(character in reference for character in "\r\n\0")
2067
+ ):
2068
+ fail(f"verified {channel} reference {name} is missing or invalid")
2069
+ references[name] = reference
2070
+
2071
+ validator = pathlib.Path(args.reference_validator or "")
2072
+ if validator.is_symlink() or not validator.is_file():
2073
+ raise ExistingWiringValidationUnavailable
2074
+ for reference in references.values():
2075
+ try:
2076
+ result = subprocess.run(
2077
+ [sys.executable, "-I", str(validator), "--validate-reference", reference],
2078
+ stdin=subprocess.DEVNULL,
2079
+ stdout=subprocess.DEVNULL,
2080
+ stderr=subprocess.DEVNULL,
2081
+ check=False,
2082
+ timeout=30,
2083
+ )
2084
+ except (OSError, subprocess.TimeoutExpired) as exc:
2085
+ raise ExistingWiringValidationUnavailable from exc
2086
+ if result.returncode != 0:
2087
+ raise ExistingWiringValidationUnavailable
2088
+ return references, metadata
2089
+
2090
+
2091
+ def prepare_unconfigured(
2092
+ args: argparse.Namespace,
2093
+ originals: dict[str, Snapshot],
2094
+ delta_path: pathlib.Path,
2095
+ generated_path: pathlib.Path,
2096
+ base_path: pathlib.Path,
2097
+ role_path: pathlib.Path,
2098
+ marker_key: str,
2099
+ transaction: CrashConsistentTransaction,
2100
+ modes: dict[str, int],
2101
+ ) -> None:
2102
+ """Durably disable a never-verified channel without touching valid wiring."""
2103
+
2104
+ role = load_snapshot_mapping(role_path, originals["role"])
2105
+ role_channel = role.get(args.channel)
2106
+ if isinstance(role_channel, dict) and role_channel.get("provisioning_status") == "verified":
2107
+ raise ExistingWiringAlreadyVerified
2108
+
2109
+ base = load_mapping(base_path)
2110
+ delta = load_snapshot_mapping(delta_path, originals["delta"], required=False)
2111
+ platforms = delta.setdefault("platforms", {})
2112
+ if not isinstance(platforms, dict):
2113
+ fail("platforms delta must be a mapping")
2114
+ platform = platforms.setdefault(args.channel, {})
2115
+ if not isinstance(platform, dict):
2116
+ fail(f"platforms.{args.channel} delta must be a mapping")
2117
+ platform["enabled"] = False
2118
+ delta_content = render_delta(delta, originals["delta"].content)
2119
+ generated_content = render_generated(base, delta)
2120
+ role_content = update_role_status(
2121
+ originals["role"].content, args.channel, "deferred"
2122
+ )
2123
+ run_transaction(
2124
+ transaction,
2125
+ originals,
2126
+ [
2127
+ ("deferred-delta", "delta", delta_content, modes["delta"]),
2128
+ ("deferred-generated", "generated", generated_content, modes["generated"]),
2129
+ ("deferred-role", "role", role_content, originals["role"].mode),
2130
+ ("deferred-marker", marker_key, None, modes[marker_key]),
2131
+ ],
2132
+ )
2133
+
2134
+
2135
+ def run_transaction(
2136
+ transaction: CrashConsistentTransaction,
2137
+ originals: dict[str, Snapshot],
2138
+ specifications: list[tuple[str, str, bytes | None, int]],
2139
+ ) -> None:
2140
+ try:
2141
+ transaction.prepare(originals, specifications)
2142
+ transaction.execute()
2143
+ except BaseException as exc:
2144
+ if transaction.directory.exists() and not isinstance(
2145
+ exc, (KeyboardInterrupt, SystemExit, TransactionConflict)
2146
+ ):
2147
+ try:
2148
+ transaction.recover_if_needed()
2149
+ except TransactionConflict:
2150
+ raise
2151
+ if (
2152
+ not transaction.directory.exists()
2153
+ and not isinstance(exc, (KeyboardInterrupt, SystemExit, TransactionConflict))
2154
+ ):
2155
+ raise TransactionRecoveryError(
2156
+ f"{exc}; all local channel files restored"
2157
+ ) from exc
2158
+ raise
2159
+
2160
+
2161
+ def commit_locked(args: argparse.Namespace) -> None:
2162
+ channel = args.channel
2163
+ profile = pathlib.Path(args.profile)
2164
+ if profile.is_symlink() or not profile.is_dir():
2165
+ fail(f"profile root must be a real directory: {profile}")
2166
+ delta_path = profile / "config.delta.yaml"
2167
+ generated_path = profile / "config.yaml"
2168
+ base_path = profile.parent.parent / "config.yaml"
2169
+ role_path = pathlib.Path(args.role_yaml)
2170
+ registry_path = pathlib.Path(args.registry)
2171
+ env_path = pathlib.Path(args.runtime_env)
2172
+ marker_path = pathlib.Path(args.done_marker)
2173
+ expected_marker = role_path.parent / ".scripts" / f".done-{'30-telegram' if channel == 'telegram' else '31-slack'}"
2174
+ if marker_path != expected_marker:
2175
+ fail(f"done marker does not match the {channel} role contract")
2176
+ if (args.reconcile_existing or args.prepare_unconfigured) and (
2177
+ args.reference or args.metadata
2178
+ ):
2179
+ fail("snapshot-derived transaction modes do not accept reference inputs")
2180
+ if not args.reconcile_existing and not args.prepare_unconfigured:
2181
+ references = parse_pairs(
2182
+ args.reference, CHANNEL_REFERENCE_KEYS[channel], "reference"
2183
+ )
2184
+ metadata = parse_pairs(args.metadata, CHANNEL_FIELDS[channel], "metadata")
2185
+ if metadata["provisioning_status"] != "verified" or any(
2186
+ not metadata[key] for key in CHANNEL_FIELDS[channel] if key != "team_name"
2187
+ ):
2188
+ fail(f"{channel} verified metadata is incomplete")
2189
+ for reference in references.values():
2190
+ if not reference.startswith("op://") or any(
2191
+ ch in reference for ch in "\r\n\0"
2192
+ ):
2193
+ fail("invalid 1Password reference")
2194
+
2195
+ targets = {
2196
+ "delta": delta_path,
2197
+ "generated": generated_path,
2198
+ "role": role_path,
2199
+ "registry": registry_path,
2200
+ "runtime_env": env_path,
2201
+ "telegram_marker": role_path.parent / ".scripts" / ".done-30-telegram",
2202
+ "slack_marker": role_path.parent / ".scripts" / ".done-31-slack",
2203
+ }
2204
+ modes = {
2205
+ "delta": 0o600,
2206
+ "generated": 0o600,
2207
+ "role": 0o644,
2208
+ "registry": 0o600,
2209
+ "runtime_env": 0o600,
2210
+ "telegram_marker": 0o600,
2211
+ "slack_marker": 0o600,
2212
+ }
2213
+ transaction = CrashConsistentTransaction(
2214
+ profile=profile,
2215
+ registry=registry_path,
2216
+ channel=channel,
2217
+ agent_id=args.agent_id,
2218
+ targets=targets,
2219
+ modes=modes,
2220
+ )
2221
+ transaction.recover_if_needed()
2222
+ originals = {key: snapshot(path, modes[key]) for key, path in targets.items()}
2223
+ originals_by_path = {path: originals[key] for key, path in targets.items()}
2224
+ marker_key = f"{channel}_marker"
2225
+ if args.prepare_unconfigured:
2226
+ PROFILE_LOCK.test_snapshot_barrier(f"channel-prepare:{channel}")
2227
+ prepare_unconfigured(
2228
+ args,
2229
+ originals,
2230
+ delta_path,
2231
+ generated_path,
2232
+ base_path,
2233
+ role_path,
2234
+ marker_key,
2235
+ transaction,
2236
+ modes,
2237
+ )
2238
+ return
2239
+ PROFILE_LOCK.test_snapshot_barrier(f"channel:{channel}")
2240
+ if args.reconcile_existing:
2241
+ references, metadata = existing_wiring(
2242
+ args, originals_by_path, delta_path, role_path
2243
+ )
2244
+ allowed_value = snapshot_allowed_value(originals["runtime_env"], channel)
2245
+ else:
2246
+ allowed_value = args.allowed_value
2247
+ base = load_mapping(base_path)
2248
+ delta = load_snapshot_mapping(delta_path, originals["delta"], required=False)
2249
+ onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
2250
+ if not isinstance(onepassword, dict):
2251
+ fail("secrets.onepassword delta must be a mapping")
2252
+ onepassword["enabled"] = True
2253
+ secret_env = onepassword.setdefault("env", {})
2254
+ if not isinstance(secret_env, dict):
2255
+ fail("secrets.onepassword.env delta must be a mapping")
2256
+ secret_env.update(references)
2257
+ platforms = delta.setdefault("platforms", {})
2258
+ if not isinstance(platforms, dict):
2259
+ fail("platforms delta must be a mapping")
2260
+ platform = platforms.setdefault(channel, {})
2261
+ if not isinstance(platform, dict):
2262
+ fail(f"platforms.{channel} delta must be a mapping")
2263
+ platform["enabled"] = False
2264
+
2265
+ disabled_delta = render_delta(delta, originals["delta"].content)
2266
+ disabled_generated = render_generated(base, delta)
2267
+ role_content = update_role(originals["role"].content, channel, metadata)
2268
+ env_content = update_runtime_env(originals["runtime_env"].content, channel, allowed_value)
2269
+ registry_content = update_registry(
2270
+ originals["registry"].content,
2271
+ channel,
2272
+ args.agent_id,
2273
+ args.role_dir,
2274
+ args.profile_name,
2275
+ metadata,
2276
+ )
2277
+ platform["enabled"] = True
2278
+ enabled_delta = render_delta(delta, originals["delta"].content)
2279
+ enabled_generated = render_generated(base, delta)
2280
+ run_transaction(
2281
+ transaction,
2282
+ originals,
2283
+ [
2284
+ ("disabled-delta", "delta", disabled_delta, modes["delta"]),
2285
+ ("disabled-generated", "generated", disabled_generated, modes["generated"]),
2286
+ ("runtime-policy", "runtime_env", env_content, modes["runtime_env"]),
2287
+ ("role-identity", "role", role_content, originals["role"].mode),
2288
+ ("registry-identity", "registry", registry_content, modes["registry"]),
2289
+ ("enabled-delta", "delta", enabled_delta, modes["delta"]),
2290
+ ("enabled-generated", "generated", enabled_generated, modes["generated"]),
2291
+ ("completion-marker", marker_key, b"", originals[marker_key].mode),
2292
+ ],
2293
+ )
2294
+
2295
+
2296
+ def commit(args: argparse.Namespace) -> None:
2297
+ profile = pathlib.Path(args.profile)
2298
+ registry = pathlib.Path(args.registry)
2299
+ try:
2300
+ # The helper owns this order. During migration, RegistryLock can adopt
2301
+ # the exact inherited lock description held by an unchanged shell
2302
+ # caller; direct invocation still acquires and validates it itself.
2303
+ with RegistryLock(registry):
2304
+ with PROFILE_LOCK.ProfileConfigLock(profile):
2305
+ commit_locked(args)
2306
+ except ExistingWiringUnavailable as exc:
2307
+ raise SystemExit(2) from exc
2308
+ except ExistingWiringValidationUnavailable as exc:
2309
+ raise SystemExit(75) from exc
2310
+ except ExistingWiringAlreadyVerified as exc:
2311
+ raise SystemExit(3) from exc
2312
+ except PROFILE_LOCK.ProfileConfigLockError as exc:
2313
+ fail(str(exc))
2314
+ except (OSError, TransactionConflict, TransactionRecoveryError) as exc:
2315
+ fail(str(exc))
2316
+
2317
+
2318
+ def parser() -> argparse.ArgumentParser:
2319
+ result = argparse.ArgumentParser()
2320
+ result.add_argument("--channel", choices=tuple(CHANNEL_FIELDS), required=True)
2321
+ result.add_argument("--profile", required=True)
2322
+ result.add_argument("--role-yaml", required=True)
2323
+ result.add_argument("--registry", required=True)
2324
+ result.add_argument("--runtime-env", required=True)
2325
+ result.add_argument("--done-marker", required=True)
2326
+ result.add_argument("--agent-id", required=True)
2327
+ result.add_argument("--role-dir", required=True)
2328
+ result.add_argument("--profile-name", required=True)
2329
+ result.add_argument("--allowed-value", default="")
2330
+ mode = result.add_mutually_exclusive_group()
2331
+ mode.add_argument("--reconcile-existing", action="store_true")
2332
+ mode.add_argument("--prepare-unconfigured", action="store_true")
2333
+ result.add_argument("--reference-validator")
2334
+ result.add_argument("--reference", nargs=2, action="append", default=[])
2335
+ result.add_argument("--metadata", nargs=2, action="append", default=[])
2336
+ return result
2337
+
2338
+
2339
+ if __name__ == "__main__":
2340
+ commit(parser().parse_args())