gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Assess which workspace layout model best matches the observed gr2 behavior.
3
+
4
+ This is a UX prototype. It does not prescribe the final architecture on its own.
5
+ It makes the current mismatch explicit so we can iterate with evidence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import tomllib
13
+ from pathlib import Path
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Probe observed gr2 layout vs candidate models")
18
+ parser.add_argument("workspace_root", type=Path)
19
+ parser.add_argument("--owner-unit", required=True)
20
+ parser.add_argument("--json", action="store_true")
21
+ return parser.parse_args()
22
+
23
+
24
+ def load_spec(workspace_root: Path) -> dict:
25
+ with (workspace_root / ".grip" / "workspace_spec.toml").open("rb") as fh:
26
+ return tomllib.load(fh)
27
+
28
+
29
+ def repo_names(spec: dict) -> list[str]:
30
+ return [repo["name"] for repo in spec.get("repos", [])]
31
+
32
+
33
+ def probe(workspace_root: Path, owner_unit: str) -> dict:
34
+ spec = load_spec(workspace_root)
35
+ repos = repo_names(spec)
36
+ observations: list[dict[str, object]] = []
37
+ shared_git_count = 0
38
+ unit_git_count = 0
39
+
40
+ for repo in repos:
41
+ shared_root = workspace_root / "repos" / repo
42
+ unit_root = workspace_root / "agents" / owner_unit / repo
43
+ lane_root = workspace_root / "agents" / owner_unit / "lanes" / "feat-auth" / "repos" / repo
44
+
45
+ shared_git = (shared_root / ".git").exists()
46
+ unit_git = (unit_root / ".git").exists()
47
+ lane_git = (lane_root / ".git").exists()
48
+ shared_exists = shared_root.exists()
49
+ unit_exists = unit_root.exists()
50
+
51
+ if shared_git:
52
+ shared_git_count += 1
53
+ if unit_git:
54
+ unit_git_count += 1
55
+
56
+ observations.append(
57
+ {
58
+ "repo": repo,
59
+ "shared_path_exists": shared_exists,
60
+ "shared_git": shared_git,
61
+ "unit_path_exists": unit_exists,
62
+ "unit_git": unit_git,
63
+ "lane_git": lane_git,
64
+ }
65
+ )
66
+
67
+ shared_first_score = 0
68
+ unit_first_score = 0
69
+ reasons: list[str] = []
70
+
71
+ if unit_git_count == len(repos):
72
+ unit_first_score += 3
73
+ reasons.append("all repos materialized as unit-local git checkouts")
74
+ if shared_git_count == len(repos):
75
+ shared_first_score += 3
76
+ reasons.append("all repos materialized as shared git checkouts")
77
+ if shared_git_count == 0 and any(item["shared_path_exists"] for item in observations):
78
+ unit_first_score += 2
79
+ reasons.append("shared repo paths exist as placeholders rather than active checkouts")
80
+ if any(item["lane_git"] for item in observations):
81
+ shared_first_score += 1
82
+ unit_first_score += 1
83
+ reasons.append("lane-local repo materialization exists, so a routed model may be emerging")
84
+ else:
85
+ unit_first_score += 1
86
+ reasons.append("lane-local repos are not yet materialized")
87
+
88
+ if unit_first_score > shared_first_score:
89
+ recommendation = "ratify-unit-local-first"
90
+ summary = (
91
+ "Current behavior matches a unit-local-first model more closely than a shared-repo-first model."
92
+ )
93
+ elif shared_first_score > unit_first_score:
94
+ recommendation = "push-shared-repo-model"
95
+ summary = (
96
+ "Current behavior already resembles a shared-repo-first model strongly enough to continue that direction."
97
+ )
98
+ else:
99
+ recommendation = "hybrid-needs-clarification"
100
+ summary = "Observed behavior is split enough that the model should be clarified explicitly."
101
+
102
+ return {
103
+ "owner_unit": owner_unit,
104
+ "recommendation": recommendation,
105
+ "summary": summary,
106
+ "shared_first_score": shared_first_score,
107
+ "unit_first_score": unit_first_score,
108
+ "reasons": reasons,
109
+ "observations": observations,
110
+ }
111
+
112
+
113
+ def main() -> int:
114
+ args = parse_args()
115
+ result = probe(args.workspace_root.resolve(), args.owner_unit)
116
+ if args.json:
117
+ print(json.dumps(result, indent=2))
118
+ return 0
119
+
120
+ print("gr2 prototype layout-model probe")
121
+ print(f"owner: {result['owner_unit']}")
122
+ print(f"recommendation: {result['recommendation']}")
123
+ print(f"summary: {result['summary']}")
124
+ print(f"score shared-first={result['shared_first_score']} unit-local-first={result['unit_first_score']}")
125
+ print("reasons:")
126
+ for reason in result["reasons"]:
127
+ print(f"- {reason}")
128
+ print("observations:")
129
+ print("REPO\tSHARED_PATH\tSHARED_GIT\tUNIT_PATH\tUNIT_GIT\tLANE_GIT")
130
+ for row in result["observations"]:
131
+ print(
132
+ f"{row['repo']}\t{str(row['shared_path_exists']).lower()}\t{str(row['shared_git']).lower()}\t"
133
+ f"{str(row['unit_path_exists']).lower()}\t{str(row['unit_git']).lower()}\t{str(row['lane_git']).lower()}"
134
+ )
135
+ return 0
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
@@ -0,0 +1,546 @@
1
+ """Prototype 1 of the propagation daemon: ONE declared managed replica, a real source.
2
+
3
+ Prototype 0 (``propagation_state_machine``) proved the state contract on synthetic
4
+ repositories. This module runs that same machine on a loop against a single destination
5
+ that a declaration names as a managed replica, so the daemon can watch a real canonical
6
+ upstream, fetch, plan, apply, verify, acknowledge, and leave a neutral receipt behind
7
+ for every operation it ran. Nothing here grants the daemon authority over an authoring
8
+ clone: the declaration can only name a replica, and the machine's own gates (direction,
9
+ base unmoved, clean, fast-forward) refuse everything the declaration cannot vouch for.
10
+
11
+ What one tick does, in order:
12
+
13
+ 1. observe the source with ``git ls-remote``; if the cursor already names the source
14
+ revision there is no operation, no receipt, and nothing is written;
15
+ 2. otherwise drive the machine once and take its receipt, whatever its state --
16
+ a refusal is a receipt too, because "I did not apply and here is why" is the
17
+ only honest thing a replica manager can say about a destination it refused;
18
+ 3. write the receipt as its own JSON file, with the per-state latency derived from
19
+ the receipt's own transition timestamps rather than from a stopwatch around
20
+ the call, so the numbers describe what the journal describes;
21
+ 4. emit one ``propagation.receipt`` event on the gr2 outbox carrying the receipt's
22
+ one-line summary. The design names a one-line channel notification; this module
23
+ writes that line to the outbox and to stdout and knows no channel, so the
24
+ prototype depends on nothing outside ``gr2``.
25
+
26
+ Success for Prototype 1 is measured latency plus exact receipts, not the absence of an
27
+ exception. Every receipt names the exact source and destination revisions, and every
28
+ latency figure is recomputable from the receipt that carries it.
29
+
30
+ The git environment is a declared choice. Tests run isolated from the host's
31
+ configuration (the Prototype 0 default); a real private remote needs the host's
32
+ credential helper, so a declaration may say ``"git_env": "inherit"``. The daemon never
33
+ creates a commit (fast-forward only), so inheriting the host configuration does not
34
+ invoke signing. In both modes ``GIT_TERMINAL_PROMPT=0`` is set: a daemon never answers
35
+ a prompt, so a missing credential fails the tick instead of hanging the loop on a tty.
36
+ A tick whose git call fails is printed as ``propagation tick-failed`` and counted, and
37
+ the loop continues; the next tick replays whatever the machine left pending.
38
+
39
+ The first dogfood run found the inherit seam the hard way: the machine collapsed an
40
+ explicit empty environment into its isolated default because ``{}`` is falsy, so the
41
+ clone that ``ensure_replica`` made with the host's credentials was followed by an
42
+ ``ls-remote`` that prompted for a username. The machine now distinguishes ``None``
43
+ from ``{}`` and the seam is witnessed at both ends.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import json
50
+ import os
51
+ import subprocess
52
+ import sys
53
+ import time
54
+ from collections.abc import Callable
55
+ from dataclasses import dataclass, field, replace
56
+ from datetime import UTC, datetime
57
+ from pathlib import Path
58
+ from typing import TextIO
59
+
60
+ from gr2.prototypes.propagation_state_machine import (
61
+ Coordinate,
62
+ Destination,
63
+ DestinationKind,
64
+ DestinationUnreadable,
65
+ Direction,
66
+ Operation,
67
+ Policy,
68
+ Propagator,
69
+ Receipt,
70
+ SourceUnobservable,
71
+ State,
72
+ )
73
+ from gr2.python_cli.events import EventType, emit
74
+
75
+ _ISOLATED_GIT_ENV = {
76
+ "GIT_CONFIG_GLOBAL": os.devnull,
77
+ "GIT_CONFIG_NOSYSTEM": "1",
78
+ }
79
+
80
+ ACTOR = "propagation-daemon"
81
+
82
+
83
+ class DeclarationError(ValueError):
84
+ """The declaration does not describe exactly one managed replica."""
85
+
86
+
87
+ class DeclarationMismatch(RuntimeError):
88
+ """The destination path exists and is not the declared replica."""
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Declaration:
93
+ """Exactly one managed replica of one branch of one source.
94
+
95
+ Every field is opaque to the machine except ``branch`` (what ls-remote asks for) and
96
+ ``destination_path`` (where the replica lives). ``kind`` is carried so a reader of the
97
+ declaration can see the only value it may hold; ``load_declaration`` refuses any other.
98
+ """
99
+
100
+ source_url: str
101
+ branch: str
102
+ destination_id: str
103
+ destination_path: Path
104
+ state_dir: Path
105
+ outbox_root: Path
106
+ coordinate: Coordinate
107
+ interval_seconds: float
108
+ policy_hash: str
109
+ git_env_mode: str = "isolated"
110
+ kind: DestinationKind = DestinationKind.REPLICA
111
+
112
+ @property
113
+ def receipts_dir(self) -> Path:
114
+ return self.state_dir / "receipts"
115
+
116
+ @property
117
+ def git_env(self) -> dict[str, str]:
118
+ # A daemon never answers a prompt: in both modes a missing credential must fail
119
+ # the git call (and the tick) rather than hang the loop on a tty. "inherit" carries
120
+ # ONLY that override, so the host's credential helper and config are in force.
121
+ base = {} if self.git_env_mode == "inherit" else dict(_ISOLATED_GIT_ENV)
122
+ return {**base, "GIT_TERMINAL_PROMPT": "0"}
123
+
124
+ def destination(self) -> Destination:
125
+ return Destination(
126
+ destination_id=self.destination_id, path=self.destination_path, kind=self.kind
127
+ )
128
+
129
+ def policy(self) -> Policy:
130
+ # downward only: the one direction a managed replica can be the destination of
131
+ return Policy(policy_hash=self.policy_hash, allowed_directions=frozenset({Direction.DOWN}))
132
+
133
+
134
+ _REQUIRED = (
135
+ "source_url",
136
+ "branch",
137
+ "destination_id",
138
+ "destination_path",
139
+ "state_dir",
140
+ "outbox_root",
141
+ "coordinate",
142
+ )
143
+
144
+
145
+ def declaration_from_dict(data: dict[str, object]) -> Declaration:
146
+ missing = [key for key in _REQUIRED if key not in data]
147
+ if missing:
148
+ raise DeclarationError(f"declaration is missing {missing}")
149
+ kind = str(data.get("kind", DestinationKind.REPLICA))
150
+ if kind != str(DestinationKind.REPLICA):
151
+ # the one thing this daemon must never be told to do: an authoring clone is not
152
+ # a destination it may drive, and that is decided here, before any git call
153
+ raise DeclarationError(f"this daemon drives managed replicas only; declared kind={kind!r}")
154
+ git_env_mode = str(data.get("git_env", "isolated"))
155
+ if git_env_mode not in {"isolated", "inherit"}:
156
+ raise DeclarationError(f"git_env must be 'isolated' or 'inherit', got {git_env_mode!r}")
157
+ coord = data["coordinate"]
158
+ if not isinstance(coord, dict):
159
+ raise DeclarationError("coordinate must be an object")
160
+ for key in ("source", "layer", "artifact_class"):
161
+ if key not in coord:
162
+ raise DeclarationError(f"coordinate is missing {key!r}")
163
+ interval = float(data.get("interval_seconds", 30.0))
164
+ if interval <= 0:
165
+ raise DeclarationError("interval_seconds must be positive")
166
+ destination_id = str(data["destination_id"])
167
+ coordinate = Coordinate(
168
+ source=str(coord["source"]),
169
+ destination=destination_id,
170
+ layer=str(coord["layer"]),
171
+ direction=Direction.DOWN,
172
+ operation=Operation.APPLY,
173
+ artifact_class=str(coord["artifact_class"]),
174
+ )
175
+ return Declaration(
176
+ source_url=str(data["source_url"]),
177
+ branch=str(data["branch"]),
178
+ destination_id=destination_id,
179
+ destination_path=Path(str(data["destination_path"])).expanduser(),
180
+ state_dir=Path(str(data["state_dir"])).expanduser(),
181
+ outbox_root=Path(str(data["outbox_root"])).expanduser(),
182
+ coordinate=coordinate,
183
+ interval_seconds=interval,
184
+ policy_hash=str(data.get("policy_hash", "prototype-1-downward-only")),
185
+ git_env_mode=git_env_mode,
186
+ )
187
+
188
+
189
+ def load_declaration(path: Path) -> Declaration:
190
+ with path.open() as handle:
191
+ data = json.load(handle)
192
+ if not isinstance(data, dict):
193
+ raise DeclarationError("declaration must be a JSON object")
194
+ return declaration_from_dict(data)
195
+
196
+
197
+ # --------------------------------------------------------------------------- replica
198
+
199
+
200
+ def _git(repo: Path, *args: str, env: dict[str, str]) -> str:
201
+ proc = subprocess.run(
202
+ ["git", "-C", str(repo), *args],
203
+ check=True,
204
+ capture_output=True,
205
+ text=True,
206
+ env={**os.environ, **env},
207
+ )
208
+ return proc.stdout.strip()
209
+
210
+
211
+ def ensure_replica(declaration: Declaration) -> Path:
212
+ """Make the declared replica exist, or refuse to treat what is there as the replica.
213
+
214
+ Absent: clone the declared branch of the declared source, single-branch, so the only
215
+ thing at that path is what the declaration says. Present: it must be a git checkout
216
+ whose ``origin`` is the declared source and whose current branch is the declared
217
+ branch; anything else is refused here, before the machine ever reads it, because the
218
+ alternative is a daemon fast-forwarding a clone that happens to sit at the path.
219
+ """
220
+ path = declaration.destination_path
221
+ env = declaration.git_env
222
+ if not path.exists():
223
+ path.parent.mkdir(parents=True, exist_ok=True)
224
+ subprocess.run(
225
+ [
226
+ "git",
227
+ "clone",
228
+ "-q",
229
+ "--branch",
230
+ declaration.branch,
231
+ "--single-branch",
232
+ declaration.source_url,
233
+ str(path),
234
+ ],
235
+ check=True,
236
+ capture_output=True,
237
+ text=True,
238
+ env={**os.environ, **env},
239
+ )
240
+ return path
241
+ try:
242
+ origin = _git(path, "remote", "get-url", "origin", env=env)
243
+ branch = _git(path, "rev-parse", "--abbrev-ref", "HEAD", env=env)
244
+ except (subprocess.CalledProcessError, OSError) as exc:
245
+ raise DeclarationMismatch(f"{path} exists and is not a git checkout: {exc}") from exc
246
+ if origin != declaration.source_url:
247
+ raise DeclarationMismatch(
248
+ f"{path} has origin {origin!r}, declaration names {declaration.source_url!r}"
249
+ )
250
+ if branch != declaration.branch:
251
+ raise DeclarationMismatch(
252
+ f"{path} is on {branch!r}, declaration names {declaration.branch!r}"
253
+ )
254
+ return path
255
+
256
+
257
+ # --------------------------------------------------------------------------- ticks
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class TickResult:
262
+ observed_at: str
263
+ source_rev: str
264
+ cursor_before: str | None
265
+ receipt: Receipt | None
266
+ receipt_path: Path | None
267
+ latency_seconds: dict[str, float] | None
268
+ summary: str
269
+
270
+ @property
271
+ def was_operation(self) -> bool:
272
+ return self.receipt is not None
273
+
274
+
275
+ def _parse_ts(value: str) -> datetime:
276
+ return datetime.fromisoformat(value)
277
+
278
+
279
+ def latency_from_receipt(receipt: Receipt) -> dict[str, float]:
280
+ """Per-state latency derived from the receipt's own transition timestamps.
281
+
282
+ Keys are ``<from>-><to>`` for each consecutive pair and ``total`` from the first to
283
+ the last transition. A replayed receipt carries the ORIGINAL transitions, so its
284
+ latency describes the run that did the work, not the replay that found it.
285
+ """
286
+ transitions = receipt.transitions
287
+ out: dict[str, float] = {}
288
+ for previous, current in zip(transitions, transitions[1:], strict=False):
289
+ delta = (_parse_ts(current.timestamp) - _parse_ts(previous.timestamp)).total_seconds()
290
+ out[f"{previous.state}->{current.state}"] = round(delta, 6)
291
+ if transitions:
292
+ total = (
293
+ _parse_ts(transitions[-1].timestamp) - _parse_ts(transitions[0].timestamp)
294
+ ).total_seconds()
295
+ out["total"] = round(total, 6)
296
+ return out
297
+
298
+
299
+ def _short(rev: str | None) -> str:
300
+ return (rev or "-")[:12]
301
+
302
+
303
+ def summarize(receipt: Receipt, latency: dict[str, float]) -> str:
304
+ """The one line a channel reader needs: what, where, which revisions, how long."""
305
+ landed = _short(receipt.after) if receipt.after else "unchanged"
306
+ head = (
307
+ f"propagation {receipt.state}: {receipt.coordinate.destination} "
308
+ f"at {_short(receipt.expected_base)} -> {landed}, intended {_short(receipt.source_rev)} "
309
+ f"(source {receipt.coordinate.source}, attempt {receipt.attempt}"
310
+ )
311
+ if receipt.replayed:
312
+ head += ", replayed"
313
+ head += f", total {latency.get('total', 0.0):.3f}s)"
314
+ if receipt.state is State.REFUSED and receipt.refusal_reason:
315
+ head += f" refused: {receipt.refusal_reason}"
316
+ return head
317
+
318
+
319
+ def write_receipt(
320
+ declaration: Declaration, receipt: Receipt, latency: dict[str, float], observed_at: str
321
+ ) -> Path:
322
+ declaration.receipts_dir.mkdir(parents=True, exist_ok=True)
323
+ stamp = observed_at.replace(":", "").replace("+00:00", "Z")
324
+ name = f"{stamp}-{receipt.pending_id[:12]}-{receipt.state}.json"
325
+ path = declaration.receipts_dir / name
326
+ payload = {
327
+ "daemon": ACTOR,
328
+ "declaration": {
329
+ "source_url": declaration.source_url,
330
+ "branch": declaration.branch,
331
+ "destination_id": declaration.destination_id,
332
+ "destination_path": str(declaration.destination_path),
333
+ },
334
+ "observed_at": observed_at,
335
+ "latency_seconds": latency,
336
+ "receipt": receipt.as_dict(),
337
+ }
338
+ tmp = path.with_suffix(".json.tmp")
339
+ tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
340
+ os.replace(tmp, path)
341
+ return path
342
+
343
+
344
+ def notify(declaration: Declaration, receipt: Receipt, summary: str, receipt_path: Path) -> None:
345
+ emit(
346
+ EventType.PROPAGATION_RECEIPT,
347
+ declaration.outbox_root,
348
+ ACTOR,
349
+ declaration.destination_id,
350
+ {
351
+ "summary": summary,
352
+ "state": str(receipt.state),
353
+ "pending_id": receipt.pending_id,
354
+ "operation_id": receipt.operation_id,
355
+ "source_rev": receipt.source_rev,
356
+ "expected_base": receipt.expected_base,
357
+ "after": receipt.after,
358
+ "replayed": receipt.replayed,
359
+ "receipt_path": str(receipt_path),
360
+ },
361
+ )
362
+
363
+
364
+ def make_propagator(declaration: Declaration) -> Propagator:
365
+ # The machine only ever stringifies ``source_remote`` (ls-remote, fetch), so a URL
366
+ # passes through as-is. It must NOT be wrapped in Path: Path collapses the "//" of a
367
+ # URL scheme and the source would silently become a relative directory.
368
+ return Propagator(
369
+ source_remote=declaration.source_url, # type: ignore[arg-type]
370
+ branch=declaration.branch,
371
+ state_dir=declaration.state_dir,
372
+ policy=declaration.policy(),
373
+ git_env=declaration.git_env,
374
+ )
375
+
376
+
377
+ def tick(declaration: Declaration, propagator: Propagator) -> TickResult:
378
+ observed_at = datetime.now(UTC).isoformat()
379
+ coordinate = declaration.coordinate
380
+ observation = propagator.observe_source(coordinate)
381
+ if not observation.is_new:
382
+ # Not an operation, but not a shortcut past the machine either: it must account
383
+ # for a cursor that sits at the source revision, and it raises JournalInconsistent
384
+ # for one the journal never acknowledged. An earlier version of this tick returned
385
+ # here without asking, and "current; not an operation" would have hidden corrupted
386
+ # sink state on every tick forever. Found by the witness, not by the dogfood.
387
+ propagator.run(coordinate, declaration.destination(), observation=observation)
388
+ return TickResult(
389
+ observed_at=observed_at,
390
+ source_rev=observation.source_rev,
391
+ cursor_before=observation.cursor,
392
+ receipt=None,
393
+ receipt_path=None,
394
+ latency_seconds=None,
395
+ summary=(
396
+ f"propagation current: {declaration.destination_id} cursor already at "
397
+ f"{_short(observation.source_rev)}; not an operation"
398
+ ),
399
+ )
400
+ receipt = propagator.run(coordinate, declaration.destination(), observation=observation)
401
+ if receipt is None:
402
+ # the machine's contract allows None for "not new"; with the observation shared it
403
+ # cannot answer that here, but a None is still not an operation and writes nothing
404
+ return TickResult(
405
+ observed_at=observed_at,
406
+ source_rev=observation.source_rev,
407
+ cursor_before=observation.cursor,
408
+ receipt=None,
409
+ receipt_path=None,
410
+ latency_seconds=None,
411
+ summary=(
412
+ f"propagation current: {declaration.destination_id} source returned to the "
413
+ f"cursor between observe and run; not an operation"
414
+ ),
415
+ )
416
+ latency = latency_from_receipt(receipt)
417
+ summary = summarize(receipt, latency)
418
+ receipt_path = write_receipt(declaration, receipt, latency, observed_at)
419
+ notify(declaration, receipt, summary, receipt_path)
420
+ return TickResult(
421
+ observed_at=observed_at,
422
+ source_rev=observation.source_rev,
423
+ cursor_before=observation.cursor,
424
+ receipt=receipt,
425
+ receipt_path=receipt_path,
426
+ latency_seconds=latency,
427
+ summary=summary,
428
+ )
429
+
430
+
431
+ @dataclass
432
+ class LoopStats:
433
+ ticks: int = 0
434
+ operations: int = 0
435
+ failures: int = 0
436
+ by_state: dict[str, int] = field(default_factory=dict)
437
+ last: TickResult | None = None
438
+
439
+
440
+ # Every way a tick can fail on the environment rather than on this module, derived from
441
+ # the machine's raise sites: the source cannot be observed (before any state is touched),
442
+ # the destination cannot be read (at observe, plan, or verify, each a point the machine
443
+ # replays from), or a git call failed / could not be spawned. DestinationUnreadable WRAPS
444
+ # the last two, so catching them without it would let the wrapped form escape.
445
+ _TICK_FAILURES = (
446
+ SourceUnobservable,
447
+ DestinationUnreadable,
448
+ subprocess.CalledProcessError,
449
+ OSError,
450
+ )
451
+
452
+
453
+ def _git_failure_line(exc: BaseException) -> str:
454
+ if isinstance(exc, SourceUnobservable):
455
+ return str(exc)
456
+ if isinstance(exc, DestinationUnreadable):
457
+ return f"destination unreadable: {exc}"
458
+ if isinstance(exc, subprocess.CalledProcessError):
459
+ argv = (
460
+ " ".join(str(part) for part in exc.cmd)
461
+ if isinstance(exc.cmd, list | tuple)
462
+ else exc.cmd
463
+ )
464
+ stderr = (exc.stderr or "").strip().splitlines()
465
+ tail = stderr[-1] if stderr else ""
466
+ return f"git exited {exc.returncode} ({argv}): {tail}"
467
+ return f"{type(exc).__name__}: {exc}"
468
+
469
+
470
+ def run_loop(
471
+ declaration: Declaration,
472
+ *,
473
+ once: bool = False,
474
+ stop: Callable[[], bool] | None = None,
475
+ sleep: Callable[[float], None] = time.sleep,
476
+ out: TextIO | None = None,
477
+ ) -> LoopStats:
478
+ """Tick until ``stop()`` says so (or once). Every tick prints exactly one line.
479
+
480
+ A tick that fails on the environment (the source unobservable, the destination
481
+ unreadable because the checkout went away or a volume unmounted, a credential refused,
482
+ the mirror fetch interrupted) is printed and counted and the loop goes on: nothing
483
+ about the declaration changed, and whatever the machine left pending is replayed on
484
+ the next tick, which is the machine's own kill-and-replay contract. The machine names
485
+ the first two ``SourceUnobservable`` (raised before any state is touched) and
486
+ ``DestinationUnreadable`` (raised at observe, plan, or verify, each a replay point);
487
+ the rest arrive as ``CalledProcessError`` / ``OSError`` from its git calls. What
488
+ propagates, by design: ``JournalInconsistent`` and ``LookupError`` from the machine (a
489
+ journal that cannot account for its own cursor is corrupted sink state, and guessing
490
+ would hide it), ``DeclarationMismatch`` from ``ensure_replica`` at startup, and any
491
+ defect in this module.
492
+
493
+ ``out`` defaults to the stdout in force at CALL time, not at import time, so a caller
494
+ that redirects stdout (a test, a wrapper, a supervisor) gets the lines.
495
+ """
496
+ stream = out if out is not None else sys.stdout
497
+ ensure_replica(declaration)
498
+ propagator = make_propagator(declaration)
499
+ stats = LoopStats()
500
+ while True:
501
+ observed_at = datetime.now(UTC).isoformat()
502
+ try:
503
+ result = tick(declaration, propagator)
504
+ except _TICK_FAILURES as exc:
505
+ stats.ticks += 1
506
+ stats.failures += 1
507
+ print(
508
+ f"{observed_at} propagation tick-failed: {declaration.destination_id} "
509
+ f"{_git_failure_line(exc)}; this tick left no receipt, the next tick "
510
+ f"replays whatever the machine left pending",
511
+ file=stream,
512
+ flush=True,
513
+ )
514
+ else:
515
+ stats.ticks += 1
516
+ stats.last = result
517
+ if result.receipt is not None:
518
+ stats.operations += 1
519
+ key = str(result.receipt.state)
520
+ stats.by_state[key] = stats.by_state.get(key, 0) + 1
521
+ print(f"{result.observed_at} {result.summary}", file=stream, flush=True)
522
+ if once or (stop is not None and stop()):
523
+ return stats
524
+ sleep(declaration.interval_seconds)
525
+
526
+
527
+ def main(argv: list[str] | None = None) -> int:
528
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
529
+ parser.add_argument("--declaration", required=True, type=Path)
530
+ parser.add_argument("--once", action="store_true", help="one tick, then exit")
531
+ parser.add_argument(
532
+ "--interval", type=float, default=None, help="override the declared interval"
533
+ )
534
+ args = parser.parse_args(argv)
535
+ declaration = load_declaration(args.declaration)
536
+ if args.interval is not None:
537
+ declaration = replace(declaration, interval_seconds=float(args.interval))
538
+ try:
539
+ run_loop(declaration, once=args.once)
540
+ except KeyboardInterrupt:
541
+ print("propagation daemon: stopped", flush=True)
542
+ return 0
543
+
544
+
545
+ if __name__ == "__main__": # pragma: no cover - exercised through main() in tests
546
+ sys.exit(main())