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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Reconstruct a native thread from source-attested paged Codex history."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
_PAGE_SIZE = 100
|
|
8
|
+
_MAX_PAGES = 10_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _remaining(deadline: float) -> float:
|
|
12
|
+
remaining = deadline - time.monotonic()
|
|
13
|
+
if remaining <= 0:
|
|
14
|
+
raise TimeoutError("Codex history paging timed out")
|
|
15
|
+
return remaining
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _full_turn_items(request: Callable[..., dict], thread_id: str,
|
|
19
|
+
turn_id: str, deadline: float) -> list[dict]:
|
|
20
|
+
items: list[dict] = []
|
|
21
|
+
cursor: str | None = None
|
|
22
|
+
seen: set[str] = set()
|
|
23
|
+
for _ in range(_MAX_PAGES):
|
|
24
|
+
params: dict[str, Any] = {
|
|
25
|
+
"threadId": thread_id, "turnId": turn_id,
|
|
26
|
+
"limit": _PAGE_SIZE, "sortDirection": "asc",
|
|
27
|
+
}
|
|
28
|
+
if cursor is not None:
|
|
29
|
+
params["cursor"] = cursor
|
|
30
|
+
page = request("thread/items/list", params, timeout=_remaining(deadline))
|
|
31
|
+
entries = page.get("data") if isinstance(page, dict) else None
|
|
32
|
+
if not isinstance(entries, list):
|
|
33
|
+
raise ValueError("Codex item-history page is malformed")
|
|
34
|
+
for wrapper in entries:
|
|
35
|
+
if (not isinstance(wrapper, dict) or wrapper.get("turnId") != turn_id
|
|
36
|
+
or not isinstance(wrapper.get("item"), dict)):
|
|
37
|
+
raise ValueError("Codex item-history page belongs to another turn")
|
|
38
|
+
items.append(wrapper["item"])
|
|
39
|
+
next_cursor = page.get("nextCursor")
|
|
40
|
+
if next_cursor is None:
|
|
41
|
+
return items
|
|
42
|
+
if (not isinstance(next_cursor, str) or not next_cursor
|
|
43
|
+
or next_cursor in seen or not entries):
|
|
44
|
+
raise ValueError("Codex item-history cursor did not advance")
|
|
45
|
+
seen.add(next_cursor)
|
|
46
|
+
cursor = next_cursor
|
|
47
|
+
raise ValueError("Codex item history exceeded page budget")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fill_thread(
|
|
51
|
+
thread: dict, request: Callable[..., dict], *, deadline: float,
|
|
52
|
+
limit: int | None = None, newest_first: bool = False,
|
|
53
|
+
exact_turn_id: str | None = None,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""Return the thread/read shape, with actual turn statuses and full items."""
|
|
56
|
+
thread_id = thread.get("id") if isinstance(thread, dict) else None
|
|
57
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
58
|
+
raise ValueError("Codex source metadata has no original thread id")
|
|
59
|
+
if limit is not None and (type(limit) is not int or limit < 1):
|
|
60
|
+
raise ValueError("Codex history turn limit must be positive")
|
|
61
|
+
if exact_turn_id is not None and (not isinstance(exact_turn_id, str)
|
|
62
|
+
or not exact_turn_id):
|
|
63
|
+
raise ValueError("Codex exact turn id is invalid")
|
|
64
|
+
descending = newest_first or exact_turn_id is not None
|
|
65
|
+
turns: list[dict] = []
|
|
66
|
+
seen: set[str] = set()
|
|
67
|
+
seen_turns: set[str] = set()
|
|
68
|
+
cursor: str | None = None
|
|
69
|
+
for _ in range(_MAX_PAGES):
|
|
70
|
+
params: dict[str, Any] = {
|
|
71
|
+
"threadId": thread_id,
|
|
72
|
+
"limit": min(_PAGE_SIZE, limit - len(turns)) if limit else _PAGE_SIZE,
|
|
73
|
+
"sortDirection": "desc" if descending else "asc",
|
|
74
|
+
"itemsView": "notLoaded" if exact_turn_id else "full",
|
|
75
|
+
}
|
|
76
|
+
if cursor is not None:
|
|
77
|
+
params["cursor"] = cursor
|
|
78
|
+
page = request("thread/turns/list", params, timeout=_remaining(deadline))
|
|
79
|
+
data = page.get("data") if isinstance(page, dict) else None
|
|
80
|
+
if not isinstance(data, list):
|
|
81
|
+
raise ValueError("Codex turn-history page is malformed")
|
|
82
|
+
for turn in data:
|
|
83
|
+
if (not isinstance(turn, dict) or not isinstance(turn.get("id"), str)
|
|
84
|
+
or not turn["id"] or not isinstance(turn.get("status"), str)
|
|
85
|
+
or not isinstance(turn.get("items"), list)
|
|
86
|
+
or turn["id"] in seen_turns):
|
|
87
|
+
raise ValueError("Codex native turn-history entry is malformed")
|
|
88
|
+
seen_turns.add(turn["id"])
|
|
89
|
+
if exact_turn_id is not None and turn["id"] != exact_turn_id:
|
|
90
|
+
continue
|
|
91
|
+
result = dict(turn)
|
|
92
|
+
if turn.get("itemsView", "full") != "full":
|
|
93
|
+
result["items"] = _full_turn_items(request, thread_id, turn["id"], deadline)
|
|
94
|
+
result["itemsView"] = "full"
|
|
95
|
+
turns.append(result)
|
|
96
|
+
if exact_turn_id is not None or (limit is not None and len(turns) >= limit):
|
|
97
|
+
return {**thread, "turns": list(reversed(turns)) if descending else turns,
|
|
98
|
+
"historySource": "thread/turns/list"}
|
|
99
|
+
next_cursor = page.get("nextCursor")
|
|
100
|
+
if next_cursor is None:
|
|
101
|
+
return {**thread, "turns": list(reversed(turns)) if descending else turns,
|
|
102
|
+
"historySource": "thread/turns/list"}
|
|
103
|
+
if (not isinstance(next_cursor, str) or not next_cursor
|
|
104
|
+
or next_cursor in seen or not data):
|
|
105
|
+
raise ValueError("Codex turn-history cursor did not advance")
|
|
106
|
+
seen.add(next_cursor)
|
|
107
|
+
cursor = next_cursor
|
|
108
|
+
raise ValueError("Codex turn history exceeded page budget")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_original_thread(request: Callable[..., dict], thread_id: str, *,
|
|
112
|
+
deadline: float, limit: int | None = None,
|
|
113
|
+
newest_first: bool = False,
|
|
114
|
+
exact_turn_id: str | None = None,
|
|
115
|
+
expected_source: str | None = None) -> dict:
|
|
116
|
+
"""One provider connection: metadata proof followed by bounded page reads."""
|
|
117
|
+
reply = request("thread/read", {"threadId": thread_id, "includeTurns": False},
|
|
118
|
+
timeout=_remaining(deadline))
|
|
119
|
+
thread = reply.get("thread") if isinstance(reply, dict) else None
|
|
120
|
+
if not isinstance(thread, dict) or thread.get("id") != thread_id:
|
|
121
|
+
raise ValueError("Codex source metadata did not return the expected thread")
|
|
122
|
+
if expected_source is not None and thread.get("threadSource") != expected_source:
|
|
123
|
+
raise ValueError("Codex original thread source changed before history paging")
|
|
124
|
+
return fill_thread(thread, request, deadline=deadline, limit=limit,
|
|
125
|
+
newest_first=newest_first, exact_turn_id=exact_turn_id)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_fork_source_point(request: Callable[..., dict], thread_id: str, *,
|
|
129
|
+
deadline: float) -> dict:
|
|
130
|
+
"""Read only an unmanaged native source's metadata and newest turn.
|
|
131
|
+
|
|
132
|
+
This is NOT worker history authorization. It never starts, resumes,
|
|
133
|
+
unarchives or mutates the native source. Admission is a separate child
|
|
134
|
+
thread/fork with its own independently verified source tag.
|
|
135
|
+
"""
|
|
136
|
+
from uuid import UUID
|
|
137
|
+
if (not isinstance(thread_id, str)
|
|
138
|
+
or str(UUID(thread_id)) != thread_id):
|
|
139
|
+
raise ValueError("import needs one exact native Codex thread UUID")
|
|
140
|
+
reply = request(
|
|
141
|
+
"thread/read", {"threadId": thread_id, "includeTurns": False},
|
|
142
|
+
timeout=_remaining(deadline),
|
|
143
|
+
)
|
|
144
|
+
thread = reply.get("thread") if isinstance(reply, dict) else None
|
|
145
|
+
if not isinstance(thread, dict) or thread.get("id") != thread_id:
|
|
146
|
+
raise ValueError("Codex native import source ID is not proven")
|
|
147
|
+
page = request("thread/turns/list", {
|
|
148
|
+
"threadId": thread_id, "limit": 1,
|
|
149
|
+
"sortDirection": "desc", "itemsView": "notLoaded",
|
|
150
|
+
}, timeout=_remaining(deadline))
|
|
151
|
+
items = page.get("data") if isinstance(page, dict) else None
|
|
152
|
+
if not isinstance(items, list) or len(items) > 1:
|
|
153
|
+
raise ValueError("Codex native import source turn page malformed")
|
|
154
|
+
last = items[0] if items else None
|
|
155
|
+
if last is not None and (not isinstance(last, dict)
|
|
156
|
+
or not isinstance(last.get("id"), str)):
|
|
157
|
+
raise ValueError("Codex native import source turn ID unavailable")
|
|
158
|
+
return {"thread": thread, "lastTurn": last}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Read one Codex thread's history from Codex's local history database.
|
|
2
|
+
|
|
3
|
+
Codex keeps a SQLite projection of every thread's rollout,
|
|
4
|
+
``$CODEX_HOME/thread_history_1.sqlite``. Its ``thread_items.item_json`` holds
|
|
5
|
+
the same ThreadItem JSON app-server returns, so a thread rebuilt from it feeds
|
|
6
|
+
:func:`ccx.ops.conversation.build_codex` unchanged and yields the same turn
|
|
7
|
+
sequence and item ordinals. Reading it takes milliseconds where app-server
|
|
8
|
+
paging can take minutes, and loads nothing into codexd.
|
|
9
|
+
|
|
10
|
+
It is Codex's private store, so every read is defensive: opened read-only
|
|
11
|
+
(``mode=ro``, never ``immutable=1``, so SQLite still honours the WAL), gated on
|
|
12
|
+
the tables and columns used here, short, and always closed. Any doubt returns
|
|
13
|
+
``None`` and the caller falls back to app-server ``thread/read``.
|
|
14
|
+
|
|
15
|
+
The thread id must come from the worker's verified original source
|
|
16
|
+
(``codex.telemetry._original_source``), never from a record field read here.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sqlite3
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
HISTORY_DB_NAME = "thread_history_1.sqlite"
|
|
27
|
+
|
|
28
|
+
# The schema this reader was checked against (2026-11, Codex `_sqlx_migrations`
|
|
29
|
+
# version 8). A newer version is still read when the required columns exist;
|
|
30
|
+
# the document then says so in a warning.
|
|
31
|
+
VERIFIED_SCHEMA_VERSION = 8
|
|
32
|
+
|
|
33
|
+
_REQUIRED = {
|
|
34
|
+
"thread_turns": {"thread_id", "turn_id", "rollout_ordinal", "status",
|
|
35
|
+
"error_json", "started_at", "completed_at", "duration_ms"},
|
|
36
|
+
"thread_items": {"thread_id", "turn_id", "item_id", "rollout_ordinal",
|
|
37
|
+
"item_json"},
|
|
38
|
+
"_sqlx_migrations": {"version", "success"},
|
|
39
|
+
}
|
|
40
|
+
_TURN_STATUSES = frozenset({"completed", "failed", "inProgress", "interrupted"})
|
|
41
|
+
_BUSY_TIMEOUT_S = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def history_db_path() -> Path:
|
|
45
|
+
# Like Codex itself, an empty CODEX_HOME means unset.
|
|
46
|
+
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser() / HISTORY_DB_NAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _schema_ok(conn: sqlite3.Connection) -> bool:
|
|
50
|
+
for table, columns in _REQUIRED.items():
|
|
51
|
+
found = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
52
|
+
if not columns <= found:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_thread_db(thread_id: str, *, path: Path | None = None) -> dict | None:
|
|
58
|
+
"""Return the ``thread/read(includeTurns)`` shape for one thread, or ``None``.
|
|
59
|
+
|
|
60
|
+
Turns come in ``rollout_ordinal`` order, including turns with no items;
|
|
61
|
+
each turn's items come in their own ``rollout_ordinal`` order. The result
|
|
62
|
+
carries ``historySource: "codex-history-db"`` and ``historyDbVersion``.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
65
|
+
return None
|
|
66
|
+
db = path or history_db_path()
|
|
67
|
+
if not db.is_file():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
conn = sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True,
|
|
71
|
+
timeout=_BUSY_TIMEOUT_S)
|
|
72
|
+
except sqlite3.Error:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
if not _schema_ok(conn):
|
|
76
|
+
return None
|
|
77
|
+
row = conn.execute(
|
|
78
|
+
"SELECT max(version) FROM _sqlx_migrations WHERE success").fetchone()
|
|
79
|
+
version = row[0] if row else None
|
|
80
|
+
# One read transaction: turns and items come from the same snapshot.
|
|
81
|
+
conn.execute("BEGIN")
|
|
82
|
+
turn_rows = conn.execute(
|
|
83
|
+
"SELECT turn_id, status, error_json, started_at, completed_at, duration_ms"
|
|
84
|
+
" FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal",
|
|
85
|
+
(thread_id,)).fetchall()
|
|
86
|
+
item_rows = conn.execute(
|
|
87
|
+
"SELECT turn_id, item_json FROM thread_items"
|
|
88
|
+
" WHERE thread_id = ? ORDER BY rollout_ordinal",
|
|
89
|
+
(thread_id,)).fetchall()
|
|
90
|
+
conn.execute("COMMIT")
|
|
91
|
+
except sqlite3.Error:
|
|
92
|
+
return None
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
if not turn_rows:
|
|
97
|
+
return None
|
|
98
|
+
items: dict[str, list[dict]] = {}
|
|
99
|
+
for turn_id, item_json in item_rows:
|
|
100
|
+
try:
|
|
101
|
+
item = json.loads(item_json)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
return None
|
|
106
|
+
items.setdefault(turn_id, []).append(item)
|
|
107
|
+
known = {r[0] for r in turn_rows}
|
|
108
|
+
if set(items) - known:
|
|
109
|
+
return None # items of a turn the projection doesn't list: not coherent
|
|
110
|
+
|
|
111
|
+
turns = []
|
|
112
|
+
for turn_id, status, error_json, started, completed, duration in turn_rows:
|
|
113
|
+
if status not in _TURN_STATUSES:
|
|
114
|
+
return None
|
|
115
|
+
error = None
|
|
116
|
+
if error_json:
|
|
117
|
+
try:
|
|
118
|
+
error = json.loads(error_json)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
turns.append({
|
|
122
|
+
"id": turn_id, "status": status, "error": error,
|
|
123
|
+
"startedAt": started, "completedAt": completed, "durationMs": duration,
|
|
124
|
+
"items": items.get(turn_id, []), "itemsView": "full",
|
|
125
|
+
})
|
|
126
|
+
return {"id": thread_id, "turns": turns, "historySource": "codex-history-db",
|
|
127
|
+
"historyDbVersion": version}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["HISTORY_DB_NAME", "VERIFIED_SCHEMA_VERSION", "history_db_path",
|
|
131
|
+
"read_thread_db"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Targeted Codex parent-notification delivery reconciliation.
|
|
2
|
+
|
|
3
|
+
Notification delivery only needs to answer one question: did a native
|
|
4
|
+
``worker_notification`` tool output with this delivery id already land?
|
|
5
|
+
Hydrating an entire thread with ``thread/read(includeTurns=true)`` is both
|
|
6
|
+
unnecessary and harmful for long-lived paginated Codex threads. Modern Codex
|
|
7
|
+
exposes ``thread/items/list`` for incremental history access, so CCX pages
|
|
8
|
+
that newest-first and never hydrates the full history for reconciliation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
ITEM_PAGE_SIZE = 100
|
|
17
|
+
MAX_ITEM_PAGES = 1000
|
|
18
|
+
ITEM_PAGE_TIMEOUT_S = 10.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _notification_payload(item: Any, *, name: str = "worker_notification") -> dict[str, Any] | None:
|
|
22
|
+
if not isinstance(item, dict):
|
|
23
|
+
return None
|
|
24
|
+
# ``thread/items/list`` wraps each native item with its containing turn id.
|
|
25
|
+
candidate = item.get("item") if isinstance(item.get("item"), dict) else item
|
|
26
|
+
if not isinstance(candidate, dict):
|
|
27
|
+
return None
|
|
28
|
+
if (
|
|
29
|
+
candidate.get("type") != "functionCallOutput"
|
|
30
|
+
or candidate.get("name") != name
|
|
31
|
+
):
|
|
32
|
+
return None
|
|
33
|
+
raw = candidate.get("output")
|
|
34
|
+
if isinstance(raw, dict):
|
|
35
|
+
return raw
|
|
36
|
+
if not isinstance(raw, str):
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(raw)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return None
|
|
42
|
+
return value if isinstance(value, dict) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _item_delivery_match(
|
|
46
|
+
item: Any,
|
|
47
|
+
delivery_id: str,
|
|
48
|
+
*,
|
|
49
|
+
legacy_event_id: str | None = None,
|
|
50
|
+
name: str = "worker_notification",
|
|
51
|
+
) -> dict[str, Any] | None:
|
|
52
|
+
value = _notification_payload(item, name=name)
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
matches = (
|
|
56
|
+
value.get("deliveryId") == delivery_id
|
|
57
|
+
or bool(
|
|
58
|
+
legacy_event_id
|
|
59
|
+
and value.get("deliveryId") is None
|
|
60
|
+
and value.get("eventId") == legacy_event_id
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
if not matches:
|
|
64
|
+
return None
|
|
65
|
+
turn_id = item.get("turnId") if isinstance(item, dict) else None
|
|
66
|
+
return {
|
|
67
|
+
"deliveryId": delivery_id,
|
|
68
|
+
"eventId": value.get("eventId"),
|
|
69
|
+
"turnId": turn_id if isinstance(turn_id, str) and turn_id else None,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _paged_delivery_match(
|
|
74
|
+
request: Callable[..., dict[str, Any]],
|
|
75
|
+
thread_id: str,
|
|
76
|
+
delivery_id: str,
|
|
77
|
+
*,
|
|
78
|
+
legacy_event_id: str | None = None,
|
|
79
|
+
stop_at_turn_id: str | None = None,
|
|
80
|
+
name: str = "worker_notification",
|
|
81
|
+
) -> dict[str, Any] | None:
|
|
82
|
+
cursor: str | None = None
|
|
83
|
+
seen_cursors: set[str] = set()
|
|
84
|
+
for _ in range(MAX_ITEM_PAGES):
|
|
85
|
+
params: dict[str, Any] = {
|
|
86
|
+
"threadId": thread_id,
|
|
87
|
+
"limit": ITEM_PAGE_SIZE,
|
|
88
|
+
"sortDirection": "desc",
|
|
89
|
+
}
|
|
90
|
+
if cursor is not None:
|
|
91
|
+
params["cursor"] = cursor
|
|
92
|
+
result = request("thread/items/list", params, timeout=ITEM_PAGE_TIMEOUT_S)
|
|
93
|
+
data = result.get("data") if isinstance(result, dict) else None
|
|
94
|
+
if not isinstance(data, list):
|
|
95
|
+
raise RuntimeError("thread/items/list returned malformed data")
|
|
96
|
+
for entry in data:
|
|
97
|
+
match = _item_delivery_match(
|
|
98
|
+
entry, delivery_id, legacy_event_id=legacy_event_id, name=name
|
|
99
|
+
)
|
|
100
|
+
if match is not None:
|
|
101
|
+
return match
|
|
102
|
+
if (
|
|
103
|
+
stop_at_turn_id
|
|
104
|
+
and isinstance(entry, dict)
|
|
105
|
+
and entry.get("turnId") == stop_at_turn_id
|
|
106
|
+
):
|
|
107
|
+
# Newest-first reached a turn that had already completed
|
|
108
|
+
# before the first attempt; nothing older can hold it.
|
|
109
|
+
return None
|
|
110
|
+
next_cursor = result.get("nextCursor")
|
|
111
|
+
if not isinstance(next_cursor, str) or not next_cursor:
|
|
112
|
+
return None
|
|
113
|
+
if next_cursor in seen_cursors:
|
|
114
|
+
raise RuntimeError("thread/items/list repeated a pagination cursor")
|
|
115
|
+
seen_cursors.add(next_cursor)
|
|
116
|
+
cursor = next_cursor
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"thread/items/list exceeded {MAX_ITEM_PAGES} pages while reconciling notification"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def notification_delivery_match(
|
|
123
|
+
request: Callable[..., dict[str, Any]],
|
|
124
|
+
thread_id: str,
|
|
125
|
+
delivery_id: str,
|
|
126
|
+
*,
|
|
127
|
+
legacy_event_id: str | None = None,
|
|
128
|
+
stop_at_turn_id: str | None = None,
|
|
129
|
+
name: str = "worker_notification",
|
|
130
|
+
) -> dict[str, Any] | None:
|
|
131
|
+
"""Find a landed notification, newest first.
|
|
132
|
+
|
|
133
|
+
``stop_at_turn_id`` names a turn that had completed before the delivery's
|
|
134
|
+
first attempt: the paged walk ends there instead of at the thread's start.
|
|
135
|
+
"""
|
|
136
|
+
return _paged_delivery_match(
|
|
137
|
+
request, thread_id, delivery_id,
|
|
138
|
+
legacy_event_id=legacy_event_id, stop_at_turn_id=stop_at_turn_id,
|
|
139
|
+
name=name,
|
|
140
|
+
)
|