kiwimatecoder 0.1.0__py3-none-any.whl
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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/team.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Shared team policy overlays for multi-user repositories.
|
|
2
|
+
|
|
3
|
+
A team policy is a JSON file with the same shape as a project
|
|
4
|
+
``.kiwimatecoder.json`` that is layered on top of global and project config.
|
|
5
|
+
Policy values always win; when ``team.enforce`` is on, each policy key replaces
|
|
6
|
+
the merged value wholesale and a project overlay may not add or override any
|
|
7
|
+
key in the policy namespace (a cloned repository cannot widen or weaken the
|
|
8
|
+
team's policy).
|
|
9
|
+
|
|
10
|
+
There is **no server component**: the file is read from the local path the user
|
|
11
|
+
configured in ``team.policy_path`` and API keys are never read from it. A
|
|
12
|
+
missing or corrupt policy never breaks config loading; :func:`policy_issues`
|
|
13
|
+
reports it and ``config validate`` / ``doctor`` surface it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
# Config keys a shared policy is allowed to control. ``team`` itself is not a
|
|
24
|
+
# policy key: a policy cannot redirect or disable policy loading.
|
|
25
|
+
POLICY_KEYS: tuple[str, ...] = (
|
|
26
|
+
"default_mode",
|
|
27
|
+
"selected_provider",
|
|
28
|
+
"active_providers",
|
|
29
|
+
"selected_model",
|
|
30
|
+
"model_filters",
|
|
31
|
+
"command_rules",
|
|
32
|
+
"tool_permissions",
|
|
33
|
+
"sampling",
|
|
34
|
+
"trusted_workspace",
|
|
35
|
+
"output_style",
|
|
36
|
+
"system_prompt",
|
|
37
|
+
"verify_command",
|
|
38
|
+
"budget",
|
|
39
|
+
"mcp_servers",
|
|
40
|
+
"plugins",
|
|
41
|
+
"subagents",
|
|
42
|
+
"sandbox",
|
|
43
|
+
"remote",
|
|
44
|
+
"network",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Keys that are never policy-controlled: ``keys`` would carry secrets and
|
|
48
|
+
# ``version``/``team`` are config bookkeeping. They are ignored silently.
|
|
49
|
+
_IGNORED_KEYS = frozenset({"keys", "version", "team"})
|
|
50
|
+
|
|
51
|
+
# path -> (mtime, size, parsed policy); re-read only when the file changes.
|
|
52
|
+
_CACHE: dict[str, tuple[float, int, dict[str, Any]]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PolicyError(Exception):
|
|
56
|
+
"""Raised when a configured policy file is missing or unreadable."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def policy_keys() -> tuple[str, ...]:
|
|
60
|
+
"""Return the config keys a shared policy may control."""
|
|
61
|
+
return POLICY_KEYS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clear_policy_cache() -> None:
|
|
65
|
+
"""Drop the mtime cache (tests and callers that rewrite a policy file)."""
|
|
66
|
+
_CACHE.clear()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _team_section(cfg: dict[str, Any] | None) -> dict[str, Any]:
|
|
70
|
+
"""Return the normalized ``team`` section from ``cfg`` (or active config)."""
|
|
71
|
+
if cfg is None:
|
|
72
|
+
from kiwimatecoder import config as config_module
|
|
73
|
+
|
|
74
|
+
cfg = config_module.load_config()
|
|
75
|
+
stored = cfg.get("team") or {}
|
|
76
|
+
if not isinstance(stored, dict):
|
|
77
|
+
return {"policy_path": "", "enforce": False}
|
|
78
|
+
path = stored.get("policy_path")
|
|
79
|
+
enforce = stored.get("enforce")
|
|
80
|
+
return {
|
|
81
|
+
"policy_path": path.strip() if isinstance(path, str) else "",
|
|
82
|
+
"enforce": enforce if isinstance(enforce, bool) else False,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_policy_file(path: Path) -> dict[str, Any]:
|
|
87
|
+
"""Read, validate, and filter one policy file (cached by mtime+size)."""
|
|
88
|
+
try:
|
|
89
|
+
stat = path.stat()
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise PolicyError(f"Team policy file not found: {path}") from exc
|
|
92
|
+
cache_key = str(path)
|
|
93
|
+
cached = _CACHE.get(cache_key)
|
|
94
|
+
if cached and cached[0] == stat.st_mtime and cached[1] == stat.st_size:
|
|
95
|
+
return copy.deepcopy(cached[2])
|
|
96
|
+
try:
|
|
97
|
+
raw = path.read_text(encoding="utf-8")
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
raise PolicyError(f"Cannot read team policy {path}: {exc}") from exc
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(raw)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise PolicyError(f"Team policy {path} is not valid JSON: {exc}") from exc
|
|
104
|
+
if not isinstance(data, dict):
|
|
105
|
+
raise PolicyError(f"Team policy {path} must be a JSON object.")
|
|
106
|
+
policy = {key: value for key, value in data.items() if key in POLICY_KEYS}
|
|
107
|
+
_CACHE[cache_key] = (stat.st_mtime, stat.st_size, copy.deepcopy(policy))
|
|
108
|
+
return policy
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_policy(
|
|
112
|
+
path: str | Path | None = None, cfg: dict[str, Any] | None = None
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Load the shared policy, returning only policy-controlled keys.
|
|
115
|
+
|
|
116
|
+
``path`` defaults to ``team.policy_path`` in ``cfg`` (or the active config
|
|
117
|
+
when ``cfg`` is None); an unset path yields an empty policy. Raises
|
|
118
|
+
:class:`PolicyError` for a missing file, unreadable data, invalid JSON, or
|
|
119
|
+
a document that is not a JSON object. ``keys`` is never read from a policy.
|
|
120
|
+
"""
|
|
121
|
+
if path is None:
|
|
122
|
+
resolved = _team_section(cfg)["policy_path"]
|
|
123
|
+
if not resolved:
|
|
124
|
+
return {}
|
|
125
|
+
target = Path(resolved).expanduser()
|
|
126
|
+
else:
|
|
127
|
+
target = Path(path).expanduser()
|
|
128
|
+
return _read_policy_file(target)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def policy_issues(cfg: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
|
132
|
+
"""Return issues with the configured policy file.
|
|
133
|
+
|
|
134
|
+
A missing or corrupt policy never breaks config loading, so this is how
|
|
135
|
+
``config validate`` and ``doctor`` surface it. Unknown policy keys are
|
|
136
|
+
warnings because a newer release may add keys this one does not know.
|
|
137
|
+
"""
|
|
138
|
+
path = _team_section(cfg)["policy_path"]
|
|
139
|
+
if not path:
|
|
140
|
+
return []
|
|
141
|
+
target = Path(path).expanduser()
|
|
142
|
+
if not target.is_file():
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
"level": "error",
|
|
146
|
+
"key": "team.policy_path",
|
|
147
|
+
"message": f"Team policy file not found: {path}",
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
try:
|
|
151
|
+
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
152
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
"level": "error",
|
|
156
|
+
"key": "team.policy_path",
|
|
157
|
+
"message": f"Cannot read team policy {path}: {exc}",
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
if not isinstance(raw, dict):
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
"level": "error",
|
|
164
|
+
"key": "team.policy_path",
|
|
165
|
+
"message": f"Team policy {path} must be a JSON object.",
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
issues: list[dict[str, str]] = []
|
|
169
|
+
for key in sorted(set(raw) - set(POLICY_KEYS) - _IGNORED_KEYS):
|
|
170
|
+
issues.append(
|
|
171
|
+
{
|
|
172
|
+
"level": "warning",
|
|
173
|
+
"key": f"team.policy_path.{key}",
|
|
174
|
+
"message": f"Unknown policy key '{key}' is ignored.",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return issues
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _deep_overlay(cfg: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
181
|
+
"""Deep-merge ``overlay`` onto ``cfg`` (overlay values win per sub-key)."""
|
|
182
|
+
for key, value in overlay.items():
|
|
183
|
+
if isinstance(value, dict) and isinstance(cfg.get(key), dict):
|
|
184
|
+
merged = dict(cfg[key])
|
|
185
|
+
for sub_key, sub_value in value.items():
|
|
186
|
+
if isinstance(sub_value, dict) and isinstance(merged.get(sub_key), dict):
|
|
187
|
+
merged[sub_key] = {**merged[sub_key], **sub_value}
|
|
188
|
+
else:
|
|
189
|
+
merged[sub_key] = sub_value
|
|
190
|
+
cfg[key] = merged
|
|
191
|
+
else:
|
|
192
|
+
cfg[key] = value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def apply_policy(
|
|
196
|
+
cfg: dict[str, Any],
|
|
197
|
+
*,
|
|
198
|
+
project: dict[str, Any] | None = None,
|
|
199
|
+
global_cfg: dict[str, Any] | None = None,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Apply the configured policy to ``cfg`` in place and return it.
|
|
202
|
+
|
|
203
|
+
Called by :func:`kiwimatecoder.config.load_config` after the project
|
|
204
|
+
overlay so policy values win. With ``team.enforce`` on, every policy key
|
|
205
|
+
replaces the merged value wholesale and project values for policy keys the
|
|
206
|
+
policy does not define are rolled back to the global config, so a
|
|
207
|
+
repository cannot widen or weaken the policy namespace. Failures are
|
|
208
|
+
swallowed so a missing/corrupt file can never break config loading;
|
|
209
|
+
:func:`policy_issues` reports them.
|
|
210
|
+
"""
|
|
211
|
+
section = _team_section(cfg)
|
|
212
|
+
path = section["policy_path"]
|
|
213
|
+
if not path:
|
|
214
|
+
return {}
|
|
215
|
+
try:
|
|
216
|
+
policy = load_policy(path, cfg)
|
|
217
|
+
except PolicyError:
|
|
218
|
+
return {}
|
|
219
|
+
if not policy:
|
|
220
|
+
return {}
|
|
221
|
+
if section["enforce"]:
|
|
222
|
+
if project:
|
|
223
|
+
for key in POLICY_KEYS:
|
|
224
|
+
if key not in project or key in policy:
|
|
225
|
+
continue
|
|
226
|
+
if global_cfg is not None and key in global_cfg:
|
|
227
|
+
cfg[key] = copy.deepcopy(global_cfg[key])
|
|
228
|
+
else:
|
|
229
|
+
cfg.pop(key, None)
|
|
230
|
+
# A project selected_provider seeds active_providers before the
|
|
231
|
+
# policy is applied; roll that seeding back too unless the policy
|
|
232
|
+
# pins the roster (or a provider) itself.
|
|
233
|
+
if (
|
|
234
|
+
"selected_provider" in project
|
|
235
|
+
and "active_providers" not in project
|
|
236
|
+
and "selected_provider" not in policy
|
|
237
|
+
and "active_providers" not in policy
|
|
238
|
+
):
|
|
239
|
+
if global_cfg is not None and "active_providers" in global_cfg:
|
|
240
|
+
cfg["active_providers"] = copy.deepcopy(
|
|
241
|
+
global_cfg["active_providers"]
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
cfg.pop("active_providers", None)
|
|
245
|
+
for key, value in policy.items():
|
|
246
|
+
cfg[key] = copy.deepcopy(value)
|
|
247
|
+
else:
|
|
248
|
+
_deep_overlay(cfg, policy)
|
|
249
|
+
return policy
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Opt-in local telemetry, debug logging, and crash reports.
|
|
2
|
+
|
|
3
|
+
Nothing leaves the machine: events are appended as one compact JSON object per
|
|
4
|
+
line to a user-owned log (``~/.kiwimatecoder/logs/kiwimatecoder.log`` by
|
|
5
|
+
default) with secret-looking strings redacted. Telemetry is **off by default**
|
|
6
|
+
and does nothing until ``telemetry.enabled`` is true and a level other than
|
|
7
|
+
``off`` is configured. The log rotates to ``<log_file>.1`` once it would grow
|
|
8
|
+
past ``telemetry.max_log_bytes``.
|
|
9
|
+
|
|
10
|
+
``KIWIMATECODER_DEBUG=1`` (or ``true``/``yes``/``on``) forces the ``debug``
|
|
11
|
+
level for one run without changing config, which is handy when diagnosing a
|
|
12
|
+
stubborn provider or network issue.
|
|
13
|
+
|
|
14
|
+
Crash reports are opt-in too: :func:`install_crash_handler` (called by
|
|
15
|
+
:func:`configure` while telemetry is enabled) wraps ``sys.excepthook`` and
|
|
16
|
+
``threading.excepthook`` to write a redacted ``crash-<ts>.json`` bundle before
|
|
17
|
+
delegating to the previous hook.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import traceback
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Callable
|
|
31
|
+
|
|
32
|
+
from kiwimatecoder import __version__, config, events
|
|
33
|
+
from kiwimatecoder.redaction import redact
|
|
34
|
+
|
|
35
|
+
DEBUG_ENV = "KIWIMATECODER_DEBUG"
|
|
36
|
+
LOG_DIR_NAME = "logs"
|
|
37
|
+
LOG_FILE_NAME = "kiwimatecoder.log"
|
|
38
|
+
CRASH_PREFIX = "crash-"
|
|
39
|
+
MAX_RECENT_LINES = 50
|
|
40
|
+
_RECENT_MAX_BYTES = 64 * 1024
|
|
41
|
+
_LEVEL_RANK = {"off": 0, "error": 1, "info": 2, "debug": 3}
|
|
42
|
+
_DEBUG_TRUTHY = {"1", "true", "yes", "on"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_dir() -> Path:
|
|
46
|
+
"""Return the telemetry log directory (resolved lazily for tests)."""
|
|
47
|
+
return config.CONFIG_DIR / LOG_DIR_NAME
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_log_file() -> Path:
|
|
51
|
+
"""Return the default telemetry log path."""
|
|
52
|
+
return log_dir() / LOG_FILE_NAME
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _debug_env_enabled() -> bool:
|
|
56
|
+
return os.environ.get(DEBUG_ENV, "").strip().lower() in _DEBUG_TRUTHY
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _chmod(path: Path, mode: int) -> None:
|
|
60
|
+
try:
|
|
61
|
+
os.chmod(path, mode)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ensure_private_dir(path: Path) -> None:
|
|
67
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
_chmod(path, 0o700)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_private_text(path: Path, text: str) -> None:
|
|
72
|
+
_ensure_private_dir(path.parent)
|
|
73
|
+
tmp = path.with_name(path.name + ".kiwi.tmp")
|
|
74
|
+
try:
|
|
75
|
+
tmp.write_text(text, encoding="utf-8")
|
|
76
|
+
os.replace(tmp, path)
|
|
77
|
+
except OSError:
|
|
78
|
+
try:
|
|
79
|
+
tmp.unlink()
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
raise
|
|
83
|
+
_chmod(path, 0o600)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _redact_value(value: Any) -> Any:
|
|
87
|
+
"""Recursively redact secret-looking strings in a JSON-friendly value."""
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return redact(value)
|
|
90
|
+
if isinstance(value, dict):
|
|
91
|
+
return {str(key): _redact_value(item) for key, item in value.items()}
|
|
92
|
+
if isinstance(value, (list, tuple)):
|
|
93
|
+
return [_redact_value(item) for item in value]
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Telemetry:
|
|
98
|
+
"""Local JSON-line telemetry with rotation and lifecycle subscriptions."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._enabled = False
|
|
102
|
+
self._level = "off"
|
|
103
|
+
self._log_file: Path | None = None
|
|
104
|
+
self._max_bytes = int(config.TELEMETRY_DEFAULTS["max_log_bytes"])
|
|
105
|
+
self._unsubscribers: list[Callable[[], None]] = []
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def enabled(self) -> bool:
|
|
109
|
+
"""Whether telemetry is currently recording."""
|
|
110
|
+
return self._enabled
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def level(self) -> str:
|
|
114
|
+
"""The effective level: off, error, info, or debug."""
|
|
115
|
+
return self._level
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def log_file(self) -> Path:
|
|
119
|
+
"""The active log path (the default when unset)."""
|
|
120
|
+
return self._log_file or default_log_file()
|
|
121
|
+
|
|
122
|
+
def configure(
|
|
123
|
+
self,
|
|
124
|
+
cfg: dict[str, Any] | None = None,
|
|
125
|
+
*,
|
|
126
|
+
bus: events.EventBus | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Apply telemetry config, (re)subscribe, and arm crash reports.
|
|
129
|
+
|
|
130
|
+
``KIWIMATECODER_DEBUG`` forces ``enabled``/``debug`` for this run.
|
|
131
|
+
Never raises: a broken config leaves telemetry off.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
settings = config.get_telemetry(cfg)
|
|
135
|
+
except Exception:
|
|
136
|
+
settings = dict(config.TELEMETRY_DEFAULTS)
|
|
137
|
+
enabled = bool(settings["enabled"])
|
|
138
|
+
level = str(settings["level"])
|
|
139
|
+
if _debug_env_enabled():
|
|
140
|
+
enabled = True
|
|
141
|
+
level = "debug"
|
|
142
|
+
self._enabled = enabled
|
|
143
|
+
self._level = level if enabled else "off"
|
|
144
|
+
log_file = str(settings["log_file"] or "").strip()
|
|
145
|
+
self._log_file = Path(log_file).expanduser() if log_file else default_log_file()
|
|
146
|
+
self._max_bytes = int(settings["max_log_bytes"])
|
|
147
|
+
self._detach()
|
|
148
|
+
if self._enabled and self._level != "off":
|
|
149
|
+
target_bus = bus if bus is not None else events.BUS
|
|
150
|
+
for name in events.EVENT_NAMES:
|
|
151
|
+
self._unsubscribers.append(
|
|
152
|
+
target_bus.subscribe(name, self._on_event)
|
|
153
|
+
)
|
|
154
|
+
install_crash_handler()
|
|
155
|
+
else:
|
|
156
|
+
uninstall_crash_handler()
|
|
157
|
+
|
|
158
|
+
def _detach(self) -> None:
|
|
159
|
+
for unsubscribe in self._unsubscribers:
|
|
160
|
+
try:
|
|
161
|
+
unsubscribe()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
self._unsubscribers.clear()
|
|
165
|
+
|
|
166
|
+
def _on_event(self, event: events.Event) -> None:
|
|
167
|
+
try:
|
|
168
|
+
self.log_event(event.name, **dict(event.payload))
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def log_event(self, name: str, **fields: Any) -> None:
|
|
173
|
+
"""Append one JSON line when the current level admits the event.
|
|
174
|
+
|
|
175
|
+
Pass ``level="error"``/``"debug"`` to filter by severity; the default
|
|
176
|
+
is ``info``. Every string field is redacted. Never raises.
|
|
177
|
+
"""
|
|
178
|
+
level = str(fields.pop("level", "info")).strip().lower() or "info"
|
|
179
|
+
if not self._enabled or self._level == "off":
|
|
180
|
+
return
|
|
181
|
+
if _LEVEL_RANK.get(level, 2) > _LEVEL_RANK.get(self._level, 0):
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
entry: dict[str, Any] = {
|
|
185
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
186
|
+
"level": level,
|
|
187
|
+
"event": redact(str(name)),
|
|
188
|
+
}
|
|
189
|
+
for key, value in fields.items():
|
|
190
|
+
entry[str(key)] = _redact_value(value)
|
|
191
|
+
line = json.dumps(
|
|
192
|
+
entry, ensure_ascii=False, separators=(",", ":"), default=str
|
|
193
|
+
)
|
|
194
|
+
self._append_line(line)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
def log_exception(
|
|
199
|
+
self, exc: BaseException, context: str | None = None
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Log one exception with a redacted traceback at the error level."""
|
|
202
|
+
try:
|
|
203
|
+
tb = "".join(
|
|
204
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
205
|
+
)
|
|
206
|
+
self.log_event(
|
|
207
|
+
"exception",
|
|
208
|
+
level="error",
|
|
209
|
+
error=redact(f"{type(exc).__name__}: {exc}"),
|
|
210
|
+
context=context or "",
|
|
211
|
+
traceback=redact(tb),
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def _append_line(self, line: str) -> None:
|
|
217
|
+
try:
|
|
218
|
+
path = self.log_file
|
|
219
|
+
_ensure_private_dir(path.parent)
|
|
220
|
+
payload = (line + "\n").encode("utf-8", "replace")
|
|
221
|
+
if path.exists() and path.stat().st_size + len(payload) > self._max_bytes:
|
|
222
|
+
rotated = path.with_name(path.name + ".1")
|
|
223
|
+
try:
|
|
224
|
+
os.replace(path, rotated)
|
|
225
|
+
except OSError:
|
|
226
|
+
try:
|
|
227
|
+
path.unlink()
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
_chmod(rotated, 0o600)
|
|
231
|
+
with path.open("ab") as handle:
|
|
232
|
+
handle.write(payload)
|
|
233
|
+
_chmod(path, 0o600)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
TELEMETRY = Telemetry()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def configure(
|
|
242
|
+
cfg: dict[str, Any] | None = None, *, bus: events.EventBus | None = None
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Configure the default telemetry manager."""
|
|
245
|
+
TELEMETRY.configure(cfg, bus=bus)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def enabled() -> bool:
|
|
249
|
+
"""Whether the default manager is recording."""
|
|
250
|
+
return TELEMETRY.enabled
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def log_event(name: str, **fields: Any) -> None:
|
|
254
|
+
"""Log one event on the default manager."""
|
|
255
|
+
TELEMETRY.log_event(name, **fields)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def log_exception(exc: BaseException, context: str | None = None) -> None:
|
|
259
|
+
"""Log one exception on the default manager."""
|
|
260
|
+
TELEMETRY.log_exception(exc, context)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def current_log_path() -> Path:
|
|
264
|
+
"""Return the default manager's active log path."""
|
|
265
|
+
return TELEMETRY.log_file
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _recent_lines(limit: int = MAX_RECENT_LINES) -> list[str]:
|
|
269
|
+
"""Return the tail of the log, bounded by bytes and lines."""
|
|
270
|
+
try:
|
|
271
|
+
path = TELEMETRY.log_file
|
|
272
|
+
if not path.is_file():
|
|
273
|
+
return []
|
|
274
|
+
with path.open("rb") as handle:
|
|
275
|
+
handle.seek(0, os.SEEK_END)
|
|
276
|
+
size = handle.tell()
|
|
277
|
+
handle.seek(max(0, size - _RECENT_MAX_BYTES))
|
|
278
|
+
data = handle.read()
|
|
279
|
+
except OSError:
|
|
280
|
+
return []
|
|
281
|
+
return data.decode("utf-8", "replace").splitlines()[-limit:]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _unique_crash_path(now: datetime.datetime) -> Path:
|
|
285
|
+
base = log_dir() / f"{CRASH_PREFIX}{now.strftime('%Y%m%d_%H%M%S')}.json"
|
|
286
|
+
if not base.exists():
|
|
287
|
+
return base
|
|
288
|
+
counter = 1
|
|
289
|
+
while True:
|
|
290
|
+
candidate = base.with_name(f"{base.stem}-{counter}.json")
|
|
291
|
+
if not candidate.exists():
|
|
292
|
+
return candidate
|
|
293
|
+
counter += 1
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _write_crash_report(exc: BaseException | None) -> Path | None:
|
|
297
|
+
"""Write a redacted crash bundle; returns its path (None when skipped)."""
|
|
298
|
+
try:
|
|
299
|
+
if not TELEMETRY.enabled or TELEMETRY.level == "off":
|
|
300
|
+
return None
|
|
301
|
+
now = datetime.datetime.now()
|
|
302
|
+
if isinstance(exc, BaseException):
|
|
303
|
+
trace = "".join(
|
|
304
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
305
|
+
)
|
|
306
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
307
|
+
else:
|
|
308
|
+
trace = ""
|
|
309
|
+
error = type(exc).__name__ if exc is not None else "unknown"
|
|
310
|
+
payload: dict[str, Any] = {
|
|
311
|
+
"timestamp": now.isoformat(),
|
|
312
|
+
"version": __version__,
|
|
313
|
+
"platform": platform.platform(),
|
|
314
|
+
"python": platform.python_version(),
|
|
315
|
+
"exception": redact(error),
|
|
316
|
+
"traceback": redact(trace),
|
|
317
|
+
"recent": [redact(line) for line in _recent_lines()],
|
|
318
|
+
}
|
|
319
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
|
320
|
+
path = _unique_crash_path(now)
|
|
321
|
+
_write_private_text(path, data + "\n")
|
|
322
|
+
return path
|
|
323
|
+
except Exception:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def crash_report_paths() -> list[Path]:
|
|
328
|
+
"""Return the recorded crash bundles, oldest first."""
|
|
329
|
+
try:
|
|
330
|
+
return sorted(log_dir().glob(f"{CRASH_PREFIX}*.json"))
|
|
331
|
+
except OSError:
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_lock = threading.Lock()
|
|
336
|
+
_installed_sys_hook: Any = None
|
|
337
|
+
_installed_thread_hook: Any = None
|
|
338
|
+
_previous_sys_hook: Any = None
|
|
339
|
+
_previous_thread_hook: Any = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def install_crash_handler() -> None:
|
|
343
|
+
"""Wrap the excepthooks to write a crash bundle, then call the originals.
|
|
344
|
+
|
|
345
|
+
Idempotent; while telemetry is disabled the hooks are not installed (and
|
|
346
|
+
:func:`uninstall_crash_handler` restores whatever was there before).
|
|
347
|
+
"""
|
|
348
|
+
global _installed_sys_hook, _installed_thread_hook
|
|
349
|
+
global _previous_sys_hook, _previous_thread_hook
|
|
350
|
+
with _lock:
|
|
351
|
+
if _installed_sys_hook is not None:
|
|
352
|
+
return
|
|
353
|
+
_previous_sys_hook = sys.excepthook
|
|
354
|
+
_previous_thread_hook = threading.excepthook
|
|
355
|
+
|
|
356
|
+
def _sys_hook(
|
|
357
|
+
exc_type: type[BaseException],
|
|
358
|
+
exc_value: BaseException | None,
|
|
359
|
+
tb: Any,
|
|
360
|
+
) -> None:
|
|
361
|
+
try:
|
|
362
|
+
_write_crash_report(exc_value)
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
_previous_sys_hook(exc_type, exc_value, tb)
|
|
366
|
+
|
|
367
|
+
def _thread_hook(args: threading.ExceptHookArgs) -> None:
|
|
368
|
+
try:
|
|
369
|
+
_write_crash_report(args.exc_value)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
_previous_thread_hook(args)
|
|
373
|
+
|
|
374
|
+
sys.excepthook = _sys_hook
|
|
375
|
+
threading.excepthook = _thread_hook
|
|
376
|
+
_installed_sys_hook = _sys_hook
|
|
377
|
+
_installed_thread_hook = _thread_hook
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def uninstall_crash_handler() -> None:
|
|
381
|
+
"""Restore the excepthooks captured by :func:`install_crash_handler`."""
|
|
382
|
+
global _installed_sys_hook, _installed_thread_hook
|
|
383
|
+
with _lock:
|
|
384
|
+
if _installed_sys_hook is not None and sys.excepthook is _installed_sys_hook:
|
|
385
|
+
sys.excepthook = _previous_sys_hook
|
|
386
|
+
if (
|
|
387
|
+
_installed_thread_hook is not None
|
|
388
|
+
and threading.excepthook is _installed_thread_hook
|
|
389
|
+
):
|
|
390
|
+
threading.excepthook = _previous_thread_hook
|
|
391
|
+
_installed_sys_hook = None
|
|
392
|
+
_installed_thread_hook = None
|