cctally 1.114.0 → 1.115.0

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 (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -1
@@ -0,0 +1,386 @@
1
+ """Pure account-bound protocol kernel (#825/#832, spec sections 5.1-5.4).
2
+
3
+ One durable journal and one stats index are fenced by a durable
4
+ ``account_bound_cutover`` op and a per-record writer-generation stamp. This
5
+ module owns:
6
+
7
+ * the protocol constants (writer generation, op kinds, the cutover op id);
8
+ * the stamp the shared append preparation applies to every record the current
9
+ binary writes;
10
+ * the historical boundary the cutover op records, and its payload codec;
11
+ * the admission predicate every accounting reader applies BEFORE it interprets
12
+ a record (ingest, rebuild, rederive, journal-repair, correction selection,
13
+ cache recovery and the deep doctor scans).
14
+
15
+ "After the cutover" is a PHYSICAL journal position (segment and byte offset of
16
+ a complete line), never ``at``, capture time or provider time. A record is
17
+ historical when it lies inside the frozen complete-line extent the cutover op
18
+ recorded for its segment; a segment absent from that map (a later month, or a
19
+ bootstrap segment created after the cutover, which sorts FIRST in canonical
20
+ order) is post-boundary in its entirety.
21
+
22
+ The fence is Claude-scoped (operator Q5). Exclusion is the default: a
23
+ post-boundary record without the current writer generation is admitted only
24
+ when an explicit field its real producer writes proves it Codex-only. An
25
+ unknown or unclassifiable record is excluded, never admitted by omission.
26
+
27
+ Pure: no I/O, no clock, no SQLite.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import dataclasses
32
+ import hashlib
33
+ import re
34
+ from typing import Mapping
35
+
36
+
37
+ PROTOCOL_GENERATION = "account-bound-v1"
38
+ WRITER_GENERATION_KEY = "wpg"
39
+ CUTOVER_OP_KIND = "account_bound_cutover"
40
+ CUTOVER_OP_ID = "account-bound-cutover-v1"
41
+ LEGACY_SPOOL_ACTIVITY_KIND = "legacy_spool_activity"
42
+ #: Spec revision 5 (section 7, operator decision on Codex design-fix-round-5
43
+ #: Q4): the `legacy_spool_activity` reason of an incomplete inventory. Such an
44
+ #: op records typed failure evidence and no digest, so it anchors no baseline.
45
+ INVENTORY_INCOMPLETE_REASON = "inventory-incomplete"
46
+ #: Fix round K12 (K11R-4; spec 5.5 step 6, 5.6 item 1(b) and section 7): the
47
+ #: reason of the anchoring op the first complete inventory appends after a
48
+ #: cutover that recorded inventory uncertainty and so anchored no baseline.
49
+ #: It records the complete digest and ends that episode. Like every
50
+ #: `legacy_spool_activity` op it is a trigger, but of the same uncertainty,
51
+ #: never an observed legacy change, and it clears nothing (residual 6).
52
+ INVENTORY_COMPLETE_REASON = "inventory-complete"
53
+ #: The reasons whose unresolved markers an incomplete inventory left.
54
+ INVENTORY_REASONS = (INVENTORY_INCOMPLETE_REASON, INVENTORY_COMPLETE_REASON)
55
+ #: The operations an inventory can fail at (the evidence's ``operation``).
56
+ INVENTORY_OPERATIONS = ("list", "stat", "read")
57
+
58
+ #: The versioned successors of the two administrative op kinds whose
59
+ #: previous-release validators require exact keys (spec 5.1). The previous
60
+ #: release ignores an unknown op kind, so the current binary emits only these.
61
+ PROTOCOL_RESOLUTION_V2_KIND = "journal_protocol_resolution_v2"
62
+ WEEKLY_OBSERVATION_DECISION_V2_KIND = "claude_weekly_observation_decision_v2"
63
+
64
+ #: Admission families. ``codex`` is the only family admitted without a stamp.
65
+ FAMILIES = (
66
+ "claude-observation",
67
+ "claude-op",
68
+ "claude-event",
69
+ "administrative",
70
+ "protocol",
71
+ "codex",
72
+ "unknown",
73
+ )
74
+
75
+ ADMISSION_REASONS = ("no-cutover", "historical", "stamped", "codex-only",
76
+ "excluded")
77
+
78
+ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
79
+
80
+ _CLAUDE_OP_KINDS = frozenset((
81
+ "account_observe", "account_label", "accounts_cutover",
82
+ "weekly_credit_floor", "sync_week",
83
+ ))
84
+ _CODEX_OP_KINDS = frozenset((
85
+ "codex_file_account", "codex_window_attribution",
86
+ "codex_window_attribution_retract",
87
+ ))
88
+ _ADMIN_OP_KINDS = frozenset((
89
+ "journal_protocol_resolution", "claude_weekly_observation_decision",
90
+ PROTOCOL_RESOLUTION_V2_KIND, WEEKLY_OBSERVATION_DECISION_V2_KIND,
91
+ ))
92
+ _PROTOCOL_OP_KINDS = frozenset((CUTOVER_OP_KIND, LEGACY_SPOOL_ACTIVITY_KIND))
93
+ _ADMIN_RECORD_TYPES = frozenset(("correction", "correction_batch"))
94
+ _CLAUDE_EVT_KINDS = frozenset((
95
+ "snapshot_accept", "weekly_cost_snapshot", "week_reset", "five_hour_credit",
96
+ "five_hour_block_close", "percent_milestone", "five_hour_milestone",
97
+ "weekly_credit_effects", "projected", "project_budget", "meter_rate_change",
98
+ ))
99
+ #: Evt kinds admitted without a stamp only when their payload proves a Codex
100
+ #: quota source (``payload.source == "codex"``, the source every Codex quota
101
+ #: identity carries and every Codex quota table filters on).
102
+ _QUOTA_EVT_KINDS = frozenset(("quota_alert_arming", "quota_threshold_event"))
103
+
104
+
105
+ @dataclasses.dataclass(frozen=True)
106
+ class Coordinate:
107
+ """Physical journal position of a complete line: its segment name and the
108
+ byte offset at which the line STARTS."""
109
+
110
+ segment: str
111
+ offset: int
112
+
113
+
114
+ @dataclasses.dataclass(frozen=True)
115
+ class CutoverBoundary:
116
+ """The historical boundary recorded by the ``account_bound_cutover`` op.
117
+
118
+ ``segment``/``end_offset`` name where the op itself was appended: the
119
+ target segment and its complete-line end at that moment, which is the op's
120
+ own start offset. ``frozen_extents`` maps every
121
+ segment that existed before the op to its complete-line byte length at that
122
+ moment; only bytes inside those extents are historical.
123
+
124
+ Spec revision 5: when the establishment could not complete its legacy
125
+ spool inventory, the op records the uncertainty itself —
126
+ ``legacy_spool_inventory_failure`` holds the typed failure evidence and
127
+ ``legacy_spool_baseline_digest`` is None, so the op anchors no baseline
128
+ (the next complete inventory installs one under its own anchoring op).
129
+ """
130
+
131
+ segment: str
132
+ end_offset: int
133
+ frozen_extents: Mapping[str, int]
134
+ legacy_spool_baseline_digest: "str | None"
135
+ legacy_spool_inventory_failure: "Mapping[str, object] | None" = None
136
+
137
+
138
+ @dataclasses.dataclass(frozen=True)
139
+ class Admission:
140
+ admitted: bool
141
+ family: str
142
+ reason: str
143
+
144
+
145
+ # --------------------------------------------------------------------------
146
+ # writer-generation stamp
147
+ # --------------------------------------------------------------------------
148
+
149
+ def stamp(record: dict) -> dict:
150
+ """Return a shallow copy of ``record`` carrying the current writer
151
+ generation. Idempotent; refuses a record that already names a DIFFERENT
152
+ generation (a caller must never relabel another protocol's record). The
153
+ payload is never touched: the stamp is transport metadata."""
154
+ if not isinstance(record, dict):
155
+ raise ValueError("journal record must be a dict")
156
+ existing = record.get(WRITER_GENERATION_KEY)
157
+ if existing is not None and existing != PROTOCOL_GENERATION:
158
+ raise ValueError(
159
+ f"record already carries writer generation {existing!r}")
160
+ out = dict(record)
161
+ out[WRITER_GENERATION_KEY] = PROTOCOL_GENERATION
162
+ return out
163
+
164
+
165
+ def is_stamped(record: dict) -> bool:
166
+ return (isinstance(record, dict)
167
+ and record.get(WRITER_GENERATION_KEY) == PROTOCOL_GENERATION)
168
+
169
+
170
+ def strip_stamp(record: dict) -> dict:
171
+ """``record`` without its writer-generation field — the comparison shape
172
+ for semantic event duplicate/conflict classification."""
173
+ if not isinstance(record, dict) or WRITER_GENERATION_KEY not in record:
174
+ return record
175
+ return {k: v for k, v in record.items() if k != WRITER_GENERATION_KEY}
176
+
177
+
178
+ # --------------------------------------------------------------------------
179
+ # boundary
180
+ # --------------------------------------------------------------------------
181
+
182
+ def is_historical(coord: Coordinate, boundary: CutoverBoundary) -> bool:
183
+ extent = boundary.frozen_extents.get(coord.segment)
184
+ return extent is not None and coord.offset < extent
185
+
186
+
187
+ def inventory_failure_evidence(*, path: str, operation: str,
188
+ error: str, message: str = "") -> dict:
189
+ """The typed evidence of a failed legacy spool inventory (spec revision
190
+ 5): the path, the failed operation and the error (its errno name). It is
191
+ diagnostic evidence only, never accounting input."""
192
+ evidence = {"path": str(path), "operation": str(operation),
193
+ "error": str(error)}
194
+ if message:
195
+ evidence["message"] = str(message)
196
+ if not is_inventory_failure(evidence):
197
+ raise ValueError(f"invalid inventory failure evidence {evidence!r}")
198
+ return evidence
199
+
200
+
201
+ def is_inventory_failure(value) -> bool:
202
+ """Whether ``value`` is well-formed inventory failure evidence."""
203
+ if not isinstance(value, dict):
204
+ return False
205
+ if not (isinstance(value.get("path"), str)
206
+ and value.get("operation") in INVENTORY_OPERATIONS
207
+ and isinstance(value.get("error"), str) and value["error"]):
208
+ return False
209
+ message = value.get("message")
210
+ return message is None or isinstance(message, str)
211
+
212
+
213
+ def boundary_payload(boundary: CutoverBoundary) -> dict:
214
+ """The camelCase payload fields of the cutover op (without ``kind``).
215
+ ``legacySpoolInventoryFailure`` is present only on a cutover that
216
+ recorded inventory uncertainty, so a complete cutover's bytes are
217
+ unchanged."""
218
+ payload = {
219
+ "segment": boundary.segment,
220
+ "endOffset": boundary.end_offset,
221
+ "frozenExtents": {k: boundary.frozen_extents[k]
222
+ for k in sorted(boundary.frozen_extents)},
223
+ "legacySpoolBaselineDigest": boundary.legacy_spool_baseline_digest,
224
+ }
225
+ if boundary.legacy_spool_inventory_failure is not None:
226
+ payload["legacySpoolInventoryFailure"] = dict(
227
+ boundary.legacy_spool_inventory_failure)
228
+ return payload
229
+
230
+
231
+ def _nonneg_int(value) -> bool:
232
+ return type(value) is int and value >= 0
233
+
234
+
235
+ def boundary_from_op(op_record: dict) -> CutoverBoundary:
236
+ """Decode the boundary from a cutover op. Raises ``ValueError`` on any
237
+ shape failure: a malformed cutover op never yields a boundary."""
238
+ if not isinstance(op_record, dict) or op_record.get("t") != "op":
239
+ raise ValueError("cutover boundary must come from an op record")
240
+ payload = op_record.get("payload")
241
+ if not isinstance(payload, dict) or payload.get("kind") != CUTOVER_OP_KIND:
242
+ raise ValueError("record is not an account_bound_cutover op")
243
+ segment = payload.get("segment")
244
+ end_offset = payload.get("endOffset")
245
+ extents = payload.get("frozenExtents")
246
+ digest = payload.get("legacySpoolBaselineDigest")
247
+ if not isinstance(segment, str) or not segment:
248
+ raise ValueError("cutover boundary segment is invalid")
249
+ if not _nonneg_int(end_offset):
250
+ raise ValueError("cutover boundary endOffset is invalid")
251
+ if not isinstance(extents, dict) or not all(
252
+ isinstance(k, str) and k and _nonneg_int(v)
253
+ for k, v in extents.items()):
254
+ raise ValueError("cutover boundary frozenExtents is invalid")
255
+ failure = payload.get("legacySpoolInventoryFailure")
256
+ if failure is None:
257
+ if not isinstance(digest, str) or not _DIGEST_RE.match(digest):
258
+ raise ValueError("cutover boundary legacy spool digest is invalid")
259
+ else:
260
+ # Revision 5: an uncertain inventory records its evidence and NO
261
+ # digest; a digest beside failure evidence is malformed.
262
+ if not is_inventory_failure(failure) or digest is not None:
263
+ raise ValueError(
264
+ "cutover boundary legacy spool inventory failure is invalid")
265
+ failure = dict(failure)
266
+ return CutoverBoundary(
267
+ segment=segment,
268
+ end_offset=end_offset,
269
+ frozen_extents=dict(extents),
270
+ legacy_spool_baseline_digest=digest,
271
+ legacy_spool_inventory_failure=failure,
272
+ )
273
+
274
+
275
+ # --------------------------------------------------------------------------
276
+ # admission
277
+ # --------------------------------------------------------------------------
278
+
279
+ def _family(record: dict) -> tuple[str, bool]:
280
+ """``(family, codex_only)`` from an explicit allow-list per kind. Anything
281
+ not named here is ``("unknown", False)``."""
282
+ if not isinstance(record, dict):
283
+ return ("unknown", False)
284
+ t = record.get("t")
285
+ if t in _ADMIN_RECORD_TYPES:
286
+ return ("administrative", False)
287
+ payload = record.get("payload")
288
+ if not isinstance(payload, dict):
289
+ return ("unknown", False)
290
+ kind = payload.get("kind")
291
+ if t == "obs":
292
+ provider = record.get("provider")
293
+ if provider == "claude":
294
+ return ("claude-observation", False)
295
+ if provider == "codex" and kind == "quota_window_snapshot":
296
+ return ("codex", True)
297
+ return ("unknown", False)
298
+ if t == "op":
299
+ if kind in _CODEX_OP_KINDS:
300
+ return ("codex", True)
301
+ if kind in ("account_observe", "account_label"):
302
+ if payload.get("provider") == "codex":
303
+ return ("codex", True)
304
+ return ("claude-op", False)
305
+ if kind in _CLAUDE_OP_KINDS:
306
+ return ("claude-op", False)
307
+ if kind in _ADMIN_OP_KINDS:
308
+ return ("administrative", False)
309
+ if kind in _PROTOCOL_OP_KINDS:
310
+ return ("protocol", False)
311
+ return ("unknown", False)
312
+ if t == "evt":
313
+ if kind in _CLAUDE_EVT_KINDS:
314
+ return ("claude-event", False)
315
+ if kind == "budget":
316
+ if payload.get("vendor") == "codex":
317
+ return ("codex", True)
318
+ return ("claude-event", False)
319
+ if kind in _QUOTA_EVT_KINDS:
320
+ if payload.get("source") == "codex":
321
+ return ("codex", True)
322
+ return ("claude-event", False)
323
+ return ("unknown", False)
324
+ return ("unknown", False)
325
+
326
+
327
+ def record_family(record: dict) -> str:
328
+ return _family(record)[0]
329
+
330
+
331
+ def classify_admission(record: dict, coord: Coordinate,
332
+ boundary: CutoverBoundary | None) -> Admission:
333
+ """The single admission predicate (spec 5.3).
334
+
335
+ Admitted when there is no cutover yet, when the record lies inside the
336
+ historical boundary, when it carries the current writer generation, or when
337
+ its family is proven Codex-only. Every other post-boundary record is
338
+ excluded."""
339
+ family, codex_only = _family(record)
340
+ if boundary is None:
341
+ return Admission(True, family, "no-cutover")
342
+ if is_historical(coord, boundary):
343
+ return Admission(True, family, "historical")
344
+ if is_stamped(record):
345
+ return Admission(True, family, "stamped")
346
+ if codex_only:
347
+ return Admission(True, family, "codex-only")
348
+ return Admission(False, family, "excluded")
349
+
350
+
351
+ # --------------------------------------------------------------------------
352
+ # excluded-population digest (spec section 8)
353
+ # --------------------------------------------------------------------------
354
+
355
+ _DIGEST_MODULUS = 1 << 256
356
+
357
+
358
+ def _coordinate_term(coord: Coordinate) -> int:
359
+ data = f"{coord.segment}\x00{coord.offset}".encode("utf-8")
360
+ return int.from_bytes(hashlib.sha256(data).digest(), "big")
361
+
362
+
363
+ def coordinate_population_digest(coords, base: str | None = None) -> str:
364
+ """An ORDER-INDEPENDENT digest over a set of physical coordinates: the sum
365
+ of each coordinate's sha256 modulo 2**256. ``base`` extends an earlier
366
+ digest incrementally, so an ingest cycle can add the coordinates it newly
367
+ excludes and a full scan reproduces the same value in any order."""
368
+ total = 0
369
+ if base is not None:
370
+ if not isinstance(base, str) or not _DIGEST_RE.match(base):
371
+ raise ValueError("coordinate population digest is invalid")
372
+ total = int(base[len("sha256:"):], 16)
373
+ for coord in coords:
374
+ total = (total + _coordinate_term(coord)) % _DIGEST_MODULUS
375
+ return "sha256:" + format(total, "064x")
376
+
377
+
378
+ #: The record type of an inert positional placeholder for consumers that need
379
+ #: a dict in an excluded record's slot. No reader recognises the type, so the
380
+ #: selector, the pipeline and the legacy classifier all skip it while it still
381
+ #: consumes the slot's sequence number.
382
+ EXCLUDED_PLACEHOLDER_TYPE = "excluded"
383
+
384
+
385
+ def excluded_placeholder() -> dict:
386
+ return {"t": EXCLUDED_PLACEHOLDER_TYPE}
@@ -46,6 +46,8 @@ import threading
46
46
  from dataclasses import dataclass
47
47
  from typing import Any, Callable, Iterable
48
48
 
49
+ import _lib_codex_rollout_container as _rc
50
+
49
51
 
50
52
  def _cctally():
51
53
  """Resolve the current `cctally` module at call-time (spec §5.5)."""
@@ -687,6 +689,7 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
687
689
  keyed on ``(scope roots, source_path)``. Outside one the roots are
688
690
  resolved per call, which is the historic behaviour every CLI caller keeps.
689
691
  """
692
+ source_path = _rc.logical_path(source_path)
690
693
  scope = getattr(_CODEX_PATH_SCOPE, "scope", None)
691
694
  if scope is not None:
692
695
  return scope.parts(source_path)
@@ -713,6 +716,7 @@ def _session_path_parts_for_roots(
713
716
  files stay free of maintainer absolute paths), then basename. Direct-JSONL
714
717
  roots yield an id relative to <entry> itself (no sessions/ prefix).
715
718
  """
719
+ source_path = _rc.logical_path(source_path)
716
720
  p = pathlib.Path(source_path)
717
721
  rel: pathlib.PurePath | None = None
718
722
  for root in roots:
@@ -69,7 +69,19 @@ PROGRESS_KEY = "codex_journal_coverage_progress"
69
69
  #: `REASON_MALFORMED` and falls back to a full replay — the same outcome a bump
70
70
  #: would produce. A bump would additionally invalidate certificates written by
71
71
  #: an in-development binary that already carries the field, for no gain.
72
- COVERAGE_VERSION = 1
72
+ #:
73
+ #: Version 2 (#946; #825/#832 spec 4.7 revision 2, Codex design-fix-round-3
74
+ #: Q5): every earlier writer took `appliedThrough` from an ingest cursor that
75
+ #: could advance past a torn suffix. `_repair_torn_tail` then wrote a record
76
+ #: across that coordinate, the next ingest started inside it and skipped it,
77
+ #: and a contiguous advance certified it covered — a version-1 certificate can
78
+ #: therefore claim coverage of an unapplied record at an endpoint that follows
79
+ #: a newline, which no endpoint check can see. `prior_is_extendable` and
80
+ #: `certificate_is_valid` both reject version 1, so the first current binary
81
+ #: forces the full journal recovery and never extends or restamps one. A
82
+ #: previous release reading a version-2 certificate rejects it the same way
83
+ #: and replays (C2).
84
+ COVERAGE_VERSION = 2
73
85
 
74
86
  #: Bumped whenever the journal-record-to-cache-row materialization changes —
75
87
  #: `_apply_quota_records`, `_apply_file_account_records`, or the §3.5 precedence
@@ -12,18 +12,28 @@ import fcntl
12
12
  import os
13
13
  import time
14
14
  from pathlib import Path
15
+ from typing import Callable
15
16
 
16
17
 
17
18
  def acquire_ordered_flocks(
18
19
  locks: list[tuple[Path, int]],
19
20
  *,
20
21
  timeout: float | None = None,
22
+ on_failure: Callable[[Path, BaseException], None] | None = None,
21
23
  ) -> list[int] | None:
22
- """Acquire ``(path, LOCK_SH|LOCK_EX)`` entries in caller-supplied order."""
24
+ """Acquire ``(path, LOCK_SH|LOCK_EX)`` entries in caller-supplied order.
25
+
26
+ ``on_failure(path, exc)``, when given, is told which lock the set lost to
27
+ before this returns None (``exc`` a ``BlockingIOError``: the lock was
28
+ held) or re-raises an ``OSError`` (an I/O error no holder caused), so a
29
+ caller can wait for exactly that lock's release (#825/#832).
30
+ """
23
31
  deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
24
32
  held: list[int] = []
33
+ current: Path | None = None
25
34
  try:
26
35
  for path, mode in locks:
36
+ current = path
27
37
  fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
28
38
  os.fchmod(fd, 0o600)
29
39
  held.append(fd)
@@ -31,14 +41,18 @@ def acquire_ordered_flocks(
31
41
  try:
32
42
  fcntl.flock(fd, mode | fcntl.LOCK_NB)
33
43
  break
34
- except BlockingIOError:
44
+ except BlockingIOError as exc:
35
45
  if deadline is None or time.monotonic() >= deadline:
36
46
  release_cache_writer_flocks(held)
47
+ if on_failure is not None:
48
+ on_failure(path, exc)
37
49
  return None
38
50
  time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
39
51
  return held
40
- except BaseException:
52
+ except BaseException as exc:
41
53
  release_cache_writer_flocks(held)
54
+ if on_failure is not None and current is not None and isinstance(exc, OSError):
55
+ on_failure(current, exc)
42
56
  raise
43
57
 
44
58
 
@@ -47,6 +61,7 @@ def acquire_cache_writer_flocks(
47
61
  provider_path: Path | None = None,
48
62
  *,
49
63
  timeout: float | None = None,
64
+ on_failure: Callable[[Path, BaseException], None] | None = None,
50
65
  ) -> list[int] | None:
51
66
  """Acquire global then optional provider flock, or return ``None``.
52
67
 
@@ -61,6 +76,7 @@ def acquire_cache_writer_flocks(
61
76
  return acquire_ordered_flocks(
62
77
  [(path, fcntl.LOCK_EX) for path in paths],
63
78
  timeout=timeout,
79
+ on_failure=on_failure,
64
80
  )
65
81
 
66
82
 
@@ -0,0 +1,142 @@
1
+ """Clone-first file and tree copies (stdlib only; #940 D2, spec §6.7).
2
+
3
+ On APFS, ``fclonefileat(2)`` gives a copy that shares the source's blocks
4
+ until either side is written, so a copy of a large test scaffold writes no
5
+ data. This module takes that clone wherever it can and a real copy wherever
6
+ it cannot, and otherwise behaves exactly as the stdlib copier it stands in
7
+ for:
8
+
9
+ - :func:`copy2` is ``shutil.copy2`` for the same arguments: a directory
10
+ destination keeps the source's basename, ``follow_symlinks=False`` copies a
11
+ link as a link, the same file raises ``shutil.SameFileError``, and an
12
+ existing destination is overwritten in place (same inode), which a clone
13
+ cannot do, so that case is a real copy. Metadata is ``shutil.copystat``'s,
14
+ applied after the clone as after a copy.
15
+ - :func:`copytree` is ``shutil.copytree`` with :func:`copy2` as its file
16
+ copier, so symlink policy, ignore lists, dangling-link errors and
17
+ destination collisions are the stdlib's own code.
18
+
19
+ A clone is attempted only on Darwin. An unsupported filesystem, a
20
+ cross-volume destination, a missing ``fclonefileat`` and any non-Darwin
21
+ platform take the real copy; any other error (EIO, ENOSPC, EACCES, …)
22
+ propagates and is never retried as a copy. A clone is never a hardlink: the
23
+ copy is a new inode with a link count of one.
24
+
25
+ The optional ``counts`` mapping is incremented under ``"clone"`` or
26
+ ``"copy"`` per file, so a caller or a measurement can see which path ran.
27
+ This module is public and imports nothing outside the standard library.
28
+
29
+ It binds ``fclonefileat`` itself rather than importing the binding in
30
+ ``bin/_lib_remote_staging.py`` (#940 D-2 F17): that module is private, so it
31
+ is absent from the public tree that this module and its public callers ship
32
+ in, and a public module may not import it. The staging module may import
33
+ from this one instead (spec §6.7).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import ctypes
39
+ import errno
40
+ import os
41
+ import shutil
42
+ import stat
43
+ import sys
44
+
45
+ # Errors that mean "this filesystem or this pair of volumes cannot clone",
46
+ # never "the data could not be written". EEXIST is a destination created
47
+ # after the existence check: `shutil.copy2` overwrites it, and so do we.
48
+ _FALLBACK_ERRNOS = frozenset(
49
+ code for code in (
50
+ getattr(errno, "ENOTSUP", None), getattr(errno, "EOPNOTSUPP", None),
51
+ errno.ENOSYS, errno.EXDEV, errno.EINVAL, errno.EPERM, errno.EEXIST)
52
+ if code is not None)
53
+
54
+ _AT_FDCWD = -2 # Darwin's AT_FDCWD
55
+ _CLONE_NOOWNERCOPY = 0x0002 # leave ownership to the caller, as a copy does
56
+
57
+ _LIBC_CLONE = [] # the resolved fclonefileat, once looked up
58
+ # The platform this module decides on; a test sets it here instead of
59
+ # patching the interpreter-wide ``sys.platform`` (#940 D-2 F16).
60
+ _PLATFORM = sys.platform
61
+
62
+
63
+ def _fclonefileat():
64
+ """The C ``fclonefileat`` on Darwin, else None."""
65
+ if _PLATFORM != "darwin":
66
+ return None
67
+ if not _LIBC_CLONE:
68
+ libc = ctypes.CDLL(None, use_errno=True)
69
+ clone = getattr(libc, "fclonefileat", None)
70
+ if clone is not None:
71
+ clone.argtypes = (ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32)
72
+ clone.restype = ctypes.c_int
73
+ _LIBC_CLONE.append(clone)
74
+ return _LIBC_CLONE[0]
75
+
76
+
77
+ def _clone_fd(source_fd: int, destination: str) -> None:
78
+ """Clone the open regular file `source_fd` to the new path `destination`."""
79
+ clone = _fclonefileat()
80
+ if clone is None:
81
+ raise OSError(errno.ENOTSUP, "fclonefileat is unavailable", destination)
82
+ if clone(source_fd, _AT_FDCWD, os.fsencode(os.path.abspath(destination)),
83
+ _CLONE_NOOWNERCOPY) != 0:
84
+ code = ctypes.get_errno()
85
+ raise OSError(code, os.strerror(code), destination)
86
+
87
+
88
+ def _count(counts, method: str) -> None:
89
+ if counts is not None:
90
+ counts[method] = counts.get(method, 0) + 1
91
+
92
+
93
+ def copy2(src, dst, *, follow_symlinks: bool = True, counts=None):
94
+ """``shutil.copy2(src, dst)``, by clone where the filesystem allows it.
95
+
96
+ Returns the destination path, as ``shutil.copy2`` does.
97
+ """
98
+ if os.path.isdir(dst):
99
+ dst = os.path.join(dst, os.path.basename(src))
100
+ if (not follow_symlinks and os.path.islink(src)) or os.path.lexists(dst):
101
+ # A link copied as a link, or a destination to overwrite in place.
102
+ result = shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
103
+ _count(counts, "copy")
104
+ return result
105
+ try:
106
+ fd = os.open(src, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0))
107
+ except OSError:
108
+ # The stdlib's own error for this source (missing, unreadable, …).
109
+ return shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
110
+ try:
111
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
112
+ # A FIFO, a device or a directory: the stdlib decides.
113
+ os.close(fd)
114
+ fd = -1
115
+ return shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
116
+ try:
117
+ _clone_fd(fd, dst)
118
+ except OSError as exc:
119
+ if exc.errno not in _FALLBACK_ERRNOS:
120
+ raise
121
+ result = shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
122
+ _count(counts, "copy")
123
+ return result
124
+ shutil.copystat(src, dst, follow_symlinks=follow_symlinks)
125
+ _count(counts, "clone")
126
+ return dst
127
+ finally:
128
+ if fd >= 0:
129
+ os.close(fd)
130
+
131
+
132
+ def copytree(src, dst, symlinks: bool = False, ignore=None,
133
+ ignore_dangling_symlinks: bool = False, dirs_exist_ok: bool = False,
134
+ *, counts=None):
135
+ """``shutil.copytree`` with :func:`copy2` as its file copier."""
136
+ def copier(source, destination):
137
+ return copy2(source, destination, counts=counts)
138
+
139
+ return shutil.copytree(src, dst, symlinks=symlinks, ignore=ignore,
140
+ copy_function=copier,
141
+ ignore_dangling_symlinks=ignore_dangling_symlinks,
142
+ dirs_exist_ok=dirs_exist_ok)