ccx-native 0.0.1 → 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.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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
|