ccx-native 0.0.1 → 0.2.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,827 @@
1
+ """Sparse durable evidence for what a native Codex turn actually did.
2
+
3
+ ``thread/read`` reconstructs a thread's content. It does not carry the moments
4
+ CCX observed while the turn ran: when an item started, when it completed, the
5
+ exact native millisecond timestamps on those notifications, or a structured
6
+ ``error`` the turn recovered from. Those exist only as live notifications, and
7
+ once they are reduced into ``lastTurn``/``lastReport``/``lifecycle`` the detail
8
+ is gone. This journal is where the authoritative event survives the reduction.
9
+
10
+ It is deliberately sparse. Only lifecycle evidence is written —
11
+ ``turn/started``, ``item/started``, ``item/completed``, ``error``,
12
+ ``turn/completed`` — never the token, output, or reasoning delta streams, which
13
+ would turn a small durable record into a firehose that costs a worker its
14
+ throughput. One file per worker, owner-only, outside the lifecycle record so
15
+ that record stays compact.
16
+
17
+ Ordering: ``sequence`` is CCX's own per-worker observation order, and nothing
18
+ else. It is not the content order of the normalized document (that is
19
+ ``ordinal``), and it is not native time (that is ``nativeTimestampMs``, present
20
+ only where the native notification actually carried one).
21
+
22
+ Reading is one simple pass over the file, and :func:`build_index` materializes
23
+ the folded result — the same memory tradeoff the conversation document already
24
+ accepts, and the reason the journal stays sparse rather than becoming a delta
25
+ log nobody can afford to read back.
26
+
27
+ Durability: every selected event is flushed to the OS before ``record``
28
+ returns, so a CCX crash loses nothing, and turn settlement additionally
29
+ ``fsync``s — the barrier that bounds what a machine crash can take to the tail
30
+ of one unsettled turn. Nothing here waits on the network or the model.
31
+
32
+ Integrity is stated, never repaired. ``sequence`` is part of it: a gap, a
33
+ duplicate, a backwards number, a malformed line, and a line that parses but is
34
+ not an envelope all mean evidence was lost, and each is reported. A partial
35
+ tail's number is reserved rather than reused, so the gap it leaves is the
36
+ durable record that one event may be missing. And degradation is sticky: a
37
+ writer rehydrates what the worker's record already knew, a reader merges the
38
+ file's integrity with that record, and no later success anywhere presents a
39
+ worker's evidence as complete once CCX has recorded that it is not.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import hashlib
45
+ import json
46
+ import os
47
+ import pathlib
48
+ import threading
49
+ import uuid
50
+ from typing import Any
51
+
52
+ from ccx.codex.paths import ensure_dirs, evidence_path
53
+ from ccx.codex.provenance import parse_client_message_id
54
+ from ccx.codex.records import now_ms, read_record
55
+ from ccx.system.sanitize import safe_native
56
+
57
+ SCHEMA_VERSION = 1
58
+
59
+ # The header CCX writes when it begins journaling. It is what separates "no
60
+ # such event ever happened" from "CCX was not yet watching", which a consumer
61
+ # otherwise cannot tell apart from an empty stretch of journal.
62
+ HEADER_METHOD = "ccx/evidence-started"
63
+
64
+ # Authoritative lifecycle evidence only. Deltas are excluded by construction:
65
+ # adding one here is how this file stops being sparse.
66
+ TOKEN_USAGE_METHOD = "thread/tokenUsage/updated"
67
+ COMPACTION_CONTEXT_METHOD = "ccx/compaction/context"
68
+
69
+ # CCX's own lifecycle facts, which no native notification carries: the
70
+ # worker's state transitions and its approval requests coming and going. They
71
+ # exist for the event feed (`ccx/event_feed.py`) and are deliberately kept out
72
+ # of turn and item evidence: their params never carry a top-level `turnId`, and
73
+ # `build_index` skips them.
74
+ LIFECYCLE_METHOD = "ccx/lifecycle"
75
+ INPUT_REQUESTED_METHOD = "ccx/input/requested"
76
+ INPUT_RESOLVED_METHOD = "ccx/input/resolved"
77
+ COMPACT_STARTED_METHOD = "ccx/compact/started"
78
+ COMPACT_ENDED_METHOD = "ccx/compact/ended"
79
+ # A content-free wake, journaled only after a delivery receipt became
80
+ # accepted. It is never evidence: readers reconcile the delivery id.
81
+ DELIVERY_CHANGED_METHOD = "ccx/delivery/changed"
82
+ BACKGROUND_CHANGED_METHOD = "ccx/background/changed"
83
+ CONTEXT_CHANGED_METHOD = "ccx/context/changed"
84
+ FEED_METHODS = frozenset({
85
+ DELIVERY_CHANGED_METHOD,
86
+ BACKGROUND_CHANGED_METHOD,
87
+ CONTEXT_CHANGED_METHOD,
88
+ LIFECYCLE_METHOD,
89
+ INPUT_REQUESTED_METHOD,
90
+ INPUT_RESOLVED_METHOD,
91
+ COMPACT_STARTED_METHOD,
92
+ COMPACT_ENDED_METHOD,
93
+ })
94
+
95
+ JOURNALED_METHODS = frozenset({
96
+ "turn/started",
97
+ "item/started",
98
+ "item/completed",
99
+ "error",
100
+ "turn/completed",
101
+ TOKEN_USAGE_METHOD,
102
+ COMPACTION_CONTEXT_METHOD,
103
+ }) | FEED_METHODS
104
+
105
+ # Settlement and bounded telemetry enrichments get a real durability barrier.
106
+ _DURABLE_METHODS = frozenset({
107
+ "turn/completed",
108
+ COMPACTION_CONTEXT_METHOD,
109
+ })
110
+
111
+ _ITEM_TIMESTAMP = {
112
+ "item/started": "startedAtMs",
113
+ "item/completed": "completedAtMs",
114
+ }
115
+
116
+
117
+ def journal_path(short: str) -> pathlib.Path:
118
+ return evidence_path(short)
119
+
120
+
121
+ def _owned_evidence_fd(target: str, flags: int, *, store=None) -> int:
122
+ """Pinned nofollow actual journal inode, never a pathname authority.
123
+
124
+ The worker record's full global UUID is the only owner index. A symlink,
125
+ replaced file inode, wrong uid or too-open file is refused, not chmodded
126
+ into trust. The ACTIVE lease bounds opening, not native turn completion.
127
+ """
128
+ from ccx import constants
129
+ from ccx.store.root import require_ccx_active
130
+ from ccx.store.identity import EngineMismatch
131
+ import stat
132
+ ctx = store if store is not None else require_ccx_active(constants.CCX_ROOT)
133
+ owner = ctx.resolve(target)
134
+ if owner.engine != "codex":
135
+ raise EngineMismatch("Codex journal requires Codex full UUID")
136
+ with ctx.lease():
137
+ with ctx._gen("workers", "codex", owner.worker_id) as directory:
138
+ fd = os.open(
139
+ "evidence.jsonl",
140
+ flags | os.O_NOFOLLOW | os.O_CLOEXEC,
141
+ 0o600, dir_fd=directory,
142
+ )
143
+ try:
144
+ actual = os.fstat(fd)
145
+ named = os.stat(
146
+ "evidence.jsonl", dir_fd=directory,
147
+ follow_symlinks=False,
148
+ )
149
+ if (not stat.S_ISREG(actual.st_mode)
150
+ or actual.st_uid != os.getuid()
151
+ or actual.st_mode & 0o077
152
+ or (actual.st_dev, actual.st_ino)
153
+ != (named.st_dev, named.st_ino)):
154
+ raise PermissionError(
155
+ "Codex full-UUID journal inode not owned/private"
156
+ )
157
+ except BaseException:
158
+ os.close(fd)
159
+ raise
160
+ return fd
161
+
162
+
163
+ def _int_or_none(value: Any) -> int | None:
164
+ if isinstance(value, bool) or not isinstance(value, int):
165
+ return None
166
+ return value
167
+
168
+
169
+ def _text_or_none(value: Any) -> str | None:
170
+ return value if isinstance(value, str) and value else None
171
+
172
+
173
+ def _is_envelope(value: Any) -> bool:
174
+ """Is this parsed line actually one of our journal envelopes?
175
+
176
+ Deliberately the minimum this schema needs and nothing more — an integer
177
+ ``sequence`` alone does not make a JSON object evidence, and a line that
178
+ parses but cannot be interpreted is lost evidence, not a valid record. This
179
+ is not, and must not grow into, a general schema validator.
180
+ """
181
+ if not isinstance(value, dict):
182
+ return False
183
+ if _int_or_none(value.get("schemaVersion")) is None:
184
+ return False
185
+ if _int_or_none(value.get("sequence")) is None:
186
+ return False
187
+ if _int_or_none(value.get("observedAtMs")) is None:
188
+ return False
189
+ if value.get("method") not in JOURNALED_METHODS | {HEADER_METHOD}:
190
+ return False
191
+ return isinstance(value.get("params"), dict)
192
+
193
+
194
+ def envelope(
195
+ method: str,
196
+ params: dict[str, Any],
197
+ *,
198
+ sequence: int,
199
+ thread_id: str | None = None,
200
+ ) -> dict[str, Any]:
201
+ """One journal line: sanitized native params plus CCX's own observation.
202
+
203
+ The convenience ids are read out of ``params`` rather than supplied
204
+ alongside it, so an envelope cannot disagree with the event it carries.
205
+ """
206
+ safe_params = safe_native(params)
207
+ if not isinstance(safe_params, dict):
208
+ safe_params = {"value": safe_params}
209
+ turn = safe_params.get("turn")
210
+ turn = turn if isinstance(turn, dict) else {}
211
+ item = safe_params.get("item")
212
+ item = item if isinstance(item, dict) else {}
213
+ return {
214
+ "schemaVersion": SCHEMA_VERSION,
215
+ "sequence": sequence,
216
+ # CCX receipt time. Never presented as native time.
217
+ "observedAtMs": now_ms(),
218
+ "method": method,
219
+ "threadId": _text_or_none(safe_params.get("threadId")) or thread_id,
220
+ "turnId": (
221
+ _text_or_none(safe_params.get("turnId"))
222
+ or _text_or_none(turn.get("id"))
223
+ ),
224
+ "itemId": _text_or_none(item.get("id")),
225
+ "itemType": _text_or_none(item.get("type")),
226
+ # Exactly the native timestamp the notification carried, or nothing.
227
+ "nativeTimestampMs": _int_or_none(
228
+ safe_params.get(_ITEM_TIMESTAMP.get(method, ""))
229
+ ),
230
+ "params": safe_params,
231
+ }
232
+
233
+
234
+ class JournalRead:
235
+ """The result of one pass over a worker's journal."""
236
+
237
+ __slots__ = (
238
+ "short", "path", "exists", "events", "last_sequence",
239
+ "partial_tail", "malformed_lines", "sequence_issues",
240
+ "journal_instance_id",
241
+ )
242
+
243
+ def __init__(
244
+ self,
245
+ short: str,
246
+ path: pathlib.Path,
247
+ *,
248
+ exists: bool,
249
+ events: list[dict[str, Any]],
250
+ last_sequence: int,
251
+ partial_tail: bool,
252
+ malformed_lines: list[int],
253
+ sequence_issues: list[str] | None = None,
254
+ journal_instance_id: str | None = None,
255
+ ):
256
+ self.short = short
257
+ self.path = path
258
+ self.exists = exists
259
+ self.events = events
260
+ self.last_sequence = last_sequence
261
+ self.partial_tail = partial_tail
262
+ self.malformed_lines = malformed_lines
263
+ self.sequence_issues = sequence_issues or []
264
+ self.journal_instance_id = journal_instance_id
265
+
266
+ @property
267
+ def complete(self) -> bool:
268
+ """Is every line this journal holds intact?
269
+
270
+ A partial tail (a write interrupted mid-line), a malformed complete
271
+ line, and a break in the sequence chain all mean evidence was lost.
272
+ None of them is skipped quietly, and none of them is repaired.
273
+ """
274
+ return not (
275
+ self.partial_tail or self.malformed_lines or self.sequence_issues
276
+ )
277
+
278
+ @property
279
+ def warnings(self) -> list[str]:
280
+ notes = []
281
+ if self.partial_tail:
282
+ notes.append(
283
+ "the evidence journal ends in a partial line: a write was "
284
+ "interrupted, so its last event is incomplete"
285
+ )
286
+ if self.malformed_lines:
287
+ shown = ", ".join(str(n) for n in self.malformed_lines[:10])
288
+ notes.append(
289
+ f"{len(self.malformed_lines)} malformed evidence line(s) "
290
+ f"(line {shown})"
291
+ )
292
+ notes.extend(self.sequence_issues[:10])
293
+ if len(self.sequence_issues) > 10:
294
+ notes.append(
295
+ f"{len(self.sequence_issues) - 10} further sequence problem(s)"
296
+ )
297
+ return notes
298
+
299
+
300
+ def _explicit_instance_id(header: dict[str, Any]) -> str | None:
301
+ params = header.get("params")
302
+ params = params if isinstance(params, dict) else {}
303
+ return _text_or_none(params.get("journalInstanceId"))
304
+
305
+
306
+ def header_instance_id(header: dict[str, Any]) -> str:
307
+ """The journal instance a valid evidence-start envelope names.
308
+
309
+ Pre-instance-ID journals still need a stable epoch: it is derived from
310
+ their first valid header, and the writer persists the same value on its
311
+ next open.
312
+ """
313
+ explicit = _explicit_instance_id(header)
314
+ if explicit is not None:
315
+ return explicit
316
+ seed = json.dumps(
317
+ header,
318
+ sort_keys=True,
319
+ separators=(",", ":"),
320
+ ).encode("utf-8")
321
+ return "legacy-" + hashlib.sha256(seed).hexdigest()[:32]
322
+
323
+
324
+ def read_journal(short: str, *, events: bool = True) -> JournalRead:
325
+ """Scan a worker's journal once, for its events and/or its sequence.
326
+
327
+ Recovery reads the file itself rather than trusting a counter in the
328
+ lifecycle record: the record and the journal are written separately, and
329
+ only the journal knows what actually reached the disk.
330
+ """
331
+ path = journal_path(short)
332
+ collected: list[dict[str, Any]] = []
333
+ last_sequence = 0
334
+ previous = 0
335
+ partial_tail = False
336
+ malformed: list[int] = []
337
+ issues: list[str] = []
338
+ journal_instance_id: str | None = None
339
+ try:
340
+ with os.fdopen(_owned_evidence_fd(short, os.O_RDONLY), "rb") as handle:
341
+ for number, raw in enumerate(handle, 1):
342
+ if not raw.endswith(b"\n"):
343
+ # An unterminated fragment is not an event. It is never
344
+ # parsed and its sequence is never claimed.
345
+ partial_tail = True
346
+ break
347
+ line = raw.strip()
348
+ if not line:
349
+ continue
350
+ try:
351
+ value = json.loads(line)
352
+ except ValueError:
353
+ malformed.append(number)
354
+ continue
355
+ # A parseable line can still be unusable. Its sequence is read
356
+ # anyway when it is safe to: recovery must never hand the same
357
+ # number to a future event just because this line was damaged.
358
+ sequence = (
359
+ _int_or_none(value.get("sequence"))
360
+ if isinstance(value, dict) else None
361
+ )
362
+ if not _is_envelope(value):
363
+ malformed.append(number)
364
+ else:
365
+ if value.get("method") == HEADER_METHOD:
366
+ explicit = _explicit_instance_id(value)
367
+ if explicit is not None:
368
+ if (
369
+ journal_instance_id is not None
370
+ and journal_instance_id != explicit
371
+ ):
372
+ issues.append(
373
+ f"line {number}: evidence journal instance changed "
374
+ f"from {journal_instance_id} to {explicit}"
375
+ )
376
+ else:
377
+ journal_instance_id = explicit
378
+ elif journal_instance_id is None:
379
+ journal_instance_id = header_instance_id(value)
380
+ if events:
381
+ collected.append(value)
382
+ if sequence is None:
383
+ continue
384
+ if sequence == previous:
385
+ issues.append(
386
+ f"line {number}: duplicate evidence sequence {sequence}"
387
+ )
388
+ elif sequence < previous:
389
+ issues.append(
390
+ f"line {number}: evidence sequence {sequence} goes "
391
+ f"backwards after {previous}"
392
+ )
393
+ elif sequence > previous + 1:
394
+ # The writer reserves a number before it writes, so a gap
395
+ # is positive evidence that an event was lost.
396
+ missing = (
397
+ f"{previous + 1}" if sequence == previous + 2
398
+ else f"{previous + 1}-{sequence - 1}"
399
+ )
400
+ issues.append(
401
+ f"line {number}: evidence sequence gap ({missing} "
402
+ f"never landed)"
403
+ )
404
+ previous = max(previous, sequence)
405
+ last_sequence = max(last_sequence, sequence)
406
+ except FileNotFoundError:
407
+ return JournalRead(
408
+ short, path, exists=False, events=[], last_sequence=0,
409
+ partial_tail=False, malformed_lines=[], journal_instance_id=None,
410
+ )
411
+ return JournalRead(
412
+ short, path, exists=True, events=collected,
413
+ last_sequence=last_sequence, partial_tail=partial_tail,
414
+ malformed_lines=malformed, sequence_issues=issues,
415
+ journal_instance_id=journal_instance_id,
416
+ )
417
+
418
+
419
+ class EvidenceJournal:
420
+ """The append-only writer for one worker's journal.
421
+
422
+ Opened lazily, so a worker that never produces evidence never creates a
423
+ file — which is itself the honest answer to "was CCX journaling?".
424
+ """
425
+
426
+ def __init__(
427
+ self,
428
+ short: str,
429
+ *,
430
+ codex_version: str | None = None,
431
+ health: dict[str, Any] | None = None,
432
+ ):
433
+ self.short = short
434
+ self.path = journal_path(short)
435
+ self.codex_version = codex_version
436
+ self._lock = threading.Lock()
437
+ self._handle = None
438
+ self._closed = False
439
+ self._sequence = 0
440
+ self._partial_tail = False
441
+ self._journal_instance_id: str | None = None
442
+ self._degraded: dict[str, Any] | None = None
443
+ # A worker outlives the process that watched it. Rehydrating the
444
+ # durable health block is what makes degradation survive a daemon
445
+ # restart: once CCX knows an event was lost, no later successful append
446
+ # may present that worker's evidence as complete again. Only an
447
+ # explicit repair could ever clear it, and none exists.
448
+ if isinstance(health, dict) and health.get("status") == "degraded":
449
+ self._degraded = {
450
+ "reason": str(
451
+ health.get("degradedReason")
452
+ or "evidence was already recorded as degraded"
453
+ )[:300],
454
+ "atMs": _int_or_none(health.get("degradedAtMs")) or now_ms(),
455
+ }
456
+
457
+ def state(self) -> dict[str, Any]:
458
+ """The smallest durable description of this journal's health."""
459
+ with self._lock:
460
+ return self._state_locked()
461
+
462
+ def _state_locked(self) -> dict[str, Any]:
463
+ degraded = self._degraded
464
+ return {
465
+ "schemaVersion": SCHEMA_VERSION,
466
+ "status": "degraded" if degraded else "complete",
467
+ "sequence": self._sequence,
468
+ "journalInstanceId": self._journal_instance_id,
469
+ "degradedReason": degraded["reason"] if degraded else None,
470
+ "degradedAtMs": degraded["atMs"] if degraded else None,
471
+ }
472
+
473
+ @property
474
+ def degraded(self) -> dict[str, Any] | None:
475
+ return self._degraded
476
+
477
+ @property
478
+ def closed(self) -> bool:
479
+ """Has a teardown released this journal? A later write would reopen
480
+ it with a fresh header, so CCX's own lifecycle lines stop here."""
481
+ return self._closed
482
+
483
+ @property
484
+ def observed(self) -> bool:
485
+ """Has this worker any evidence story to persist at all?
486
+
487
+ False for a worker that never journaled anything, so a pre-journal
488
+ worker keeps a null evidence block rather than being credited with
489
+ complete evidence it never produced.
490
+ """
491
+ return self._sequence > 0 or self._degraded is not None
492
+
493
+ def _degrade(self, reason: str) -> None:
494
+ # Once evidence is known to be incomplete it stays that way: a later
495
+ # successful append does not make the lost event reappear.
496
+ if self._degraded is None:
497
+ self._degraded = {"reason": reason[:300], "atMs": now_ms()}
498
+
499
+ def _open_locked(self) -> None:
500
+ if self._handle is not None:
501
+ return
502
+ ensure_dirs()
503
+ scan = read_journal(self.short, events=False)
504
+ self._sequence = scan.last_sequence
505
+ self._partial_tail = scan.partial_tail
506
+ self._journal_instance_id = (
507
+ scan.journal_instance_id or str(uuid.uuid4())
508
+ )
509
+ if scan.partial_tail:
510
+ # The interrupted line was mid-write, so it had already been given
511
+ # the next number. Reserve it rather than handing it to a new
512
+ # event: the resulting gap is the durable record that one event may
513
+ # have been lost here.
514
+ self._sequence += 1
515
+ if not scan.complete:
516
+ self._degrade(
517
+ "the existing journal is incomplete: "
518
+ + "; ".join(scan.warnings)
519
+ )
520
+ descriptor = _owned_evidence_fd(
521
+ self.short, os.O_WRONLY | os.O_CREAT | os.O_APPEND,
522
+ )
523
+ self._handle = os.fdopen(descriptor, "a", encoding="utf-8")
524
+ if scan.partial_tail:
525
+ # Close the interrupted fragment on its own line. Appending
526
+ # straight onto it would fuse a broken record and a good one into
527
+ # a single unreadable line, losing the new event as well.
528
+ self._handle.write("\n")
529
+ self._write_locked(HEADER_METHOD, {
530
+ "schemaVersion": SCHEMA_VERSION,
531
+ "short": self.short,
532
+ "journalInstanceId": self._journal_instance_id,
533
+ "codexVersion": self.codex_version,
534
+ "resumedFromSequence": scan.last_sequence,
535
+ "partialTail": scan.partial_tail,
536
+ }, durable=True)
537
+
538
+ def _write_locked(
539
+ self,
540
+ method: str,
541
+ params: dict[str, Any],
542
+ *,
543
+ thread_id: str | None = None,
544
+ durable: bool = False,
545
+ ) -> int:
546
+ # A failed write still consumes its number rather than handing it to
547
+ # the next event: sequence is strictly increasing, and a gap is honest
548
+ # evidence that something was lost.
549
+ self._sequence += 1
550
+ line = envelope(
551
+ method, params, sequence=self._sequence, thread_id=thread_id
552
+ )
553
+ self._handle.write(json.dumps(line, separators=(",", ":")) + "\n")
554
+ # Always past CCX's own buffers, so a CCX crash cannot lose an event.
555
+ self._handle.flush()
556
+ if durable:
557
+ os.fsync(self._handle.fileno())
558
+ return self._sequence
559
+
560
+ def record(
561
+ self,
562
+ method: str,
563
+ params: dict[str, Any],
564
+ *,
565
+ thread_id: str | None = None,
566
+ durable: bool | None = None,
567
+ ) -> int | None:
568
+ """Journal one selected event. Never raises.
569
+
570
+ A journal that cannot be written is a degraded observation, not a
571
+ reason to lose a running native turn — so the failure is latched into
572
+ this journal's state (which the caller persists) instead of
573
+ propagating into the notification path that drives worker health.
574
+ """
575
+ if method not in JOURNALED_METHODS:
576
+ return None
577
+ try:
578
+ with self._lock:
579
+ self._open_locked()
580
+ return self._write_locked(
581
+ method,
582
+ params,
583
+ thread_id=thread_id,
584
+ durable=(
585
+ method in _DURABLE_METHODS
586
+ if durable is None else bool(durable)
587
+ ),
588
+ )
589
+ except Exception as error: # noqa: BLE001 - see docstring
590
+ with self._lock:
591
+ self._degrade(f"{error.__class__.__name__}: {error}")
592
+ return None
593
+
594
+ def close(self) -> None:
595
+ """Flush and release the journal. Never raises.
596
+
597
+ The final durability barrier failing is exactly the case where the tail
598
+ of the evidence may not be on disk, so it degrades the journal's health
599
+ rather than vanishing — but it still must not take down the worker
600
+ teardown that called it.
601
+ """
602
+ with self._lock:
603
+ self._closed = True
604
+ handle, self._handle = self._handle, None
605
+ if handle is None:
606
+ return
607
+ try:
608
+ handle.flush()
609
+ os.fsync(handle.fileno())
610
+ except Exception as error: # noqa: BLE001 - see docstring
611
+ self._degrade(
612
+ f"the journal's final flush failed: "
613
+ f"{error.__class__.__name__}: {error}"
614
+ )
615
+ try:
616
+ handle.close()
617
+ except Exception as error: # noqa: BLE001 - see docstring
618
+ self._degrade(
619
+ f"the journal could not be closed cleanly: "
620
+ f"{error.__class__.__name__}: {error}"
621
+ )
622
+
623
+
624
+ # One CCX client id seen on two live items names neither of them.
625
+ _UNSET = object()
626
+
627
+
628
+ def _merged_health(
629
+ read: JournalRead, health: dict[str, Any] | None
630
+ ) -> tuple[str, list[str]]:
631
+ """Fold the file's own integrity together with the durable record health.
632
+
633
+ The two can disagree in both directions, and only one of those directions
634
+ is evidence loss. A file ahead of the record is ordinary: the journal is
635
+ flushed before the record is written, so a crash in between leaves exactly
636
+ that. A record ahead of the file, or a record that remembers evidence where
637
+ no file remains, means something the worker durably knew about is gone.
638
+ """
639
+ warnings = list(read.warnings)
640
+ recorded_sequence = 0
641
+ if isinstance(health, dict):
642
+ recorded_sequence = _int_or_none(health.get("sequence")) or 0
643
+ if health.get("status") == "degraded":
644
+ reason = _text_or_none(health.get("degradedReason"))
645
+ warnings.append(
646
+ "the worker recorded its evidence as degraded"
647
+ + (f": {reason}" if reason else "")
648
+ )
649
+ if not read.exists and recorded_sequence > 0:
650
+ warnings.append(
651
+ f"the worker recorded {recorded_sequence} evidence event(s) but "
652
+ f"the journal file is gone"
653
+ )
654
+ elif read.exists and recorded_sequence > read.last_sequence:
655
+ warnings.append(
656
+ f"the worker recorded evidence through sequence "
657
+ f"{recorded_sequence} but the journal ends at "
658
+ f"{read.last_sequence}"
659
+ )
660
+ if warnings:
661
+ return "degraded", warnings
662
+ return ("complete" if read.exists else "absent"), warnings
663
+
664
+
665
+ def build_index(
666
+ read: JournalRead, *, health: dict[str, Any] | None = None
667
+ ) -> dict[str, Any]:
668
+ """Fold a journal into the evidence a normalized document can use.
669
+
670
+ Identity is exact and native: an item is only ever keyed by the
671
+ ``(turnId, itemId)`` the event stream itself carried. Live and
672
+ reconstructed ``thread/read`` item ids may legitimately differ (Scope 02),
673
+ so an unmatched historical item stays unmatched — nothing here joins two
674
+ source-native ids because they look similar.
675
+
676
+ The one exception is not a heuristic at all: a native ``userMessage``
677
+ carries the ``clientId`` CCX itself minted, on both surfaces. That is a
678
+ real shared identity for the semantic message, so it is indexed separately
679
+ — and only when the id parses as one CCX owns and names exactly one live
680
+ item in its turn.
681
+
682
+ ``health`` is the worker's durable evidence block. Degradation recorded
683
+ there outranks a file whose surviving lines happen to parse cleanly.
684
+ """
685
+ turns: dict[str, dict[str, Any]] = {}
686
+ items: dict[str, dict[str, dict[str, Any]]] = {}
687
+ clients: dict[str, dict[str, str | None]] = {}
688
+ began_at: int | None = None
689
+ codex_version: str | None = None
690
+ counted = 0
691
+
692
+ for event in read.events:
693
+ method = event.get("method")
694
+ sequence = _int_or_none(event.get("sequence"))
695
+ observed = _int_or_none(event.get("observedAtMs"))
696
+ if method == HEADER_METHOD:
697
+ if began_at is None:
698
+ began_at = observed
699
+ params = event.get("params")
700
+ if isinstance(params, dict):
701
+ codex_version = _text_or_none(params.get("codexVersion"))
702
+ continue
703
+ if method not in JOURNALED_METHODS or method in FEED_METHODS:
704
+ continue
705
+ counted += 1
706
+ turn_id = _text_or_none(event.get("turnId"))
707
+ if turn_id is None:
708
+ continue
709
+ turn = turns.setdefault(turn_id, {
710
+ "startedAtMs": None,
711
+ "completedAtMs": None,
712
+ "sequences": [],
713
+ "errorCount": 0,
714
+ # Turn-level coverage: whether CCX watched this turn from its start
715
+ # through its settlement, and how many distinct item lifecycles it
716
+ # saw while it did.
717
+ "sawTurnStarted": False,
718
+ "sawTurnCompleted": False,
719
+ "distinctItemCount": 0,
720
+ })
721
+ if sequence is not None:
722
+ turn["sequences"].append(sequence)
723
+ if method == "turn/started":
724
+ turn["sawTurnStarted"] = True
725
+ turn["startedAtMs"] = turn["startedAtMs"] or observed
726
+ elif method == "turn/completed":
727
+ turn["sawTurnCompleted"] = True
728
+ turn["completedAtMs"] = observed
729
+ elif method == "error":
730
+ turn["errorCount"] += 1
731
+
732
+ item_id = _text_or_none(event.get("itemId"))
733
+ if item_id is None:
734
+ continue
735
+ turn_items = items.setdefault(turn_id, {})
736
+ new_item = item_id not in turn_items
737
+ entry = turn_items.setdefault(item_id, {
738
+ "itemType": None,
739
+ "clientId": None,
740
+ "startedAtMs": None,
741
+ "completedAtMs": None,
742
+ "sequences": [],
743
+ "completed": None,
744
+ })
745
+ if new_item:
746
+ turn["distinctItemCount"] += 1
747
+ entry["itemType"] = entry["itemType"] or _text_or_none(
748
+ event.get("itemType")
749
+ )
750
+ if sequence is not None:
751
+ entry["sequences"].append(sequence)
752
+ params = event.get("params")
753
+ item = params.get("item") if isinstance(params, dict) else None
754
+ if isinstance(item, dict):
755
+ client_id = _text_or_none(item.get("clientId"))
756
+ if client_id and parse_client_message_id(client_id):
757
+ entry["clientId"] = entry["clientId"] or client_id
758
+ turn_clients = clients.setdefault(turn_id, {})
759
+ known = turn_clients.get(client_id, _UNSET)
760
+ if known is _UNSET:
761
+ turn_clients[client_id] = item_id
762
+ elif known != item_id:
763
+ # The same CCX id on two different live items is not an
764
+ # identity any more. Refuse it rather than pick one.
765
+ turn_clients[client_id] = None
766
+ native = _int_or_none(event.get("nativeTimestampMs"))
767
+ if method == "item/started":
768
+ entry["startedAtMs"] = entry["startedAtMs"] or native
769
+ elif method == "item/completed":
770
+ entry["completedAtMs"] = native
771
+ if isinstance(item, dict):
772
+ entry["completed"] = item
773
+
774
+ status, warnings = _merged_health(read, health)
775
+ return {
776
+ "available": read.exists,
777
+ # File integrity alone; `status` is the answer that also accounts for
778
+ # what the worker durably recorded.
779
+ "complete": status == "complete",
780
+ "status": status,
781
+ "beganAtMs": began_at,
782
+ "codexVersion": codex_version,
783
+ "eventCount": counted,
784
+ "turns": turns,
785
+ "items": items,
786
+ "clients": clients,
787
+ "warnings": warnings,
788
+ }
789
+
790
+
791
+ def read_index(short: str) -> dict[str, Any]:
792
+ """One-call read + fold for a worker's journal, health included."""
793
+ record = read_record(short) or {}
794
+ health = record.get("evidence")
795
+ return build_index(
796
+ read_journal(short),
797
+ health=health if isinstance(health, dict) else None,
798
+ )
799
+
800
+
801
+ def export_artifacts(short: str) -> list[tuple[str, str, str]]:
802
+ """``(kind, filename, source path)`` for whatever evidence exists.
803
+
804
+ Empty when a worker has no journal, so an export never advertises evidence
805
+ it does not have.
806
+ """
807
+ path = journal_path(short)
808
+ if not path.exists():
809
+ return []
810
+ return [("codex-evidence-jsonl", "codex-evidence.jsonl", str(path))]
811
+
812
+
813
+ __all__ = [
814
+ "COMPACTION_CONTEXT_METHOD",
815
+ "HEADER_METHOD",
816
+ "JOURNALED_METHODS",
817
+ "TOKEN_USAGE_METHOD",
818
+ "SCHEMA_VERSION",
819
+ "EvidenceJournal",
820
+ "JournalRead",
821
+ "build_index",
822
+ "envelope",
823
+ "export_artifacts",
824
+ "journal_path",
825
+ "read_index",
826
+ "read_journal",
827
+ ]