cctally 1.68.0 → 1.69.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/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_lib_alert_axes.py
CHANGED
|
@@ -66,3 +66,14 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
|
|
|
66
66
|
)
|
|
67
67
|
|
|
68
68
|
AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {d.id: d for d in AXIS_REGISTRY}
|
|
69
|
+
|
|
70
|
+
# Durable notifier-only quota thresholds deliberately do not participate in
|
|
71
|
+
# the dashboard envelope. Keep their metadata available to backend callers
|
|
72
|
+
# without expanding the complete Python↔TypeScript dashboard registry.
|
|
73
|
+
BACKEND_ONLY_AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
|
|
74
|
+
AlertAxisDescriptor("quota", "QUOTA", "Quota", "quota_threshold_events"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
BACKEND_ONLY_AXIS_BY_ID: "dict[str, AlertAxisDescriptor]" = {
|
|
78
|
+
d.id: d for d in BACKEND_ONLY_AXIS_REGISTRY
|
|
79
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Pure Codex native-hook planning and bounded lifecycle-lock primitives."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from _lib_source_identity import source_root_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CODEX_HOOK_EVENTS = ("Stop", "SubagentStop")
|
|
19
|
+
CODEX_HOOK_TIMEOUT_SECONDS = 30
|
|
20
|
+
CODEX_HOOK_THROTTLE_SECONDS = 15
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexHooksError(ValueError):
|
|
24
|
+
"""A hooks.json shape is unsafe to modify."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CodexHookRoot:
|
|
29
|
+
source_root_key: str
|
|
30
|
+
codex_home: pathlib.Path
|
|
31
|
+
hooks_path: pathlib.Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CodexLifecycleLock:
|
|
36
|
+
root: CodexHookRoot
|
|
37
|
+
marker_path: pathlib.Path
|
|
38
|
+
lock_path: pathlib.Path
|
|
39
|
+
fd: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def codex_hook_command(binary: str) -> str:
|
|
43
|
+
path = pathlib.Path(binary)
|
|
44
|
+
if not path.is_absolute():
|
|
45
|
+
raise CodexHooksError("Codex hook target must be an absolute path")
|
|
46
|
+
return f"{shlex.quote(str(path))} hook-tick --foreground --source codex"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _command_tokens(command: object) -> list[str] | None:
|
|
50
|
+
if not isinstance(command, str) or not command.strip():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return shlex.split(command)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_owned_codex_hook_command(command: object, binary: str) -> bool:
|
|
59
|
+
# The absolute installed path changes across package-manager upgrades, so
|
|
60
|
+
# ownership is the exact argument tail plus an absolute ``cctally`` binary,
|
|
61
|
+
# not a string-equality check against today's install location.
|
|
62
|
+
tokens = _command_tokens(command)
|
|
63
|
+
return bool(
|
|
64
|
+
tokens
|
|
65
|
+
and len(tokens) == 5
|
|
66
|
+
and pathlib.Path(tokens[0]).is_absolute()
|
|
67
|
+
and pathlib.Path(tokens[0]).name == "cctally"
|
|
68
|
+
and tokens[1:] == ["hook-tick", "--foreground", "--source", "codex"]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_document(document: object) -> dict:
|
|
73
|
+
if not isinstance(document, dict):
|
|
74
|
+
raise CodexHooksError("hooks.json must be a JSON object")
|
|
75
|
+
hooks = document.get("hooks")
|
|
76
|
+
if hooks is None:
|
|
77
|
+
return document
|
|
78
|
+
if not isinstance(hooks, dict):
|
|
79
|
+
raise CodexHooksError("hooks.json: `hooks` is not an object")
|
|
80
|
+
for event, groups in hooks.items():
|
|
81
|
+
if not isinstance(event, str):
|
|
82
|
+
raise CodexHooksError("hooks.json: event name is not a string")
|
|
83
|
+
if not isinstance(groups, list):
|
|
84
|
+
raise CodexHooksError(f"hooks.json: `hooks.{event}` is not a list")
|
|
85
|
+
for index, group in enumerate(groups):
|
|
86
|
+
if not isinstance(group, dict):
|
|
87
|
+
raise CodexHooksError(
|
|
88
|
+
f"hooks.json: `hooks.{event}[{index}]` is not an object"
|
|
89
|
+
)
|
|
90
|
+
handlers = group.get("hooks")
|
|
91
|
+
if not isinstance(handlers, list):
|
|
92
|
+
raise CodexHooksError(
|
|
93
|
+
f"hooks.json: `hooks.{event}[{index}].hooks` is not a list"
|
|
94
|
+
)
|
|
95
|
+
if any(not isinstance(handler, dict) for handler in handlers):
|
|
96
|
+
raise CodexHooksError(
|
|
97
|
+
f"hooks.json: `hooks.{event}[{index}].hooks` has a non-object handler"
|
|
98
|
+
)
|
|
99
|
+
return document
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _owned_handler(command: str) -> dict:
|
|
103
|
+
return {"type": "command", "command": command, "timeout": CODEX_HOOK_TIMEOUT_SECONDS}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_owned_handler(handler: dict, binary: str) -> bool:
|
|
107
|
+
return is_owned_codex_hook_command(handler.get("command"), binary)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_canonical_owned_codex_hook_handler(handler: object, binary: str) -> bool:
|
|
111
|
+
"""Return true only for the one exact handler shape setup owns today."""
|
|
112
|
+
if not isinstance(handler, dict):
|
|
113
|
+
return False
|
|
114
|
+
return handler == _owned_handler(codex_hook_command(binary))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_owned_handler(handler: dict, command: str) -> None:
|
|
118
|
+
handler.clear()
|
|
119
|
+
handler.update(_owned_handler(command))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _hook_groups(document: dict) -> dict:
|
|
123
|
+
hooks = document.get("hooks")
|
|
124
|
+
if hooks is None:
|
|
125
|
+
hooks = {}
|
|
126
|
+
document["hooks"] = hooks
|
|
127
|
+
return hooks
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _plan_install(document: object, binary: str) -> tuple[dict, dict[str, int]]:
|
|
131
|
+
_validate_document(document)
|
|
132
|
+
planned = copy.deepcopy(document)
|
|
133
|
+
hooks = _hook_groups(planned)
|
|
134
|
+
command = codex_hook_command(binary)
|
|
135
|
+
added: dict[str, int] = {}
|
|
136
|
+
for event in CODEX_HOOK_EVENTS:
|
|
137
|
+
groups = hooks.setdefault(event, [])
|
|
138
|
+
found = False
|
|
139
|
+
kept_groups: list[dict] = []
|
|
140
|
+
for group in groups:
|
|
141
|
+
kept_handlers: list[dict] = []
|
|
142
|
+
for handler in group["hooks"]:
|
|
143
|
+
if _is_owned_handler(handler, binary):
|
|
144
|
+
if not found:
|
|
145
|
+
_normalize_owned_handler(handler, command)
|
|
146
|
+
kept_handlers.append(handler)
|
|
147
|
+
found = True
|
|
148
|
+
continue
|
|
149
|
+
kept_handlers.append(handler)
|
|
150
|
+
if kept_handlers:
|
|
151
|
+
group["hooks"] = kept_handlers
|
|
152
|
+
kept_groups.append(group)
|
|
153
|
+
hooks[event] = kept_groups
|
|
154
|
+
if not found:
|
|
155
|
+
kept_groups.append({"hooks": [_owned_handler(command)]})
|
|
156
|
+
added[event] = 1
|
|
157
|
+
else:
|
|
158
|
+
added[event] = 0
|
|
159
|
+
return planned, added
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _plan_uninstall(document: object, binary: str) -> tuple[dict, dict[str, int]]:
|
|
163
|
+
_validate_document(document)
|
|
164
|
+
planned = copy.deepcopy(document)
|
|
165
|
+
hooks = planned.get("hooks")
|
|
166
|
+
removed: dict[str, int] = {event: 0 for event in CODEX_HOOK_EVENTS}
|
|
167
|
+
if not isinstance(hooks, dict):
|
|
168
|
+
return planned, removed
|
|
169
|
+
for event in CODEX_HOOK_EVENTS:
|
|
170
|
+
groups = hooks.get(event)
|
|
171
|
+
if not isinstance(groups, list):
|
|
172
|
+
continue
|
|
173
|
+
kept_groups: list[dict] = []
|
|
174
|
+
for group in groups:
|
|
175
|
+
kept_handlers = [
|
|
176
|
+
handler for handler in group["hooks"]
|
|
177
|
+
if not _is_owned_handler(handler, binary)
|
|
178
|
+
]
|
|
179
|
+
removed[event] += len(group["hooks"]) - len(kept_handlers)
|
|
180
|
+
if kept_handlers:
|
|
181
|
+
group["hooks"] = kept_handlers
|
|
182
|
+
kept_groups.append(group)
|
|
183
|
+
if kept_groups:
|
|
184
|
+
hooks[event] = kept_groups
|
|
185
|
+
else:
|
|
186
|
+
hooks.pop(event, None)
|
|
187
|
+
if not hooks:
|
|
188
|
+
planned.pop("hooks", None)
|
|
189
|
+
return planned, removed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _read_hooks_document(path: pathlib.Path) -> dict:
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return {}
|
|
195
|
+
try:
|
|
196
|
+
raw = path.read_text(encoding="utf-8")
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
raise CodexHooksError(f"cannot read {path}: {exc}") from exc
|
|
199
|
+
if not raw.strip():
|
|
200
|
+
return {}
|
|
201
|
+
try:
|
|
202
|
+
document = json.loads(raw)
|
|
203
|
+
except json.JSONDecodeError as exc:
|
|
204
|
+
raise CodexHooksError(f"{path} is not valid JSON: {exc}") from exc
|
|
205
|
+
return _validate_document(document)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _backup_path(path: pathlib.Path) -> pathlib.Path:
|
|
209
|
+
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
|
|
210
|
+
return path.with_name(path.name + f".cctally-backup-{stamp}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def harden_hooks_permissions(path: pathlib.Path) -> None:
|
|
214
|
+
"""Enforce the private-file posture even when a reinstall is content-idempotent."""
|
|
215
|
+
try:
|
|
216
|
+
os.chmod(path.parent, 0o700)
|
|
217
|
+
if path.exists():
|
|
218
|
+
os.chmod(path, 0o600)
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
raise CodexHooksError(f"cannot secure {path}: {exc}") from exc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _write_hooks_document_atomic(
|
|
224
|
+
path: pathlib.Path,
|
|
225
|
+
document: object | None = None,
|
|
226
|
+
*,
|
|
227
|
+
transform: Callable[[dict], tuple[dict, dict[str, int]]] | None = None,
|
|
228
|
+
harden_unchanged: bool = False,
|
|
229
|
+
) -> pathlib.Path | None | tuple[dict, dict[str, int], bool, pathlib.Path | None]:
|
|
230
|
+
if (document is None) == (transform is None):
|
|
231
|
+
raise ValueError("provide exactly one of document or transform")
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
233
|
+
lock_path = path.with_name(path.name + ".cctally.lock")
|
|
234
|
+
try:
|
|
235
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
raise CodexHooksError(f"cannot lock {path}: {exc}") from exc
|
|
238
|
+
try:
|
|
239
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
240
|
+
changes: dict[str, int] | None = None
|
|
241
|
+
changed = True
|
|
242
|
+
if transform is not None:
|
|
243
|
+
current = _read_hooks_document(path)
|
|
244
|
+
planned, changes = transform(current)
|
|
245
|
+
_validate_document(planned)
|
|
246
|
+
changed = planned != current
|
|
247
|
+
else:
|
|
248
|
+
planned = _validate_document(document)
|
|
249
|
+
# Snapshot only after holding the writer lock: otherwise two setup
|
|
250
|
+
# processes can both back up an obsolete pre-update document before
|
|
251
|
+
# either reaches the atomic replacement below.
|
|
252
|
+
backup: pathlib.Path | None = None
|
|
253
|
+
if changed and path.exists():
|
|
254
|
+
backup = _backup_path(path)
|
|
255
|
+
if not backup.exists():
|
|
256
|
+
try:
|
|
257
|
+
shutil.copy2(path, backup)
|
|
258
|
+
os.chmod(backup, 0o600)
|
|
259
|
+
except OSError as exc:
|
|
260
|
+
raise CodexHooksError(f"cannot back up {path}: {exc}") from exc
|
|
261
|
+
if changed:
|
|
262
|
+
tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
|
|
263
|
+
try:
|
|
264
|
+
tmp.write_text(
|
|
265
|
+
json.dumps(planned, indent=2, ensure_ascii=False) + "\n",
|
|
266
|
+
encoding="utf-8",
|
|
267
|
+
)
|
|
268
|
+
os.chmod(tmp, 0o600)
|
|
269
|
+
os.replace(tmp, path)
|
|
270
|
+
harden_hooks_permissions(path)
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
try:
|
|
273
|
+
tmp.unlink()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
raise CodexHooksError(f"cannot write {path}: {exc}") from exc
|
|
277
|
+
elif harden_unchanged and path.exists():
|
|
278
|
+
harden_hooks_permissions(path)
|
|
279
|
+
finally:
|
|
280
|
+
try:
|
|
281
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
282
|
+
finally:
|
|
283
|
+
os.close(fd)
|
|
284
|
+
if transform is not None:
|
|
285
|
+
assert changes is not None
|
|
286
|
+
return planned, changes, changed, backup
|
|
287
|
+
return backup
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def codex_hook_roots(paths: list[pathlib.Path]) -> list[CodexHookRoot]:
|
|
291
|
+
roots: dict[str, CodexHookRoot] = {}
|
|
292
|
+
for raw in paths:
|
|
293
|
+
try:
|
|
294
|
+
home = raw.resolve()
|
|
295
|
+
except OSError:
|
|
296
|
+
home = raw.absolute()
|
|
297
|
+
if not home.is_dir():
|
|
298
|
+
continue
|
|
299
|
+
key = source_root_key(str(home))
|
|
300
|
+
roots.setdefault(key, CodexHookRoot(key, home, home / "hooks.json"))
|
|
301
|
+
return [roots[key] for key in sorted(roots)]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def acquire_due_lifecycle_locks(
|
|
305
|
+
app_dir: pathlib.Path,
|
|
306
|
+
roots: list[CodexHookRoot],
|
|
307
|
+
*,
|
|
308
|
+
now: float,
|
|
309
|
+
throttle_seconds: float = CODEX_HOOK_THROTTLE_SECONDS,
|
|
310
|
+
) -> list[CodexLifecycleLock]:
|
|
311
|
+
base = app_dir / "codex-hook-tick"
|
|
312
|
+
base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
313
|
+
try:
|
|
314
|
+
os.chmod(base, 0o700)
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
held: list[CodexLifecycleLock] = []
|
|
318
|
+
for root in sorted(roots, key=lambda item: item.source_root_key):
|
|
319
|
+
lock_path = base / f"{root.source_root_key}.lock"
|
|
320
|
+
marker_path = base / f"{root.source_root_key}.last-success"
|
|
321
|
+
fd = -1
|
|
322
|
+
try:
|
|
323
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
324
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
325
|
+
except OSError:
|
|
326
|
+
try:
|
|
327
|
+
if fd >= 0:
|
|
328
|
+
os.close(fd)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
continue
|
|
332
|
+
try:
|
|
333
|
+
age = now - marker_path.stat().st_mtime
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
age = float("inf")
|
|
336
|
+
except OSError:
|
|
337
|
+
age = float("inf")
|
|
338
|
+
if age < throttle_seconds:
|
|
339
|
+
try:
|
|
340
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
341
|
+
finally:
|
|
342
|
+
os.close(fd)
|
|
343
|
+
continue
|
|
344
|
+
held.append(CodexLifecycleLock(root, marker_path, lock_path, fd))
|
|
345
|
+
return held
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def mark_lifecycle_success(locks: list[CodexLifecycleLock]) -> None:
|
|
349
|
+
for lock in locks:
|
|
350
|
+
fd = os.open(lock.marker_path, os.O_CREAT | os.O_WRONLY, 0o600)
|
|
351
|
+
try:
|
|
352
|
+
os.utime(lock.marker_path, None)
|
|
353
|
+
os.chmod(lock.marker_path, 0o600)
|
|
354
|
+
finally:
|
|
355
|
+
os.close(fd)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def release_lifecycle_locks(locks: list[CodexLifecycleLock]) -> None:
|
|
359
|
+
for lock in reversed(locks):
|
|
360
|
+
try:
|
|
361
|
+
fcntl.flock(lock.fd, fcntl.LOCK_UN)
|
|
362
|
+
except OSError:
|
|
363
|
+
pass
|
|
364
|
+
try:
|
|
365
|
+
os.close(lock.fd)
|
|
366
|
+
except OSError:
|
|
367
|
+
pass
|