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,1478 @@
|
|
|
1
|
+
"""Prototype 0: a propagation state machine over git repositories.
|
|
2
|
+
|
|
3
|
+
This prototype proves the state contract for moving one change from a source
|
|
4
|
+
repository to one or more destination clones, before any daemon is allowed near
|
|
5
|
+
a real authoring clone. It is deliberately neutral: every identifier it carries
|
|
6
|
+
(source, destination, layer, artifact class, policy hash) is an opaque string
|
|
7
|
+
the caller resolves; the machine interprets none of them and holds no notion of
|
|
8
|
+
who an agent or a workspace is.
|
|
9
|
+
|
|
10
|
+
The one rule the contract enforces:
|
|
11
|
+
|
|
12
|
+
Every state transition names the observation that establishes it, and that
|
|
13
|
+
observation must be capable of returning a different answer.
|
|
14
|
+
|
|
15
|
+
States, in order, each with the observation that establishes it:
|
|
16
|
+
|
|
17
|
+
observed a source change was detected at a named revision
|
|
18
|
+
fetched the object is present in the sink's mirror and its digest was
|
|
19
|
+
recomputed from the object itself
|
|
20
|
+
planned a plan exists, bound to the destination base it was computed
|
|
21
|
+
against, with every gate recorded individually
|
|
22
|
+
applied the destination itself reports the intended revision (read
|
|
23
|
+
back from the destination, never from the verb's exit status)
|
|
24
|
+
verified the declared postcondition was checked by name and holds
|
|
25
|
+
acknowledged the source cursor advances, only now
|
|
26
|
+
|
|
27
|
+
and three more reachable from any of them:
|
|
28
|
+
|
|
29
|
+
refused a named refusal condition fired, and which one is recorded
|
|
30
|
+
partial some declared targets verified and others did not, both listed
|
|
31
|
+
unverifiable the effect may or may not have happened and no observation
|
|
32
|
+
available to the sink can tell; never collapsed into either
|
|
33
|
+
neighbour, because one invites a double apply and the other
|
|
34
|
+
invites work on an effect that may not exist
|
|
35
|
+
|
|
36
|
+
Idempotency and compare-and-swap:
|
|
37
|
+
|
|
38
|
+
* every operation has an id derived from (coordinate, source revision, expected
|
|
39
|
+
base, digest); the journal is keyed so a replay resumes the same operation
|
|
40
|
+
* every destination transition is compare-and-swap on the expected base; a
|
|
41
|
+
moved base refuses and reports what was observed, it never merges or forces
|
|
42
|
+
* the apply step tolerates exactly one benign discrepancy: a destination that
|
|
43
|
+
already reports the intended revision is recorded as applied without running
|
|
44
|
+
the verb again, which is how a sink killed mid-apply replays without applying
|
|
45
|
+
twice
|
|
46
|
+
|
|
47
|
+
Replay rules the journal implements:
|
|
48
|
+
|
|
49
|
+
* an acknowledged operation replays as a no-op returning the original outcome
|
|
50
|
+
* a refused operation is terminal for that attempt only: a later run at the same
|
|
51
|
+
source revision starts a new attempt, because a refusal describes a moment
|
|
52
|
+
(a dirty worktree, a moved base) and the author may have changed it
|
|
53
|
+
* anything else resumes from the last recorded state without skipping a state
|
|
54
|
+
|
|
55
|
+
Everything under ``state_dir`` belongs to the sink: ``journal.jsonl`` (append
|
|
56
|
+
only, one row per transition or note, fsynced), ``cursors/`` (one small file per
|
|
57
|
+
coordinate, written only on acknowledged), and ``mirror.git`` (a bare mirror the
|
|
58
|
+
sink fetches into, so destinations are read but never written before apply).
|
|
59
|
+
|
|
60
|
+
Fault injection for tests: ``kill_after`` raises :class:`SinkKilled` right after
|
|
61
|
+
the named state is journaled (or right after the apply verb ran, before the read
|
|
62
|
+
back, with ``KILL_AFTER_APPLY_VERB``); ``after_apply_verb`` runs a callable
|
|
63
|
+
between the verb and the read back so a destination can be made unreadable;
|
|
64
|
+
``before_apply_verb`` runs a callable after the apply step's own head check and
|
|
65
|
+
before the verb, so a destination can be moved inside that window.
|
|
66
|
+
|
|
67
|
+
Prototype 2 (the contribution protocol) adds ONE destination kind and no new
|
|
68
|
+
state: :attr:`DestinationKind.CANONICAL` is a bare remote that OWNS the branch, and
|
|
69
|
+
an operation with ``direction=up`` lands on it by a fast-forward push guarded by
|
|
70
|
+
``--force-with-lease`` on the expected base. The receiving repository enforces the
|
|
71
|
+
compare-and-swap; the plan's fast-forward gate guarantees the lease never forces;
|
|
72
|
+
a rejected lease is a REFUSAL carrying the revision the owner holds, not an error.
|
|
73
|
+
Replanning a refused contribution is the author's act — the machine never rebases,
|
|
74
|
+
merges, or forces on anyone's behalf.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
import hashlib
|
|
80
|
+
import json
|
|
81
|
+
import os
|
|
82
|
+
import subprocess
|
|
83
|
+
from collections.abc import Callable, Iterable
|
|
84
|
+
from dataclasses import asdict, dataclass, field
|
|
85
|
+
from datetime import UTC, datetime
|
|
86
|
+
from enum import StrEnum
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
|
|
89
|
+
KILL_AFTER_APPLY_VERB = "apply-verb"
|
|
90
|
+
|
|
91
|
+
_GATE_IDS = (
|
|
92
|
+
"policy.direction",
|
|
93
|
+
"destination.base-unmoved",
|
|
94
|
+
"destination.clean",
|
|
95
|
+
"destination.fast-forward",
|
|
96
|
+
)
|
|
97
|
+
_POSTCONDITION = "head-is-intended-after-and-tree-matches-digest"
|
|
98
|
+
# A canonical destination is bare: the postcondition is about its BRANCH ref, and
|
|
99
|
+
# "clean" is not part of it because there is no worktree to be clean.
|
|
100
|
+
_POSTCONDITION_CANONICAL = "branch-is-intended-after-and-tree-matches-digest"
|
|
101
|
+
_GATE_NOT_RUN = "not-run"
|
|
102
|
+
|
|
103
|
+
# The prototype runs git without the user's global or system configuration so
|
|
104
|
+
# that signing, hook paths, and identity settings on the host cannot reach the
|
|
105
|
+
# synthetic repositories. Callers may pass their own environment.
|
|
106
|
+
_ISOLATED_GIT_ENV = {
|
|
107
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
108
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SinkKilled(RuntimeError):
|
|
113
|
+
"""The sink process died at a fault-injection point."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DestinationUnreadable(RuntimeError):
|
|
117
|
+
"""A read against the destination returned an error instead of an answer."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class MalformedLine:
|
|
122
|
+
"""A journal line that could not be parsed, kept so a drop is never silent.
|
|
123
|
+
|
|
124
|
+
``index`` counts lines in the file, blank ones included, so it names the line a
|
|
125
|
+
reader would count to by hand.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
index: int
|
|
129
|
+
excerpt: str
|
|
130
|
+
reason: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class JournalInconsistent(RuntimeError):
|
|
134
|
+
"""The cursor names a revision the journal cannot account for.
|
|
135
|
+
|
|
136
|
+
A corrupted sink state: the prototype says so and stops rather than guessing an
|
|
137
|
+
outcome. Raised deliberately and left to propagate by every caller, by name.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SourceUnobservable(RuntimeError):
|
|
142
|
+
"""``git ls-remote`` against the source failed or advertised no such branch.
|
|
143
|
+
|
|
144
|
+
Raised before any cursor, journal, or destination state is touched, so a caller that
|
|
145
|
+
loops (a daemon) can count it and try again on its next tick with nothing to repair.
|
|
146
|
+
A ``RuntimeError`` subclass, so a caller that caught the bare class still does.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Direction(StrEnum):
|
|
151
|
+
DOWN = "down"
|
|
152
|
+
UP = "up"
|
|
153
|
+
ACROSS = "across"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Operation(StrEnum):
|
|
157
|
+
APPLY = "apply"
|
|
158
|
+
CONTRIBUTE = "contribute"
|
|
159
|
+
OBSERVE = "observe"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DestinationKind(StrEnum):
|
|
163
|
+
REPLICA = "replica"
|
|
164
|
+
AUTHORING = "authoring"
|
|
165
|
+
# Prototype 2 (the contribution protocol): a bare remote that OWNS the branch and
|
|
166
|
+
# accepts a contribution by a fast-forward push guarded by ``--force-with-lease`` on the
|
|
167
|
+
# expected base — compare-and-swap enforced by the receiving repository, not by
|
|
168
|
+
# this process. It has no worktree, so cleanliness is not a property it has.
|
|
169
|
+
CANONICAL = "canonical"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class State(StrEnum):
|
|
173
|
+
OBSERVED = "observed"
|
|
174
|
+
FETCHED = "fetched"
|
|
175
|
+
PLANNED = "planned"
|
|
176
|
+
APPLIED = "applied"
|
|
177
|
+
VERIFIED = "verified"
|
|
178
|
+
ACKNOWLEDGED = "acknowledged"
|
|
179
|
+
REFUSED = "refused"
|
|
180
|
+
PARTIAL = "partial"
|
|
181
|
+
UNVERIFIABLE = "unverifiable"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_ORDERED = (
|
|
185
|
+
State.OBSERVED,
|
|
186
|
+
State.FETCHED,
|
|
187
|
+
State.PLANNED,
|
|
188
|
+
State.APPLIED,
|
|
189
|
+
State.VERIFIED,
|
|
190
|
+
State.ACKNOWLEDGED,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class Coordinate:
|
|
196
|
+
source: str
|
|
197
|
+
destination: str
|
|
198
|
+
layer: str
|
|
199
|
+
direction: Direction
|
|
200
|
+
operation: Operation
|
|
201
|
+
artifact_class: str
|
|
202
|
+
|
|
203
|
+
def key(self) -> str:
|
|
204
|
+
"""Canonical, injective key: the JSON of ``as_dict()`` with sorted keys.
|
|
205
|
+
|
|
206
|
+
The fields are OPAQUE identifiers, so no delimiter can be assumed absent
|
|
207
|
+
from them. A ``"|".join`` made ``("source|dest", "target", ...)`` and
|
|
208
|
+
``("source", "dest|target", ...)`` the same key, and the key names the
|
|
209
|
+
journal rows and the cursor file, so two coordinates would have shared
|
|
210
|
+
replay state (found in review of the first cut). JSON string encoding
|
|
211
|
+
escapes every byte that could be mistaken for structure, so the key
|
|
212
|
+
round-trips: ``json.loads(key) == as_dict()``. Distinct coordinates
|
|
213
|
+
therefore cannot collide, and the witness asserts the round-trip rather
|
|
214
|
+
than any particular pair.
|
|
215
|
+
"""
|
|
216
|
+
return json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def from_key(cls, key: str) -> Coordinate:
|
|
220
|
+
data = json.loads(key)
|
|
221
|
+
return cls(
|
|
222
|
+
source=str(data["source"]),
|
|
223
|
+
destination=str(data["destination"]),
|
|
224
|
+
layer=str(data["layer"]),
|
|
225
|
+
direction=Direction(str(data["direction"])),
|
|
226
|
+
operation=Operation(str(data["operation"])),
|
|
227
|
+
artifact_class=str(data["artifact_class"]),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def as_dict(self) -> dict[str, str]:
|
|
231
|
+
return {
|
|
232
|
+
"source": self.source,
|
|
233
|
+
"destination": self.destination,
|
|
234
|
+
"layer": self.layer,
|
|
235
|
+
"direction": str(self.direction),
|
|
236
|
+
"operation": str(self.operation),
|
|
237
|
+
"artifact_class": self.artifact_class,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class Destination:
|
|
243
|
+
destination_id: str
|
|
244
|
+
path: Path
|
|
245
|
+
kind: DestinationKind
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class Policy:
|
|
250
|
+
"""Opaque policy inputs. The caller resolves them; the machine only applies them.
|
|
251
|
+
|
|
252
|
+
``allowed_directions`` has no default on purpose: a permissive default would
|
|
253
|
+
be the decision, whoever wrote it.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
policy_hash: str
|
|
257
|
+
allowed_directions: frozenset[Direction]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class GateResult:
|
|
262
|
+
gate_id: str
|
|
263
|
+
result: str
|
|
264
|
+
detail: str
|
|
265
|
+
result_hash: str
|
|
266
|
+
|
|
267
|
+
def as_dict(self) -> dict[str, str]:
|
|
268
|
+
return asdict(self)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class Transition:
|
|
273
|
+
state: State
|
|
274
|
+
observation: dict[str, object]
|
|
275
|
+
timestamp: str
|
|
276
|
+
operation_id: str | None
|
|
277
|
+
|
|
278
|
+
def as_dict(self) -> dict[str, object]:
|
|
279
|
+
return {
|
|
280
|
+
"state": str(self.state),
|
|
281
|
+
"observation": self.observation,
|
|
282
|
+
"timestamp": self.timestamp,
|
|
283
|
+
"operation_id": self.operation_id,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True)
|
|
288
|
+
class Observation:
|
|
289
|
+
source_rev: str
|
|
290
|
+
cursor: str | None
|
|
291
|
+
is_new: bool
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class Receipt:
|
|
296
|
+
pending_id: str
|
|
297
|
+
attempt: int
|
|
298
|
+
operation_id: str | None
|
|
299
|
+
coordinate: Coordinate
|
|
300
|
+
source_rev: str
|
|
301
|
+
expected_base: str
|
|
302
|
+
observed_base: str | None
|
|
303
|
+
after: str | None
|
|
304
|
+
digest: str | None
|
|
305
|
+
plan_hash: str | None
|
|
306
|
+
policy_hash: str
|
|
307
|
+
gate_results: tuple[GateResult, ...]
|
|
308
|
+
state: State
|
|
309
|
+
postcondition_checked: str | None
|
|
310
|
+
postcondition_holds: bool | None
|
|
311
|
+
timestamp: str
|
|
312
|
+
refusal_reason: str | None
|
|
313
|
+
detail: str
|
|
314
|
+
transitions: tuple[Transition, ...]
|
|
315
|
+
replayed: bool
|
|
316
|
+
|
|
317
|
+
def as_dict(self) -> dict[str, object]:
|
|
318
|
+
return {
|
|
319
|
+
"pending_id": self.pending_id,
|
|
320
|
+
"attempt": self.attempt,
|
|
321
|
+
"operation_id": self.operation_id,
|
|
322
|
+
"coordinate": self.coordinate.as_dict(),
|
|
323
|
+
"source_rev": self.source_rev,
|
|
324
|
+
"expected_base": self.expected_base,
|
|
325
|
+
"observed_base": self.observed_base,
|
|
326
|
+
"after": self.after,
|
|
327
|
+
"digest": self.digest,
|
|
328
|
+
"plan_hash": self.plan_hash,
|
|
329
|
+
"policy_hash": self.policy_hash,
|
|
330
|
+
"gate_results": [g.as_dict() for g in self.gate_results],
|
|
331
|
+
"state": str(self.state),
|
|
332
|
+
"postcondition_checked": self.postcondition_checked,
|
|
333
|
+
"postcondition_holds": self.postcondition_holds,
|
|
334
|
+
"timestamp": self.timestamp,
|
|
335
|
+
"refusal_reason": self.refusal_reason,
|
|
336
|
+
"detail": self.detail,
|
|
337
|
+
"transitions": [t.as_dict() for t in self.transitions],
|
|
338
|
+
"replayed": self.replayed,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True)
|
|
343
|
+
class PlanOutcome:
|
|
344
|
+
state: State
|
|
345
|
+
reached: tuple[str, ...]
|
|
346
|
+
not_reached: tuple[tuple[str, str], ...]
|
|
347
|
+
receipts: tuple[Receipt, ...]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --------------------------------------------------------------------------- helpers
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _now() -> str:
|
|
354
|
+
return datetime.now(UTC).isoformat()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _sha256_json(obj: object) -> str:
|
|
358
|
+
return hashlib.sha256(
|
|
359
|
+
json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
360
|
+
).hexdigest()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def operation_id_for(coordinate_key: str, source_rev: str, expected_base: str, digest: str) -> str:
|
|
364
|
+
return _sha256_json(
|
|
365
|
+
{
|
|
366
|
+
"coordinate": coordinate_key,
|
|
367
|
+
"source_rev": source_rev,
|
|
368
|
+
"expected_base": expected_base,
|
|
369
|
+
"digest": digest,
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def pending_id_for(coordinate_key: str, source_rev: str, attempt: int) -> str:
|
|
375
|
+
return _sha256_json(
|
|
376
|
+
{"coordinate": coordinate_key, "source_rev": source_rev, "attempt": attempt}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _git(repo: Path, *args: str, env: dict[str, str] | None = None) -> str:
|
|
381
|
+
proc = subprocess.run(
|
|
382
|
+
["git", "-C", str(repo), *args],
|
|
383
|
+
capture_output=True,
|
|
384
|
+
text=True,
|
|
385
|
+
env={**os.environ, **(env or _ISOLATED_GIT_ENV)},
|
|
386
|
+
)
|
|
387
|
+
if proc.returncode != 0:
|
|
388
|
+
raise subprocess.CalledProcessError(proc.returncode, proc.args, proc.stdout, proc.stderr)
|
|
389
|
+
return proc.stdout.strip()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def tree_digest(repo: Path, rev: str, env: dict[str, str] | None = None) -> str:
|
|
393
|
+
"""The content digest of a revision: its tree id, recomputed from the object store."""
|
|
394
|
+
return _git(repo, "rev-parse", f"{rev}^{{tree}}", env=env)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# --------------------------------------------------------------------------- journal
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class Journal:
|
|
401
|
+
"""Append-only record of transitions and notes, plus per-coordinate cursors."""
|
|
402
|
+
|
|
403
|
+
def __init__(self, state_dir: Path) -> None:
|
|
404
|
+
self.state_dir = state_dir
|
|
405
|
+
self.path = state_dir / "journal.jsonl"
|
|
406
|
+
self.cursors_dir = state_dir / "cursors"
|
|
407
|
+
# Rows are cached against the file's (size, mtime_ns) because Prototype 1 asks
|
|
408
|
+
# the machine to account for an observation on EVERY tick, including idle ones,
|
|
409
|
+
# which moved this parse from once-per-change to once-per-tick. The Propagator
|
|
410
|
+
# is built once per loop and holds one Journal, so the cache spans ticks.
|
|
411
|
+
#
|
|
412
|
+
# LIMITATION, stated rather than assumed away: an in-place mutation that changes
|
|
413
|
+
# neither size nor mtime_ns is not detected. The append-only writer never
|
|
414
|
+
# produces that state, and the next real append invalidates the entry — but a
|
|
415
|
+
# cache sitting on a corruption-detection path should name what it cannot see.
|
|
416
|
+
self._cache_key: tuple[int, int] | None = None
|
|
417
|
+
self._cached_rows: list[dict[str, object]] = []
|
|
418
|
+
self._malformed: tuple[MalformedLine, ...] = ()
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def malformed_lines(self) -> tuple[MalformedLine, ...]:
|
|
422
|
+
"""Lines the last read could not parse. Empty is the ordinary answer."""
|
|
423
|
+
return self._malformed
|
|
424
|
+
|
|
425
|
+
# -- rows
|
|
426
|
+
|
|
427
|
+
def _rows(self) -> list[dict[str, object]]:
|
|
428
|
+
"""Every readable row, oldest first. Unreadable lines are dropped and counted.
|
|
429
|
+
|
|
430
|
+
A kill between ``_write``'s write and its fsync leaves a partial line, so an
|
|
431
|
+
unparseable line is an ordinary consequence of the writer being killed — not
|
|
432
|
+
evidence of corruption, and not a reason to refuse. Raising here would exit the
|
|
433
|
+
daemon on its first tick and on every restart after it, because the loop lets
|
|
434
|
+
anything outside its named failure list propagate.
|
|
435
|
+
|
|
436
|
+
Position does NOT discriminate cause. A tear is trailing only until the daemon
|
|
437
|
+
restarts and appends again, after which the same orphan sits mid-file with intact
|
|
438
|
+
rows on both sides. The question that does discriminate is whether the CURSOR can
|
|
439
|
+
still be accounted for, and ``replay`` already asks it and already raises
|
|
440
|
+
``JournalInconsistent`` by name. Dropping a line here lets that check be the one
|
|
441
|
+
that decides, instead of a bare ``ValueError`` from this parse pre-empting it.
|
|
442
|
+
|
|
443
|
+
Returned rows are shared with the cache and must be treated as read-only; every
|
|
444
|
+
consumer in this module filters or copies rather than mutating.
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
stat = self.path.stat()
|
|
448
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
449
|
+
self._cache_key, self._cached_rows, self._malformed = None, [], ()
|
|
450
|
+
return []
|
|
451
|
+
|
|
452
|
+
key = (stat.st_size, stat.st_mtime_ns)
|
|
453
|
+
if key == self._cache_key:
|
|
454
|
+
return self._cached_rows
|
|
455
|
+
|
|
456
|
+
rows: list[dict[str, object]] = []
|
|
457
|
+
malformed: list[MalformedLine] = []
|
|
458
|
+
for index, raw in enumerate(self.path.read_text().splitlines()):
|
|
459
|
+
line = raw.strip()
|
|
460
|
+
if not line:
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
rows.append(json.loads(line))
|
|
464
|
+
except json.JSONDecodeError as exc:
|
|
465
|
+
malformed.append(
|
|
466
|
+
MalformedLine(index=index, excerpt=line[:120], reason=str(exc))
|
|
467
|
+
)
|
|
468
|
+
self._cache_key, self._cached_rows, self._malformed = key, rows, tuple(malformed)
|
|
469
|
+
return rows
|
|
470
|
+
|
|
471
|
+
def _write(self, row: dict[str, object]) -> None:
|
|
472
|
+
"""Append one row, terminating a torn remnant first.
|
|
473
|
+
|
|
474
|
+
Without the repair, appending onto a partial line GLUES this row to it, and the
|
|
475
|
+
tear costs two rows instead of one: the interrupted write, and the next write,
|
|
476
|
+
which was itself correct and fsynced. Terminating first confines the damage to
|
|
477
|
+
the line that was actually interrupted.
|
|
478
|
+
|
|
479
|
+
Two writers racing can at worst both terminate, leaving a blank line, which
|
|
480
|
+
``_rows`` already skips.
|
|
481
|
+
"""
|
|
482
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
unterminated = False
|
|
484
|
+
try:
|
|
485
|
+
with self.path.open("rb") as probe:
|
|
486
|
+
if probe.seek(0, os.SEEK_END):
|
|
487
|
+
probe.seek(-1, os.SEEK_END)
|
|
488
|
+
unterminated = probe.read(1) != b"\n"
|
|
489
|
+
except FileNotFoundError:
|
|
490
|
+
pass
|
|
491
|
+
with self.path.open("a") as handle:
|
|
492
|
+
if unterminated:
|
|
493
|
+
handle.write("\n")
|
|
494
|
+
handle.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
495
|
+
handle.flush()
|
|
496
|
+
os.fsync(handle.fileno())
|
|
497
|
+
|
|
498
|
+
def append(
|
|
499
|
+
self,
|
|
500
|
+
*,
|
|
501
|
+
pending_id: str,
|
|
502
|
+
attempt: int,
|
|
503
|
+
operation_id: str | None,
|
|
504
|
+
coordinate_key: str,
|
|
505
|
+
source_rev: str,
|
|
506
|
+
state: State,
|
|
507
|
+
observation: dict[str, object],
|
|
508
|
+
) -> Transition:
|
|
509
|
+
transition = Transition(
|
|
510
|
+
state=state, observation=observation, timestamp=_now(), operation_id=operation_id
|
|
511
|
+
)
|
|
512
|
+
self._write(
|
|
513
|
+
{
|
|
514
|
+
"kind": "transition",
|
|
515
|
+
"pending_id": pending_id,
|
|
516
|
+
"attempt": attempt,
|
|
517
|
+
"operation_id": operation_id,
|
|
518
|
+
"coordinate_key": coordinate_key,
|
|
519
|
+
"source_rev": source_rev,
|
|
520
|
+
**transition.as_dict(),
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
return transition
|
|
524
|
+
|
|
525
|
+
def note(
|
|
526
|
+
self,
|
|
527
|
+
*,
|
|
528
|
+
pending_id: str,
|
|
529
|
+
attempt: int,
|
|
530
|
+
coordinate_key: str,
|
|
531
|
+
source_rev: str,
|
|
532
|
+
note: str,
|
|
533
|
+
data: dict[str, object] | None = None,
|
|
534
|
+
) -> None:
|
|
535
|
+
self._write(
|
|
536
|
+
{
|
|
537
|
+
"kind": "note",
|
|
538
|
+
"pending_id": pending_id,
|
|
539
|
+
"attempt": attempt,
|
|
540
|
+
"coordinate_key": coordinate_key,
|
|
541
|
+
"source_rev": source_rev,
|
|
542
|
+
"note": note,
|
|
543
|
+
"data": data or {},
|
|
544
|
+
"timestamp": _now(),
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
def rows_for(self, coordinate_key: str, source_rev: str) -> list[dict[str, object]]:
|
|
549
|
+
return [
|
|
550
|
+
r
|
|
551
|
+
for r in self._rows()
|
|
552
|
+
if r.get("coordinate_key") == coordinate_key and r.get("source_rev") == source_rev
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
def find(self, coordinate_key: str, source_rev: str) -> list[list[Transition]]:
|
|
556
|
+
"""Every attempt at (coordinate, source revision), oldest first, each as its transitions."""
|
|
557
|
+
attempts: dict[int, list[Transition]] = {}
|
|
558
|
+
for row in self.rows_for(coordinate_key, source_rev):
|
|
559
|
+
if row.get("kind") != "transition":
|
|
560
|
+
continue
|
|
561
|
+
attempts.setdefault(int(row["attempt"]), []).append(
|
|
562
|
+
Transition(
|
|
563
|
+
state=State(str(row["state"])),
|
|
564
|
+
observation=dict(row["observation"]), # type: ignore[arg-type]
|
|
565
|
+
timestamp=str(row["timestamp"]),
|
|
566
|
+
operation_id=row.get("operation_id"), # type: ignore[arg-type]
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
return [attempts[k] for k in sorted(attempts)]
|
|
570
|
+
|
|
571
|
+
def notes(self, coordinate_key: str, source_rev: str, note: str) -> int:
|
|
572
|
+
return sum(
|
|
573
|
+
1
|
|
574
|
+
for r in self.rows_for(coordinate_key, source_rev)
|
|
575
|
+
if r.get("kind") == "note" and r.get("note") == note
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# -- cursors
|
|
579
|
+
|
|
580
|
+
def cursor_path(self, coordinate_key: str) -> Path:
|
|
581
|
+
return self.cursors_dir / (hashlib.sha256(coordinate_key.encode()).hexdigest() + ".json")
|
|
582
|
+
|
|
583
|
+
def cursor(self, coordinate_key: str) -> str | None:
|
|
584
|
+
path = self.cursor_path(coordinate_key)
|
|
585
|
+
if not path.exists():
|
|
586
|
+
return None
|
|
587
|
+
try:
|
|
588
|
+
data = json.loads(path.read_text())
|
|
589
|
+
except (json.JSONDecodeError, OSError):
|
|
590
|
+
return None
|
|
591
|
+
value = data.get("source_rev")
|
|
592
|
+
return str(value) if value else None
|
|
593
|
+
|
|
594
|
+
def advance_cursor(self, coordinate_key: str, source_rev: str, pending_id: str) -> None:
|
|
595
|
+
self.cursors_dir.mkdir(parents=True, exist_ok=True)
|
|
596
|
+
path = self.cursor_path(coordinate_key)
|
|
597
|
+
tmp = path.with_suffix(".tmp")
|
|
598
|
+
tmp.write_text(
|
|
599
|
+
json.dumps(
|
|
600
|
+
{
|
|
601
|
+
"coordinate_key": coordinate_key,
|
|
602
|
+
"source_rev": source_rev,
|
|
603
|
+
"pending_id": pending_id,
|
|
604
|
+
"advanced_at": _now(),
|
|
605
|
+
},
|
|
606
|
+
indent=2,
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
with tmp.open("rb") as handle:
|
|
610
|
+
os.fsync(handle.fileno())
|
|
611
|
+
tmp.rename(path)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ------------------------------------------------------------------------ the machine
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@dataclass
|
|
618
|
+
class _Op:
|
|
619
|
+
"""The in-flight operation, reconstructed from the journal on resume."""
|
|
620
|
+
|
|
621
|
+
coordinate: Coordinate
|
|
622
|
+
destination: Destination
|
|
623
|
+
source_rev: str
|
|
624
|
+
attempt: int
|
|
625
|
+
pending_id: str
|
|
626
|
+
previous_cursor: str | None
|
|
627
|
+
expected_base: str | None = None
|
|
628
|
+
digest: str | None = None
|
|
629
|
+
operation_id: str | None = None
|
|
630
|
+
observed_base: str | None = None
|
|
631
|
+
intended_after: str | None = None
|
|
632
|
+
plan_hash: str | None = None
|
|
633
|
+
gate_results: tuple[GateResult, ...] = ()
|
|
634
|
+
transitions: list[Transition] = field(default_factory=list)
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def key(self) -> str:
|
|
638
|
+
return self.coordinate.key()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class Propagator:
|
|
642
|
+
def __init__(
|
|
643
|
+
self,
|
|
644
|
+
source_remote: Path,
|
|
645
|
+
branch: str,
|
|
646
|
+
state_dir: Path,
|
|
647
|
+
policy: Policy,
|
|
648
|
+
*,
|
|
649
|
+
kill_after: State | str | None = None,
|
|
650
|
+
after_apply_verb: Callable[[], None] | None = None,
|
|
651
|
+
before_apply_verb: Callable[[], None] | None = None,
|
|
652
|
+
git_env: dict[str, str] | None = None,
|
|
653
|
+
) -> None:
|
|
654
|
+
self.source_remote = source_remote
|
|
655
|
+
self.branch = branch
|
|
656
|
+
self.state_dir = state_dir
|
|
657
|
+
self.policy = policy
|
|
658
|
+
self.kill_after = kill_after
|
|
659
|
+
self.after_apply_verb = after_apply_verb
|
|
660
|
+
# Prototype 2 test seam: runs AFTER the apply step's own head check and BEFORE
|
|
661
|
+
# the verb, so a test can move a canonical destination inside the window the
|
|
662
|
+
# process-side check cannot see. The lease is what must catch that move.
|
|
663
|
+
self.before_apply_verb = before_apply_verb
|
|
664
|
+
# None means "the default, isolated from the host"; an explicit {} means "inherit the
|
|
665
|
+
# host environment" and must not collapse into the default because it is falsy
|
|
666
|
+
self.git_env = _ISOLATED_GIT_ENV if git_env is None else git_env
|
|
667
|
+
self.journal = Journal(state_dir)
|
|
668
|
+
self.mirror = state_dir / "mirror.git"
|
|
669
|
+
|
|
670
|
+
# -- observation of the source
|
|
671
|
+
|
|
672
|
+
def observe_source_at(self, cursor: str | None) -> Observation:
|
|
673
|
+
out = subprocess.run(
|
|
674
|
+
["git", "ls-remote", str(self.source_remote), f"refs/heads/{self.branch}"],
|
|
675
|
+
capture_output=True,
|
|
676
|
+
text=True,
|
|
677
|
+
env={**os.environ, **self.git_env},
|
|
678
|
+
)
|
|
679
|
+
if out.returncode != 0 or not out.stdout.strip():
|
|
680
|
+
stderr = out.stderr.strip().splitlines()
|
|
681
|
+
tail = stderr[-1] if stderr else "no output"
|
|
682
|
+
raise SourceUnobservable(
|
|
683
|
+
f"git ls-remote exited {out.returncode} for {self.source_remote} "
|
|
684
|
+
f"refs/heads/{self.branch}: {tail}"
|
|
685
|
+
)
|
|
686
|
+
source_rev = out.stdout.split()[0]
|
|
687
|
+
return Observation(source_rev=source_rev, cursor=cursor, is_new=(source_rev != cursor))
|
|
688
|
+
|
|
689
|
+
def observe_source(self, coordinate: Coordinate) -> Observation:
|
|
690
|
+
return self.observe_source_at(self.journal.cursor(coordinate.key()))
|
|
691
|
+
|
|
692
|
+
# -- destination reads (never writes before apply)
|
|
693
|
+
|
|
694
|
+
def read_head(self, destination: Destination) -> str:
|
|
695
|
+
# A canonical destination is read at its BRANCH ref, never HEAD: a bare
|
|
696
|
+
# repository's HEAD is a symref that may point anywhere, and the thing a
|
|
697
|
+
# contribution lands on is the branch the owner declared.
|
|
698
|
+
ref = (
|
|
699
|
+
f"refs/heads/{self.branch}" if destination.kind is DestinationKind.CANONICAL else "HEAD"
|
|
700
|
+
)
|
|
701
|
+
try:
|
|
702
|
+
return _git(destination.path, "rev-parse", "--verify", ref, env=self.git_env)
|
|
703
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
704
|
+
raise DestinationUnreadable(f"{destination.destination_id}: {exc}") from exc
|
|
705
|
+
|
|
706
|
+
def _porcelain(self, destination: Destination) -> str:
|
|
707
|
+
return _git(destination.path, "status", "--porcelain", env=self.git_env)
|
|
708
|
+
|
|
709
|
+
def _observed_ref_source(self, destination: Destination) -> str:
|
|
710
|
+
"""The ref the mirror fetches for ahead/behind: the branch for a canonical, else HEAD."""
|
|
711
|
+
if destination.kind is DestinationKind.CANONICAL:
|
|
712
|
+
return f"refs/heads/{self.branch}"
|
|
713
|
+
return "HEAD"
|
|
714
|
+
|
|
715
|
+
# -- driver
|
|
716
|
+
|
|
717
|
+
def run(
|
|
718
|
+
self,
|
|
719
|
+
coordinate: Coordinate,
|
|
720
|
+
destination: Destination,
|
|
721
|
+
observation: Observation | None = None,
|
|
722
|
+
*,
|
|
723
|
+
stop_after: State | None = None,
|
|
724
|
+
) -> Receipt | None:
|
|
725
|
+
"""Drive one coordinate. ``None`` means no new source revision: not an operation.
|
|
726
|
+
|
|
727
|
+
Raises ``JournalInconsistent`` if the cursor sits at a revision the journal never
|
|
728
|
+
acknowledged. A caller that has already observed the source this tick may pass
|
|
729
|
+
its ``observation`` so the source is not asked twice; the cursor check runs on it
|
|
730
|
+
all the same, because "nothing new" is exactly the answer that would hide a
|
|
731
|
+
corrupted sink state, whoever observed.
|
|
732
|
+
|
|
733
|
+
``stop_after=State.PLANNED`` drives the operation through its gates and stops
|
|
734
|
+
BEFORE the apply verb, journaled at ``planned``; a later ``run`` resumes it from
|
|
735
|
+
there. This is how a contribution SET prepares every member against its owner's
|
|
736
|
+
current base before landing any of them (Prototype 2).
|
|
737
|
+
"""
|
|
738
|
+
return self._run(
|
|
739
|
+
coordinate,
|
|
740
|
+
destination,
|
|
741
|
+
report_current=False,
|
|
742
|
+
observation=observation,
|
|
743
|
+
stop_after=stop_after,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
def _run(
|
|
747
|
+
self,
|
|
748
|
+
coordinate: Coordinate,
|
|
749
|
+
destination: Destination,
|
|
750
|
+
*,
|
|
751
|
+
report_current: bool,
|
|
752
|
+
observation: Observation | None = None,
|
|
753
|
+
stop_after: State | None = None,
|
|
754
|
+
) -> Receipt | None:
|
|
755
|
+
key = coordinate.key()
|
|
756
|
+
if observation is None:
|
|
757
|
+
observation = self.observe_source(coordinate)
|
|
758
|
+
if not observation.is_new:
|
|
759
|
+
# The cursor is AT the source revision, which only happens after an
|
|
760
|
+
# acknowledgement at that revision; _terminal_receipt RAISES if the
|
|
761
|
+
# journal carries no such acknowledgement, on both paths, because a
|
|
762
|
+
# cursor the journal cannot account for is a corrupted sink state and
|
|
763
|
+
# "nothing new" is exactly the answer that would hide it. For an
|
|
764
|
+
# aggregate the target was declared and already reached; report the
|
|
765
|
+
# terminal outcome rather than dropping the target, because a dropped
|
|
766
|
+
# target is indistinguishable from one never part of the invocation.
|
|
767
|
+
terminal = self._terminal_receipt(coordinate, observation.source_rev)
|
|
768
|
+
return terminal if report_current else None
|
|
769
|
+
source_rev = observation.source_rev
|
|
770
|
+
|
|
771
|
+
attempts = self.journal.find(key, source_rev)
|
|
772
|
+
if attempts:
|
|
773
|
+
last = attempts[-1]
|
|
774
|
+
last_state = last[-1].state
|
|
775
|
+
if last_state is State.ACKNOWLEDGED:
|
|
776
|
+
# terminal: the original outcome, no verb, and the cursor repaired if it lagged
|
|
777
|
+
attempt = len(attempts)
|
|
778
|
+
pending_id = pending_id_for(key, source_rev, attempt)
|
|
779
|
+
self.journal.note(
|
|
780
|
+
pending_id=pending_id,
|
|
781
|
+
attempt=attempt,
|
|
782
|
+
coordinate_key=key,
|
|
783
|
+
source_rev=source_rev,
|
|
784
|
+
note="replayed-terminal",
|
|
785
|
+
data={"state": str(last_state)},
|
|
786
|
+
)
|
|
787
|
+
self.journal.advance_cursor(key, source_rev, pending_id)
|
|
788
|
+
return self._receipt(coordinate, source_rev, attempt, replayed=True)
|
|
789
|
+
if last_state is State.REFUSED:
|
|
790
|
+
op = self._fresh(
|
|
791
|
+
coordinate,
|
|
792
|
+
destination,
|
|
793
|
+
source_rev,
|
|
794
|
+
observation.cursor,
|
|
795
|
+
attempt=len(attempts) + 1,
|
|
796
|
+
)
|
|
797
|
+
else:
|
|
798
|
+
op = self._resume(
|
|
799
|
+
coordinate,
|
|
800
|
+
destination,
|
|
801
|
+
source_rev,
|
|
802
|
+
observation.cursor,
|
|
803
|
+
attempt=len(attempts),
|
|
804
|
+
transitions=last,
|
|
805
|
+
)
|
|
806
|
+
else:
|
|
807
|
+
op = self._fresh(coordinate, destination, source_rev, observation.cursor, attempt=1)
|
|
808
|
+
|
|
809
|
+
self._drive(op, stop_after=stop_after)
|
|
810
|
+
return self._receipt(coordinate, source_rev, op.attempt, replayed=False)
|
|
811
|
+
|
|
812
|
+
def _terminal_receipt(self, coordinate: Coordinate, source_rev: str) -> Receipt:
|
|
813
|
+
"""The original outcome for a coordinate whose cursor is already at ``source_rev``."""
|
|
814
|
+
attempts = self.journal.find(coordinate.key(), source_rev)
|
|
815
|
+
if not attempts or attempts[-1][-1].state is not State.ACKNOWLEDGED:
|
|
816
|
+
# A cursor at a revision the journal never acknowledged is a corrupted
|
|
817
|
+
# sink state, and the prototype says so rather than guessing an outcome.
|
|
818
|
+
raise JournalInconsistent(
|
|
819
|
+
f"cursor for {coordinate.destination} is at {source_rev} but the journal "
|
|
820
|
+
"carries no acknowledged attempt at that revision"
|
|
821
|
+
)
|
|
822
|
+
return self._receipt(coordinate, source_rev, len(attempts), replayed=True)
|
|
823
|
+
|
|
824
|
+
def run_all(self, targets: Iterable[tuple[Coordinate, Destination]]) -> PlanOutcome | None:
|
|
825
|
+
"""Drive every declared target and classify the aggregate.
|
|
826
|
+
|
|
827
|
+
A declared target whose cursor is already at the source revision was reached
|
|
828
|
+
by an earlier invocation; it is reported as reached with its terminal receipt
|
|
829
|
+
(``replayed=True``), never dropped. Dropping it would reclassify a replayed
|
|
830
|
+
``partial`` as ``refused``, which is what the first cut did (found in review):
|
|
831
|
+
the aggregate must distinguish "already reached" from "not part of this
|
|
832
|
+
invocation", and the only way to do that is to keep reporting it.
|
|
833
|
+
``None`` only when no targets were declared.
|
|
834
|
+
"""
|
|
835
|
+
receipts: list[Receipt] = []
|
|
836
|
+
for coordinate, destination in targets:
|
|
837
|
+
receipt = self._run(coordinate, destination, report_current=True)
|
|
838
|
+
if receipt is not None:
|
|
839
|
+
receipts.append(receipt)
|
|
840
|
+
if not receipts:
|
|
841
|
+
return None
|
|
842
|
+
reached = tuple(r.coordinate.destination for r in receipts if r.state is State.ACKNOWLEDGED)
|
|
843
|
+
not_reached = tuple(
|
|
844
|
+
(r.coordinate.destination, r.refusal_reason or r.detail or f"state={r.state}")
|
|
845
|
+
for r in receipts
|
|
846
|
+
if r.state is not State.ACKNOWLEDGED
|
|
847
|
+
)
|
|
848
|
+
if not not_reached:
|
|
849
|
+
state = State.ACKNOWLEDGED
|
|
850
|
+
elif not reached:
|
|
851
|
+
state = State.REFUSED
|
|
852
|
+
else:
|
|
853
|
+
state = State.PARTIAL
|
|
854
|
+
return PlanOutcome(
|
|
855
|
+
state=state, reached=reached, not_reached=not_reached, receipts=tuple(receipts)
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# -- attempt construction
|
|
859
|
+
|
|
860
|
+
def _fresh(
|
|
861
|
+
self,
|
|
862
|
+
coordinate: Coordinate,
|
|
863
|
+
destination: Destination,
|
|
864
|
+
source_rev: str,
|
|
865
|
+
cursor: str | None,
|
|
866
|
+
*,
|
|
867
|
+
attempt: int,
|
|
868
|
+
) -> _Op:
|
|
869
|
+
return _Op(
|
|
870
|
+
coordinate=coordinate,
|
|
871
|
+
destination=destination,
|
|
872
|
+
source_rev=source_rev,
|
|
873
|
+
attempt=attempt,
|
|
874
|
+
pending_id=pending_id_for(coordinate.key(), source_rev, attempt),
|
|
875
|
+
previous_cursor=cursor,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def _resume(
|
|
879
|
+
self,
|
|
880
|
+
coordinate: Coordinate,
|
|
881
|
+
destination: Destination,
|
|
882
|
+
source_rev: str,
|
|
883
|
+
cursor: str | None,
|
|
884
|
+
*,
|
|
885
|
+
attempt: int,
|
|
886
|
+
transitions: list[Transition],
|
|
887
|
+
) -> _Op:
|
|
888
|
+
op = self._fresh(coordinate, destination, source_rev, cursor, attempt=attempt)
|
|
889
|
+
op.transitions = list(transitions)
|
|
890
|
+
for t in transitions:
|
|
891
|
+
obs = t.observation
|
|
892
|
+
if t.state is State.OBSERVED:
|
|
893
|
+
op.expected_base = str(obs["expected_base"])
|
|
894
|
+
elif t.state is State.FETCHED:
|
|
895
|
+
op.digest = str(obs["digest"])
|
|
896
|
+
op.operation_id = t.operation_id
|
|
897
|
+
elif t.state is State.PLANNED:
|
|
898
|
+
op.observed_base = str(obs["observed_base"])
|
|
899
|
+
op.intended_after = str(obs["intended_after"])
|
|
900
|
+
op.plan_hash = str(obs["plan_hash"])
|
|
901
|
+
op.gate_results = tuple(
|
|
902
|
+
GateResult(**g)
|
|
903
|
+
for g in obs["gate_results"] # type: ignore[arg-type]
|
|
904
|
+
)
|
|
905
|
+
return op
|
|
906
|
+
|
|
907
|
+
# -- the state machine
|
|
908
|
+
|
|
909
|
+
def _drive(self, op: _Op, *, stop_after: State | None = None) -> None:
|
|
910
|
+
last = op.transitions[-1].state if op.transitions else None
|
|
911
|
+
if last is None:
|
|
912
|
+
self._observe(op)
|
|
913
|
+
last = State.OBSERVED
|
|
914
|
+
if last is State.OBSERVED:
|
|
915
|
+
self._fetch(op)
|
|
916
|
+
last = State.FETCHED
|
|
917
|
+
if last is State.FETCHED:
|
|
918
|
+
if not self._plan(op):
|
|
919
|
+
return # refused at plan; journaled
|
|
920
|
+
last = State.PLANNED
|
|
921
|
+
if stop_after is State.PLANNED and last is State.PLANNED:
|
|
922
|
+
return # prepared: gates evaluated and journaled, no verb has run
|
|
923
|
+
if last in (State.PLANNED, State.UNVERIFIABLE):
|
|
924
|
+
if not self._apply(op):
|
|
925
|
+
return # refused or unverifiable; journaled
|
|
926
|
+
last = State.APPLIED
|
|
927
|
+
if last is State.APPLIED:
|
|
928
|
+
if not self._verify(op):
|
|
929
|
+
return # postcondition did not hold; journaled as a note, state stays applied
|
|
930
|
+
last = State.VERIFIED
|
|
931
|
+
if last is State.VERIFIED:
|
|
932
|
+
self._acknowledge(op)
|
|
933
|
+
|
|
934
|
+
def _record(self, op: _Op, state: State, observation: dict[str, object]) -> None:
|
|
935
|
+
transition = self.journal.append(
|
|
936
|
+
pending_id=op.pending_id,
|
|
937
|
+
attempt=op.attempt,
|
|
938
|
+
operation_id=op.operation_id,
|
|
939
|
+
coordinate_key=op.key,
|
|
940
|
+
source_rev=op.source_rev,
|
|
941
|
+
state=state,
|
|
942
|
+
observation=observation,
|
|
943
|
+
)
|
|
944
|
+
op.transitions.append(transition)
|
|
945
|
+
if self.kill_after == state:
|
|
946
|
+
raise SinkKilled(f"killed after {state}")
|
|
947
|
+
|
|
948
|
+
def _note(self, op: _Op, note: str, data: dict[str, object] | None = None) -> None:
|
|
949
|
+
self.journal.note(
|
|
950
|
+
pending_id=op.pending_id,
|
|
951
|
+
attempt=op.attempt,
|
|
952
|
+
coordinate_key=op.key,
|
|
953
|
+
source_rev=op.source_rev,
|
|
954
|
+
note=note,
|
|
955
|
+
data=data,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
def _observe(self, op: _Op) -> None:
|
|
959
|
+
op.expected_base = self.read_head(op.destination)
|
|
960
|
+
self._record(
|
|
961
|
+
op,
|
|
962
|
+
State.OBSERVED,
|
|
963
|
+
{
|
|
964
|
+
"source_rev": op.source_rev,
|
|
965
|
+
"source_branch": self.branch,
|
|
966
|
+
"previous_cursor": op.previous_cursor,
|
|
967
|
+
"expected_base": op.expected_base,
|
|
968
|
+
"established_by": (
|
|
969
|
+
"ls-remote on the source reported a revision the cursor did not name"
|
|
970
|
+
),
|
|
971
|
+
},
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
def _fetch(self, op: _Op) -> None:
|
|
975
|
+
if not self.mirror.exists():
|
|
976
|
+
self.mirror.parent.mkdir(parents=True, exist_ok=True)
|
|
977
|
+
subprocess.run(
|
|
978
|
+
["git", "init", "-q", "--bare", str(self.mirror)],
|
|
979
|
+
check=True,
|
|
980
|
+
capture_output=True,
|
|
981
|
+
text=True,
|
|
982
|
+
env={**os.environ, **self.git_env},
|
|
983
|
+
)
|
|
984
|
+
_git(
|
|
985
|
+
self.mirror,
|
|
986
|
+
"fetch",
|
|
987
|
+
"-q",
|
|
988
|
+
str(self.source_remote),
|
|
989
|
+
f"+refs/heads/{self.branch}:refs/remotes/source/{self.branch}",
|
|
990
|
+
env=self.git_env,
|
|
991
|
+
)
|
|
992
|
+
# presence is asserted against the object itself, not the fetch's exit status
|
|
993
|
+
_git(self.mirror, "cat-file", "-e", f"{op.source_rev}^{{commit}}", env=self.git_env)
|
|
994
|
+
op.digest = tree_digest(self.mirror, op.source_rev, env=self.git_env)
|
|
995
|
+
assert op.expected_base is not None
|
|
996
|
+
op.operation_id = operation_id_for(op.key, op.source_rev, op.expected_base, op.digest)
|
|
997
|
+
self._record(
|
|
998
|
+
op,
|
|
999
|
+
State.FETCHED,
|
|
1000
|
+
{
|
|
1001
|
+
"digest": op.digest,
|
|
1002
|
+
"mirror": str(self.mirror),
|
|
1003
|
+
"established_by": (
|
|
1004
|
+
"cat-file -e on the mirror and the tree id recomputed from the object"
|
|
1005
|
+
),
|
|
1006
|
+
},
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
def _gate(self, gate_id: str, passed: bool, detail: str) -> GateResult:
|
|
1010
|
+
result = "pass" if passed else "fail"
|
|
1011
|
+
return GateResult(
|
|
1012
|
+
gate_id=gate_id,
|
|
1013
|
+
result=result,
|
|
1014
|
+
detail=detail,
|
|
1015
|
+
result_hash=_sha256_json({"gate": gate_id, "result": result, "detail": detail}),
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
def _gate_not_run(self, gate_id: str, detail: str) -> GateResult:
|
|
1019
|
+
"""A gate judged inapplicable to this destination kind, recorded as such.
|
|
1020
|
+
|
|
1021
|
+
``not-run`` is neither pass nor fail: it does not refuse, and it does not let a
|
|
1022
|
+
reader mistake "the list was empty" for "every gate passed".
|
|
1023
|
+
"""
|
|
1024
|
+
return GateResult(
|
|
1025
|
+
gate_id=gate_id,
|
|
1026
|
+
result=_GATE_NOT_RUN,
|
|
1027
|
+
detail=detail,
|
|
1028
|
+
result_hash=_sha256_json({"gate": gate_id, "result": _GATE_NOT_RUN, "detail": detail}),
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
def _plan(self, op: _Op) -> bool:
|
|
1032
|
+
assert op.expected_base is not None and op.digest is not None
|
|
1033
|
+
gates: list[GateResult] = []
|
|
1034
|
+
|
|
1035
|
+
allowed = op.coordinate.direction in self.policy.allowed_directions
|
|
1036
|
+
gates.append(
|
|
1037
|
+
self._gate(
|
|
1038
|
+
"policy.direction",
|
|
1039
|
+
allowed,
|
|
1040
|
+
f"direction={op.coordinate.direction} "
|
|
1041
|
+
f"allowed={sorted(str(d) for d in self.policy.allowed_directions)}",
|
|
1042
|
+
)
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
op.observed_base = self.read_head(op.destination)
|
|
1046
|
+
gates.append(
|
|
1047
|
+
self._gate(
|
|
1048
|
+
"destination.base-unmoved",
|
|
1049
|
+
op.observed_base == op.expected_base,
|
|
1050
|
+
f"expected_base={op.expected_base} observed_base={op.observed_base}",
|
|
1051
|
+
)
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
if op.destination.kind is DestinationKind.CANONICAL:
|
|
1055
|
+
# a bare repository has no worktree; the gate is recorded as NOT RUN rather
|
|
1056
|
+
# than omitted, so a receipt with an empty gate list and one where this
|
|
1057
|
+
# gate was judged inapplicable remain distinguishable (a gate list must say
|
|
1058
|
+
# which gates ran, individually; an aggregate cannot)
|
|
1059
|
+
gates.append(
|
|
1060
|
+
self._gate_not_run(
|
|
1061
|
+
"destination.clean",
|
|
1062
|
+
"bare canonical destination: cleanliness is not a property it has",
|
|
1063
|
+
)
|
|
1064
|
+
)
|
|
1065
|
+
else:
|
|
1066
|
+
porcelain = self._porcelain(op.destination)
|
|
1067
|
+
gates.append(
|
|
1068
|
+
self._gate(
|
|
1069
|
+
"destination.clean",
|
|
1070
|
+
porcelain == "",
|
|
1071
|
+
"worktree and index clean"
|
|
1072
|
+
if porcelain == ""
|
|
1073
|
+
else f"dirty: {len(porcelain.splitlines())} path(s)",
|
|
1074
|
+
)
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
# the destination's branch (canonical) or HEAD (clone) is fetched INTO the
|
|
1078
|
+
# mirror (a read of the destination), so ahead/behind is computed in the
|
|
1079
|
+
# sink's own store
|
|
1080
|
+
observed_tag = hashlib.sha256(op.destination.destination_id.encode()).hexdigest()[:16]
|
|
1081
|
+
observed_ref = f"refs/observed/{observed_tag}"
|
|
1082
|
+
_git(
|
|
1083
|
+
self.mirror,
|
|
1084
|
+
"fetch",
|
|
1085
|
+
"-q",
|
|
1086
|
+
str(op.destination.path),
|
|
1087
|
+
f"+{self._observed_ref_source(op.destination)}:{observed_ref}",
|
|
1088
|
+
env=self.git_env,
|
|
1089
|
+
)
|
|
1090
|
+
counts = _git(
|
|
1091
|
+
self.mirror,
|
|
1092
|
+
"rev-list",
|
|
1093
|
+
"--left-right",
|
|
1094
|
+
"--count",
|
|
1095
|
+
f"{op.source_rev}...{observed_ref}",
|
|
1096
|
+
env=self.git_env,
|
|
1097
|
+
)
|
|
1098
|
+
behind_s, ahead_s = counts.split()
|
|
1099
|
+
behind, ahead = int(behind_s), int(ahead_s)
|
|
1100
|
+
gates.append(
|
|
1101
|
+
self._gate(
|
|
1102
|
+
"destination.fast-forward",
|
|
1103
|
+
ahead == 0,
|
|
1104
|
+
f"ahead={ahead} behind={behind} (destination relative to source revision)",
|
|
1105
|
+
)
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
op.intended_after = op.source_rev
|
|
1109
|
+
op.gate_results = tuple(gates)
|
|
1110
|
+
op.plan_hash = _sha256_json(
|
|
1111
|
+
{
|
|
1112
|
+
"operation_id": op.operation_id,
|
|
1113
|
+
"intended_after": op.intended_after,
|
|
1114
|
+
"policy_hash": self.policy.policy_hash,
|
|
1115
|
+
"gates": [g.gate_id for g in gates],
|
|
1116
|
+
"destination_kind": str(op.destination.kind),
|
|
1117
|
+
}
|
|
1118
|
+
)
|
|
1119
|
+
self._record(
|
|
1120
|
+
op,
|
|
1121
|
+
State.PLANNED,
|
|
1122
|
+
{
|
|
1123
|
+
"observed_base": op.observed_base,
|
|
1124
|
+
"intended_after": op.intended_after,
|
|
1125
|
+
"plan_hash": op.plan_hash,
|
|
1126
|
+
"policy_hash": self.policy.policy_hash,
|
|
1127
|
+
"gate_results": [g.as_dict() for g in gates],
|
|
1128
|
+
"established_by": (
|
|
1129
|
+
"every gate evaluated and recorded individually against the destination "
|
|
1130
|
+
"as read now"
|
|
1131
|
+
),
|
|
1132
|
+
},
|
|
1133
|
+
)
|
|
1134
|
+
failed = [g for g in gates if g.result == "fail"]
|
|
1135
|
+
if failed:
|
|
1136
|
+
first = failed[0]
|
|
1137
|
+
self._record(
|
|
1138
|
+
op,
|
|
1139
|
+
State.REFUSED,
|
|
1140
|
+
{
|
|
1141
|
+
"refusal_reason": f"{first.gate_id}: {first.detail}",
|
|
1142
|
+
"failed_gates": [g.gate_id for g in failed],
|
|
1143
|
+
"observed_base": op.observed_base,
|
|
1144
|
+
},
|
|
1145
|
+
)
|
|
1146
|
+
return False
|
|
1147
|
+
return True
|
|
1148
|
+
|
|
1149
|
+
def _apply(self, op: _Op) -> bool:
|
|
1150
|
+
assert op.expected_base is not None and op.intended_after is not None
|
|
1151
|
+
try:
|
|
1152
|
+
head = self.read_head(op.destination)
|
|
1153
|
+
except DestinationUnreadable as exc:
|
|
1154
|
+
# nothing has been done to the destination yet, so this is a named refusal
|
|
1155
|
+
self._record(
|
|
1156
|
+
op,
|
|
1157
|
+
State.REFUSED,
|
|
1158
|
+
{"refusal_reason": f"destination.unreadable: {exc}", "observed_base": None},
|
|
1159
|
+
)
|
|
1160
|
+
return False
|
|
1161
|
+
|
|
1162
|
+
if head == op.intended_after:
|
|
1163
|
+
# the verb landed in an earlier life of this sink; do not run it again
|
|
1164
|
+
self._record(
|
|
1165
|
+
op,
|
|
1166
|
+
State.APPLIED,
|
|
1167
|
+
{
|
|
1168
|
+
"after": head,
|
|
1169
|
+
"observed_base": head,
|
|
1170
|
+
"verb_ran_now": False,
|
|
1171
|
+
"established_by": (
|
|
1172
|
+
"read-back found the intended revision already at the destination"
|
|
1173
|
+
),
|
|
1174
|
+
},
|
|
1175
|
+
)
|
|
1176
|
+
return True
|
|
1177
|
+
if head != op.expected_base:
|
|
1178
|
+
self._record(
|
|
1179
|
+
op,
|
|
1180
|
+
State.REFUSED,
|
|
1181
|
+
{
|
|
1182
|
+
"refusal_reason": (
|
|
1183
|
+
f"expected_base moved: expected {op.expected_base}, observed {head}"
|
|
1184
|
+
),
|
|
1185
|
+
"observed_base": head,
|
|
1186
|
+
},
|
|
1187
|
+
)
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
self._note(
|
|
1191
|
+
op, "apply-verb-started", {"expected_base": head, "intended_after": op.intended_after}
|
|
1192
|
+
)
|
|
1193
|
+
if self.before_apply_verb is not None:
|
|
1194
|
+
self.before_apply_verb()
|
|
1195
|
+
if op.destination.kind is DestinationKind.CANONICAL:
|
|
1196
|
+
# Prototype 2 (the contribution protocol): the contribution lands by a push FROM
|
|
1197
|
+
# the mirror (which holds the fetched source objects) TO the owner's bare remote, as a
|
|
1198
|
+
# fast-forward of ``expected_base`` guarded by ``--force-with-lease`` on
|
|
1199
|
+
# that same base. The receiving repository enforces the compare-and-swap:
|
|
1200
|
+
# if its branch is no longer at ``expected_base`` when the push arrives —
|
|
1201
|
+
# including a move inside the window after this process's own head check —
|
|
1202
|
+
# the push is rejected and nothing lands. That rejection is a REFUSAL with
|
|
1203
|
+
# the observed base, not an exception: it is the protocol doing its job.
|
|
1204
|
+
# Why ``--force-with-lease`` can never FORCE here: the plan's
|
|
1205
|
+
# ``destination.fast-forward`` gate already established that ``intended_after``
|
|
1206
|
+
# descends from ``expected_base`` (ahead == 0), so when the lease holds the
|
|
1207
|
+
# update is a genuine fast-forward, and when the owner moved the lease rejects
|
|
1208
|
+
# it before any update. Mutation-checked: forcing the gate to pass lets a stale
|
|
1209
|
+
# contribution overwrite the owner (W2b goes red); dropping the lease for a bare
|
|
1210
|
+
# ``--force`` lets a move inside the check→push window be overwritten (W2d goes
|
|
1211
|
+
# red). The two halves are one mechanism and neither is sufficient alone.
|
|
1212
|
+
lease = f"--force-with-lease=refs/heads/{self.branch}:{op.expected_base}"
|
|
1213
|
+
push = subprocess.run(
|
|
1214
|
+
[
|
|
1215
|
+
"git",
|
|
1216
|
+
"-C",
|
|
1217
|
+
str(self.mirror),
|
|
1218
|
+
"push",
|
|
1219
|
+
"-q",
|
|
1220
|
+
lease,
|
|
1221
|
+
str(op.destination.path),
|
|
1222
|
+
f"{op.intended_after}:refs/heads/{self.branch}",
|
|
1223
|
+
],
|
|
1224
|
+
capture_output=True,
|
|
1225
|
+
text=True,
|
|
1226
|
+
env={**os.environ, **self.git_env},
|
|
1227
|
+
)
|
|
1228
|
+
if push.returncode != 0:
|
|
1229
|
+
try:
|
|
1230
|
+
moved_to = self.read_head(op.destination)
|
|
1231
|
+
except DestinationUnreadable as exc:
|
|
1232
|
+
self._record(
|
|
1233
|
+
op,
|
|
1234
|
+
State.UNVERIFIABLE,
|
|
1235
|
+
{
|
|
1236
|
+
"detail": (
|
|
1237
|
+
"the lease push returned non-zero and the destination could "
|
|
1238
|
+
f"not be read back: {exc}"
|
|
1239
|
+
),
|
|
1240
|
+
"observed_base": None,
|
|
1241
|
+
},
|
|
1242
|
+
)
|
|
1243
|
+
return False
|
|
1244
|
+
if moved_to != op.expected_base:
|
|
1245
|
+
self._record(
|
|
1246
|
+
op,
|
|
1247
|
+
State.REFUSED,
|
|
1248
|
+
{
|
|
1249
|
+
"refusal_reason": (
|
|
1250
|
+
f"destination.lease-refused: the owner's branch moved to "
|
|
1251
|
+
f"{moved_to} after the base check; expected {op.expected_base}"
|
|
1252
|
+
),
|
|
1253
|
+
"observed_base": moved_to,
|
|
1254
|
+
"established_by": (
|
|
1255
|
+
"the receiving repository rejected the lease; read back names "
|
|
1256
|
+
"the revision it holds"
|
|
1257
|
+
),
|
|
1258
|
+
},
|
|
1259
|
+
)
|
|
1260
|
+
return False
|
|
1261
|
+
raise RuntimeError(
|
|
1262
|
+
"lease push failed although the owner's branch is still at the expected "
|
|
1263
|
+
f"base: {push.stderr.strip()}"
|
|
1264
|
+
)
|
|
1265
|
+
else:
|
|
1266
|
+
_git(
|
|
1267
|
+
op.destination.path,
|
|
1268
|
+
"fetch",
|
|
1269
|
+
"-q",
|
|
1270
|
+
str(self.mirror),
|
|
1271
|
+
f"refs/remotes/source/{self.branch}",
|
|
1272
|
+
env=self.git_env,
|
|
1273
|
+
)
|
|
1274
|
+
_git(
|
|
1275
|
+
op.destination.path, "merge", "-q", "--ff-only", op.intended_after, env=self.git_env
|
|
1276
|
+
)
|
|
1277
|
+
if self.after_apply_verb is not None:
|
|
1278
|
+
self.after_apply_verb()
|
|
1279
|
+
if self.kill_after == KILL_AFTER_APPLY_VERB:
|
|
1280
|
+
raise SinkKilled("killed after the apply verb, before the read back")
|
|
1281
|
+
|
|
1282
|
+
try:
|
|
1283
|
+
after = self.read_head(op.destination)
|
|
1284
|
+
except DestinationUnreadable as exc:
|
|
1285
|
+
self._record(
|
|
1286
|
+
op,
|
|
1287
|
+
State.UNVERIFIABLE,
|
|
1288
|
+
{
|
|
1289
|
+
"detail": (
|
|
1290
|
+
f"the apply verb returned but the destination could not be read back: {exc}"
|
|
1291
|
+
),
|
|
1292
|
+
"observed_base": None,
|
|
1293
|
+
},
|
|
1294
|
+
)
|
|
1295
|
+
return False
|
|
1296
|
+
|
|
1297
|
+
if after != op.intended_after:
|
|
1298
|
+
self._record(
|
|
1299
|
+
op,
|
|
1300
|
+
State.REFUSED,
|
|
1301
|
+
{
|
|
1302
|
+
"refusal_reason": (
|
|
1303
|
+
f"destination.apply-mismatch: verb returned but destination reports {after}"
|
|
1304
|
+
),
|
|
1305
|
+
"observed_base": after,
|
|
1306
|
+
},
|
|
1307
|
+
)
|
|
1308
|
+
return False
|
|
1309
|
+
|
|
1310
|
+
self._record(
|
|
1311
|
+
op,
|
|
1312
|
+
State.APPLIED,
|
|
1313
|
+
{
|
|
1314
|
+
"after": after,
|
|
1315
|
+
"observed_base": head,
|
|
1316
|
+
"verb_ran_now": True,
|
|
1317
|
+
"established_by": (
|
|
1318
|
+
"rev-parse HEAD on the destination after the verb reports the intended revision"
|
|
1319
|
+
),
|
|
1320
|
+
},
|
|
1321
|
+
)
|
|
1322
|
+
return True
|
|
1323
|
+
|
|
1324
|
+
def _verify(self, op: _Op) -> bool:
|
|
1325
|
+
assert op.intended_after is not None and op.digest is not None
|
|
1326
|
+
head = self.read_head(op.destination)
|
|
1327
|
+
tree = tree_digest(op.destination.path, head, env=self.git_env)
|
|
1328
|
+
if op.destination.kind is DestinationKind.CANONICAL:
|
|
1329
|
+
postcondition = _POSTCONDITION_CANONICAL
|
|
1330
|
+
holds = head == op.intended_after and tree == op.digest
|
|
1331
|
+
detail = f"branch={head} tree={tree}"
|
|
1332
|
+
else:
|
|
1333
|
+
postcondition = _POSTCONDITION
|
|
1334
|
+
porcelain = self._porcelain(op.destination)
|
|
1335
|
+
holds = head == op.intended_after and tree == op.digest and porcelain == ""
|
|
1336
|
+
detail = f"head={head} tree={tree} clean={porcelain == ''}"
|
|
1337
|
+
if not holds:
|
|
1338
|
+
self._note(
|
|
1339
|
+
op, "postcondition-failed", {"postcondition": postcondition, "detail": detail}
|
|
1340
|
+
)
|
|
1341
|
+
return False
|
|
1342
|
+
self._record(
|
|
1343
|
+
op,
|
|
1344
|
+
State.VERIFIED,
|
|
1345
|
+
{
|
|
1346
|
+
"postcondition_checked": postcondition,
|
|
1347
|
+
"holds": True,
|
|
1348
|
+
"detail": detail,
|
|
1349
|
+
"established_by": "the named postcondition re-read from the destination",
|
|
1350
|
+
},
|
|
1351
|
+
)
|
|
1352
|
+
return True
|
|
1353
|
+
|
|
1354
|
+
def _acknowledge(self, op: _Op) -> None:
|
|
1355
|
+
# the journal row is the acknowledgment; the cursor is derived from it and
|
|
1356
|
+
# repaired from it on replay, so the row is written first
|
|
1357
|
+
self._record(
|
|
1358
|
+
op,
|
|
1359
|
+
State.ACKNOWLEDGED,
|
|
1360
|
+
{"cursor": op.source_rev, "established_by": "verified was recorded for this operation"},
|
|
1361
|
+
)
|
|
1362
|
+
self.journal.advance_cursor(op.key, op.source_rev, op.pending_id)
|
|
1363
|
+
|
|
1364
|
+
# -- receipts
|
|
1365
|
+
|
|
1366
|
+
def _receipt(
|
|
1367
|
+
self, coordinate: Coordinate, source_rev: str, attempt: int, *, replayed: bool
|
|
1368
|
+
) -> Receipt:
|
|
1369
|
+
key = coordinate.key()
|
|
1370
|
+
# rows are read in journal order: a note and a later transition about the same
|
|
1371
|
+
# fact resolve to whichever came last, never to whichever kind is handled last
|
|
1372
|
+
rows = [r for r in self.journal.rows_for(key, source_rev) if int(r["attempt"]) == attempt]
|
|
1373
|
+
transitions: list[Transition] = []
|
|
1374
|
+
|
|
1375
|
+
expected_base = ""
|
|
1376
|
+
observed_base: str | None = None
|
|
1377
|
+
after: str | None = None
|
|
1378
|
+
digest: str | None = None
|
|
1379
|
+
operation_id: str | None = None
|
|
1380
|
+
plan_hash: str | None = None
|
|
1381
|
+
gate_results: tuple[GateResult, ...] = ()
|
|
1382
|
+
postcondition_checked: str | None = None
|
|
1383
|
+
postcondition_holds: bool | None = None
|
|
1384
|
+
refusal_reason: str | None = None
|
|
1385
|
+
detail = ""
|
|
1386
|
+
|
|
1387
|
+
for row in rows:
|
|
1388
|
+
if row.get("kind") == "note":
|
|
1389
|
+
if row.get("note") == "postcondition-failed":
|
|
1390
|
+
data = row.get("data") or {}
|
|
1391
|
+
postcondition_checked = str(data.get("postcondition", _POSTCONDITION)) # type: ignore[union-attr]
|
|
1392
|
+
postcondition_holds = False
|
|
1393
|
+
detail = str(data.get("detail", "")) # type: ignore[union-attr]
|
|
1394
|
+
continue
|
|
1395
|
+
t = Transition(
|
|
1396
|
+
state=State(str(row["state"])),
|
|
1397
|
+
observation=dict(row["observation"]), # type: ignore[arg-type]
|
|
1398
|
+
timestamp=str(row["timestamp"]),
|
|
1399
|
+
operation_id=row.get("operation_id"), # type: ignore[arg-type]
|
|
1400
|
+
)
|
|
1401
|
+
transitions.append(t)
|
|
1402
|
+
obs = t.observation
|
|
1403
|
+
if t.state is State.OBSERVED:
|
|
1404
|
+
expected_base = str(obs["expected_base"])
|
|
1405
|
+
elif t.state is State.FETCHED:
|
|
1406
|
+
digest = str(obs["digest"])
|
|
1407
|
+
operation_id = t.operation_id
|
|
1408
|
+
elif t.state is State.PLANNED:
|
|
1409
|
+
observed_base = str(obs["observed_base"])
|
|
1410
|
+
plan_hash = str(obs["plan_hash"])
|
|
1411
|
+
gate_results = tuple(GateResult(**g) for g in obs["gate_results"]) # type: ignore[arg-type]
|
|
1412
|
+
elif t.state is State.APPLIED:
|
|
1413
|
+
after = str(obs["after"])
|
|
1414
|
+
observed_base = str(obs["observed_base"])
|
|
1415
|
+
elif t.state is State.VERIFIED:
|
|
1416
|
+
postcondition_checked = str(obs["postcondition_checked"])
|
|
1417
|
+
postcondition_holds = True
|
|
1418
|
+
detail = str(obs.get("detail", ""))
|
|
1419
|
+
elif t.state is State.REFUSED:
|
|
1420
|
+
refusal_reason = str(obs["refusal_reason"])
|
|
1421
|
+
if obs.get("observed_base") is not None:
|
|
1422
|
+
observed_base = str(obs["observed_base"])
|
|
1423
|
+
elif t.state is State.UNVERIFIABLE:
|
|
1424
|
+
detail = str(obs["detail"])
|
|
1425
|
+
|
|
1426
|
+
if not transitions:
|
|
1427
|
+
raise LookupError(
|
|
1428
|
+
f"no transitions journaled for attempt {attempt} of {key} @ {source_rev}"
|
|
1429
|
+
)
|
|
1430
|
+
state = transitions[-1].state
|
|
1431
|
+
return Receipt(
|
|
1432
|
+
pending_id=pending_id_for(key, source_rev, attempt),
|
|
1433
|
+
attempt=attempt,
|
|
1434
|
+
operation_id=operation_id,
|
|
1435
|
+
coordinate=coordinate,
|
|
1436
|
+
source_rev=source_rev,
|
|
1437
|
+
expected_base=expected_base,
|
|
1438
|
+
observed_base=observed_base,
|
|
1439
|
+
after=after,
|
|
1440
|
+
digest=digest,
|
|
1441
|
+
plan_hash=plan_hash,
|
|
1442
|
+
policy_hash=self.policy.policy_hash,
|
|
1443
|
+
gate_results=gate_results,
|
|
1444
|
+
state=state,
|
|
1445
|
+
postcondition_checked=postcondition_checked,
|
|
1446
|
+
postcondition_holds=postcondition_holds,
|
|
1447
|
+
timestamp=transitions[-1].timestamp,
|
|
1448
|
+
refusal_reason=refusal_reason,
|
|
1449
|
+
detail=detail,
|
|
1450
|
+
transitions=tuple(transitions),
|
|
1451
|
+
replayed=replayed,
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
__all__ = [
|
|
1456
|
+
"KILL_AFTER_APPLY_VERB",
|
|
1457
|
+
"Coordinate",
|
|
1458
|
+
"Destination",
|
|
1459
|
+
"DestinationKind",
|
|
1460
|
+
"DestinationUnreadable",
|
|
1461
|
+
"JournalInconsistent",
|
|
1462
|
+
"MalformedLine",
|
|
1463
|
+
"Direction",
|
|
1464
|
+
"GateResult",
|
|
1465
|
+
"Journal",
|
|
1466
|
+
"Observation",
|
|
1467
|
+
"Operation",
|
|
1468
|
+
"PlanOutcome",
|
|
1469
|
+
"Policy",
|
|
1470
|
+
"Propagator",
|
|
1471
|
+
"Receipt",
|
|
1472
|
+
"SinkKilled",
|
|
1473
|
+
"State",
|
|
1474
|
+
"Transition",
|
|
1475
|
+
"operation_id_for",
|
|
1476
|
+
"pending_id_for",
|
|
1477
|
+
"tree_digest",
|
|
1478
|
+
]
|