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
gr2/python_cli/events.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""gr2 event system runtime.
|
|
2
|
+
|
|
3
|
+
Implements the event contract from HOOK-EVENT-CONTRACT.md sections 3-8:
|
|
4
|
+
- EventType enum (section 7.2)
|
|
5
|
+
- emit() function (sections 4.2, 7.1)
|
|
6
|
+
- Outbox management with rotation (sections 4.1-4.4)
|
|
7
|
+
- Cursor-based consumer model (section 5.1)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_RESERVED_NAMES = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"version",
|
|
25
|
+
"event_id",
|
|
26
|
+
"seq",
|
|
27
|
+
"timestamp",
|
|
28
|
+
"type",
|
|
29
|
+
"workspace",
|
|
30
|
+
"actor",
|
|
31
|
+
"agent_id",
|
|
32
|
+
"owner_unit",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_ROTATION_THRESHOLD = 10 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EventEmitError(RuntimeError):
|
|
40
|
+
"""The event could not be durably recorded."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EventType(str, Enum):
|
|
44
|
+
LANE_CREATED = "lane.created"
|
|
45
|
+
LANE_ENTERED = "lane.entered"
|
|
46
|
+
LANE_EXITED = "lane.exited"
|
|
47
|
+
LANE_SWITCHED = "lane.switched"
|
|
48
|
+
LANE_ARCHIVED = "lane.archived"
|
|
49
|
+
|
|
50
|
+
LEASE_ACQUIRED = "lease.acquired"
|
|
51
|
+
LEASE_RELEASED = "lease.released"
|
|
52
|
+
LEASE_EXPIRED = "lease.expired"
|
|
53
|
+
LEASE_FORCE_BROKEN = "lease.force_broken"
|
|
54
|
+
|
|
55
|
+
HOOK_STARTED = "hook.started"
|
|
56
|
+
HOOK_COMPLETED = "hook.completed"
|
|
57
|
+
HOOK_FAILED = "hook.failed"
|
|
58
|
+
HOOK_SKIPPED = "hook.skipped"
|
|
59
|
+
|
|
60
|
+
PR_CREATED = "pr.created"
|
|
61
|
+
PR_STATUS_CHANGED = "pr.status_changed"
|
|
62
|
+
PR_CHECKS_PASSED = "pr.checks_passed"
|
|
63
|
+
PR_CHECKS_FAILED = "pr.checks_failed"
|
|
64
|
+
PR_REVIEW_SUBMITTED = "pr.review_submitted"
|
|
65
|
+
PR_MERGED = "pr.merged"
|
|
66
|
+
PR_MERGE_FAILED = "pr.merge_failed"
|
|
67
|
+
|
|
68
|
+
SYNC_STARTED = "sync.started"
|
|
69
|
+
SYNC_CACHE_SEEDED = "sync.cache_seeded"
|
|
70
|
+
SYNC_CACHE_REFRESHED = "sync.cache_refreshed"
|
|
71
|
+
SYNC_REPO_UPDATED = "sync.repo_updated"
|
|
72
|
+
SYNC_REPO_FETCHED = "sync.repo_fetched"
|
|
73
|
+
SYNC_REPO_SKIPPED = "sync.repo_skipped"
|
|
74
|
+
SYNC_CONFLICT = "sync.conflict"
|
|
75
|
+
SYNC_COMPLETED = "sync.completed"
|
|
76
|
+
|
|
77
|
+
# Execution
|
|
78
|
+
EXEC_STARTED = "exec.started"
|
|
79
|
+
EXEC_COMPLETED = "exec.completed"
|
|
80
|
+
EXEC_FAILED = "exec.failed"
|
|
81
|
+
|
|
82
|
+
FAILURE_RESOLVED = "failure.resolved"
|
|
83
|
+
LEASE_RECLAIMED = "lease.reclaimed"
|
|
84
|
+
|
|
85
|
+
WORKSPACE_MATERIALIZED = "workspace.materialized"
|
|
86
|
+
WORKSPACE_FILE_PROJECTED = "workspace.file_projected"
|
|
87
|
+
|
|
88
|
+
# one event per propagation receipt: the daemon's notification line, carried on the
|
|
89
|
+
# outbox so a consumer can relay it without the daemon knowing any channel
|
|
90
|
+
PROPAGATION_RECEIPT = "propagation.receipt"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _outbox_path(workspace_root: Path) -> Path:
|
|
94
|
+
return workspace_root / ".grip" / "events" / "outbox.jsonl"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cursors_dir(workspace_root: Path) -> Path:
|
|
98
|
+
return workspace_root / ".grip" / "events" / "cursors"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _lock_path(outbox: Path) -> Path:
|
|
102
|
+
return outbox.parent / "outbox.lock"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _event_locking_enabled() -> bool:
|
|
106
|
+
"""Allow the stress harness to reproduce the pre-lock behavior."""
|
|
107
|
+
return os.environ.get("GR2_DISABLE_EVENT_LOCKING") != "1"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def _event_write_lock(outbox: Path):
|
|
112
|
+
"""Serialize sequence allocation, rotation, and append across processes."""
|
|
113
|
+
with _lock_path(outbox).open("a+") as lock_file:
|
|
114
|
+
locking_enabled = _event_locking_enabled()
|
|
115
|
+
if locking_enabled:
|
|
116
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
if locking_enabled:
|
|
121
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# DELIBERATELY PLAIN CLASSES, NOT @dataclass, AND THE REASON IS LOAD-BEARING.
|
|
125
|
+
#
|
|
126
|
+
# This module is loaded out-of-tree by spawned workers via
|
|
127
|
+
# importlib.util.spec_from_file_location() + exec_module(), which does NOT
|
|
128
|
+
# register the module in sys.modules. @dataclass resolves field types through
|
|
129
|
+
# sys.modules[cls.__module__].__dict__, so under that loader it raises
|
|
130
|
+
# AttributeError: 'NoneType' object has no attribute '__dict__' AT IMPORT --
|
|
131
|
+
# every worker dies before running a line of its own.
|
|
132
|
+
#
|
|
133
|
+
# Measured: adding @dataclass here killed both writers in the concurrent-emit
|
|
134
|
+
# integrity test before either reached sequence allocation. The failure was
|
|
135
|
+
# visible an hour earlier in an ad-hoc probe and was dismissed as a loader
|
|
136
|
+
# artifact; it was a portability constraint on this file. Anything importable by
|
|
137
|
+
# a spawned worker must import without sys.modules registration.
|
|
138
|
+
# test_events_torn_line.py carries a witness for exactly this.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MalformedLine:
|
|
142
|
+
"""One line the reader could not turn into an event, and why."""
|
|
143
|
+
|
|
144
|
+
__slots__ = ("ordinal", "reason", "excerpt")
|
|
145
|
+
|
|
146
|
+
def __init__(self, ordinal: int, reason: str, excerpt: str) -> None:
|
|
147
|
+
self.ordinal = ordinal
|
|
148
|
+
self.reason = reason
|
|
149
|
+
self.excerpt = excerpt
|
|
150
|
+
|
|
151
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
152
|
+
return f"MalformedLine(ordinal={self.ordinal!r}, reason={self.reason!r})"
|
|
153
|
+
|
|
154
|
+
def __eq__(self, other: object) -> bool:
|
|
155
|
+
if not isinstance(other, MalformedLine):
|
|
156
|
+
return NotImplemented
|
|
157
|
+
return (self.ordinal, self.reason, self.excerpt) == (
|
|
158
|
+
other.ordinal,
|
|
159
|
+
other.reason,
|
|
160
|
+
other.excerpt,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class EventRead:
|
|
165
|
+
"""Events read, AND the lines that could not be read.
|
|
166
|
+
|
|
167
|
+
Both halves come from the SAME read. A second pass to "check health" would
|
|
168
|
+
describe a different moment, and the outbox is appended to concurrently.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
__slots__ = ("events", "malformed")
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
events: list[dict[str, object]],
|
|
176
|
+
malformed: tuple[MalformedLine, ...],
|
|
177
|
+
) -> None:
|
|
178
|
+
self.events = events
|
|
179
|
+
self.malformed = malformed
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
182
|
+
return f"EventRead(events={len(self.events)}, malformed={len(self.malformed)})"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_line(raw: bytes) -> tuple[str | None, str]:
|
|
186
|
+
"""Decode one line, or say why it could not be decoded.
|
|
187
|
+
|
|
188
|
+
The DECODE is the acquisition, not a transformation of an existing string:
|
|
189
|
+
reading the outbox in text mode manufactures the line as text outside every
|
|
190
|
+
guard, so a single invalid byte escapes as UnicodeDecodeError from a place
|
|
191
|
+
no parse guard can reach. Reading bytes and decoding per line puts the one
|
|
192
|
+
operation that can fail on file CONTENT inside the funnel.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
return raw.decode("utf-8"), ""
|
|
196
|
+
except UnicodeDecodeError as exc:
|
|
197
|
+
return None, str(exc)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_object(line: str) -> tuple[dict[str, object] | None, str]:
|
|
201
|
+
"""Parse one line into a JSON object, or say why it is not one.
|
|
202
|
+
|
|
203
|
+
This IS an enumerated tuple -- what changed is how the entries were chosen.
|
|
204
|
+
The previous guard, (JSONDecodeError, TypeError), was a list of what had been
|
|
205
|
+
SEEN: it caught syntax errors, and missed RecursionError, which json.loads
|
|
206
|
+
raises on deeply nested input and which is NOT a ValueError, so deep nesting
|
|
207
|
+
escaped a reader whose contract is to not raise on file content.
|
|
208
|
+
|
|
209
|
+
This tuple is derived from what the OPERATION can raise: ValueError as the
|
|
210
|
+
base class covering JSONDecodeError and any other value error, plus
|
|
211
|
+
RecursionError, the one thing json.loads raises that ValueError does not
|
|
212
|
+
cover. Structure -- is the result an object? -- is then checked separately
|
|
213
|
+
below, because a valid JSON array parses cleanly and is still not an event.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
obj = json.loads(line)
|
|
217
|
+
except (ValueError, RecursionError) as exc:
|
|
218
|
+
return None, str(exc)
|
|
219
|
+
if not isinstance(obj, dict):
|
|
220
|
+
return None, f"line is a {type(obj).__name__}, not an object"
|
|
221
|
+
return obj, ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_seq(obj: dict[str, object]) -> tuple[int | None, str]:
|
|
225
|
+
"""The event's sequence number, or why it cannot be used as one.
|
|
226
|
+
|
|
227
|
+
bool is an int subclass, so `isinstance(True, int)` is True and `max(0, True)`
|
|
228
|
+
quietly yields 1. A float slips through comparison and arithmetic and then
|
|
229
|
+
fails at serialization: 1e999 is valid JSON input, becomes inf, and
|
|
230
|
+
json.dumps writes `Infinity`, which is not valid JSON output. Validate the
|
|
231
|
+
type here rather than discovering it downstream.
|
|
232
|
+
"""
|
|
233
|
+
seq = obj.get("seq")
|
|
234
|
+
if isinstance(seq, bool) or not isinstance(seq, int):
|
|
235
|
+
return None, f"seq is {type(seq).__name__}, not an integer"
|
|
236
|
+
return seq, ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _iter_outbox(outbox: Path) -> Iterator[tuple[dict[str, object] | None, str, bytes]]:
|
|
240
|
+
"""Yield (object, reason, raw) per line, never raising on file CONTENT.
|
|
241
|
+
|
|
242
|
+
Splitting bytes on b"\n" rather than iterating text: see _decode_line.
|
|
243
|
+
"""
|
|
244
|
+
# DELIBERATELY CATCHES NOTHING. An I/O failure is not a malformed line: it
|
|
245
|
+
# is not knowing what the file contains, and the caller's correct response
|
|
246
|
+
# differs. _current_seq() is on the WRITE path, where swallowing this
|
|
247
|
+
# returns 0 and emit() then allocates a sequence number that duplicates
|
|
248
|
+
# existing ones -- silent event-log corruption, which is why an earlier fix
|
|
249
|
+
# removed exactly this OSError-to-zero fallback and left a test standing
|
|
250
|
+
# guard over it. Reintroducing it here was caught by that test and not by
|
|
251
|
+
# any witness of mine.
|
|
252
|
+
blob = outbox.read_bytes()
|
|
253
|
+
for raw in blob.split(b"\n"):
|
|
254
|
+
if not raw.strip():
|
|
255
|
+
continue
|
|
256
|
+
line, reason = _decode_line(raw)
|
|
257
|
+
if line is None:
|
|
258
|
+
yield None, reason, raw
|
|
259
|
+
continue
|
|
260
|
+
obj, reason = _read_object(line)
|
|
261
|
+
yield obj, reason, raw
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _excerpt(raw: bytes) -> str:
|
|
265
|
+
return raw[:120].decode("utf-8", "replace")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _current_seq(outbox: Path) -> int:
|
|
269
|
+
"""Highest sequence number in the outbox.
|
|
270
|
+
|
|
271
|
+
DELIBERATELY REPORTS NO COUNT, and that is a decision rather than an
|
|
272
|
+
omission. This runs once per emit, inside the write lock, so a count here
|
|
273
|
+
would be a per-APPEND number reported to whoever happened to be writing --
|
|
274
|
+
the wrong altitude and the wrong audience. Unreadable lines are surfaced
|
|
275
|
+
once, from read_events_detailed(), where the number is per-READ and reaches
|
|
276
|
+
a consumer that can act on it.
|
|
277
|
+
"""
|
|
278
|
+
if not outbox.exists():
|
|
279
|
+
return 0
|
|
280
|
+
last_seq = 0
|
|
281
|
+
for obj, _reason, _raw in _iter_outbox(outbox):
|
|
282
|
+
if obj is None:
|
|
283
|
+
continue
|
|
284
|
+
seq, _seq_reason = _read_seq(obj)
|
|
285
|
+
if seq is None:
|
|
286
|
+
continue
|
|
287
|
+
last_seq = max(last_seq, seq)
|
|
288
|
+
return last_seq
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _maybe_rotate(outbox: Path) -> None:
|
|
292
|
+
if not outbox.exists():
|
|
293
|
+
return
|
|
294
|
+
try:
|
|
295
|
+
size = outbox.stat().st_size
|
|
296
|
+
except OSError:
|
|
297
|
+
return
|
|
298
|
+
if size <= _ROTATION_THRESHOLD:
|
|
299
|
+
return
|
|
300
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
301
|
+
archive = outbox.parent / f"outbox.{ts}.jsonl"
|
|
302
|
+
outbox.rename(archive)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def emit(
|
|
306
|
+
event_type: EventType,
|
|
307
|
+
workspace_root: Path,
|
|
308
|
+
actor: str,
|
|
309
|
+
owner_unit: str,
|
|
310
|
+
payload: dict[str, object],
|
|
311
|
+
*,
|
|
312
|
+
agent_id: str | None = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
collisions = _RESERVED_NAMES & payload.keys()
|
|
315
|
+
if collisions:
|
|
316
|
+
raise ValueError(f"payload keys collide with reserved envelope/context names: {collisions}")
|
|
317
|
+
|
|
318
|
+
outbox = _outbox_path(workspace_root)
|
|
319
|
+
try:
|
|
320
|
+
outbox.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
with _event_write_lock(outbox):
|
|
322
|
+
seq = _current_seq(outbox) + 1
|
|
323
|
+
delay = float(os.environ.get("GR2_EVENT_TEST_DELAY", "0"))
|
|
324
|
+
if delay:
|
|
325
|
+
time.sleep(delay)
|
|
326
|
+
_maybe_rotate(outbox)
|
|
327
|
+
|
|
328
|
+
event: dict[str, object] = {
|
|
329
|
+
"version": 1,
|
|
330
|
+
"event_id": os.urandom(8).hex(),
|
|
331
|
+
"seq": seq,
|
|
332
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
333
|
+
"type": str(event_type.value),
|
|
334
|
+
"workspace": workspace_root.name,
|
|
335
|
+
"actor": actor,
|
|
336
|
+
"owner_unit": owner_unit,
|
|
337
|
+
}
|
|
338
|
+
if agent_id is not None:
|
|
339
|
+
event["agent_id"] = agent_id
|
|
340
|
+
event.update(payload)
|
|
341
|
+
|
|
342
|
+
# TERMINATOR REPAIR. A previous write that died between write() and
|
|
343
|
+
# fsync() leaves a last line with no "\n". Appending onto that GLUES
|
|
344
|
+
# two records into one line, and the damage runs FORWARD from the
|
|
345
|
+
# tear: the torn record and THE NEXT HEALTHY APPEND fuse into one
|
|
346
|
+
# unparseable line, while the record before the tear is untouched.
|
|
347
|
+
# So a torn write costs that record and the next one written after
|
|
348
|
+
# it, permanently, because every later append builds on the glued
|
|
349
|
+
# line. Probe the last byte and heal the seam before writing.
|
|
350
|
+
# (Direction measured, not reasoned: see the glue witness in
|
|
351
|
+
# test_events_torn_line.py. An earlier version of this comment
|
|
352
|
+
# stated it backwards.)
|
|
353
|
+
with outbox.open("a+b") as event_file:
|
|
354
|
+
event_file.seek(0, os.SEEK_END)
|
|
355
|
+
if event_file.tell() > 0:
|
|
356
|
+
event_file.seek(-1, os.SEEK_END)
|
|
357
|
+
if event_file.read(1) != b"\n":
|
|
358
|
+
event_file.write(b"\n")
|
|
359
|
+
payload_bytes = json.dumps(event, separators=(",", ":")).encode("utf-8")
|
|
360
|
+
event_file.write(payload_bytes + b"\n")
|
|
361
|
+
event_file.flush()
|
|
362
|
+
os.fsync(event_file.fileno())
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
raise EventEmitError(f"event emit failed for {outbox}") from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def emit_after_outcome(
|
|
368
|
+
event_type: EventType,
|
|
369
|
+
workspace_root: Path,
|
|
370
|
+
actor: str,
|
|
371
|
+
owner_unit: str,
|
|
372
|
+
payload: dict[str, object],
|
|
373
|
+
*,
|
|
374
|
+
agent_id: str | None = None,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Report a completed outcome without replacing it on sink failure.
|
|
377
|
+
|
|
378
|
+
Callers must use this only after work that cannot honestly be reported as
|
|
379
|
+
failed. Pre-work and no-work sites use strict ``emit`` directly.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
emit(
|
|
384
|
+
event_type=event_type,
|
|
385
|
+
workspace_root=workspace_root,
|
|
386
|
+
actor=actor,
|
|
387
|
+
owner_unit=owner_unit,
|
|
388
|
+
payload=payload,
|
|
389
|
+
agent_id=agent_id,
|
|
390
|
+
)
|
|
391
|
+
except EventEmitError as exc:
|
|
392
|
+
print(
|
|
393
|
+
f"gr2: could not record {event_type.value} after its outcome completed ({exc})",
|
|
394
|
+
file=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def read_events_detailed(workspace_root: Path, consumer: str) -> EventRead:
|
|
399
|
+
"""New events for `consumer`, AND the lines that could not be read.
|
|
400
|
+
|
|
401
|
+
This is the primitive; read_events() is the list-shaped wrapper kept for the
|
|
402
|
+
eleven existing call sites, all of them tests. The count is a RETURN VALUE rather than
|
|
403
|
+
hidden state: these are module-level functions with no instance to hang
|
|
404
|
+
health on, and a module-level accumulator would be wrong under concurrent
|
|
405
|
+
readers, which the outbox explicitly has.
|
|
406
|
+
|
|
407
|
+
An unreadable line here is a LOST EVENT -- for the channel bridge it is a
|
|
408
|
+
message that never reaches a channel -- so silence is the failure, not the
|
|
409
|
+
safe default.
|
|
410
|
+
"""
|
|
411
|
+
outbox = _outbox_path(workspace_root)
|
|
412
|
+
if not outbox.exists():
|
|
413
|
+
return EventRead([], ())
|
|
414
|
+
|
|
415
|
+
cursor = _load_cursor(workspace_root, consumer)
|
|
416
|
+
last_seq = cursor.get("last_seq", 0)
|
|
417
|
+
if isinstance(last_seq, bool) or not isinstance(last_seq, int):
|
|
418
|
+
# A hand-edited or truncated cursor must not brick every future read.
|
|
419
|
+
last_seq = 0
|
|
420
|
+
|
|
421
|
+
events: list[dict[str, object]] = []
|
|
422
|
+
malformed: list[MalformedLine] = []
|
|
423
|
+
try:
|
|
424
|
+
lines = list(_iter_outbox(outbox))
|
|
425
|
+
except FileNotFoundError:
|
|
426
|
+
# ONLY this one, and only on the read path: _maybe_rotate() renames the
|
|
427
|
+
# outbox, so a reader can legitimately lose the file between exists()
|
|
428
|
+
# and the read. The events are not gone, they are in an archive. Any
|
|
429
|
+
# OTHER OSError is a real failure and propagates -- a reader that
|
|
430
|
+
# swallows EIO reports "no new events" forever.
|
|
431
|
+
return EventRead([], ())
|
|
432
|
+
for ordinal, (obj, reason, raw) in enumerate(lines, start=1):
|
|
433
|
+
if obj is None:
|
|
434
|
+
malformed.append(MalformedLine(ordinal, reason, _excerpt(raw)))
|
|
435
|
+
continue
|
|
436
|
+
seq, seq_reason = _read_seq(obj)
|
|
437
|
+
if seq is None:
|
|
438
|
+
# An event whose seq is unusable cannot be ordered against the
|
|
439
|
+
# cursor. Comparing it anyway is how "x" <= 0 raises TypeError from
|
|
440
|
+
# inside a reader whose job is to not raise on file content.
|
|
441
|
+
malformed.append(MalformedLine(ordinal, seq_reason, _excerpt(raw)))
|
|
442
|
+
continue
|
|
443
|
+
if seq <= last_seq:
|
|
444
|
+
continue
|
|
445
|
+
events.append(obj)
|
|
446
|
+
|
|
447
|
+
if events:
|
|
448
|
+
last_event = events[-1]
|
|
449
|
+
_save_cursor(
|
|
450
|
+
workspace_root,
|
|
451
|
+
consumer,
|
|
452
|
+
{
|
|
453
|
+
"consumer": consumer,
|
|
454
|
+
"last_seq": last_event["seq"],
|
|
455
|
+
"last_event_id": last_event.get("event_id", ""),
|
|
456
|
+
"last_read": datetime.now(timezone.utc).isoformat(),
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return EventRead(events, tuple(malformed))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def read_events(workspace_root: Path, consumer: str) -> list[dict[str, object]]:
|
|
464
|
+
"""New events for `consumer`, DISCARDING the unreadable-line report.
|
|
465
|
+
|
|
466
|
+
Kept list-shaped because eleven call sites index and len() the result, all
|
|
467
|
+
of them tests -- no production caller remains once the bridge moves to
|
|
468
|
+
read_events_detailed(), so this is a test-compatibility surface.
|
|
469
|
+
It discards information, which is exactly the silence this sweep exists to
|
|
470
|
+
remove -- so if you are writing a NEW consumer, call read_events_detailed()
|
|
471
|
+
and say something about `malformed`. The one production consumer, the
|
|
472
|
+
channel bridge, does.
|
|
473
|
+
"""
|
|
474
|
+
return read_events_detailed(workspace_root, consumer).events
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def warn_unreadable(read: EventRead, stream=None, *, show_content: bool = False) -> bool:
|
|
478
|
+
"""Report unreadable outbox lines on stderr. True when any were reported.
|
|
479
|
+
|
|
480
|
+
stderr, never stdout: a --json consumer's stdout must stay parseable, and a
|
|
481
|
+
warning written there turns a health report into a parse failure.
|
|
482
|
+
|
|
483
|
+
THE EXCERPT IS NOT PRINTED BY DEFAULT, and that is a deliberate call rather
|
|
484
|
+
than lost fidelity. `ordinal` and `reason` are STRUCTURAL -- a line number
|
|
485
|
+
and a parser complaint -- and carry no payload. The excerpt is CONTENT, and
|
|
486
|
+
printing it moves bytes out of a file the operator already owns into places
|
|
487
|
+
that get copied: CI logs, terminal scrollback, transcripts pasted into a
|
|
488
|
+
chat. Measured: a malformed line containing an API-key-shaped string echoed
|
|
489
|
+
that string verbatim.
|
|
490
|
+
|
|
491
|
+
Redacting it instead was rejected. Matching "secret-looking" patterns in
|
|
492
|
+
arbitrary bytes is a denylist over untrusted input, which leaks by
|
|
493
|
+
construction -- the same defect shape this module's parse guard exists to
|
|
494
|
+
avoid. So the excerpt stays on MalformedLine, where a caller that needs it
|
|
495
|
+
can ask, and stays out of the default report. Pass show_content=True to
|
|
496
|
+
include it when you are debugging a specific file and know what is in it.
|
|
497
|
+
"""
|
|
498
|
+
if not read.malformed:
|
|
499
|
+
return False
|
|
500
|
+
out = stream if stream is not None else sys.stderr
|
|
501
|
+
count = len(read.malformed)
|
|
502
|
+
plural = "" if count == 1 else "s"
|
|
503
|
+
print(
|
|
504
|
+
f"warning: skipped {count} unreadable line{plural} in the event outbox",
|
|
505
|
+
file=out,
|
|
506
|
+
)
|
|
507
|
+
for bad in read.malformed:
|
|
508
|
+
detail = f": {bad.excerpt}" if show_content else ""
|
|
509
|
+
print(f" line {bad.ordinal}: {bad.reason}{detail}", file=out)
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _load_cursor(workspace_root: Path, consumer: str) -> dict[str, object]:
|
|
514
|
+
cursor_file = _cursors_dir(workspace_root) / f"{consumer}.json"
|
|
515
|
+
if not cursor_file.exists():
|
|
516
|
+
return {}
|
|
517
|
+
try:
|
|
518
|
+
return json.loads(cursor_file.read_text())
|
|
519
|
+
except (json.JSONDecodeError, OSError):
|
|
520
|
+
return {}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _save_cursor(workspace_root: Path, consumer: str, data: dict[str, object]) -> None:
|
|
524
|
+
cursors = _cursors_dir(workspace_root)
|
|
525
|
+
cursors.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
cursor_file = cursors / f"{consumer}.json"
|
|
527
|
+
tmp = cursor_file.with_suffix(".tmp")
|
|
528
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
529
|
+
tmp.rename(cursor_file)
|