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,665 @@
|
|
|
1
|
+
"""Prototype 2: the contribution protocol around the propagation state machine.
|
|
2
|
+
|
|
3
|
+
The machine (``propagation_state_machine``) lands ONE contribution on a canonical
|
|
4
|
+
remote by compare-and-swap. This module is the protocol that decides WHICH owner a
|
|
5
|
+
change is proposed to, lands SEVERAL contributions that must go together, refuses
|
|
6
|
+
to RETIRE a workspace that still holds open contributions, and gives declared
|
|
7
|
+
append-only surfaces the one behaviour they are allowed that everything else is
|
|
8
|
+
not: two writers, both land, neither replans.
|
|
9
|
+
|
|
10
|
+
Everything here is neutral: layer refs, destination ids, and surface names are
|
|
11
|
+
opaque strings the caller resolves. The module holds no notion of who an agent or
|
|
12
|
+
an org is, and who MAY contribute where is a policy it is handed, never derives.
|
|
13
|
+
|
|
14
|
+
Four pieces, each the smallest shape that its witness needs:
|
|
15
|
+
|
|
16
|
+
``ResolvedManifest`` / ``ResolvedEntry``
|
|
17
|
+
Ownership is a RECORDED fact, never a guess over path shape. Every resolved
|
|
18
|
+
entry carries ``declared_by`` (the layer whose declaration put it in the
|
|
19
|
+
workspace), ``overridden_by`` (the layer whose override currently governs it,
|
|
20
|
+
if any) and ``write_mode`` (``own`` / ``contribute`` / ``append`` / ``read``).
|
|
21
|
+
``owner()`` is ``overridden_by or declared_by``; ``classify()`` answers, for a
|
|
22
|
+
path, whether a change is local authoring, a contribution (and to whom), an
|
|
23
|
+
append, or refused. Two layers declaring the same entry without an override is
|
|
24
|
+
a NAMED collision at resolution, not a silent precedence.
|
|
25
|
+
|
|
26
|
+
Today's resolver FLATTENS this information away (it merges repos by name and
|
|
27
|
+
records no provenance), so this dataclass is the specification of the field
|
|
28
|
+
the resolver must grow, and the witnesses run against the stub.
|
|
29
|
+
|
|
30
|
+
``ContributionSet``
|
|
31
|
+
Several contributions that must land together land in DECLARED ORDER with one
|
|
32
|
+
receipt per member and one set receipt naming what landed — all-or-REPORT,
|
|
33
|
+
not all-or-nothing. ``prepare()`` drives every member through its gates and
|
|
34
|
+
stops before any verb (``stop_after=PLANNED``); if any member refuses there,
|
|
35
|
+
nothing has landed and the set reports it. ``land()`` then resumes each member
|
|
36
|
+
in order and STOPS at the first member that does not reach acknowledged,
|
|
37
|
+
leaving earlier members landed (git history is forward-only; a rollback would
|
|
38
|
+
be a new forward operation, never an un-push) and naming the landed, the
|
|
39
|
+
refusing, and the not-attempted members in the set receipt.
|
|
40
|
+
|
|
41
|
+
``Subspace``
|
|
42
|
+
A workspace that holds contributions. ``retire()`` REFUSES while any of its
|
|
43
|
+
contributions is not terminal — not acknowledged and not explicitly
|
|
44
|
+
abandoned — and lists their operation ids. ``abandon()`` writes an
|
|
45
|
+
``abandoned`` note to the contribution's own journal, so a workspace can never
|
|
46
|
+
be retired with a change that simply evaporates.
|
|
47
|
+
|
|
48
|
+
``AppendSurface``
|
|
49
|
+
A declared append-only file: one guarded append point (an exclusive lock held
|
|
50
|
+
across write + flush + fsync), each record numbered in arrival order. Two
|
|
51
|
+
writers both land; no expected base exists because the operation commutes.
|
|
52
|
+
This is commutative BY DECLARATION, never inferred from the shape of a change,
|
|
53
|
+
and it is a file-level surface: a git-tracked path is never an append surface
|
|
54
|
+
to this protocol, because landing two appends there would require merging on
|
|
55
|
+
an author's behalf.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import fcntl
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
64
|
+
from dataclasses import asdict, dataclass, field
|
|
65
|
+
from datetime import UTC, datetime
|
|
66
|
+
from enum import StrEnum
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
from gr2.prototypes.propagation_state_machine import (
|
|
70
|
+
Coordinate,
|
|
71
|
+
Destination,
|
|
72
|
+
MalformedLine,
|
|
73
|
+
Propagator,
|
|
74
|
+
Receipt,
|
|
75
|
+
State,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
ABANDONED_NOTE = "abandoned"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WriteMode(StrEnum):
|
|
82
|
+
OWN = "own"
|
|
83
|
+
CONTRIBUTE = "contribute"
|
|
84
|
+
APPEND = "append"
|
|
85
|
+
READ = "read"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ResolutionCollision(ValueError):
|
|
89
|
+
"""Two layers declared the same entry and neither said ``override``.
|
|
90
|
+
|
|
91
|
+
Named and refused at resolution; never resolved by precedence silently.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ResolvedEntry:
|
|
97
|
+
"""One surface of a materialized workspace, with its provenance recorded."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
path: str
|
|
101
|
+
declared_by: str
|
|
102
|
+
overridden_by: str | None
|
|
103
|
+
write_mode: WriteMode
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def owner(self) -> str:
|
|
107
|
+
return self.overridden_by or self.declared_by
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Classification:
|
|
112
|
+
"""What a change to ``path`` is, under this workspace's recorded ownership."""
|
|
113
|
+
|
|
114
|
+
entry: ResolvedEntry | None
|
|
115
|
+
mode: WriteMode
|
|
116
|
+
owner: str | None
|
|
117
|
+
reason: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Declaration:
|
|
122
|
+
"""One layer's declaration of one entry, as the resolver sees it before merging."""
|
|
123
|
+
|
|
124
|
+
layer: str
|
|
125
|
+
name: str
|
|
126
|
+
path: str
|
|
127
|
+
overrides: bool = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ResolvedManifest:
|
|
131
|
+
"""Ownership as a lookup. Built by ``resolve``; read by ``classify``."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, entries: Iterable[ResolvedEntry]) -> None:
|
|
134
|
+
self._entries = {e.name: e for e in entries}
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def resolve(
|
|
138
|
+
cls, this_layer: str, declarations: Sequence[Declaration], *, appends: Iterable[str] = ()
|
|
139
|
+
) -> ResolvedManifest:
|
|
140
|
+
"""Merge layer declarations (ancestors first) with provenance kept.
|
|
141
|
+
|
|
142
|
+
A later declaration of a name already declared is a ``ResolutionCollision``
|
|
143
|
+
unless it says ``overrides``; an override records ``overridden_by`` and keeps
|
|
144
|
+
``declared_by``. Names in ``appends`` are declared append-only by their owner.
|
|
145
|
+
"""
|
|
146
|
+
merged: dict[str, ResolvedEntry] = {}
|
|
147
|
+
append_names = set(appends)
|
|
148
|
+
for d in declarations:
|
|
149
|
+
prior = merged.get(d.name)
|
|
150
|
+
if prior is None:
|
|
151
|
+
merged[d.name] = ResolvedEntry(
|
|
152
|
+
name=d.name,
|
|
153
|
+
path=d.path,
|
|
154
|
+
declared_by=d.layer,
|
|
155
|
+
overridden_by=None,
|
|
156
|
+
write_mode=WriteMode.READ,
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
if not d.overrides:
|
|
160
|
+
raise ResolutionCollision(
|
|
161
|
+
f"{d.name!r} declared by {prior.declared_by!r} at {prior.path!r} and again by "
|
|
162
|
+
f"{d.layer!r} at {d.path!r} without override"
|
|
163
|
+
)
|
|
164
|
+
merged[d.name] = ResolvedEntry(
|
|
165
|
+
name=d.name,
|
|
166
|
+
path=d.path,
|
|
167
|
+
declared_by=prior.declared_by,
|
|
168
|
+
overridden_by=d.layer,
|
|
169
|
+
write_mode=WriteMode.READ,
|
|
170
|
+
)
|
|
171
|
+
entries = []
|
|
172
|
+
for e in merged.values():
|
|
173
|
+
if e.name in append_names:
|
|
174
|
+
mode = WriteMode.APPEND
|
|
175
|
+
elif e.owner == this_layer:
|
|
176
|
+
mode = WriteMode.OWN
|
|
177
|
+
else:
|
|
178
|
+
mode = WriteMode.CONTRIBUTE
|
|
179
|
+
entries.append(
|
|
180
|
+
ResolvedEntry(
|
|
181
|
+
name=e.name,
|
|
182
|
+
path=e.path,
|
|
183
|
+
declared_by=e.declared_by,
|
|
184
|
+
overridden_by=e.overridden_by,
|
|
185
|
+
write_mode=mode,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return cls(entries)
|
|
189
|
+
|
|
190
|
+
def entry(self, name: str) -> ResolvedEntry:
|
|
191
|
+
return self._entries[name]
|
|
192
|
+
|
|
193
|
+
def owner(self, name: str) -> str:
|
|
194
|
+
return self._entries[name].owner
|
|
195
|
+
|
|
196
|
+
def classify(self, path: str) -> Classification:
|
|
197
|
+
"""The longest declared path prefix wins; a path under no entry is refused (READ)."""
|
|
198
|
+
best: ResolvedEntry | None = None
|
|
199
|
+
for e in self._entries.values():
|
|
200
|
+
root = e.path.rstrip("/") + "/"
|
|
201
|
+
if (path == e.path or path.startswith(root)) and (
|
|
202
|
+
best is None or len(e.path) > len(best.path)
|
|
203
|
+
):
|
|
204
|
+
best = e
|
|
205
|
+
if best is None:
|
|
206
|
+
return Classification(
|
|
207
|
+
entry=None,
|
|
208
|
+
mode=WriteMode.READ,
|
|
209
|
+
owner=None,
|
|
210
|
+
reason=f"{path!r} is under no declared entry",
|
|
211
|
+
)
|
|
212
|
+
return Classification(
|
|
213
|
+
entry=best,
|
|
214
|
+
mode=best.write_mode,
|
|
215
|
+
owner=best.owner,
|
|
216
|
+
reason=f"{path!r} is under {best.name!r} declared_by={best.declared_by!r} "
|
|
217
|
+
f"overridden_by={best.overridden_by!r}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --------------------------------------------------------------------------- sets
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class SetMember:
|
|
226
|
+
member_id: str
|
|
227
|
+
coordinate: Coordinate
|
|
228
|
+
destination: Destination
|
|
229
|
+
propagator: Propagator
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class MemberOutcome:
|
|
234
|
+
member_id: str
|
|
235
|
+
state: str # a State value, or "not-attempted"
|
|
236
|
+
receipt: Receipt | None
|
|
237
|
+
|
|
238
|
+
def as_dict(self) -> dict[str, object]:
|
|
239
|
+
return {
|
|
240
|
+
"member_id": self.member_id,
|
|
241
|
+
"state": self.state,
|
|
242
|
+
"receipt": self.receipt.as_dict() if self.receipt is not None else None,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class SetReceipt:
|
|
248
|
+
set_id: str
|
|
249
|
+
phase: str # "prepared" | "refused-at-prepare" | "landed" | "stopped"
|
|
250
|
+
order: tuple[str, ...]
|
|
251
|
+
outcomes: tuple[MemberOutcome, ...]
|
|
252
|
+
timestamp: str
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def landed(self) -> tuple[str, ...]:
|
|
256
|
+
return tuple(o.member_id for o in self.outcomes if o.state == str(State.ACKNOWLEDGED))
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def refused(self) -> tuple[str, ...]:
|
|
260
|
+
return tuple(
|
|
261
|
+
o.member_id
|
|
262
|
+
for o in self.outcomes
|
|
263
|
+
if o.state in (str(State.REFUSED), str(State.UNVERIFIABLE))
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def not_attempted(self) -> tuple[str, ...]:
|
|
268
|
+
return tuple(o.member_id for o in self.outcomes if o.state == "not-attempted")
|
|
269
|
+
|
|
270
|
+
def as_dict(self) -> dict[str, object]:
|
|
271
|
+
return {
|
|
272
|
+
"set_id": self.set_id,
|
|
273
|
+
"phase": self.phase,
|
|
274
|
+
"order": list(self.order),
|
|
275
|
+
"landed": list(self.landed),
|
|
276
|
+
"refused": list(self.refused),
|
|
277
|
+
"not_attempted": list(self.not_attempted),
|
|
278
|
+
"outcomes": [o.as_dict() for o in self.outcomes],
|
|
279
|
+
"timestamp": self.timestamp,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _now() -> str:
|
|
284
|
+
return datetime.now(UTC).isoformat(timespec="microseconds")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def state_latencies(receipt: Receipt) -> list[tuple[str, float]]:
|
|
288
|
+
"""Per-state latency from the receipt's OWN timestamps, in seconds.
|
|
289
|
+
|
|
290
|
+
Each row is ``(state, seconds since the previous transition)``; the first row is
|
|
291
|
+
``(state, 0.0)``. A measurement, not a witness: the receipts already carry the
|
|
292
|
+
timestamps, so the number comes from the artifact and not from a stopwatch around
|
|
293
|
+
it. Printed so a daemon's ``up`` budget can start from data.
|
|
294
|
+
"""
|
|
295
|
+
rows: list[tuple[str, float]] = []
|
|
296
|
+
previous: datetime | None = None
|
|
297
|
+
for t in receipt.transitions:
|
|
298
|
+
at = datetime.fromisoformat(t.timestamp)
|
|
299
|
+
rows.append((str(t.state), 0.0 if previous is None else (at - previous).total_seconds()))
|
|
300
|
+
previous = at
|
|
301
|
+
return rows
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ContributionSet:
|
|
305
|
+
"""Several contributions that must land together: prepare all, land in order, report."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, set_id: str, members: Sequence[SetMember]) -> None:
|
|
308
|
+
if not members:
|
|
309
|
+
raise ValueError("a contribution set needs at least one member")
|
|
310
|
+
ids = [m.member_id for m in members]
|
|
311
|
+
if len(set(ids)) != len(ids):
|
|
312
|
+
raise ValueError(f"duplicate member ids in set {set_id!r}: {ids}")
|
|
313
|
+
self.set_id = set_id
|
|
314
|
+
self.members = tuple(members)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def order(self) -> tuple[str, ...]:
|
|
318
|
+
return tuple(m.member_id for m in self.members)
|
|
319
|
+
|
|
320
|
+
def prepare(self) -> SetReceipt:
|
|
321
|
+
"""Drive every member to ``planned`` without running a verb.
|
|
322
|
+
|
|
323
|
+
If any member refuses at plan, the set is refused BEFORE anything lands and the
|
|
324
|
+
receipt carries every member's evidence. A member whose source has nothing new
|
|
325
|
+
(``run`` returned ``None``) is reported as not-attempted: it is not part of this
|
|
326
|
+
set's landing.
|
|
327
|
+
"""
|
|
328
|
+
outcomes: list[MemberOutcome] = []
|
|
329
|
+
for m in self.members:
|
|
330
|
+
receipt = m.propagator.run(m.coordinate, m.destination, stop_after=State.PLANNED)
|
|
331
|
+
if receipt is None:
|
|
332
|
+
outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
|
|
333
|
+
else:
|
|
334
|
+
outcomes.append(MemberOutcome(m.member_id, str(receipt.state), receipt))
|
|
335
|
+
any_refused = any(o.state == str(State.REFUSED) for o in outcomes)
|
|
336
|
+
return SetReceipt(
|
|
337
|
+
set_id=self.set_id,
|
|
338
|
+
phase="refused-at-prepare" if any_refused else "prepared",
|
|
339
|
+
order=self.order,
|
|
340
|
+
outcomes=tuple(outcomes),
|
|
341
|
+
timestamp=_now(),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def land(self, *, on_member_landed: Callable[[str], None] | None = None) -> SetReceipt:
|
|
345
|
+
"""Resume each prepared member in order; STOP at the first that does not acknowledge.
|
|
346
|
+
|
|
347
|
+
Earlier members stay landed. The receipt names landed / refused / not-attempted
|
|
348
|
+
members so the author can resolve forward. Nothing is rolled back.
|
|
349
|
+
"""
|
|
350
|
+
outcomes: list[MemberOutcome] = []
|
|
351
|
+
stopped = False
|
|
352
|
+
for m in self.members:
|
|
353
|
+
if stopped:
|
|
354
|
+
outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
|
|
355
|
+
continue
|
|
356
|
+
receipt = m.propagator.run(m.coordinate, m.destination)
|
|
357
|
+
if receipt is None:
|
|
358
|
+
outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
|
|
359
|
+
continue
|
|
360
|
+
outcomes.append(MemberOutcome(m.member_id, str(receipt.state), receipt))
|
|
361
|
+
if receipt.state is State.ACKNOWLEDGED:
|
|
362
|
+
if on_member_landed is not None:
|
|
363
|
+
on_member_landed(m.member_id)
|
|
364
|
+
else:
|
|
365
|
+
stopped = True
|
|
366
|
+
return SetReceipt(
|
|
367
|
+
set_id=self.set_id,
|
|
368
|
+
phase="stopped" if stopped else "landed",
|
|
369
|
+
order=self.order,
|
|
370
|
+
outcomes=tuple(outcomes),
|
|
371
|
+
timestamp=_now(),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# --------------------------------------------------------------------------- subspaces
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True)
|
|
379
|
+
class OpenContribution:
|
|
380
|
+
coordinate_key: str
|
|
381
|
+
source_rev: str
|
|
382
|
+
last_state: str
|
|
383
|
+
operation_id: str | None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class RetireRefused(RuntimeError):
|
|
387
|
+
"""The subspace holds contributions that are neither acknowledged nor abandoned."""
|
|
388
|
+
|
|
389
|
+
def __init__(self, subspace: str, open_contributions: Sequence[OpenContribution]) -> None:
|
|
390
|
+
self.subspace = subspace
|
|
391
|
+
self.open_contributions = tuple(open_contributions)
|
|
392
|
+
listed = ", ".join(
|
|
393
|
+
f"{o.coordinate_key} @ {o.source_rev[:12]} ({o.last_state})" for o in open_contributions
|
|
394
|
+
)
|
|
395
|
+
super().__init__(f"{subspace}: {len(open_contributions)} open contribution(s): {listed}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass
|
|
399
|
+
class Subspace:
|
|
400
|
+
"""A workspace and the contributions authored in it, each carried by its own sink."""
|
|
401
|
+
|
|
402
|
+
name: str
|
|
403
|
+
contributions: list[tuple[Coordinate, Propagator]] = field(default_factory=list)
|
|
404
|
+
retired: bool = False
|
|
405
|
+
|
|
406
|
+
def open_contributions(self) -> list[OpenContribution]:
|
|
407
|
+
"""Every (coordinate, source revision) attempt whose last state is not terminal.
|
|
408
|
+
|
|
409
|
+
Terminal means ACKNOWLEDGED, or REFUSED-and-then-ABANDONED by an explicit note.
|
|
410
|
+
A refused attempt with no abandon note is OPEN: the refusal described a moment and
|
|
411
|
+
the author has not said what becomes of the change.
|
|
412
|
+
"""
|
|
413
|
+
found: list[OpenContribution] = []
|
|
414
|
+
for coordinate, propagator in self.contributions:
|
|
415
|
+
key = coordinate.key()
|
|
416
|
+
revs: dict[str, list[dict[str, object]]] = {}
|
|
417
|
+
for row in propagator.journal._rows():
|
|
418
|
+
if row.get("coordinate_key") != key:
|
|
419
|
+
continue
|
|
420
|
+
revs.setdefault(str(row["source_rev"]), []).append(row)
|
|
421
|
+
for source_rev, rows in revs.items():
|
|
422
|
+
states = [str(r["state"]) for r in rows if "state" in r]
|
|
423
|
+
last_state = states[-1] if states else "(no transition)"
|
|
424
|
+
if last_state == str(State.ACKNOWLEDGED):
|
|
425
|
+
continue
|
|
426
|
+
if any(r.get("note") == ABANDONED_NOTE for r in rows):
|
|
427
|
+
continue
|
|
428
|
+
op_ids = [str(r["operation_id"]) for r in rows if r.get("operation_id")]
|
|
429
|
+
found.append(
|
|
430
|
+
OpenContribution(
|
|
431
|
+
coordinate_key=key,
|
|
432
|
+
source_rev=source_rev,
|
|
433
|
+
last_state=last_state,
|
|
434
|
+
operation_id=op_ids[-1] if op_ids else None,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
return found
|
|
438
|
+
|
|
439
|
+
def abandon(self, coordinate: Coordinate, source_rev: str, *, reason: str) -> None:
|
|
440
|
+
"""Explicitly give up a contribution: an ``abandoned`` note in its own journal."""
|
|
441
|
+
for c, propagator in self.contributions:
|
|
442
|
+
if c.key() != coordinate.key():
|
|
443
|
+
continue
|
|
444
|
+
attempts = propagator.journal.find(coordinate.key(), source_rev)
|
|
445
|
+
attempt = len(attempts) if attempts else 1
|
|
446
|
+
pending_id = f"{coordinate.key()}@{source_rev}"
|
|
447
|
+
if attempts and attempts[-1][-1].operation_id:
|
|
448
|
+
pending_id = str(attempts[-1][-1].operation_id)
|
|
449
|
+
propagator.journal.note(
|
|
450
|
+
pending_id=pending_id,
|
|
451
|
+
attempt=attempt,
|
|
452
|
+
coordinate_key=coordinate.key(),
|
|
453
|
+
source_rev=source_rev,
|
|
454
|
+
note=ABANDONED_NOTE,
|
|
455
|
+
data={"reason": reason},
|
|
456
|
+
)
|
|
457
|
+
return
|
|
458
|
+
raise KeyError(f"{self.name}: no contribution at {coordinate.key()}")
|
|
459
|
+
|
|
460
|
+
def retire(self) -> None:
|
|
461
|
+
"""Refuse while any contribution is open; otherwise mark retired."""
|
|
462
|
+
open_ = self.open_contributions()
|
|
463
|
+
if open_:
|
|
464
|
+
raise RetireRefused(self.name, open_)
|
|
465
|
+
self.retired = True
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# --------------------------------------------------------------------------- append surfaces
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass(frozen=True)
|
|
472
|
+
class AppendRecord:
|
|
473
|
+
seq: int
|
|
474
|
+
writer: str
|
|
475
|
+
payload: dict[str, object]
|
|
476
|
+
timestamp: str
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _decode_line(raw: bytes) -> tuple[str | None, str]:
|
|
480
|
+
"""Bytes become text HERE, so the failure that can happen HERE is guarded here.
|
|
481
|
+
|
|
482
|
+
A JSONL file is a BYTE format. Read in text mode, the decode happens while the
|
|
483
|
+
iterator produces the line — outside every ``try`` in this module — so one
|
|
484
|
+
invalid byte anywhere raises before a guard can see it and the whole scan dies.
|
|
485
|
+
The exception type was never the problem: ``UnicodeDecodeError`` subclasses
|
|
486
|
+
``ValueError``, which was already caught. The OPERATION that raises it simply sat
|
|
487
|
+
outside the guarded region. A line does not ARRIVE as text, it is MANUFACTURED as
|
|
488
|
+
text, and manufacturing it can fail on content; decoding per line confines a bad
|
|
489
|
+
byte to that line.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
return raw.decode("utf-8"), ""
|
|
494
|
+
except UnicodeDecodeError as exc:
|
|
495
|
+
return None, str(exc)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _excerpt(raw: bytes) -> str:
|
|
499
|
+
"""A malformed line must be reportable even when it is not valid text."""
|
|
500
|
+
|
|
501
|
+
return raw[:120].decode("utf-8", "replace")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _read_json_object(line: str) -> tuple[dict[str, object] | None, str]:
|
|
505
|
+
"""Parse ONE line into a JSON object, or say why it cannot be read.
|
|
506
|
+
|
|
507
|
+
``json.loads`` is the ONLY operation in this file that can raise on file
|
|
508
|
+
content. It raises ``ValueError`` (``JSONDecodeError`` subclasses it, as does
|
|
509
|
+
the integer-literal length limit) or ``RecursionError`` (deeply nested input).
|
|
510
|
+
Everything after it VALIDATES rather than coerces, and that is the whole point:
|
|
511
|
+
a coercion over untrusted bytes forces the caller to enumerate the exceptions it
|
|
512
|
+
might raise, which is a denylist, and a denylist leaks. ``int()`` on a value
|
|
513
|
+
``json`` can legitimately produce (``1e999`` parses to ``inf``) raises
|
|
514
|
+
``OverflowError`` — a type no list written from the parse side would predict.
|
|
515
|
+
Validating instead removes the raise rather than cataloguing it.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
obj = json.loads(line)
|
|
520
|
+
except (ValueError, RecursionError) as exc:
|
|
521
|
+
return None, str(exc)
|
|
522
|
+
if not isinstance(obj, dict):
|
|
523
|
+
return None, f"line is a {type(obj).__name__}, not an object"
|
|
524
|
+
return obj, ""
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _read_seq(obj: dict[str, object]) -> tuple[int | None, str]:
|
|
528
|
+
"""A sequence number must be a real integer. ``bool`` is an ``int`` and is not one."""
|
|
529
|
+
|
|
530
|
+
seq = obj.get("seq")
|
|
531
|
+
if isinstance(seq, bool) or not isinstance(seq, int):
|
|
532
|
+
return None, f"seq is {type(seq).__name__}, not an integer"
|
|
533
|
+
return seq, ""
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _read_record(obj: dict[str, object]) -> tuple[AppendRecord | None, str]:
|
|
537
|
+
"""Every field checked by TYPE, so nothing here can raise on hostile content."""
|
|
538
|
+
|
|
539
|
+
seq, reason = _read_seq(obj)
|
|
540
|
+
if seq is None:
|
|
541
|
+
return None, reason
|
|
542
|
+
writer = obj.get("writer")
|
|
543
|
+
payload = obj.get("payload")
|
|
544
|
+
timestamp = obj.get("timestamp")
|
|
545
|
+
if not isinstance(writer, str):
|
|
546
|
+
return None, f"writer is {type(writer).__name__}, not a string"
|
|
547
|
+
if not isinstance(payload, dict):
|
|
548
|
+
return None, f"payload is {type(payload).__name__}, not an object"
|
|
549
|
+
if not isinstance(timestamp, str):
|
|
550
|
+
return None, f"timestamp is {type(timestamp).__name__}, not a string"
|
|
551
|
+
return AppendRecord(seq=seq, writer=writer, payload=payload, timestamp=timestamp), ""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class AppendSurface:
|
|
555
|
+
"""A declared append-only file with one guarded append point.
|
|
556
|
+
|
|
557
|
+
``append`` takes an exclusive lock on the file, reads the last sequence number,
|
|
558
|
+
writes ``seq + 1`` with the record, flushes, fsyncs, and releases. Two writers
|
|
559
|
+
interleave in ARRIVAL order and both land; there is no expected base to refuse on
|
|
560
|
+
because appends commute. Nothing here ever rewrites an earlier record.
|
|
561
|
+
|
|
562
|
+
A writer killed between ``write`` and its terminator leaves a remnant line. Both
|
|
563
|
+
scans SKIP such a line and COUNT it (``malformed_lines``, reflecting the most
|
|
564
|
+
recent full scan): an uncounted drop is silent, and this surface is the one place
|
|
565
|
+
a caller looks. ``append`` also repairs a missing terminator before writing, so a
|
|
566
|
+
remnant never GLUES the next record onto itself — without that repair the record
|
|
567
|
+
that follows a torn write is swallowed into an unparseable line and lost.
|
|
568
|
+
|
|
569
|
+
The file is read as BYTES and decoded one line at a time. A JSONL file is a byte
|
|
570
|
+
format, and in text mode the decode happens while the iterator produces the line,
|
|
571
|
+
outside every guard here — so a single invalid byte anywhere destroyed the whole
|
|
572
|
+
scan, including every record written correctly around it. A line passes through
|
|
573
|
+
four layers: read, decode, parse, and shape-check. The last three are each guarded
|
|
574
|
+
where they happen. Unbounded line length (a file with no terminator for a very
|
|
575
|
+
long span) is a resource limit on the first layer and is NOT defended here.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
def __init__(self, path: Path) -> None:
|
|
579
|
+
self.path = path
|
|
580
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
581
|
+
self.path.touch(exist_ok=True)
|
|
582
|
+
self._malformed: tuple[MalformedLine, ...] = ()
|
|
583
|
+
|
|
584
|
+
@property
|
|
585
|
+
def malformed_lines(self) -> tuple[MalformedLine, ...]:
|
|
586
|
+
"""Lines the most recent full scan could not read. Empty is the healthy case."""
|
|
587
|
+
return self._malformed
|
|
588
|
+
|
|
589
|
+
def append(self, writer: str, payload: dict[str, object]) -> AppendRecord:
|
|
590
|
+
with open(self.path, "a+b") as handle:
|
|
591
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
592
|
+
try:
|
|
593
|
+
handle.seek(0)
|
|
594
|
+
last = 0
|
|
595
|
+
malformed: list[MalformedLine] = []
|
|
596
|
+
raw_last = b""
|
|
597
|
+
for index, raw in enumerate(handle):
|
|
598
|
+
raw_last = raw
|
|
599
|
+
stripped = raw.strip()
|
|
600
|
+
if not stripped:
|
|
601
|
+
continue
|
|
602
|
+
line, reason = _decode_line(stripped)
|
|
603
|
+
if line is not None:
|
|
604
|
+
obj, reason = _read_json_object(line)
|
|
605
|
+
if obj is not None:
|
|
606
|
+
seq, reason = _read_seq(obj)
|
|
607
|
+
if seq is not None:
|
|
608
|
+
last = max(last, seq)
|
|
609
|
+
continue
|
|
610
|
+
malformed.append(
|
|
611
|
+
MalformedLine(index=index, excerpt=_excerpt(stripped), reason=reason)
|
|
612
|
+
)
|
|
613
|
+
self._malformed = tuple(malformed)
|
|
614
|
+
record = AppendRecord(
|
|
615
|
+
seq=last + 1, writer=writer, payload=payload, timestamp=_now()
|
|
616
|
+
)
|
|
617
|
+
handle.seek(0, os.SEEK_END)
|
|
618
|
+
if raw_last and not raw_last.endswith(b"\n"):
|
|
619
|
+
handle.write(b"\n")
|
|
620
|
+
handle.write((json.dumps(asdict(record), sort_keys=True) + "\n").encode("utf-8"))
|
|
621
|
+
handle.flush()
|
|
622
|
+
os.fsync(handle.fileno())
|
|
623
|
+
return record
|
|
624
|
+
finally:
|
|
625
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
626
|
+
|
|
627
|
+
def records(self) -> list[AppendRecord]:
|
|
628
|
+
out: list[AppendRecord] = []
|
|
629
|
+
malformed: list[MalformedLine] = []
|
|
630
|
+
for index, raw in enumerate(self.path.read_bytes().split(b"\n")):
|
|
631
|
+
stripped = raw.strip()
|
|
632
|
+
if not stripped:
|
|
633
|
+
continue
|
|
634
|
+
line, reason = _decode_line(stripped)
|
|
635
|
+
if line is not None:
|
|
636
|
+
obj, reason = _read_json_object(line)
|
|
637
|
+
if obj is not None:
|
|
638
|
+
record, reason = _read_record(obj)
|
|
639
|
+
if record is not None:
|
|
640
|
+
out.append(record)
|
|
641
|
+
continue
|
|
642
|
+
malformed.append(MalformedLine(index=index, excerpt=_excerpt(stripped), reason=reason))
|
|
643
|
+
self._malformed = tuple(malformed)
|
|
644
|
+
return out
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
__all__ = [
|
|
648
|
+
"ABANDONED_NOTE",
|
|
649
|
+
"AppendRecord",
|
|
650
|
+
"AppendSurface",
|
|
651
|
+
"Classification",
|
|
652
|
+
"ContributionSet",
|
|
653
|
+
"Declaration",
|
|
654
|
+
"MemberOutcome",
|
|
655
|
+
"OpenContribution",
|
|
656
|
+
"ResolutionCollision",
|
|
657
|
+
"ResolvedEntry",
|
|
658
|
+
"ResolvedManifest",
|
|
659
|
+
"RetireRefused",
|
|
660
|
+
"SetMember",
|
|
661
|
+
"SetReceipt",
|
|
662
|
+
"Subspace",
|
|
663
|
+
"WriteMode",
|
|
664
|
+
"state_latencies",
|
|
665
|
+
]
|