gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,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)