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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- 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())
|