conduct-cli 0.5.7__tar.gz → 0.5.9__tar.gz
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.
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/PKG-INFO +1 -1
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/pyproject.toml +1 -1
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/guard.py +6 -1
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/guardmcp.py +4 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_template.py +36 -1
- conduct_cli-0.5.9/src/conduct_cli/log_util.py +180 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/main.py +4 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/mcp_server.py +4 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- conduct_cli-0.5.9/tests/test_log_util.py +123 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/README.md +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/setup.cfg +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/setup.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_switch.py +0 -0
|
@@ -572,8 +572,13 @@ def cmd_guard_install(args):
|
|
|
572
572
|
api_key=api_key,
|
|
573
573
|
)
|
|
574
574
|
_save_policy(policy)
|
|
575
|
+
# Mirror fail_mode into guard config so the hook can enforce it
|
|
576
|
+
# even when POLICY_PATH is missing or unreadable.
|
|
577
|
+
cfg = _load_guard_config()
|
|
578
|
+
cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
|
|
579
|
+
_save_guard_config(cfg)
|
|
575
580
|
rule_count = len(policy.get("rules", []))
|
|
576
|
-
print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active")
|
|
581
|
+
print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active · fail mode: {cfg['fail_mode']}")
|
|
577
582
|
except SystemExit:
|
|
578
583
|
rule_count = 0
|
|
579
584
|
|
|
@@ -467,5 +467,9 @@ def main() -> None:
|
|
|
467
467
|
_err(msg_id, -32601, f"Method not found: {method}")
|
|
468
468
|
|
|
469
469
|
|
|
470
|
+
# ponytail: telemetry error interceptor (#718)
|
|
471
|
+
from .log_util import install_error_interceptor as _ei
|
|
472
|
+
main = _ei(main)
|
|
473
|
+
|
|
470
474
|
if __name__ == "__main__":
|
|
471
475
|
main()
|
|
@@ -86,7 +86,17 @@ def _maybe_sync_policy():
|
|
|
86
86
|
POLICY_PATH.write_text(json.dumps(remote, indent=2))
|
|
87
87
|
VERSION_CACHE_PATH.write_text(json.dumps({"ts": time.time(), "version": remote_version}))
|
|
88
88
|
except Exception:
|
|
89
|
-
pass #
|
|
89
|
+
pass # sync failure does not directly block; policy file may still be present
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_fail_mode():
|
|
93
|
+
"""Read fail_mode from ~/.conduct/config.json. Default: fail_open."""
|
|
94
|
+
try:
|
|
95
|
+
cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
|
|
96
|
+
except Exception:
|
|
97
|
+
return "fail_open"
|
|
98
|
+
mode = cfg.get("fail_mode", "fail_open")
|
|
99
|
+
return mode if mode in ("fail_open", "fail_closed") else "fail_open"
|
|
90
100
|
|
|
91
101
|
|
|
92
102
|
def _load_budget_cache():
|
|
@@ -685,10 +695,35 @@ def main():
|
|
|
685
695
|
# Policy version check (cached 60s) — auto-syncs if server version differs
|
|
686
696
|
_maybe_sync_policy()
|
|
687
697
|
|
|
698
|
+
# Fail-closed gate: if the local policy file is missing and the workspace
|
|
699
|
+
# is configured for fail_closed, block every tool call with a clear
|
|
700
|
+
# message. fail_open (default) silently allows.
|
|
701
|
+
if _get_fail_mode() == "fail_closed" and not POLICY_PATH.exists():
|
|
702
|
+
tool_name = (data.get("tool_name") or "").lower()
|
|
703
|
+
tool_input = data.get("tool_input") or {}
|
|
704
|
+
session_id = data.get("session_id")
|
|
705
|
+
msg = "[ConductGuard] Policy cache unavailable — fail-closed mode is on. Run `conduct guard sync` or contact your admin."
|
|
706
|
+
print(msg)
|
|
707
|
+
print(msg, file=sys.stderr)
|
|
708
|
+
_post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id=session_id)
|
|
709
|
+
sys.exit(2)
|
|
710
|
+
|
|
688
711
|
# Hard budget cap (cached 5 min)
|
|
689
712
|
hard_blocked, reason = _load_budget_cache()
|
|
690
713
|
if hard_blocked is None:
|
|
691
714
|
hard_blocked, reason = _fetch_budget_status()
|
|
715
|
+
# Fail-closed: if the budget check returned default (None reason) AND
|
|
716
|
+
# we just hit the API for a fresh value, the API was unreachable.
|
|
717
|
+
# Treat as unavailable and block.
|
|
718
|
+
if not hard_blocked and reason is None and _get_fail_mode() == "fail_closed":
|
|
719
|
+
tool_name = (data.get("tool_name") or "").lower()
|
|
720
|
+
tool_input = data.get("tool_input") or {}
|
|
721
|
+
session_id = data.get("session_id")
|
|
722
|
+
msg = "[ConductGuard] Spend budget check unavailable — fail-closed mode is on."
|
|
723
|
+
print(msg)
|
|
724
|
+
print(msg, file=sys.stderr)
|
|
725
|
+
_post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id=session_id)
|
|
726
|
+
sys.exit(2)
|
|
692
727
|
if hard_blocked:
|
|
693
728
|
msg = f"[ConductGuard] {reason or 'Budget hard cap reached. Contact your manager.'}"
|
|
694
729
|
print(msg)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Shared log helper + error interceptor for conduct-cli (#718 Phase 1).
|
|
2
|
+
|
|
3
|
+
Stdlib only. No new dependencies. Failures here MUST NEVER block the user —
|
|
4
|
+
telemetry is best-effort. The POST runs in a daemon thread with a join
|
|
5
|
+
timeout so even a hung endpoint can't delay CLI exit by more than ~2s.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
log(level, message, **context) # print + (warn/err) post
|
|
9
|
+
install_error_interceptor(main_fn) # wrap top-level entry point
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import traceback
|
|
18
|
+
import urllib.request
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
PREFIX = "[conduct]"
|
|
25
|
+
TOOL = "conduct-cli"
|
|
26
|
+
|
|
27
|
+
_COLORS = {
|
|
28
|
+
"info": "\033[37m",
|
|
29
|
+
"ok": "\033[32m",
|
|
30
|
+
"warning": "\033[33m",
|
|
31
|
+
"error": "\033[31m",
|
|
32
|
+
}
|
|
33
|
+
_RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
_API_BASE = os.environ.get("CONDUCT_API_URL", "https://api.conductai.ai").rstrip("/")
|
|
36
|
+
_CONFIG = Path.home() / ".conductguard" / "config.json"
|
|
37
|
+
_LOG_FILE = Path(
|
|
38
|
+
os.environ.get("CONDUCT_LOG_FILE")
|
|
39
|
+
or str(Path.home() / ".conductguard" / "logs" / "events.jsonl")
|
|
40
|
+
)
|
|
41
|
+
_POST_TIMEOUT_S = 2.0
|
|
42
|
+
|
|
43
|
+
_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
|
44
|
+
_BACKUPS = 3 # keep events.jsonl.1, .2, .3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rotate() -> None:
|
|
48
|
+
"""events.jsonl → .1 → .2 → .3; oldest dropped. Silent on failure."""
|
|
49
|
+
base = str(_LOG_FILE)
|
|
50
|
+
try:
|
|
51
|
+
oldest = Path(f"{base}.{_BACKUPS}")
|
|
52
|
+
if oldest.exists():
|
|
53
|
+
oldest.unlink()
|
|
54
|
+
for i in range(_BACKUPS - 1, 0, -1):
|
|
55
|
+
src = Path(f"{base}.{i}")
|
|
56
|
+
if src.exists():
|
|
57
|
+
src.replace(Path(f"{base}.{i + 1}"))
|
|
58
|
+
if _LOG_FILE.exists():
|
|
59
|
+
_LOG_FILE.replace(Path(f"{base}.1"))
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_local(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
|
|
65
|
+
"""Append one JSONL event to the local log file. Foreground, fast, silent on failure."""
|
|
66
|
+
try:
|
|
67
|
+
_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
if _LOG_FILE.exists() and _LOG_FILE.stat().st_size >= _MAX_BYTES:
|
|
69
|
+
_rotate()
|
|
70
|
+
line = json.dumps({
|
|
71
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
72
|
+
"tool": TOOL,
|
|
73
|
+
"version": _version(),
|
|
74
|
+
"event_type": event_type,
|
|
75
|
+
"message": message,
|
|
76
|
+
"traceback": tb,
|
|
77
|
+
"run_id": os.environ.get("CONDUCT_RUN_ID"),
|
|
78
|
+
"session_id": os.environ.get("CONDUCT_SESSION_ID"),
|
|
79
|
+
"context": ctx or {},
|
|
80
|
+
})
|
|
81
|
+
with _LOG_FILE.open("a", encoding="utf-8") as f:
|
|
82
|
+
f.write(line + "\n")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass # disk failure must never block the user
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@lru_cache(maxsize=1)
|
|
88
|
+
def _read_creds() -> tuple[str | None, str | None]:
|
|
89
|
+
"""(member_token, workspace_id) from ~/.conductguard/config.json, or (None, None)."""
|
|
90
|
+
try:
|
|
91
|
+
cfg = json.loads(_CONFIG.read_text())
|
|
92
|
+
return cfg.get("member_token"), cfg.get("workspace_id")
|
|
93
|
+
except Exception:
|
|
94
|
+
return None, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=1)
|
|
98
|
+
def _version() -> str:
|
|
99
|
+
try:
|
|
100
|
+
from importlib.metadata import version
|
|
101
|
+
return version("conduct-cli")
|
|
102
|
+
except Exception:
|
|
103
|
+
return "unknown"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _post_event_sync(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
|
|
107
|
+
token, workspace_id = _read_creds()
|
|
108
|
+
if not token:
|
|
109
|
+
return # no creds = no telemetry, silent
|
|
110
|
+
body = json.dumps({
|
|
111
|
+
"tool": TOOL,
|
|
112
|
+
"version": _version(),
|
|
113
|
+
"event_type": event_type,
|
|
114
|
+
"message": message[:4000],
|
|
115
|
+
"traceback": tb,
|
|
116
|
+
"run_id": os.environ.get("CONDUCT_RUN_ID"),
|
|
117
|
+
"session_id": os.environ.get("CONDUCT_SESSION_ID"),
|
|
118
|
+
"context": ctx or {},
|
|
119
|
+
}).encode("utf-8")
|
|
120
|
+
headers = {
|
|
121
|
+
"Authorization": f"Bearer {token}",
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
}
|
|
124
|
+
if workspace_id:
|
|
125
|
+
headers["X-Workspace-Id"] = workspace_id
|
|
126
|
+
try:
|
|
127
|
+
req = urllib.request.Request(
|
|
128
|
+
f"{_API_BASE}/telemetry/events",
|
|
129
|
+
data=body,
|
|
130
|
+
headers=headers,
|
|
131
|
+
method="POST",
|
|
132
|
+
)
|
|
133
|
+
urllib.request.urlopen(req, timeout=_POST_TIMEOUT_S)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass # ponytail: silent — telemetry must never block the user
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _post_event(event_type: str, message: str, tb: str | None, ctx: dict, *, wait: bool = False) -> None:
|
|
139
|
+
"""Fire-and-forget by default; wait=True for the error interceptor so the
|
|
140
|
+
POST has a chance to land before sys.exit."""
|
|
141
|
+
t = threading.Thread(
|
|
142
|
+
target=_post_event_sync,
|
|
143
|
+
args=(event_type, message, tb, ctx),
|
|
144
|
+
daemon=True,
|
|
145
|
+
name="conduct-telemetry",
|
|
146
|
+
)
|
|
147
|
+
t.start()
|
|
148
|
+
if wait:
|
|
149
|
+
t.join(timeout=_POST_TIMEOUT_S)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def log(level: str, msg: str, **ctx) -> None:
|
|
153
|
+
"""Print to stderr with a colored prefix; warn/error also persist locally
|
|
154
|
+
AND post to telemetry. Same event, three destinations, written once each."""
|
|
155
|
+
color = _COLORS.get(level, "")
|
|
156
|
+
sys.stderr.write(f"{color}{PREFIX} {msg}{_RESET}\n")
|
|
157
|
+
if level in ("warning", "error"):
|
|
158
|
+
_write_local(level, msg, None, ctx) # foreground, always
|
|
159
|
+
_post_event(level, msg, None, ctx) # background, best-effort
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def install_error_interceptor(main_fn: Callable) -> Callable:
|
|
163
|
+
"""Wrap a CLI entry point. Unhandled exception → telemetry POST → stderr → exit 1.
|
|
164
|
+
|
|
165
|
+
SystemExit / KeyboardInterrupt pass through unchanged.
|
|
166
|
+
"""
|
|
167
|
+
def wrapped(*args, **kwargs):
|
|
168
|
+
try:
|
|
169
|
+
return main_fn(*args, **kwargs)
|
|
170
|
+
except (SystemExit, KeyboardInterrupt):
|
|
171
|
+
raise
|
|
172
|
+
except Exception as e:
|
|
173
|
+
tb = traceback.format_exc()
|
|
174
|
+
msg = f"{type(e).__name__}: {e}"
|
|
175
|
+
_write_local("error", msg, tb, {}) # foreground, ensures file row before exit
|
|
176
|
+
_post_event("error", msg, tb, {}, wait=True) # background, best-effort
|
|
177
|
+
sys.stderr.write(f"{_COLORS['error']}{PREFIX} {msg}{_RESET}\n{tb}\n")
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
wrapped.__wrapped__ = main_fn
|
|
180
|
+
return wrapped
|
|
@@ -330,5 +330,9 @@ def main() -> None:
|
|
|
330
330
|
_err(msg_id, -32601, f"Method not found: {method}")
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
# ponytail: telemetry error interceptor (#718)
|
|
334
|
+
from .log_util import install_error_interceptor as _ei
|
|
335
|
+
main = _ei(main)
|
|
336
|
+
|
|
333
337
|
if __name__ == "__main__":
|
|
334
338
|
main()
|
|
@@ -9,6 +9,7 @@ src/conduct_cli/hook_precompact_template.py
|
|
|
9
9
|
src/conduct_cli/hook_session_start_template.py
|
|
10
10
|
src/conduct_cli/hook_stop_template.py
|
|
11
11
|
src/conduct_cli/hook_template.py
|
|
12
|
+
src/conduct_cli/log_util.py
|
|
12
13
|
src/conduct_cli/main.py
|
|
13
14
|
src/conduct_cli/mcp_server.py
|
|
14
15
|
src/conduct_cli/memory.py
|
|
@@ -23,4 +24,5 @@ tests/test_bash_operator_signature.py
|
|
|
23
24
|
tests/test_guard_policy.py
|
|
24
25
|
tests/test_guard_savings.py
|
|
25
26
|
tests/test_hook_syntax.py
|
|
27
|
+
tests/test_log_util.py
|
|
26
28
|
tests/test_switch.py
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for conduct_cli.log_util — error interceptor + post path (#718)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from conduct_cli import log_util
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_interceptor_catches_exception_and_posts(monkeypatch):
|
|
10
|
+
captured = {}
|
|
11
|
+
|
|
12
|
+
def _fake_post(event_type, message, tb, ctx, *, wait=False):
|
|
13
|
+
captured["event_type"] = event_type
|
|
14
|
+
captured["message"] = message
|
|
15
|
+
captured["tb"] = tb
|
|
16
|
+
captured["wait"] = wait
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr(log_util, "_post_event", _fake_post)
|
|
19
|
+
|
|
20
|
+
def _boom():
|
|
21
|
+
raise ValueError("kaboom")
|
|
22
|
+
|
|
23
|
+
wrapped = log_util.install_error_interceptor(_boom)
|
|
24
|
+
with pytest.raises(SystemExit) as ex:
|
|
25
|
+
wrapped()
|
|
26
|
+
assert ex.value.code == 1
|
|
27
|
+
assert captured["event_type"] == "error"
|
|
28
|
+
assert "ValueError" in captured["message"]
|
|
29
|
+
assert "kaboom" in captured["message"]
|
|
30
|
+
assert captured["tb"] is not None
|
|
31
|
+
assert captured["wait"] is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_interceptor_passes_through_systemexit(monkeypatch):
|
|
35
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
36
|
+
|
|
37
|
+
def _exit_clean():
|
|
38
|
+
raise SystemExit(0)
|
|
39
|
+
|
|
40
|
+
wrapped = log_util.install_error_interceptor(_exit_clean)
|
|
41
|
+
with pytest.raises(SystemExit) as ex:
|
|
42
|
+
wrapped()
|
|
43
|
+
assert ex.value.code == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_post_skipped_when_no_creds(monkeypatch, tmp_path):
|
|
47
|
+
"""No ~/.conductguard/config.json → no POST attempted."""
|
|
48
|
+
monkeypatch.setattr(log_util, "_CONFIG", tmp_path / "missing.json")
|
|
49
|
+
log_util._read_creds.cache_clear()
|
|
50
|
+
|
|
51
|
+
called = {"urlopen": 0}
|
|
52
|
+
def _fake_urlopen(*a, **k):
|
|
53
|
+
called["urlopen"] += 1
|
|
54
|
+
monkeypatch.setattr(log_util.urllib.request, "urlopen", _fake_urlopen)
|
|
55
|
+
|
|
56
|
+
log_util._post_event_sync("error", "boom", None, {})
|
|
57
|
+
assert called["urlopen"] == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_local_file_gets_jsonl_line(monkeypatch, tmp_path):
|
|
61
|
+
"""log() writes a JSONL line for warn/error to the local file."""
|
|
62
|
+
import json
|
|
63
|
+
log_file = tmp_path / "events.jsonl"
|
|
64
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
65
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
66
|
+
|
|
67
|
+
log_util.log("error", "boom-on-disk", run="r1")
|
|
68
|
+
|
|
69
|
+
assert log_file.exists()
|
|
70
|
+
lines = log_file.read_text().strip().splitlines()
|
|
71
|
+
assert len(lines) == 1
|
|
72
|
+
row = json.loads(lines[0])
|
|
73
|
+
assert row["event_type"] == "error"
|
|
74
|
+
assert row["message"] == "boom-on-disk"
|
|
75
|
+
assert row["tool"] == log_util.TOOL
|
|
76
|
+
assert row["context"] == {"run": "r1"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_local_file_rotates_when_oversize(monkeypatch, tmp_path):
|
|
80
|
+
"""When the file passes _MAX_BYTES, current file rotates to .1 and a fresh file starts."""
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
log_file = tmp_path / "events.jsonl"
|
|
83
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
84
|
+
monkeypatch.setattr(log_util, "_MAX_BYTES", 200) # tiny cap so the test rotates quickly
|
|
85
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
86
|
+
|
|
87
|
+
# First write — creates the file
|
|
88
|
+
log_util.log("error", "first")
|
|
89
|
+
assert log_file.exists()
|
|
90
|
+
|
|
91
|
+
# Fill the file past the cap so the next write triggers rotation
|
|
92
|
+
log_file.write_text("x" * 300)
|
|
93
|
+
|
|
94
|
+
log_util.log("error", "after-rotation")
|
|
95
|
+
|
|
96
|
+
rotated = Path(str(log_file) + ".1")
|
|
97
|
+
assert rotated.exists(), "rotation should produce events.jsonl.1"
|
|
98
|
+
assert log_file.exists() and log_file.stat().st_size > 0
|
|
99
|
+
# Newest event lives in the current file, not the rotated one
|
|
100
|
+
assert "after-rotation" in log_file.read_text()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_local_file_persists_traceback_on_interceptor(monkeypatch, tmp_path):
|
|
104
|
+
"""Unhandled exception → traceback lands in the local file."""
|
|
105
|
+
import json
|
|
106
|
+
log_file = tmp_path / "events.jsonl"
|
|
107
|
+
monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
|
|
108
|
+
monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
|
|
109
|
+
|
|
110
|
+
def _boom():
|
|
111
|
+
raise RuntimeError("disk-trace")
|
|
112
|
+
|
|
113
|
+
wrapped = log_util.install_error_interceptor(_boom)
|
|
114
|
+
with pytest.raises(SystemExit):
|
|
115
|
+
wrapped()
|
|
116
|
+
|
|
117
|
+
rows = [json.loads(l) for l in log_file.read_text().splitlines()]
|
|
118
|
+
assert any(
|
|
119
|
+
r["event_type"] == "error"
|
|
120
|
+
and "RuntimeError" in r["message"]
|
|
121
|
+
and r["traceback"] and "disk-trace" in r["traceback"]
|
|
122
|
+
for r in rows
|
|
123
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|