ccx-native 0.0.0-stage → 0.1.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,636 @@
1
+ """Owned worker event feed reader with generation-bound nofollow cursors.
2
+
3
+ Uses the original engine-specific CCX lifecycle projection helpers, not native
4
+ provider access or a second name/short address resolver. The cursor binds
5
+ the original worker/generation and retained source position; append resumes
6
+ by prefix digest and journal rotation uses its original epoch/sequence.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import errno
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import stat
17
+ import math
18
+ import time
19
+ from dataclasses import dataclass
20
+ from typing import Mapping, Iterator
21
+
22
+ from ccx.ops.event_feed import (
23
+ HEADER_METHOD, KINDS, _claude_position, _claude_project,
24
+ _codex_position, _codex_project, header_instance_id, _place,
25
+ POLL_S, HEARTBEAT_DEFAULT_S,
26
+ )
27
+ from ccx.errors import InvalidRequest, SessionNotFound, worker_not_found
28
+ from ccx.store.identity import UnknownWorker
29
+
30
+ from ccx.store.store import GenerationStore
31
+ from ccx.store.root import StorageIntegrityError
32
+
33
+ CURSOR_PREFIX = "ev2."
34
+ # A bounded event page, not a cap on an append-only journal lifetime.
35
+ MAX_PAGE_BYTES = 2 * 1024 * 1024
36
+ # Claude rotates around 5,000 events. Its retained sidecar is practical to
37
+ # search farther back for a sparse kind; keep a byte ceiling for unusually
38
+ # large previews. Codex evidence is unbounded and uses forward continuation.
39
+ CLAUDE_FILTERED_TAIL_BYTES = 16 * MAX_PAGE_BYTES
40
+ CURSOR_ANCHOR_BYTES = 1024
41
+ TAIL_STEP_BYTES = 64 * 1024
42
+ MAX_PAGE_EVENTS = 1000
43
+ MAX_OVERSIZED_SKIPS = 16 # continuation instead of scanning unlimited bad rows
44
+
45
+
46
+ class EventCursorInvalid(StorageIntegrityError):
47
+ """Cursor no longer belongs to this exact generation/worker/source bytes."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _Positioned:
52
+ offset: int
53
+ obj: dict
54
+ epoch: str | None
55
+ seq: int
56
+
57
+
58
+ def _hash(data: bytes) -> str:
59
+ return hashlib.sha256(data).hexdigest()
60
+
61
+
62
+ def _cursor_payload(store: GenerationStore, engine: str, worker_id: str,
63
+ fd: int | None, offset: int, inode: int | None,
64
+ epoch: str | None = None, seq: int = 0) -> dict:
65
+ # Bind the root, owner, generation and byte boundary with a bounded anchor
66
+ # so polling does not hash the entire journal.
67
+ anchor = min(offset, CURSOR_ANCHOR_BYTES)
68
+ prefix = os.pread(fd, anchor, offset-anchor) if fd is not None else b""
69
+ return {
70
+ "v": 2, "root": _hash(os.fsencode(store.root)),
71
+ "generation": store.generation, "engine": engine, "workerId": worker_id,
72
+ "inode": inode, "offset": offset, "anchorBytes": anchor,
73
+ "prefixSha256": _hash(prefix), "p": epoch, "q": seq,
74
+ }
75
+
76
+
77
+ def _encode_cursor(payload: Mapping) -> str:
78
+ data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
79
+ return CURSOR_PREFIX + base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
80
+
81
+
82
+ def _decode_cursor(value: str, expected: Mapping, fd: int | None,
83
+ size: int, engine: str
84
+ ) -> tuple[int, bool, str | None, int]:
85
+ if (not isinstance(value, str) or not value.startswith(CURSOR_PREFIX)
86
+ or len(value) > 2048):
87
+ raise EventCursorInvalid("stale legacy/invalid cursor: full-UUID ev2 required")
88
+ try:
89
+ part = value[len(CURSOR_PREFIX):]
90
+ supplied = json.loads(base64.urlsafe_b64decode(
91
+ part + "=" * (-len(part) % 4),
92
+ ))
93
+ except (ValueError, TypeError, UnicodeError, binascii.Error) as exc:
94
+ raise EventCursorInvalid("invalid event feed cursor payload") from exc
95
+ if not isinstance(supplied, dict):
96
+ raise EventCursorInvalid("event feed cursor must be an object")
97
+ for key in ("v", "root", "generation", "engine", "workerId"):
98
+ if supplied.get(key) != expected[key]:
99
+ raise EventCursorInvalid("event cursor from other root/generation/full worker")
100
+ offset, epoch, seq = supplied.get("offset"), supplied.get("p"), supplied.get("q")
101
+ if (type(offset) is not int or offset < 0
102
+ or (epoch is not None and (not isinstance(epoch, str) or not epoch))
103
+ or type(seq) is not int or seq < 0):
104
+ raise EventCursorInvalid("invalid event cursor original byte/epoch/seq position")
105
+ anchor = supplied.get("anchorBytes")
106
+ if type(anchor) is not int or not 0 <= anchor <= min(offset, CURSOR_ANCHOR_BYTES):
107
+ raise EventCursorInvalid("invalid event cursor anchorBytes")
108
+ valid = offset <= size and (offset == 0 or (
109
+ fd is not None and os.pread(fd, 1, offset-1) == b"\n"))
110
+ if valid:
111
+ window = os.pread(fd, anchor, offset-anchor) if fd is not None else b""
112
+ valid = supplied.get("prefixSha256") == _hash(window)
113
+ return offset, valid, epoch, seq
114
+
115
+
116
+ def _lines(engine: str, raw: bytes, *, start: int = 0,
117
+ epoch: str | None = None) -> tuple[list[_Positioned], int]:
118
+ lines: list[_Positioned] = []
119
+ offset = start
120
+ for part in raw[start:].splitlines(keepends=True):
121
+ if not part.endswith(b"\n"):
122
+ break # in-flight partial final line is neither published nor cursored
123
+ offset += len(part)
124
+ try:
125
+ value = json.loads(part)
126
+ except (ValueError, UnicodeError):
127
+ continue # historical malformed evidence is not a worker alias
128
+ if not isinstance(value, dict):
129
+ continue
130
+ if engine == "claude":
131
+ line_epoch, seq = _claude_position(value)
132
+ else:
133
+ if value.get("method") == HEADER_METHOD:
134
+ epoch = header_instance_id(value)
135
+ line_epoch, seq = epoch, _codex_position(value)
136
+ if seq is not None:
137
+ lines.append(_Positioned(offset, value, line_epoch, seq))
138
+ return lines, offset
139
+
140
+
141
+ def _pinned_epoch(fd: int | None, size: int) -> str | None:
142
+ if fd is None or not size:
143
+ return None
144
+ prefix = os.pread(fd, min(TAIL_STEP_BYTES, size), 0)
145
+ for line in prefix.splitlines():
146
+ try:
147
+ event = json.loads(line)
148
+ except (ValueError, UnicodeError):
149
+ continue
150
+ if isinstance(event, dict) and event.get("method") == HEADER_METHOD:
151
+ return header_instance_id(event)
152
+ return None
153
+
154
+
155
+ def _last_complete(fd: int | None, size: int) -> int:
156
+ if fd is None or not size:
157
+ return 0
158
+ # A producer may be partway through one large JSONL event at EOF. Keep
159
+ # walking backward to the last complete newline, even when that partial
160
+ # row spans several chunks. Never mistake unfinished bytes for a journal
161
+ # integrity failure, and never search before this pinned file's start.
162
+ end = size
163
+ while end > 0:
164
+ window = min(TAIL_STEP_BYTES, end)
165
+ begin = end - window
166
+ chunk = os.pread(fd, window, begin)
167
+ cut = chunk.rfind(b"\n")
168
+ if cut >= 0:
169
+ return begin + cut + 1
170
+ end = begin
171
+ return 0
172
+
173
+
174
+ def _bounded_lines(engine: str, fd: int | None, start: int, size: int,
175
+ *, epoch: str | None = None
176
+ ) -> tuple[list[_Positioned], int, bool, dict | None]:
177
+ if fd is None or start >= size:
178
+ return [], start, False, None
179
+ pos, skipped, skipped_bytes = start, 0, 0
180
+
181
+ def gap():
182
+ return ({"reason": "oversized_event_line", "count": skipped,
183
+ "skipped_bytes": skipped_bytes,
184
+ "note": "oversized journal event(s) skipped"} if skipped else None)
185
+
186
+ while pos < size:
187
+ length = min(MAX_PAGE_BYTES, size-pos)
188
+ raw = os.pread(fd, length, pos)
189
+ parsed, consumed = _lines(engine, raw, epoch=epoch)
190
+ if consumed:
191
+ absolute = [_Positioned(pos+x.offset, x.obj, x.epoch, x.seq)
192
+ for x in parsed]
193
+ end = pos+consumed
194
+ return absolute, end, end < size and length == MAX_PAGE_BYTES, gap()
195
+ if len(raw) < MAX_PAGE_BYTES:
196
+ break # only an unfinished final line remains
197
+ # A complete oversized line is advisory, not bad worker identity.
198
+ # Search for its newline in fixed-size preads, never buffering it.
199
+ scan, boundary = pos+len(raw), None
200
+ while scan < size:
201
+ chunk = os.pread(fd, min(TAIL_STEP_BYTES, size-scan), scan)
202
+ if not chunk:
203
+ break
204
+ newline = chunk.find(b"\n")
205
+ if newline >= 0:
206
+ boundary = scan+newline+1
207
+ break
208
+ scan += len(chunk)
209
+ if boundary is None:
210
+ break # unfinished event: do not cursor partial bytes
211
+ skipped += 1
212
+ skipped_bytes += boundary-pos
213
+ pos = boundary # exact newline boundary remains cursor-verifiable
214
+ if skipped >= MAX_OVERSIZED_SKIPS:
215
+ return [], pos, pos < size, gap()
216
+ return [], pos, False, gap()
217
+
218
+
219
+ def read_full_uuid_event_page(
220
+ store: GenerationStore,
221
+ target: str,
222
+ *, after: str | None = None,
223
+ tail: int | None = None,
224
+ limit: int = MAX_PAGE_EVENTS,
225
+ kinds: list[str] | None = None,
226
+ native: bool = False,
227
+ ) -> dict:
228
+ """Pin a private inode, then inspect complete lines up to the fstat size.
229
+
230
+ Concurrent append is normal: never require size/mtime to stay unchanged
231
+ while parsing. Reading a page is bounded independent of journal age.
232
+ """
233
+ if (type(limit) is not int or not 1 <= limit <= MAX_PAGE_EVENTS
234
+ or tail is not None and (type(tail) is not int or not 0 <= tail <= MAX_PAGE_EVENTS)
235
+ or tail is not None and after is not None):
236
+ raise InvalidRequest("invalid event page limit/tail/after combination")
237
+ wanted = None
238
+ if kinds is not None:
239
+ if (not isinstance(kinds, list) or any(not isinstance(k, str) for k in kinds)
240
+ or not set(kinds).issubset(KINDS)):
241
+ raise InvalidRequest("unknown event feed kind filter")
242
+ wanted = frozenset(kinds)
243
+ with store.lease():
244
+ owner, _record = store._owned(target)
245
+ filename = "events.ndjson" if owner.engine == "claude" else "evidence.jsonl"
246
+ fd = None
247
+ try:
248
+ with store._gen("workers", owner.engine, owner.worker_id) as directory:
249
+ try:
250
+ fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
251
+ dir_fd=directory)
252
+ except FileNotFoundError:
253
+ pass
254
+ except OSError as exc:
255
+ if exc.errno in (errno.ELOOP, errno.ENOTDIR):
256
+ raise StorageIntegrityError(
257
+ "unsafe full-ID event journal inode"
258
+ ) from exc
259
+ raise
260
+ if fd is not None:
261
+ actual = os.fstat(fd)
262
+ named = os.stat(filename, dir_fd=directory, follow_symlinks=False)
263
+ if (not stat.S_ISREG(actual.st_mode)
264
+ or actual.st_uid != os.getuid() or actual.st_mode & 0o077
265
+ or (actual.st_dev, actual.st_ino)
266
+ != (named.st_dev, named.st_ino)):
267
+ raise StorageIntegrityError("unsafe full-ID event journal inode")
268
+ size, inode = actual.st_size, actual.st_ino
269
+ else:
270
+ size, inode = 0, None
271
+ reference = _cursor_payload(store, owner.engine, owner.worker_id, fd, 0, inode)
272
+ offset, valid, epoch, seq = (
273
+ _decode_cursor(after, reference, fd, size, owner.engine)
274
+ if after else (0, True, None, 0)
275
+ )
276
+ gap = None
277
+ if not valid:
278
+ # File rotation/replacement/truncation: keep existing epoch/seq
279
+ # placement and gap semantics for retained records.
280
+ replay, _, _, skipped_gap = _bounded_lines(owner.engine, fd, 0, size)
281
+ from ccx.ops.event_feed import _Line
282
+ placed, gap = _place(
283
+ owner.engine,
284
+ [_Line(x.epoch, x.seq, x.offset, x.obj) for x in replay],
285
+ {"p": epoch, "q": seq},
286
+ )
287
+ gap = gap or skipped_gap
288
+ if placed:
289
+ first = placed[0].end
290
+ before = os.pread(fd, min(first, TAIL_STEP_BYTES), max(0,first-TAIL_STEP_BYTES))
291
+ cut = before[:-1].rfind(b"\n")
292
+ offset = first-len(before)+cut+1 if cut >= 0 else 0
293
+ else:
294
+ offset = 0 if gap else size
295
+ epoch, seq = None, 0
296
+
297
+ if tail is not None:
298
+ if tail == 0:
299
+ parsed, end_offset, unread = [], _last_complete(fd, size), False
300
+ if fd is not None and end_offset:
301
+ # From-now must carry the last actual epoch/sequence.
302
+ # Otherwise a same-inode replacement of a nonempty
303
+ # journal looks like an empty-source cursor and hides a
304
+ # real rotation gap.
305
+ length = min(end_offset, TAIL_STEP_BYTES)
306
+ start = end_offset - length
307
+ chunk = os.pread(fd, length, start)
308
+ skip = 0 if start == 0 else chunk.find(b"\n") + 1
309
+ if skip or start == 0:
310
+ last_rows, _ = _lines(
311
+ owner.engine, chunk[skip:],
312
+ epoch=(_pinned_epoch(fd, size)
313
+ if owner.engine == "codex" else None),
314
+ )
315
+ if last_rows:
316
+ epoch, seq = last_rows[-1].epoch, last_rows[-1].seq
317
+ else:
318
+ # Claude rotates after ~5,000 lines: for a kind-filtered
319
+ # tail search its retained events farther back. An
320
+ # unbounded Codex journal (or an oversized Claude file)
321
+ # uses a truthful forward continuation instead of claiming
322
+ # that no older matches exist.
323
+ total = 0
324
+ parsed = []
325
+ raw = b""
326
+ eligible = 0
327
+ end_offset, unread = _last_complete(fd, size), False
328
+ scan_budget = min(end_offset, (
329
+ CLAUDE_FILTERED_TAIL_BYTES
330
+ if owner.engine == "claude" and wanted is not None and not native
331
+ else MAX_PAGE_BYTES
332
+ ))
333
+ # Grow reverse windows geometrically: never reread backing
334
+ # bytes, and avoid reparsing an entire growing prefix at
335
+ # every 64 KiB step on a large retained sidecar.
336
+ while total < scan_budget:
337
+ step = min(max(TAIL_STEP_BYTES, total),
338
+ end_offset-total, scan_budget-total)
339
+ begin = end_offset-total-step
340
+ raw = os.pread(fd, step, begin) + raw
341
+ total += step
342
+ skip = 0 if begin == 0 else raw.find(b"\n")+1
343
+ if begin and skip == 0:
344
+ continue
345
+ source, _consumed = _lines(
346
+ owner.engine, raw[skip:],
347
+ epoch=(_pinned_epoch(fd, size)
348
+ if owner.engine == "codex" else None),
349
+ )
350
+ parsed = [
351
+ _Positioned(begin+skip+x.offset,x.obj,x.epoch,x.seq)
352
+ for x in source
353
+ ]
354
+ # Project only the records in this bounded window.
355
+ # The producer is responsible for compact identity,
356
+ # admission and exactly-once terminal emission.
357
+ eligible = 0
358
+ for candidate in parsed:
359
+ projection = (
360
+ candidate.obj if native else
361
+ (_claude_project(candidate.obj)
362
+ if owner.engine == "claude"
363
+ else _codex_project(candidate.obj))
364
+ )
365
+ if projection is not None and (
366
+ native or wanted is None
367
+ or projection.get("kind") in wanted
368
+ ):
369
+ eligible += 1
370
+ if eligible >= tail or begin == 0:
371
+ unread = begin > 0
372
+ break
373
+ else:
374
+ unread = total < end_offset
375
+ if owner.engine == "codex":
376
+ seed = _pinned_epoch(fd, size)
377
+ parsed = [_Positioned(x.offset,x.obj,x.epoch or seed,x.seq)
378
+ for x in parsed]
379
+ else:
380
+ if owner.engine == "codex" and epoch is None:
381
+ epoch = _pinned_epoch(fd, size)
382
+ parsed, end_offset, unread, skipped_gap = _bounded_lines(
383
+ owner.engine, fd, offset, size, epoch=epoch,
384
+ )
385
+ gap = gap or skipped_gap
386
+ selected: list[tuple[_Positioned, dict]] = []
387
+ for line in parsed:
388
+ projection = (line.obj if native else
389
+ (_claude_project(line.obj) if owner.engine == "claude"
390
+ else _codex_project(line.obj)))
391
+ if projection is None:
392
+ continue
393
+ if not native and wanted is not None and projection.get("kind") not in wanted:
394
+ continue
395
+ selected.append((line, projection))
396
+ if tail is not None:
397
+ selected = selected[-tail:] if tail else []
398
+ # If the budget ended before finding N matches, the source
399
+ # still has unscanned RETAINED bytes. The ordinary forward
400
+ # reader can recover them from the source-head cursor.
401
+ more = bool(tail and unread and len(selected) < tail)
402
+ else:
403
+ more = len(selected) > limit or unread
404
+ selected = selected[:limit]
405
+ events = []
406
+ for line, projection in selected:
407
+ if native:
408
+ events.append(projection)
409
+ else:
410
+ boundary = _cursor_payload(store, owner.engine, owner.worker_id,
411
+ fd, line.offset, inode, line.epoch, line.seq)
412
+ events.append({
413
+ **projection, "worker": owner.worker, "worker_id": owner.worker_id,
414
+ "engine": owner.engine, "short": owner.short,
415
+ "epoch": line.epoch, "seq": line.seq,
416
+ "cursor": _encode_cursor(boundary),
417
+ })
418
+ if more and tail is not None:
419
+ # Tail goes newest-to-oldest, forward pagination goes oldest-to-
420
+ # newest. A source-head continuation (not EOF) ensures a
421
+ # caller can actually recover retained matches we did not scan.
422
+ resume, boundary_epoch, boundary_seq = 0, None, 0
423
+ else:
424
+ boundary = selected[-1][0] if more and selected else (parsed[-1] if parsed else None)
425
+ resume = selected[-1][0].offset if more and selected else end_offset
426
+ boundary_epoch = boundary.epoch if boundary else epoch
427
+ boundary_seq = boundary.seq if boundary else seq
428
+ final_cursor = _encode_cursor(_cursor_payload(
429
+ store, owner.engine, owner.worker_id, fd, resume, inode,
430
+ boundary_epoch, boundary_seq,
431
+ ))
432
+ if fd is not None:
433
+ final_stat = os.fstat(fd)
434
+ if final_stat.st_size < max(size, resume):
435
+ # The pinned inode shrank. Appends are fine; truncation
436
+ # invalidates a previously complete native journal view.
437
+ raise EventCursorInvalid("event journal truncated during read")
438
+ with store._gen("workers", owner.engine, owner.worker_id) as directory:
439
+ try:
440
+ named = os.stat(filename, dir_fd=directory,
441
+ follow_symlinks=False)
442
+ except FileNotFoundError as exc:
443
+ raise EventCursorInvalid(
444
+ "event journal removed during read"
445
+ ) from exc
446
+ if ((named.st_dev, named.st_ino) !=
447
+ (final_stat.st_dev, final_stat.st_ino)):
448
+ raise EventCursorInvalid(
449
+ "event journal inode replaced during read"
450
+ )
451
+ if (not stat.S_ISREG(named.st_mode)
452
+ or named.st_uid != os.getuid() or named.st_mode & 0o077):
453
+ raise StorageIntegrityError(
454
+ "unsafe full-ID event journal inode"
455
+ )
456
+ store.revalidate()
457
+ return {
458
+ "worker": owner.worker, "worker_id": owner.worker_id,
459
+ "engine": owner.engine, "short": owner.short,
460
+ "events": events, "next_cursor": final_cursor,
461
+ "has_more": more, "gap": gap, "source": "owned-ccx-journal",
462
+ }
463
+ finally:
464
+ if fd is not None:
465
+ os.close(fd)
466
+
467
+
468
+ def _source_marker(store: GenerationStore, target: str) -> tuple | None:
469
+ """Stat only the original owned journal, without hashing or holding a wait lease."""
470
+ with store.lease():
471
+ owner, _record = store._owned(target)
472
+ filename = "events.ndjson" if owner.engine == "claude" else "evidence.jsonl"
473
+ try:
474
+ with store._gen("workers", owner.engine, owner.worker_id) as fd:
475
+ metadata = os.stat(filename, dir_fd=fd, follow_symlinks=False)
476
+ except FileNotFoundError:
477
+ return None
478
+ if (not stat.S_ISREG(metadata.st_mode)
479
+ or metadata.st_uid != os.getuid() or metadata.st_mode & 0o077):
480
+ raise StorageIntegrityError("unsafe full-ID event journal inode")
481
+ return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
482
+
483
+
484
+ def _recorded_state(store: GenerationStore, target: str) -> dict:
485
+ """Original record's state; a follower never probes native host or daemon."""
486
+ record = store.record(target)
487
+ from ccx.ops.event_feed import state_of
488
+ return {**state_of(record), "state_source": "recorded"}
489
+
490
+
491
+ def wait_full_uuid_event_page(
492
+ store: GenerationStore, target: str, *, after: str | None = None,
493
+ tail: int | None = None, limit: int = 200, kinds: list[str] | None = None,
494
+ wait: float = 0.0, native: bool = False,
495
+ ) -> dict:
496
+ """One same-generation held page; stat the owned journal before re-hashing.
497
+
498
+ Every page carries ``runtime`` (``live``/``gone``/``unknown``, see
499
+ :mod:`runtime_presence`), observed read-only while the read is held. An
500
+ unheld page is one sample, so it is never ``gone``. A held read that sees
501
+ its live runtime go returns at once, empty; one that starts with the
502
+ runtime already gone holds as usual and reports ``gone`` at the end.
503
+ """
504
+ from ccx.store.runtime_presence import GONE, RuntimeObserver
505
+ if (type(wait) not in (int, float) or not math.isfinite(wait) or wait < 0):
506
+ raise InvalidRequest("event wait must be nonnegative finite seconds")
507
+ marker = _source_marker(store, target) if wait else None
508
+ page = read_full_uuid_event_page(
509
+ store, target, after=after, tail=tail, limit=limit, kinds=kinds,
510
+ native=native,
511
+ )
512
+ observer = RuntimeObserver(store, target)
513
+ observer.sample()
514
+
515
+ def done(current: dict, gap, timed_out: bool) -> dict:
516
+ return {**current, **_recorded_state(store, target),
517
+ "gap": gap, "timed_out": timed_out, "runtime": observer.value}
518
+
519
+ if not wait or page["events"] or page["has_more"] or page["gap"]:
520
+ return done(page, page["gap"], False)
521
+ deadline = time.monotonic() + wait
522
+ gap = page["gap"]
523
+ while True:
524
+ remaining = deadline - time.monotonic()
525
+ if remaining <= 0:
526
+ observer.sample()
527
+ return done(page, gap, True)
528
+ time.sleep(min(POLL_S, remaining))
529
+ # The source is validated (and any refusal raised) before presence:
530
+ # a runtime going away never stands in for a changed journal.
531
+ current = _source_marker(store, target)
532
+ if current != marker:
533
+ marker = current
534
+ page = read_full_uuid_event_page(
535
+ store, target, after=page["next_cursor"], limit=limit, kinds=kinds,
536
+ native=native,
537
+ )
538
+ gap = gap or page["gap"]
539
+ if page["events"] or page["has_more"] or gap:
540
+ return done(page, gap, False)
541
+ if observer.sample() == GONE and observer.seen_live:
542
+ return done(page, gap, False)
543
+
544
+
545
+ def follow_full_uuid_events(
546
+ store: GenerationStore, target: str, *, after: str | None = None,
547
+ tail: int | None = None, kinds: list[str] | None = None,
548
+ timeout: float | None = None,
549
+ heartbeat: float | None = HEARTBEAT_DEFAULT_S,
550
+ native: bool = False,
551
+ ) -> Iterator[dict]:
552
+ """Replay then follow the original owner's event page, not a native socket."""
553
+ for name, value in (("timeout", timeout), ("heartbeat", heartbeat)):
554
+ if value is not None and (
555
+ type(value) not in (int, float) or not math.isfinite(value) or value < 0
556
+ ):
557
+ raise InvalidRequest(f"event follow {name} must be nonnegative finite or None")
558
+ deadline = None if timeout is None else time.monotonic() + timeout
559
+ try:
560
+ # Take the end-of-journal cursor first: a short tail page continues
561
+ # from the journal head, so follow from here instead (an event landing
562
+ # between the two reads may show twice, never zero times).
563
+ start = wait_full_uuid_event_page(
564
+ store, target, tail=0, kinds=kinds, limit=MAX_PAGE_EVENTS,
565
+ native=native,
566
+ ) if tail else None
567
+ page = wait_full_uuid_event_page(
568
+ store, target, after=after, tail=tail, kinds=kinds, limit=MAX_PAGE_EVENTS,
569
+ native=native,
570
+ )
571
+
572
+ def beat(current: dict, gap: dict | None = None) -> dict:
573
+ return {
574
+ "kind": "heartbeat", "type": "heartbeat",
575
+ "worker": current["worker"], "worker_id": current["worker_id"],
576
+ "engine": current["engine"], "short": current["short"],
577
+ "cursor": current["next_cursor"], "gap": gap,
578
+ "ts": time.time(),
579
+ "state": current["state"], "native_state": current["native_state"],
580
+ "state_source": "recorded", "runtime": current["runtime"],
581
+ }
582
+
583
+ gap = page["gap"] # A later replay page must not erase the first gap.
584
+ if start is not None and page["has_more"]:
585
+ yield from page["events"]
586
+ page = start
587
+ while True:
588
+ yield from page["events"]
589
+ if not page["has_more"]:
590
+ break
591
+ page = wait_full_uuid_event_page(
592
+ store, target, after=page["next_cursor"], kinds=kinds,
593
+ limit=MAX_PAGE_EVENTS, native=native,
594
+ )
595
+ gap = gap or page["gap"]
596
+ cursor = page["next_cursor"]
597
+ yield beat(page, gap)
598
+ runtime = page["runtime"]
599
+ last_output = time.monotonic()
600
+ while deadline is None or time.monotonic() < deadline:
601
+ remaining = (None if deadline is None
602
+ else max(0.0, deadline - time.monotonic()))
603
+ period = heartbeat if heartbeat else HEARTBEAT_DEFAULT_S
604
+ duration = period if remaining is None else min(period, remaining)
605
+ page = wait_full_uuid_event_page(
606
+ store, target, after=cursor, kinds=kinds,
607
+ limit=MAX_PAGE_EVENTS, wait=duration, native=native,
608
+ )
609
+ # Presence comes from the held read only; an unheld page is one sample.
610
+ changed, runtime = page["runtime"] != runtime, page["runtime"]
611
+ cursor = page["next_cursor"]
612
+ if page["gap"]:
613
+ yield beat(page, page["gap"])
614
+ last_output = time.monotonic()
615
+ if page["events"]:
616
+ yield from page["events"]
617
+ last_output = time.monotonic()
618
+ while page["has_more"]:
619
+ page = wait_full_uuid_event_page(
620
+ store, target, after=cursor, kinds=kinds, limit=MAX_PAGE_EVENTS,
621
+ native=native,
622
+ )
623
+ cursor = page["next_cursor"]
624
+ if page["gap"]:
625
+ yield beat(page, page["gap"])
626
+ if page["events"]:
627
+ yield from page["events"]
628
+ last_output = time.monotonic()
629
+ # A runtime presence change is a beat at once, even after events.
630
+ if heartbeat and (changed or (
631
+ not page["events"]
632
+ and time.monotonic() - last_output >= heartbeat)):
633
+ yield beat({**page, "runtime": runtime})
634
+ last_output = time.monotonic()
635
+ except UnknownWorker as exc:
636
+ raise worker_not_found(target) from exc
File without changes