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,770 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from ccx.codex import ops as codex_ops
|
|
8
|
+
from ccx.claude import telemetry as claude_telemetry
|
|
9
|
+
from ccx.errors import (
|
|
10
|
+
DeliveryRejected,
|
|
11
|
+
InvalidRequest,
|
|
12
|
+
SessionUnavailable,
|
|
13
|
+
worker_not_found,
|
|
14
|
+
)
|
|
15
|
+
from ccx.workers import WorkerClient
|
|
16
|
+
from ccx.store.identity import canonical_worker_id
|
|
17
|
+
from ccx.ops.common import _DEFAULT_REPORT_CHARS, _DEFAULT_TRANSCRIPT_PAGE, _DEFAULT_TRANSCRIPT_TOOL_BYTES, _ENGINES, _TRANSCRIPT_CURSOR_PREFIX, _TRANSCRIPT_LOCATOR_PREFIX, _WORKERS, _decode_opaque, _encode_opaque, recorded_inspector, split_target
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _latest_native_report(
|
|
21
|
+
handle: str, engine: str, worker_id: str, *, deadline: float,
|
|
22
|
+
max_chars: int | None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Latest native turn's reply, with notes about unfinished or failed work.
|
|
25
|
+
|
|
26
|
+
Exact turn_ref reports and the send/new wait paths do not enter here.
|
|
27
|
+
In particular a phase-less/latest cached message is not completion proof.
|
|
28
|
+
"""
|
|
29
|
+
from ccx.claude import delivery as claude_delivery
|
|
30
|
+
from ccx.codex import delivery as codex_delivery
|
|
31
|
+
from ccx import constants
|
|
32
|
+
from ccx.store.root import StorageIntegrityError, NativeActionDenied, require_ccx_active
|
|
33
|
+
|
|
34
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
35
|
+
owner = store.resolve(handle)
|
|
36
|
+
record = store.record(handle)
|
|
37
|
+
lifecycle = record.get("lifecycle") or {}
|
|
38
|
+
state = lifecycle.get("state")
|
|
39
|
+
failed = state in {"crashed", "failed"}
|
|
40
|
+
running = not failed and bool(
|
|
41
|
+
lifecycle.get("activeTurnId") or lifecycle.get("activeTurnRef")
|
|
42
|
+
or state in {"busy", "waiting", "booting", "resuming", "starting"})
|
|
43
|
+
note = "the worker failed" if failed else None
|
|
44
|
+
# A turn still open when the worker failed has no final reply: its last
|
|
45
|
+
# text is partial (e.g. commentary before a tool call).
|
|
46
|
+
unfinished = failed and bool(
|
|
47
|
+
lifecycle.get("activeTurnId") or lifecycle.get("activeTurnRef"))
|
|
48
|
+
running_without_receipt = False
|
|
49
|
+
|
|
50
|
+
receipts = list((codex_delivery if engine == "codex" else
|
|
51
|
+
claude_delivery).iter_receipts(handle))
|
|
52
|
+
# A never-started original, without any recorded native turn/effect,
|
|
53
|
+
# cannot have a completed conversation. Avoid unsupported Codex listing.
|
|
54
|
+
never_started = (not receipts and not record.get("lastTurn")
|
|
55
|
+
and not record.get("lastSettledTurn")
|
|
56
|
+
and not record.get("lastReport")
|
|
57
|
+
and not lifecycle.get("activeTurnId")
|
|
58
|
+
and not lifecycle.get("activeTurnRef")
|
|
59
|
+
and (record.get("stats") or {}).get("turns", 0) == 0)
|
|
60
|
+
if never_started and record.get("historyKnownEmpty") is True:
|
|
61
|
+
raise SessionUnavailable("no conversation turn yet")
|
|
62
|
+
|
|
63
|
+
def settled(ref: str | None) -> dict[str, Any] | None:
|
|
64
|
+
if not ref:
|
|
65
|
+
return None
|
|
66
|
+
matched = [r for r in receipts if r.get("turnRef") == ref
|
|
67
|
+
and isinstance(r.get("settlement"), dict)
|
|
68
|
+
and (engine != "codex" or codex_delivery.route(r) == "new_turn")]
|
|
69
|
+
return matched[0] if len(matched) == 1 else None
|
|
70
|
+
|
|
71
|
+
running_note = ("a newer turn is waiting for input (see ccx peek)"
|
|
72
|
+
if state == "waiting" or record.get("waitingFor")
|
|
73
|
+
else "a newer turn is still running")
|
|
74
|
+
if running:
|
|
75
|
+
# A current turn may already have streamed partial assistant output.
|
|
76
|
+
# Only the last completed, receipt-proven turn is safe to show.
|
|
77
|
+
if engine == "codex":
|
|
78
|
+
last = record.get("lastTurn") or {}
|
|
79
|
+
ref = ("ccx-turn:codex:v1:" + last["turnId"]
|
|
80
|
+
if last.get("status") == "completed"
|
|
81
|
+
and isinstance(last.get("turnId"), str) else None)
|
|
82
|
+
else:
|
|
83
|
+
last = record.get("lastSettledTurn") or {}
|
|
84
|
+
ref = (last.get("turnRef") if last.get("outcome") not in
|
|
85
|
+
{"interrupted", "failed", "cancelled"} else None)
|
|
86
|
+
confirmed = settled(ref)
|
|
87
|
+
if confirmed is None:
|
|
88
|
+
if not ref:
|
|
89
|
+
raise SessionUnavailable("no completed reply yet; a turn is still running")
|
|
90
|
+
running_without_receipt = True
|
|
91
|
+
note = running_note
|
|
92
|
+
else:
|
|
93
|
+
result = report_worker(handle, turn_ref=confirmed["turnRef"],
|
|
94
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
95
|
+
max_chars=max_chars)
|
|
96
|
+
result.setdefault("outcome", None)
|
|
97
|
+
result["note"] = running_note
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
latest = None
|
|
101
|
+
chosen = None
|
|
102
|
+
latest_ref = None
|
|
103
|
+
latest_status = "completed"
|
|
104
|
+
if engine == "codex":
|
|
105
|
+
last = record.get("lastTurn") or {}
|
|
106
|
+
if last.get("status") == "completed" and isinstance(last.get("turnId"), str):
|
|
107
|
+
latest_ref = "ccx-turn:codex:v1:" + last["turnId"]
|
|
108
|
+
chosen = settled(latest_ref)
|
|
109
|
+
if chosen is None:
|
|
110
|
+
# An older codexd may have journaled the native completion
|
|
111
|
+
# before this process learned to settle the accepted receipt.
|
|
112
|
+
# Repair only the one exact opening delivery, never a steer
|
|
113
|
+
# or an unrelated older accepted turn. No thread/read here.
|
|
114
|
+
pending = [r for r in receipts
|
|
115
|
+
if r.get("turnRef") == latest_ref
|
|
116
|
+
and r.get("turnId") == last["turnId"]
|
|
117
|
+
and r.get("state") == "accepted"
|
|
118
|
+
and r.get("settlement") is None
|
|
119
|
+
and codex_delivery.route(r) == "new_turn"]
|
|
120
|
+
if len(pending) == 1:
|
|
121
|
+
from ccx.codex.completion import settle_from_journal
|
|
122
|
+
settle_from_journal(store, handle, last["turnId"])
|
|
123
|
+
receipts = list(codex_delivery.iter_receipts(handle))
|
|
124
|
+
chosen = settled(latest_ref)
|
|
125
|
+
if chosen is not None:
|
|
126
|
+
# lastTurn can still name the completed turn after another
|
|
127
|
+
# message was submitted and then killed or lost.
|
|
128
|
+
chosen_at = chosen.get("createdAtMs")
|
|
129
|
+
if type(chosen_at) is int and any(
|
|
130
|
+
r.get("state") in {"submitted", "accepted"}
|
|
131
|
+
and r.get("settlement") is None
|
|
132
|
+
and codex_delivery.route(r) == "new_turn"
|
|
133
|
+
and type(r.get("createdAtMs")) is int
|
|
134
|
+
and r["createdAtMs"] > chosen_at
|
|
135
|
+
for r in receipts
|
|
136
|
+
):
|
|
137
|
+
note = "a newer message's outcome is unknown"
|
|
138
|
+
if chosen is None:
|
|
139
|
+
from ccx.codex.runtime import read_thread
|
|
140
|
+
from ccx.codex.reports import report_from_turn, native_timestamp_ms
|
|
141
|
+
native = record.get("native") or {}
|
|
142
|
+
thread_id = native.get("threadId")
|
|
143
|
+
try:
|
|
144
|
+
source = store.codex_source(handle)
|
|
145
|
+
except (StorageIntegrityError, SessionUnavailable, NativeActionDenied) as exc:
|
|
146
|
+
if never_started:
|
|
147
|
+
raise SessionUnavailable("no conversation turn yet") from exc
|
|
148
|
+
raise
|
|
149
|
+
if (not isinstance(thread_id, str) or not thread_id
|
|
150
|
+
or native.get("threadSource") != source
|
|
151
|
+
or not isinstance(record.get("cwd"), str)):
|
|
152
|
+
if never_started:
|
|
153
|
+
raise SessionUnavailable("no conversation turn yet")
|
|
154
|
+
raise SessionUnavailable("Codex conversation history is unavailable; check the worker and retry")
|
|
155
|
+
try:
|
|
156
|
+
thread = read_thread(
|
|
157
|
+
thread_id, cwd=record["cwd"], worker_id=worker_id,
|
|
158
|
+
expected_source=source,
|
|
159
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
160
|
+
)
|
|
161
|
+
except SessionUnavailable as exc:
|
|
162
|
+
# The native Codex API does not universally implement empty
|
|
163
|
+
# thread/items/list. Only positively known empty histories
|
|
164
|
+
# warrant this more useful diagnosis.
|
|
165
|
+
if record.get("historyKnownEmpty") is True:
|
|
166
|
+
raise SessionUnavailable("no conversation turn yet") from exc
|
|
167
|
+
# A receipt proves its own settled reply even when Codex's
|
|
168
|
+
# thread history temporarily cannot be read. Do not imply
|
|
169
|
+
# this is necessarily the latest native conversation turn.
|
|
170
|
+
proven = [
|
|
171
|
+
r for r in receipts
|
|
172
|
+
if isinstance(r.get("settlement"), dict)
|
|
173
|
+
and codex_delivery.route(r) == "new_turn"
|
|
174
|
+
and type(r.get("createdAtMs")) is int
|
|
175
|
+
]
|
|
176
|
+
if not proven:
|
|
177
|
+
raise
|
|
178
|
+
chosen = max(proven, key=lambda r: r["createdAtMs"])
|
|
179
|
+
result = report_worker(
|
|
180
|
+
handle, turn_ref=chosen["turnRef"],
|
|
181
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
182
|
+
max_chars=max_chars,
|
|
183
|
+
)
|
|
184
|
+
result.setdefault("outcome", None)
|
|
185
|
+
result["note"] = "native history unavailable; showing the last recorded reply"
|
|
186
|
+
return result
|
|
187
|
+
if (not isinstance(thread, dict)
|
|
188
|
+
or thread.get("id") != thread_id
|
|
189
|
+
or thread.get("threadSource") != source):
|
|
190
|
+
raise SessionUnavailable("Codex report history does not match this worker")
|
|
191
|
+
native_turns = [t for t in (thread.get("turns") or [])
|
|
192
|
+
if isinstance(t, dict)]
|
|
193
|
+
compact_id = (record.get("lastCompact") or {}).get("turnId")
|
|
194
|
+
# Only skip positively identified internal compaction turns.
|
|
195
|
+
# No heuristic based on blank text or missing assistant output.
|
|
196
|
+
while native_turns and (
|
|
197
|
+
(isinstance(compact_id, str)
|
|
198
|
+
and native_turns[-1].get("id") == compact_id)
|
|
199
|
+
or any(
|
|
200
|
+
isinstance(item, dict) and item.get("type") == "contextCompaction"
|
|
201
|
+
for item in (native_turns[-1].get("items") or [])
|
|
202
|
+
)
|
|
203
|
+
) and not (
|
|
204
|
+
any(
|
|
205
|
+
isinstance(item, dict) and item.get("type") == "userMessage"
|
|
206
|
+
for item in (native_turns[-1].get("items") or [])
|
|
207
|
+
)
|
|
208
|
+
or report_from_turn(native_turns[-1]) is not None
|
|
209
|
+
):
|
|
210
|
+
# Automatic compaction inside a genuine user/reply turn is
|
|
211
|
+
# not an internal-only turn; never discard its final answer.
|
|
212
|
+
native_turns.pop()
|
|
213
|
+
if running_without_receipt:
|
|
214
|
+
while native_turns and (
|
|
215
|
+
native_turns[-1].get("status") != "completed"
|
|
216
|
+
or report_from_turn(native_turns[-1]) is None
|
|
217
|
+
):
|
|
218
|
+
native_turns.pop()
|
|
219
|
+
if not native_turns:
|
|
220
|
+
raise SessionUnavailable("no completed reply yet; a turn is still running")
|
|
221
|
+
if native_turns and native_turns[-1].get("status") in {
|
|
222
|
+
"inProgress", "in_progress", "pending",
|
|
223
|
+
}:
|
|
224
|
+
raise DeliveryRejected(
|
|
225
|
+
"latest Codex turn is busy; no completed report is available",
|
|
226
|
+
short=worker_id[:8], state="busy", refusal="busy",
|
|
227
|
+
)
|
|
228
|
+
# The LAST native turn decides the outcome. A later failed or
|
|
229
|
+
# interrupted turn must never borrow an older successful answer.
|
|
230
|
+
if not native_turns:
|
|
231
|
+
raise SessionUnavailable("no conversation turn yet")
|
|
232
|
+
turn = native_turns[-1]
|
|
233
|
+
status = turn.get("status")
|
|
234
|
+
if status not in {"completed", "failed", "interrupted", "cancelled"}:
|
|
235
|
+
raise SessionUnavailable("latest Codex turn has no terminal outcome")
|
|
236
|
+
tid = turn.get("id")
|
|
237
|
+
if not isinstance(tid, str) or not tid:
|
|
238
|
+
raise SessionUnavailable("latest Codex terminal turn has no native identity")
|
|
239
|
+
latest_ref = "ccx-turn:codex:v1:" + tid
|
|
240
|
+
chosen = settled(latest_ref) if status == "completed" else None
|
|
241
|
+
selected = report_from_turn(turn) if status == "completed" else None
|
|
242
|
+
latest = {
|
|
243
|
+
"turn_ref": latest_ref, "turn_id": tid, "status": status,
|
|
244
|
+
"phase": selected.get("phase") if selected else None,
|
|
245
|
+
"item_id": selected.get("itemId") if selected else None,
|
|
246
|
+
"completed_at": (selected.get("completedAt") if selected else
|
|
247
|
+
native_timestamp_ms(turn.get("completedAt"))),
|
|
248
|
+
"text": selected["text"] if selected else "",
|
|
249
|
+
"has_final_message": selected is not None,
|
|
250
|
+
}
|
|
251
|
+
else:
|
|
252
|
+
from ccx.ops.conversation_paging import _reverse_boundary_events
|
|
253
|
+
from ccx.claude.managed_turns import report_window_for_turn_ref
|
|
254
|
+
from ccx.claude.transcript import _iter_clean_turns, open_original_claude_source
|
|
255
|
+
last = record.get("lastSettledTurn") or {}
|
|
256
|
+
ref = last.get("turnRef")
|
|
257
|
+
candidate = settled(ref)
|
|
258
|
+
try:
|
|
259
|
+
with open_original_claude_source(store, owner, record) as source:
|
|
260
|
+
if source is not None:
|
|
261
|
+
pinned, _stat, eof, _path, _parent = source
|
|
262
|
+
# A reverse read over the same pinned native fd finds the
|
|
263
|
+
# last genuine user boundary without decoding months of
|
|
264
|
+
# prior JSONL on every completion notification. Ignore
|
|
265
|
+
# Claude's synthetic auto-continue as the cleaner does.
|
|
266
|
+
ordinary_boundaries: list[int] = []
|
|
267
|
+
report_end = eof
|
|
268
|
+
if running_without_receipt and ref:
|
|
269
|
+
window, available = report_window_for_turn_ref(worker_id, ref)
|
|
270
|
+
end = window.get("endOffset") if available and window else None
|
|
271
|
+
if (type(end) is int and 0 <= end <= eof
|
|
272
|
+
and window.get("endOffsetKnown") is True):
|
|
273
|
+
report_end = end
|
|
274
|
+
if running_without_receipt and report_end == eof:
|
|
275
|
+
active_ref = lifecycle.get("activeTurnRef")
|
|
276
|
+
if isinstance(active_ref, str) and active_ref:
|
|
277
|
+
window, available = report_window_for_turn_ref(worker_id, active_ref)
|
|
278
|
+
start = window.get("startOffset") if available and window else None
|
|
279
|
+
if type(start) is int and 0 <= start <= eof:
|
|
280
|
+
report_end = start
|
|
281
|
+
for event in _reverse_boundary_events(pinned, before_offset=report_end):
|
|
282
|
+
content = (event.get("message") or {}).get("content")
|
|
283
|
+
if isinstance(content, list):
|
|
284
|
+
content = "\n".join(
|
|
285
|
+
block.get("text", "") for block in content
|
|
286
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
287
|
+
)
|
|
288
|
+
if content == "Continue from where you left off.":
|
|
289
|
+
continue
|
|
290
|
+
if (not ordinary_boundaries and isinstance(content, str)
|
|
291
|
+
and (content.strip() == "/compact"
|
|
292
|
+
or "<command-name>/compact</command-name>" in content)):
|
|
293
|
+
# Claude's local /compact and its command echo
|
|
294
|
+
# are not a conversation turn. Ignore the entire
|
|
295
|
+
# trailing command when selecting a reply.
|
|
296
|
+
report_end = min(report_end, event["_ccx_start_offset"])
|
|
297
|
+
continue
|
|
298
|
+
ordinary_boundaries.append(event["_ccx_start_offset"])
|
|
299
|
+
# A second boundary is needed only for an existing
|
|
300
|
+
# receipt: without the successor's managed start event,
|
|
301
|
+
# its endOffset is unknown and the latest turn may be
|
|
302
|
+
# an entirely different user request after that receipt.
|
|
303
|
+
if len(ordinary_boundaries) >= (2 if candidate else 1):
|
|
304
|
+
break
|
|
305
|
+
start_offset = ordinary_boundaries[0] if ordinary_boundaries else 0
|
|
306
|
+
text = ""
|
|
307
|
+
seen = False
|
|
308
|
+
for part in _iter_clean_turns(
|
|
309
|
+
pinned, after_offset=start_offset, before_offset=report_end,
|
|
310
|
+
):
|
|
311
|
+
if part.get("role") == "user" and not part.get("queued"):
|
|
312
|
+
text = ""
|
|
313
|
+
seen = True
|
|
314
|
+
elif part.get("role") == "assistant":
|
|
315
|
+
seen = True
|
|
316
|
+
answer = part.get("text")
|
|
317
|
+
if isinstance(answer, str) and answer.strip():
|
|
318
|
+
text = answer.strip()
|
|
319
|
+
if seen:
|
|
320
|
+
latest = {"text": text, "has_final_message": bool(text)}
|
|
321
|
+
# The durable lastSettledTurn outcome belongs to this
|
|
322
|
+
# native answer only when its managed start/end offsets
|
|
323
|
+
# enclose the latest ordinary user boundary. An older
|
|
324
|
+
# receipt or interrupted terminal cannot be borrowed.
|
|
325
|
+
if ref and (candidate is not None or last.get("outcome") == "interrupted"):
|
|
326
|
+
window, available = report_window_for_turn_ref(worker_id, ref)
|
|
327
|
+
start = window.get("startOffset") if available and window else None
|
|
328
|
+
end = window.get("endOffset") if available and window else None
|
|
329
|
+
if (type(start) is int and start >= 0
|
|
330
|
+
and window.get("endOffsetKnown") is True
|
|
331
|
+
and window.get("settled") is True
|
|
332
|
+
and ordinary_boundaries
|
|
333
|
+
and start <= start_offset
|
|
334
|
+
and (end is None or start_offset < end)
|
|
335
|
+
and (len(ordinary_boundaries) == 1
|
|
336
|
+
or ordinary_boundaries[1] < start)):
|
|
337
|
+
if last.get("outcome") == "interrupted":
|
|
338
|
+
latest_status = "interrupted"
|
|
339
|
+
else:
|
|
340
|
+
chosen = candidate
|
|
341
|
+
except (StorageIntegrityError, OSError, ValueError) as exc:
|
|
342
|
+
if never_started:
|
|
343
|
+
raise SessionUnavailable("no conversation turn yet") from exc
|
|
344
|
+
raise SessionUnavailable("Claude conversation history is unavailable; check the worker and retry") from exc
|
|
345
|
+
if latest is None and record.get("historyKnownEmpty") is True:
|
|
346
|
+
raise SessionUnavailable("no conversation turn yet")
|
|
347
|
+
if latest is None:
|
|
348
|
+
# A synthetic/legacy source can be absent even though a single
|
|
349
|
+
# original receipt has already been positively proved. Preserve
|
|
350
|
+
# that strict receipt path unless a newer recorded terminal names
|
|
351
|
+
# a different turn. Never fabricate native transcript evidence.
|
|
352
|
+
proven = [r for r in receipts if isinstance(r.get("settlement"), dict)]
|
|
353
|
+
if candidate is not None:
|
|
354
|
+
chosen = candidate
|
|
355
|
+
elif not ref and len(proven) == 1:
|
|
356
|
+
chosen = proven[0]
|
|
357
|
+
|
|
358
|
+
if chosen is not None:
|
|
359
|
+
# Invoke precisely the old receipt-backed path, with its original
|
|
360
|
+
# effect reconciliation and strict identity checks intact.
|
|
361
|
+
result = report_worker(handle, turn_ref=chosen["turnRef"],
|
|
362
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
363
|
+
max_chars=max_chars)
|
|
364
|
+
result.setdefault("outcome", None)
|
|
365
|
+
result["note"] = note
|
|
366
|
+
return result
|
|
367
|
+
if latest is None:
|
|
368
|
+
raise SessionUnavailable("no conversation turn yet")
|
|
369
|
+
full_text = latest["text"]
|
|
370
|
+
store.revalidate()
|
|
371
|
+
return {
|
|
372
|
+
"worker": handle, "worker_id": worker_id, "engine": engine,
|
|
373
|
+
"short": worker_id[:8], "delivery_id": None, "delivery": None,
|
|
374
|
+
"native_evidence_sha256": None,
|
|
375
|
+
"turn_ref": latest.get("turn_ref"), "turn_id": latest.get("turn_id"),
|
|
376
|
+
"turn": None, "item_id": latest.get("item_id"),
|
|
377
|
+
"phase": latest.get("phase"),
|
|
378
|
+
"status": "failed" if unfinished else latest.get("status", latest_status),
|
|
379
|
+
# For Claude, the exact lastSettledTurn interrupted verdict is bound
|
|
380
|
+
# above to this latest original user boundary. Keep its JSON outcome
|
|
381
|
+
# alongside status; never relabel a different native turn.
|
|
382
|
+
"outcome": (latest.get("status") if latest.get("status") in
|
|
383
|
+
{"interrupted", "cancelled"} else
|
|
384
|
+
latest_status if latest_status in {"interrupted", "cancelled"} else None),
|
|
385
|
+
"text": full_text if max_chars is None else full_text[:max_chars],
|
|
386
|
+
"truncated": max_chars is not None and len(full_text) > max_chars,
|
|
387
|
+
"has_final_message": latest["has_final_message"] and not unfinished,
|
|
388
|
+
"settled_at_ms": latest.get("completed_at"),
|
|
389
|
+
"source": "native-transcript-latest",
|
|
390
|
+
"note": note,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def report_worker(
|
|
395
|
+
target: str, *, timeout: float = 90,
|
|
396
|
+
max_chars: int | None = _DEFAULT_REPORT_CHARS,
|
|
397
|
+
turn_ref: str | None = None,
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Default latest turn, or one exact source-proven original turn_ref."""
|
|
400
|
+
from ccx.ops.wait import reconcile_turn
|
|
401
|
+
from ccx.claude import delivery as claude_delivery
|
|
402
|
+
from ccx import constants
|
|
403
|
+
from ccx.store.root import require_ccx_active
|
|
404
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
405
|
+
raise InvalidRequest("report timeout must be positive")
|
|
406
|
+
deadline = time.monotonic() + timeout
|
|
407
|
+
if max_chars is not None and (
|
|
408
|
+
type(max_chars) is not int or max_chars < 0):
|
|
409
|
+
raise InvalidRequest("report max_chars must be nonnegative integer")
|
|
410
|
+
handle, engine, worker_id = split_target(target)
|
|
411
|
+
if turn_ref is None:
|
|
412
|
+
return _latest_native_report(
|
|
413
|
+
handle, engine, worker_id, deadline=deadline, max_chars=max_chars,
|
|
414
|
+
)
|
|
415
|
+
from ccx.codex import delivery as codex_delivery
|
|
416
|
+
receipts = list(codex_delivery.iter_receipts(handle)
|
|
417
|
+
if engine == "codex" else claude_delivery.iter_receipts(handle))
|
|
418
|
+
from ccx.ops import turn_reports
|
|
419
|
+
native_turn = turn_reports._ref_id(turn_ref, engine=engine)
|
|
420
|
+
has_receipt = any(
|
|
421
|
+
row.get("turnRef") == turn_ref
|
|
422
|
+
or (engine == "claude" and row.get("targetTurnRef") == turn_ref)
|
|
423
|
+
or (engine == "codex" and native_turn in (
|
|
424
|
+
row.get("turnId"), row.get("targetTurnId")
|
|
425
|
+
))
|
|
426
|
+
for row in receipts
|
|
427
|
+
)
|
|
428
|
+
if not has_receipt:
|
|
429
|
+
# No original semantic delivery of any state names this R. A
|
|
430
|
+
# matching pending or uncertain token remains receipt-authoritative.
|
|
431
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
432
|
+
owner = store.resolve(handle)
|
|
433
|
+
if engine == "codex":
|
|
434
|
+
from ccx.codex.runtime import read_thread
|
|
435
|
+
record = store.record(handle)
|
|
436
|
+
native_id = (record.get("native") or {}).get("threadId")
|
|
437
|
+
if not isinstance(native_id, str) or not native_id:
|
|
438
|
+
raise SessionUnavailable("Codex conversation is missing; inspect the worker")
|
|
439
|
+
if not isinstance(record.get("cwd"), str):
|
|
440
|
+
raise SessionUnavailable("Codex working directory is missing; inspect the worker")
|
|
441
|
+
thread = read_thread(
|
|
442
|
+
native_id, cwd=record["cwd"], worker_id=owner.worker_id,
|
|
443
|
+
expected_source=store.codex_source(owner.worker),
|
|
444
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
thread = None
|
|
448
|
+
exact = turn_reports.exact_report(store, owner, turn_ref, thread=thread)
|
|
449
|
+
full_text = exact["text"]
|
|
450
|
+
text = full_text if max_chars is None else full_text[:max_chars]
|
|
451
|
+
return {
|
|
452
|
+
"worker": handle, "worker_id": worker_id, "engine": engine,
|
|
453
|
+
"short": worker_id[:8], "delivery_id": None,
|
|
454
|
+
"delivery": None, "native_evidence_sha256": None,
|
|
455
|
+
"turn_ref": exact["turn_ref"], "turn_id": exact.get("turn_id"),
|
|
456
|
+
"turn": exact.get("turn"), "item_id": exact.get("item_id"),
|
|
457
|
+
"phase": exact.get("phase"), "status": "completed", "outcome": None,
|
|
458
|
+
"text": text, "truncated": len(text) != len(full_text),
|
|
459
|
+
"has_final_message": exact["has_final_message"],
|
|
460
|
+
"settled_at_ms": exact.get("completed_at"),
|
|
461
|
+
"source": "original-native-exact-turn",
|
|
462
|
+
}
|
|
463
|
+
if engine == "codex":
|
|
464
|
+
# Report observes the native original Codex thread/turn without any
|
|
465
|
+
# new provider mutation. Each receipt remains the only owner token.
|
|
466
|
+
for candidate in receipts:
|
|
467
|
+
if (candidate.get("state") == "accepted"
|
|
468
|
+
and not candidate.get("settlement")
|
|
469
|
+
and candidate.get("turnRef") == turn_ref):
|
|
470
|
+
reconcile_turn(
|
|
471
|
+
handle, delivery_id=candidate["deliveryId"],
|
|
472
|
+
timeout=max(0.001, deadline - time.monotonic()),
|
|
473
|
+
)
|
|
474
|
+
rows = [
|
|
475
|
+
row for row in codex_delivery.iter_receipts(handle)
|
|
476
|
+
if isinstance(row.get("settlement"), dict)
|
|
477
|
+
and row.get("turnRef") == turn_ref
|
|
478
|
+
]
|
|
479
|
+
if not rows:
|
|
480
|
+
raise SessionUnavailable("Codex turn is not confirmed complete; try again later")
|
|
481
|
+
rows.sort(key=lambda row: (
|
|
482
|
+
-(row.get("settledAtMs") or 0), row.get("deliveryId") or ""))
|
|
483
|
+
row = rows[0]
|
|
484
|
+
actual = row["settlement"]
|
|
485
|
+
if (actual.get("workerId") != worker_id
|
|
486
|
+
or actual.get("turnId") != row.get("turnId")
|
|
487
|
+
or actual.get("status") != "completed"):
|
|
488
|
+
raise SessionUnavailable("Codex completion could not be verified; inspect the turn")
|
|
489
|
+
raw = actual["text"]
|
|
490
|
+
text = raw if max_chars is None else raw[:max_chars]
|
|
491
|
+
return {
|
|
492
|
+
"worker": handle, "worker_id": worker_id, "engine": "codex",
|
|
493
|
+
"short": worker_id[:8], "delivery_id": row["deliveryId"],
|
|
494
|
+
"turn_id": row["turnId"], "turn_ref": row["turnRef"],
|
|
495
|
+
"phase": "completed", "status": "completed", "outcome": None,
|
|
496
|
+
"text": text, "truncated": len(text) != len(raw),
|
|
497
|
+
"has_final_message": bool(raw),
|
|
498
|
+
"settled_at_ms": row.get("settledAtMs"),
|
|
499
|
+
"native_evidence_sha256": actual.get("nativeEvidenceSha256"),
|
|
500
|
+
"delivery": codex_delivery.public_receipt(row),
|
|
501
|
+
"source": "original-native-completion-receipt",
|
|
502
|
+
}
|
|
503
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
504
|
+
matches = [
|
|
505
|
+
row for row in receipts
|
|
506
|
+
if isinstance(row.get("settlement"), dict)
|
|
507
|
+
and row.get("turnRef") == turn_ref
|
|
508
|
+
]
|
|
509
|
+
if not matches:
|
|
510
|
+
raise SessionUnavailable(
|
|
511
|
+
"No verified completed reply is available for this turn"
|
|
512
|
+
)
|
|
513
|
+
row = matches[0]
|
|
514
|
+
# Host publication is a pair of fsynced effects: first the original
|
|
515
|
+
# semantic Stop receipt, then the global native-effect accepted receipt.
|
|
516
|
+
# A report racing those two writes (or arriving after a host crash) must
|
|
517
|
+
# forward-reconcile the same original Stop source, not return a false
|
|
518
|
+
# success while the globally durable native effect remains unknown.
|
|
519
|
+
verified = reconcile_turn(handle, delivery_id=row["deliveryId"])
|
|
520
|
+
if verified["status"] != "settled":
|
|
521
|
+
raise SessionUnavailable(
|
|
522
|
+
"Claude turn is not confirmed complete; reconcile its delivery"
|
|
523
|
+
)
|
|
524
|
+
settlement = row["settlement"]
|
|
525
|
+
if (settlement.get("workerId") != worker_id
|
|
526
|
+
or settlement.get("turnRef") != row.get("turnRef")
|
|
527
|
+
or settlement.get("status") != "completed"):
|
|
528
|
+
raise SessionUnavailable("Claude completion could not be verified; inspect the delivery")
|
|
529
|
+
full_text = settlement["text"]
|
|
530
|
+
text = full_text if max_chars is None else full_text[:max_chars]
|
|
531
|
+
return {
|
|
532
|
+
"worker": handle, "worker_id": worker_id, "engine": "claude",
|
|
533
|
+
"short": worker_id[:8], "delivery_id": row["deliveryId"],
|
|
534
|
+
"turn_ref": row["turnRef"],
|
|
535
|
+
"phase": "completed", "status": "completed", "outcome": None,
|
|
536
|
+
"text": text, "truncated": len(text) != len(full_text),
|
|
537
|
+
"has_final_message": bool(full_text),
|
|
538
|
+
"settled_at_ms": row.get("settledAtMs"),
|
|
539
|
+
"native_evidence_sha256": settlement.get("nativeEvidenceSha256"),
|
|
540
|
+
"delivery": claude_delivery.public_receipt(row),
|
|
541
|
+
"source": "original-native-completion-receipt",
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def account_usage_read(
|
|
546
|
+
*,
|
|
547
|
+
engine: str,
|
|
548
|
+
timeout: float = 30,
|
|
549
|
+
) -> dict[str, Any]:
|
|
550
|
+
if engine == "codex":
|
|
551
|
+
return codex_ops.account_usage_read(timeout=timeout)
|
|
552
|
+
if engine != "claude":
|
|
553
|
+
raise InvalidRequest(f"unsupported engine: {engine!r}")
|
|
554
|
+
result = _WORKERS.usage("claude", timeout=timeout)
|
|
555
|
+
windows = []
|
|
556
|
+
for window in result.get("windows") or []:
|
|
557
|
+
used = window.get("used_percent")
|
|
558
|
+
windows.append({
|
|
559
|
+
"provider": "claude",
|
|
560
|
+
"window_id": window.get("id"),
|
|
561
|
+
"limit_id": window.get("id"),
|
|
562
|
+
"kind": window.get("label") or "quota",
|
|
563
|
+
"window_duration_seconds": None,
|
|
564
|
+
"used_percent": used,
|
|
565
|
+
"remaining_percent": (
|
|
566
|
+
max(0.0, 100.0 - float(used))
|
|
567
|
+
if isinstance(used, (int, float))
|
|
568
|
+
and not isinstance(used, bool)
|
|
569
|
+
else None
|
|
570
|
+
),
|
|
571
|
+
"resets_at": window.get("resets_at"),
|
|
572
|
+
})
|
|
573
|
+
return {
|
|
574
|
+
"provider": "claude",
|
|
575
|
+
"observed_at_ms": int(time.time() * 1000),
|
|
576
|
+
"windows": windows,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _conversation_revision(document: dict[str, Any]) -> str:
|
|
581
|
+
# This revision fences the paged conversation CONTENT, not the read operation.
|
|
582
|
+
# generatedAt, live state and top-level coverage/evidence metadata can change
|
|
583
|
+
# while the ordered normalized turns are byte-for-byte identical.
|
|
584
|
+
revision_source = {
|
|
585
|
+
"schemaVersion": document.get("schemaVersion"),
|
|
586
|
+
"turns": document.get("turns") or [],
|
|
587
|
+
}
|
|
588
|
+
raw = json.dumps(
|
|
589
|
+
revision_source,
|
|
590
|
+
sort_keys=True,
|
|
591
|
+
separators=(",", ":"),
|
|
592
|
+
ensure_ascii=False,
|
|
593
|
+
).encode("utf-8")
|
|
594
|
+
return hashlib.sha256(raw).hexdigest()
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def read_transcript(
|
|
598
|
+
locator: str,
|
|
599
|
+
*,
|
|
600
|
+
cursor: str | None = None,
|
|
601
|
+
limit: int = _DEFAULT_TRANSCRIPT_PAGE,
|
|
602
|
+
max_tool_result_bytes: int | None = _DEFAULT_TRANSCRIPT_TOOL_BYTES,
|
|
603
|
+
timeout: float = 120,
|
|
604
|
+
) -> dict[str, Any]:
|
|
605
|
+
"""Read normalized turns from the original owned full-UUID conversation."""
|
|
606
|
+
from ccx.ops import conversation as normalized
|
|
607
|
+
from ccx.errors import CursorInvalidated, OperationTimeout
|
|
608
|
+
from ccx.store.identity import IdentityError, UnknownWorker
|
|
609
|
+
|
|
610
|
+
# Independent from the source-record ccxcp2 pager and its cursor semantics.
|
|
611
|
+
payload = _decode_opaque(_TRANSCRIPT_LOCATOR_PREFIX, locator,
|
|
612
|
+
label="transcript locator")
|
|
613
|
+
expected = {"v", "engine", "worker_id", "root", "generation"}
|
|
614
|
+
if (set(payload) != expected or type(payload["v"]) is not int
|
|
615
|
+
or payload["v"] != 2
|
|
616
|
+
or not isinstance(payload["engine"], str)
|
|
617
|
+
or payload["engine"] not in _ENGINES
|
|
618
|
+
or not isinstance(payload["root"], str)
|
|
619
|
+
or len(payload["root"]) != 64
|
|
620
|
+
or any(ch not in "0123456789abcdef" for ch in payload["root"])
|
|
621
|
+
or not isinstance(payload["generation"], str)
|
|
622
|
+
or not payload["generation"]):
|
|
623
|
+
raise InvalidRequest("invalid transcript locator")
|
|
624
|
+
try:
|
|
625
|
+
canonical_worker_id(payload["worker_id"])
|
|
626
|
+
except IdentityError as error:
|
|
627
|
+
raise InvalidRequest("invalid transcript locator worker") from error
|
|
628
|
+
|
|
629
|
+
offset = 0
|
|
630
|
+
prior_revision = None
|
|
631
|
+
if cursor is not None:
|
|
632
|
+
token = _decode_opaque(_TRANSCRIPT_CURSOR_PREFIX, cursor,
|
|
633
|
+
label="transcript cursor")
|
|
634
|
+
if (set(token) != expected | {"offset", "revision"}
|
|
635
|
+
or type(token["v"]) is not int or token["v"] != 2
|
|
636
|
+
or type(token["offset"]) is not int or token["offset"] < 0
|
|
637
|
+
or not isinstance(token["revision"], str)
|
|
638
|
+
or len(token["revision"]) != 64
|
|
639
|
+
or any(ch not in "0123456789abcdef" for ch in token["revision"])
|
|
640
|
+
or not isinstance(token["generation"], str)
|
|
641
|
+
or not token["generation"]):
|
|
642
|
+
raise InvalidRequest("invalid transcript cursor")
|
|
643
|
+
if any(token[key] != payload[key] for key in
|
|
644
|
+
("engine", "worker_id", "root")):
|
|
645
|
+
raise InvalidRequest("transcript cursor belongs to another source")
|
|
646
|
+
offset, prior_revision = token["offset"], token["revision"]
|
|
647
|
+
if type(limit) is not int or not 1 <= limit <= 100:
|
|
648
|
+
raise InvalidRequest("transcript limit must be between 1 and 100")
|
|
649
|
+
normalized._requested_label(max_tool_result_bytes)
|
|
650
|
+
if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
651
|
+
or not 0 < timeout < float("inf")):
|
|
652
|
+
raise InvalidRequest("transcript timeout must be positive and finite")
|
|
653
|
+
deadline = time.monotonic() + timeout
|
|
654
|
+
|
|
655
|
+
client = WorkerClient()
|
|
656
|
+
store = client._uuid_store
|
|
657
|
+
root = hashlib.sha256(os.fsencode(store.root)).hexdigest()
|
|
658
|
+
if payload["root"] != root:
|
|
659
|
+
raise InvalidRequest("transcript locator belongs to another root")
|
|
660
|
+
if (payload["generation"] != store.generation
|
|
661
|
+
or (cursor is not None and token["generation"] != store.generation)):
|
|
662
|
+
raise CursorInvalidated(
|
|
663
|
+
"transcript generation changed; restart with a new locator"
|
|
664
|
+
)
|
|
665
|
+
try:
|
|
666
|
+
owner = store.resolve(f"{payload['engine']}:{payload['worker_id']}")
|
|
667
|
+
except UnknownWorker as error:
|
|
668
|
+
raise worker_not_found(f"{payload['engine']}:{payload['worker_id']}") from error
|
|
669
|
+
except IdentityError as error:
|
|
670
|
+
raise InvalidRequest("transcript locator engine does not own worker") from error
|
|
671
|
+
|
|
672
|
+
remaining = deadline - time.monotonic()
|
|
673
|
+
if remaining <= 0:
|
|
674
|
+
raise OperationTimeout("transcript read deadline expired")
|
|
675
|
+
document = client.conversation(
|
|
676
|
+
owner.worker, max_tool_result_bytes=max_tool_result_bytes,
|
|
677
|
+
timeout=remaining,
|
|
678
|
+
)
|
|
679
|
+
revision = _conversation_revision(document)
|
|
680
|
+
turns = list(document.get("turns") or [])
|
|
681
|
+
page = turns[offset:offset + limit]
|
|
682
|
+
next_offset = offset + len(page)
|
|
683
|
+
has_more = next_offset < len(turns)
|
|
684
|
+
next_cursor = (
|
|
685
|
+
_encode_opaque(
|
|
686
|
+
_TRANSCRIPT_CURSOR_PREFIX,
|
|
687
|
+
{**payload, "offset": next_offset, "revision": revision},
|
|
688
|
+
)
|
|
689
|
+
if has_more
|
|
690
|
+
else None
|
|
691
|
+
)
|
|
692
|
+
# Reuse the existing protocol recorded-only snapshot; never restore live inspect.
|
|
693
|
+
inspected = recorded_inspector(store, owner.worker)
|
|
694
|
+
store.revalidate()
|
|
695
|
+
if time.monotonic() >= deadline:
|
|
696
|
+
raise OperationTimeout("transcript read deadline expired")
|
|
697
|
+
return {
|
|
698
|
+
"worker": inspected,
|
|
699
|
+
"transcript_locator": locator,
|
|
700
|
+
"conversation_schema_version": document.get("schemaVersion"),
|
|
701
|
+
"turns": page,
|
|
702
|
+
"completeness": document.get("completeness"),
|
|
703
|
+
"evidence": document.get("evidence"),
|
|
704
|
+
"source_revision": revision,
|
|
705
|
+
"source_changed": (
|
|
706
|
+
prior_revision is not None and prior_revision != revision
|
|
707
|
+
),
|
|
708
|
+
"next_cursor": next_cursor,
|
|
709
|
+
"has_more": has_more,
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def conversation_page(
|
|
714
|
+
target: str,
|
|
715
|
+
*,
|
|
716
|
+
cursor: str | None = None,
|
|
717
|
+
source_record_target: int = 40,
|
|
718
|
+
max_tool_result_bytes: int | None = 16_384,
|
|
719
|
+
timeout: float = 30,
|
|
720
|
+
) -> dict[str, Any]:
|
|
721
|
+
return WorkerClient().conversation_page(
|
|
722
|
+
target,
|
|
723
|
+
cursor=cursor,
|
|
724
|
+
source_record_target=source_record_target,
|
|
725
|
+
max_tool_result_bytes=max_tool_result_bytes,
|
|
726
|
+
timeout=timeout,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def telemetry_worker(
|
|
731
|
+
target: str,
|
|
732
|
+
*,
|
|
733
|
+
after_cursor: str | None = None,
|
|
734
|
+
limit: int = 200,
|
|
735
|
+
timeout: float = 120,
|
|
736
|
+
) -> dict[str, Any]:
|
|
737
|
+
"""Original full-UUID source-bound Claude/Codex telemetry read."""
|
|
738
|
+
from ccx import constants
|
|
739
|
+
from ccx.store.root import require_ccx_active, GenerationError
|
|
740
|
+
from ccx.claude.transcript import open_original_claude_source
|
|
741
|
+
|
|
742
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
743
|
+
owner = store.resolve(target)
|
|
744
|
+
if owner.engine == "codex":
|
|
745
|
+
result = codex_ops.telemetry_worker(
|
|
746
|
+
owner.worker, after_cursor=after_cursor,
|
|
747
|
+
limit=limit, timeout=timeout,
|
|
748
|
+
)
|
|
749
|
+
store.revalidate()
|
|
750
|
+
return result
|
|
751
|
+
rec = store.record(owner.worker)
|
|
752
|
+
sid = (rec.get("native") or {}).get("sessionId")
|
|
753
|
+
try:
|
|
754
|
+
with open_original_claude_source(store, owner, rec) as original:
|
|
755
|
+
pinned = original[0] if original is not None else ""
|
|
756
|
+
end_offset = original[2] if original is not None else 0
|
|
757
|
+
view = {**rec, "short": owner.short, "sessionId": sid,
|
|
758
|
+
"transcript": pinned}
|
|
759
|
+
result = claude_telemetry.read_telemetry(
|
|
760
|
+
owner.short, after_cursor=after_cursor, limit=limit,
|
|
761
|
+
record=view, cursor_worker_id=owner.worker_id,
|
|
762
|
+
before_offset=end_offset, source_missing=original is None,
|
|
763
|
+
)
|
|
764
|
+
store.revalidate()
|
|
765
|
+
return result
|
|
766
|
+
except (GenerationError, OSError) as error:
|
|
767
|
+
raise SessionUnavailable(
|
|
768
|
+
f"Cannot read Claude activity; check the worker: {error}",
|
|
769
|
+
short=owner.short,
|
|
770
|
+
) from error
|