codebee 0.1.102 → 0.1.103
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 +5 -0
- package/LICENSE +21 -21
- package/README.md +4 -8
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -457
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -217
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +2 -2
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -659
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1185
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -95
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +2 -3
- package/app/ui/artdiff.js +74 -74
- package/app/ui/i18n.js +0 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/telemetry.py
CHANGED
|
@@ -1,292 +1,292 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""遥测回传(L2)+ 版本 ping(L3)+ 诊断包构建(L1)。
|
|
3
|
-
|
|
4
|
-
设计原则:
|
|
5
|
-
- 默认只传「匿名错误元数据」:errorlog 台账里的结构化失败记录(厂商/模型/原因码/
|
|
6
|
-
脱敏摘录/版本/OS),不含任何任务正文、章节内容、密钥、绝对路径;
|
|
7
|
-
- 上传前对每条记录再兜一道 scrub_text(不信任入台账时的第一道);
|
|
8
|
-
- 开关 settings.telemetry_errors(默认开);关掉后什么都不出机器(含版本 ping);
|
|
9
|
-
- 网络失败静默吞掉:遥测永远不影响业务,也不制造新错误;
|
|
10
|
-
- 端点:CloudBase HTTP 函数(国内可达、零运维),可用环境变量
|
|
11
|
-
TUTTI_TELEMETRY_URL 覆盖;端点为空/未配置时整个模块自动休眠。
|
|
12
|
-
|
|
13
|
-
SSRF 防线(端点可被 env 覆盖,所以请求前必须自证安全):
|
|
14
|
-
https 限定 + 域名后缀白名单 + 解析 IP 拒绝私网/环回/链路本地。
|
|
15
|
-
|
|
16
|
-
线程模型:start_background() 起一条 daemon 线程,首轮延迟 45s(不挡启动),
|
|
17
|
-
之后每 6 小时一轮(ping 每轮一次,错误按游标增量上传)。
|
|
18
|
-
"""
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import io
|
|
22
|
-
import ipaddress
|
|
23
|
-
import json
|
|
24
|
-
import os
|
|
25
|
-
import platform
|
|
26
|
-
import socket
|
|
27
|
-
import threading
|
|
28
|
-
import time
|
|
29
|
-
import urllib.request
|
|
30
|
-
import zipfile
|
|
31
|
-
|
|
32
|
-
from . import errorlog, paths, settings, tlsctx
|
|
33
|
-
|
|
34
|
-
# 端点:部署后把 CloudBase HTTP 函数 URL 填到这里(见 cloudfunctions/telemetry-collect/)
|
|
35
|
-
ENDPOINT = os.environ.get("TUTTI_TELEMETRY_URL", "").strip()
|
|
36
|
-
|
|
37
|
-
# 请求只允许发往我们自己的收集域(后缀匹配,大小写不敏感);
|
|
38
|
-
# 测试需要假端点时 monkeypatch 本常量或直接 patch _post。
|
|
39
|
-
ALLOWED_SUFFIXES = (".tcloudbase.com", ".tencentyun.com", ".tencentcs.com")
|
|
40
|
-
|
|
41
|
-
FIRST_DELAY_S = 45
|
|
42
|
-
INTERVAL_S = 6 * 3600
|
|
43
|
-
BATCH_LIMIT = 200
|
|
44
|
-
TIMEOUT_S = 10
|
|
45
|
-
|
|
46
|
-
_thread_started = False
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _cursor_path():
|
|
50
|
-
"""游标文件路径惰性计算:测试会把 paths.ERRORS_DIR 重定向到临时目录,
|
|
51
|
-
import 期绑定会钉死在真实数据目录(与 settings._FILE 同一个坑)。"""
|
|
52
|
-
return paths.ERRORS_DIR / "upload.cursor"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def enabled():
|
|
56
|
-
try:
|
|
57
|
-
return bool(settings.load().get("telemetry_errors", True))
|
|
58
|
-
except Exception:
|
|
59
|
-
return False
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _os_tag():
|
|
63
|
-
return {"win32": "windows", "darwin": "macos"}.get(platform.system().lower(),
|
|
64
|
-
platform.system().lower())
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _version():
|
|
68
|
-
try:
|
|
69
|
-
from . import selfupdate
|
|
70
|
-
return str(selfupdate.package_version() or "dev")
|
|
71
|
-
except Exception:
|
|
72
|
-
return "dev"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _endpoint_safe(url):
|
|
76
|
-
"""端点自证安全:https + 域名后缀白名单 + 解析 IP 全部公网。
|
|
77
|
-
不合格返回 ""(调用方静默跳过——遥测绝不为安全边界让步)。"""
|
|
78
|
-
if not url:
|
|
79
|
-
return ""
|
|
80
|
-
try:
|
|
81
|
-
from urllib.parse import urlparse
|
|
82
|
-
p = urlparse(url)
|
|
83
|
-
if p.scheme != "https" or not p.hostname:
|
|
84
|
-
return ""
|
|
85
|
-
host = p.hostname.lower()
|
|
86
|
-
if not any(host == s.lstrip(".") or host.endswith(s)
|
|
87
|
-
for s in ALLOWED_SUFFIXES):
|
|
88
|
-
return ""
|
|
89
|
-
for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
|
|
90
|
-
ip = ipaddress.ip_address(str(info[4][0]))
|
|
91
|
-
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
92
|
-
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
93
|
-
return ""
|
|
94
|
-
return url
|
|
95
|
-
except Exception:
|
|
96
|
-
return ""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _post(url, payload):
|
|
100
|
-
"""POST JSON 到已通过 _endpoint_safe 校验的 URL;返回状态码,异常返回 0。"""
|
|
101
|
-
safe = _endpoint_safe(url)
|
|
102
|
-
if not safe:
|
|
103
|
-
return 0
|
|
104
|
-
try:
|
|
105
|
-
req = urllib.request.Request(
|
|
106
|
-
safe, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
107
|
-
headers={"Content-Type": "application/json",
|
|
108
|
-
"User-Agent": "CodeBee-Telemetry/1"},
|
|
109
|
-
method="POST")
|
|
110
|
-
with urllib.request.urlopen(req, timeout=TIMEOUT_S,
|
|
111
|
-
context=tlsctx.context()) as resp:
|
|
112
|
-
return int(resp.status or 0)
|
|
113
|
-
except Exception:
|
|
114
|
-
return 0
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# ---------------------------------------------------------------- 上报循环
|
|
118
|
-
|
|
119
|
-
def _read_cursor():
|
|
120
|
-
try:
|
|
121
|
-
return _cursor_path().read_text(encoding="utf-8").strip()
|
|
122
|
-
except Exception:
|
|
123
|
-
return ""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def _write_cursor(v):
|
|
127
|
-
try:
|
|
128
|
-
p = _cursor_path()
|
|
129
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
-
p.write_text(str(v), encoding="utf-8")
|
|
131
|
-
except Exception:
|
|
132
|
-
pass
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _sanitize_record(r):
|
|
136
|
-
"""上传前的最后一道闸:只挑白名单字段,detail 再过一次 scrub。"""
|
|
137
|
-
keep = ("id", "ts", "day", "category", "reason", "provider", "model",
|
|
138
|
-
"tool", "role", "run_id", "task_id", "step", "exit_code",
|
|
139
|
-
"app_version", "os")
|
|
140
|
-
out = {k: r.get(k) for k in keep if k in r}
|
|
141
|
-
out["detail"] = errorlog.scrub_text(r.get("detail") or "", limit=600)
|
|
142
|
-
return out
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def run_once():
|
|
146
|
-
"""一轮上报:版本 ping + 增量错误批量。返回 (ping_ok, uploaded_n)。"""
|
|
147
|
-
if not enabled() or not ENDPOINT:
|
|
148
|
-
return (False, 0)
|
|
149
|
-
ping_ok = _post(ENDPOINT, {
|
|
150
|
-
"kind": "ping", "app": "codebee", "version": _version(),
|
|
151
|
-
"os": _os_tag(), "python": "%d.%d" % platform.python_version_tuple()[:2],
|
|
152
|
-
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
153
|
-
}) == 200
|
|
154
|
-
uploaded = 0
|
|
155
|
-
cursor = _read_cursor()
|
|
156
|
-
batch, new_cursor = errorlog.pending_since(cursor, limit=BATCH_LIMIT)
|
|
157
|
-
if batch:
|
|
158
|
-
payload = {"kind": "errors", "records": [_sanitize_record(r) for r in batch]}
|
|
159
|
-
if _post(ENDPOINT, payload) == 200:
|
|
160
|
-
_write_cursor(new_cursor)
|
|
161
|
-
uploaded = len(batch)
|
|
162
|
-
return (ping_ok, uploaded)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _loop():
|
|
166
|
-
while True:
|
|
167
|
-
try:
|
|
168
|
-
run_once()
|
|
169
|
-
except Exception:
|
|
170
|
-
pass
|
|
171
|
-
time.sleep(INTERVAL_S)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def start_background():
|
|
175
|
-
"""启动上报线程(daemon,首轮延迟 45s)。重复调用安全;未配端点直接休眠。"""
|
|
176
|
-
global _thread_started
|
|
177
|
-
if _thread_started or not ENDPOINT:
|
|
178
|
-
return
|
|
179
|
-
_thread_started = True
|
|
180
|
-
|
|
181
|
-
def _delayed():
|
|
182
|
-
time.sleep(FIRST_DELAY_S)
|
|
183
|
-
try:
|
|
184
|
-
run_once()
|
|
185
|
-
except Exception:
|
|
186
|
-
pass
|
|
187
|
-
_loop()
|
|
188
|
-
|
|
189
|
-
threading.Thread(target=_delayed, name="telemetry-up", daemon=True).start()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
# ---------------------------------------------------------------- 诊断包(L1)
|
|
193
|
-
|
|
194
|
-
def build_bundle_bytes(days=30):
|
|
195
|
-
"""构建「诊断包」zip 字节流:脱敏错误台账 + 用量台账 + 环境元信息。
|
|
196
|
-
|
|
197
|
-
只装可安全外发的聚合数据——绝不打包 tasks/runs/models.json(后者含密钥)。
|
|
198
|
-
错误记录出包前再 scrub 一遍(与上传同一道闸,出机器的东西不过两道不放行)。
|
|
199
|
-
"""
|
|
200
|
-
meta = {
|
|
201
|
-
"app": "CodeBee",
|
|
202
|
-
"version": _version(),
|
|
203
|
-
"os": _os_tag(),
|
|
204
|
-
"os_version": platform.platform(),
|
|
205
|
-
"python": platform.python_version(),
|
|
206
|
-
"telemetry_enabled": enabled(),
|
|
207
|
-
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
208
|
-
"note": "此包只含匿名错误元数据与用量统计;已剥离密钥、路径与任务内容。",
|
|
209
|
-
}
|
|
210
|
-
errors = [_sanitize_record(r) for r in errorlog.iter_records(days)]
|
|
211
|
-
buf = io.BytesIO()
|
|
212
|
-
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
213
|
-
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
214
|
-
z.writestr("codebee-diag-%s/meta.json" % stamp,
|
|
215
|
-
json.dumps(meta, ensure_ascii=False, indent=2))
|
|
216
|
-
z.writestr("codebee-diag-%s/errors-%ddays.jsonl" % (stamp, days),
|
|
217
|
-
"\n".join(json.dumps(r, ensure_ascii=False) for r in errors))
|
|
218
|
-
usage_lines = []
|
|
219
|
-
try:
|
|
220
|
-
from . import usage
|
|
221
|
-
for r in usage._iter_records(days):
|
|
222
|
-
usage_lines.append(json.dumps(r, ensure_ascii=False))
|
|
223
|
-
except Exception:
|
|
224
|
-
pass
|
|
225
|
-
z.writestr("codebee-diag-%s/usage-%ddays.jsonl" % (stamp, days),
|
|
226
|
-
"\n".join(usage_lines))
|
|
227
|
-
return buf.getvalue()
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# ---------------------------------------------------------------- 一键反馈 Issue(免费通道)
|
|
231
|
-
|
|
232
|
-
# 反馈目标仓库(Issue 预填正文,由用户在浏览器亲手提交——不自动回传)
|
|
233
|
-
ISSUE_URL = "https://github.com/Vercel-By-WXP/CodeBee/issues/new"
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def issue_report(days=30, max_groups=8, max_recent=10, detail_chars=120):
|
|
237
|
-
"""本地错误台账 → GitHub Issue 预填摘要。返回 {title, body}。
|
|
238
|
-
|
|
239
|
-
聚合 + 明细全部来自已脱敏的 errorlog 台账,出正文前再 scrub 一道
|
|
240
|
-
(与上传/诊断包同一纪律:出机器的东西过两道不放行)。明细压成单行,
|
|
241
|
-
方便 URL 预填。days=0 表示全部历史。
|
|
242
|
-
"""
|
|
243
|
-
errors = errorlog.iter_records(days)
|
|
244
|
-
groups = {}
|
|
245
|
-
for r in errors:
|
|
246
|
-
key = (str(r.get("reason") or "UNKNOWN"),
|
|
247
|
-
str(r.get("provider") or "-"),
|
|
248
|
-
str(r.get("model") or "-"))
|
|
249
|
-
g = groups.setdefault(key, {"n": 0, "last": ""})
|
|
250
|
-
g["n"] += 1
|
|
251
|
-
if str(r.get("ts") or "") > g["last"]:
|
|
252
|
-
g["last"] = str(r.get("ts") or "")
|
|
253
|
-
top = sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:max_groups]
|
|
254
|
-
version = _version()
|
|
255
|
-
|
|
256
|
-
if top:
|
|
257
|
-
t_reason, t_prov, t_model = top[0][0]
|
|
258
|
-
title = "错误反馈:%s ×%d(v%s / %s)" % (
|
|
259
|
-
t_reason, top[0][1]["n"], version, _os_tag())
|
|
260
|
-
else:
|
|
261
|
-
title = "CodeBee 错误反馈(无自动记录的错误)"
|
|
262
|
-
|
|
263
|
-
lines = [
|
|
264
|
-
"## CodeBee 错误反馈", "",
|
|
265
|
-
"- 版本:v%s(%s)" % (version, _os_tag()),
|
|
266
|
-
"- Python:%s" % platform.python_version(),
|
|
267
|
-
"- 生成时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
268
|
-
"",
|
|
269
|
-
("近 %d 天失败聚合(原因 × 次数)" % days) if days
|
|
270
|
-
else "全部历史失败聚合(原因 × 次数)",
|
|
271
|
-
]
|
|
272
|
-
if top:
|
|
273
|
-
lines.append("| 原因 | 供应商/模型 | 次数 | 最近一次 |")
|
|
274
|
-
lines.append("|---|---|---|---|")
|
|
275
|
-
for (reason, prov, model), g in top:
|
|
276
|
-
lines.append("| %s | %s/%s | %d | %s |" % (reason, prov, model,
|
|
277
|
-
g["n"], g["last"]))
|
|
278
|
-
else:
|
|
279
|
-
lines.append("(本机错误台账为空)")
|
|
280
|
-
lines += ["", "## 最近失败明细(最多 %d 条,已脱敏)" % max_recent]
|
|
281
|
-
if errors:
|
|
282
|
-
for r in errors[-max_recent:]:
|
|
283
|
-
detail = errorlog.scrub_text(r.get("detail") or "", limit=detail_chars)
|
|
284
|
-
detail = detail.replace("\r", " ").replace("\n", " ")
|
|
285
|
-
lines.append("- `%s` %s [%s/%s] %s" % (
|
|
286
|
-
str(r.get("ts") or ""), str(r.get("reason") or "UNKNOWN"),
|
|
287
|
-
str(r.get("provider") or "-"), str(r.get("model") or "-"), detail))
|
|
288
|
-
else:
|
|
289
|
-
lines.append("(无)")
|
|
290
|
-
lines += ["", "> 提示:可在 设置 → 关于与更新 → 导出诊断包 生成 zip 附在本 Issue"
|
|
291
|
-
"(含更完整的脱敏错误记录与用量统计)。", ""]
|
|
292
|
-
return {"title": title, "body": "\n".join(lines)}
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""遥测回传(L2)+ 版本 ping(L3)+ 诊断包构建(L1)。
|
|
3
|
+
|
|
4
|
+
设计原则:
|
|
5
|
+
- 默认只传「匿名错误元数据」:errorlog 台账里的结构化失败记录(厂商/模型/原因码/
|
|
6
|
+
脱敏摘录/版本/OS),不含任何任务正文、章节内容、密钥、绝对路径;
|
|
7
|
+
- 上传前对每条记录再兜一道 scrub_text(不信任入台账时的第一道);
|
|
8
|
+
- 开关 settings.telemetry_errors(默认开);关掉后什么都不出机器(含版本 ping);
|
|
9
|
+
- 网络失败静默吞掉:遥测永远不影响业务,也不制造新错误;
|
|
10
|
+
- 端点:CloudBase HTTP 函数(国内可达、零运维),可用环境变量
|
|
11
|
+
TUTTI_TELEMETRY_URL 覆盖;端点为空/未配置时整个模块自动休眠。
|
|
12
|
+
|
|
13
|
+
SSRF 防线(端点可被 env 覆盖,所以请求前必须自证安全):
|
|
14
|
+
https 限定 + 域名后缀白名单 + 解析 IP 拒绝私网/环回/链路本地。
|
|
15
|
+
|
|
16
|
+
线程模型:start_background() 起一条 daemon 线程,首轮延迟 45s(不挡启动),
|
|
17
|
+
之后每 6 小时一轮(ping 每轮一次,错误按游标增量上传)。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import ipaddress
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import socket
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import urllib.request
|
|
30
|
+
import zipfile
|
|
31
|
+
|
|
32
|
+
from . import errorlog, paths, settings, tlsctx
|
|
33
|
+
|
|
34
|
+
# 端点:部署后把 CloudBase HTTP 函数 URL 填到这里(见 cloudfunctions/telemetry-collect/)
|
|
35
|
+
ENDPOINT = os.environ.get("TUTTI_TELEMETRY_URL", "").strip()
|
|
36
|
+
|
|
37
|
+
# 请求只允许发往我们自己的收集域(后缀匹配,大小写不敏感);
|
|
38
|
+
# 测试需要假端点时 monkeypatch 本常量或直接 patch _post。
|
|
39
|
+
ALLOWED_SUFFIXES = (".tcloudbase.com", ".tencentyun.com", ".tencentcs.com")
|
|
40
|
+
|
|
41
|
+
FIRST_DELAY_S = 45
|
|
42
|
+
INTERVAL_S = 6 * 3600
|
|
43
|
+
BATCH_LIMIT = 200
|
|
44
|
+
TIMEOUT_S = 10
|
|
45
|
+
|
|
46
|
+
_thread_started = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cursor_path():
|
|
50
|
+
"""游标文件路径惰性计算:测试会把 paths.ERRORS_DIR 重定向到临时目录,
|
|
51
|
+
import 期绑定会钉死在真实数据目录(与 settings._FILE 同一个坑)。"""
|
|
52
|
+
return paths.ERRORS_DIR / "upload.cursor"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def enabled():
|
|
56
|
+
try:
|
|
57
|
+
return bool(settings.load().get("telemetry_errors", True))
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _os_tag():
|
|
63
|
+
return {"win32": "windows", "darwin": "macos"}.get(platform.system().lower(),
|
|
64
|
+
platform.system().lower())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _version():
|
|
68
|
+
try:
|
|
69
|
+
from . import selfupdate
|
|
70
|
+
return str(selfupdate.package_version() or "dev")
|
|
71
|
+
except Exception:
|
|
72
|
+
return "dev"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _endpoint_safe(url):
|
|
76
|
+
"""端点自证安全:https + 域名后缀白名单 + 解析 IP 全部公网。
|
|
77
|
+
不合格返回 ""(调用方静默跳过——遥测绝不为安全边界让步)。"""
|
|
78
|
+
if not url:
|
|
79
|
+
return ""
|
|
80
|
+
try:
|
|
81
|
+
from urllib.parse import urlparse
|
|
82
|
+
p = urlparse(url)
|
|
83
|
+
if p.scheme != "https" or not p.hostname:
|
|
84
|
+
return ""
|
|
85
|
+
host = p.hostname.lower()
|
|
86
|
+
if not any(host == s.lstrip(".") or host.endswith(s)
|
|
87
|
+
for s in ALLOWED_SUFFIXES):
|
|
88
|
+
return ""
|
|
89
|
+
for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
|
|
90
|
+
ip = ipaddress.ip_address(str(info[4][0]))
|
|
91
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
92
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
93
|
+
return ""
|
|
94
|
+
return url
|
|
95
|
+
except Exception:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _post(url, payload):
|
|
100
|
+
"""POST JSON 到已通过 _endpoint_safe 校验的 URL;返回状态码,异常返回 0。"""
|
|
101
|
+
safe = _endpoint_safe(url)
|
|
102
|
+
if not safe:
|
|
103
|
+
return 0
|
|
104
|
+
try:
|
|
105
|
+
req = urllib.request.Request(
|
|
106
|
+
safe, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
107
|
+
headers={"Content-Type": "application/json",
|
|
108
|
+
"User-Agent": "CodeBee-Telemetry/1"},
|
|
109
|
+
method="POST")
|
|
110
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S,
|
|
111
|
+
context=tlsctx.context()) as resp:
|
|
112
|
+
return int(resp.status or 0)
|
|
113
|
+
except Exception:
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------- 上报循环
|
|
118
|
+
|
|
119
|
+
def _read_cursor():
|
|
120
|
+
try:
|
|
121
|
+
return _cursor_path().read_text(encoding="utf-8").strip()
|
|
122
|
+
except Exception:
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_cursor(v):
|
|
127
|
+
try:
|
|
128
|
+
p = _cursor_path()
|
|
129
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
p.write_text(str(v), encoding="utf-8")
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sanitize_record(r):
|
|
136
|
+
"""上传前的最后一道闸:只挑白名单字段,detail 再过一次 scrub。"""
|
|
137
|
+
keep = ("id", "ts", "day", "category", "reason", "provider", "model",
|
|
138
|
+
"tool", "role", "run_id", "task_id", "step", "exit_code",
|
|
139
|
+
"app_version", "os")
|
|
140
|
+
out = {k: r.get(k) for k in keep if k in r}
|
|
141
|
+
out["detail"] = errorlog.scrub_text(r.get("detail") or "", limit=600)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_once():
|
|
146
|
+
"""一轮上报:版本 ping + 增量错误批量。返回 (ping_ok, uploaded_n)。"""
|
|
147
|
+
if not enabled() or not ENDPOINT:
|
|
148
|
+
return (False, 0)
|
|
149
|
+
ping_ok = _post(ENDPOINT, {
|
|
150
|
+
"kind": "ping", "app": "codebee", "version": _version(),
|
|
151
|
+
"os": _os_tag(), "python": "%d.%d" % platform.python_version_tuple()[:2],
|
|
152
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
153
|
+
}) == 200
|
|
154
|
+
uploaded = 0
|
|
155
|
+
cursor = _read_cursor()
|
|
156
|
+
batch, new_cursor = errorlog.pending_since(cursor, limit=BATCH_LIMIT)
|
|
157
|
+
if batch:
|
|
158
|
+
payload = {"kind": "errors", "records": [_sanitize_record(r) for r in batch]}
|
|
159
|
+
if _post(ENDPOINT, payload) == 200:
|
|
160
|
+
_write_cursor(new_cursor)
|
|
161
|
+
uploaded = len(batch)
|
|
162
|
+
return (ping_ok, uploaded)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _loop():
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
run_once()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
time.sleep(INTERVAL_S)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def start_background():
|
|
175
|
+
"""启动上报线程(daemon,首轮延迟 45s)。重复调用安全;未配端点直接休眠。"""
|
|
176
|
+
global _thread_started
|
|
177
|
+
if _thread_started or not ENDPOINT:
|
|
178
|
+
return
|
|
179
|
+
_thread_started = True
|
|
180
|
+
|
|
181
|
+
def _delayed():
|
|
182
|
+
time.sleep(FIRST_DELAY_S)
|
|
183
|
+
try:
|
|
184
|
+
run_once()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
_loop()
|
|
188
|
+
|
|
189
|
+
threading.Thread(target=_delayed, name="telemetry-up", daemon=True).start()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------- 诊断包(L1)
|
|
193
|
+
|
|
194
|
+
def build_bundle_bytes(days=30):
|
|
195
|
+
"""构建「诊断包」zip 字节流:脱敏错误台账 + 用量台账 + 环境元信息。
|
|
196
|
+
|
|
197
|
+
只装可安全外发的聚合数据——绝不打包 tasks/runs/models.json(后者含密钥)。
|
|
198
|
+
错误记录出包前再 scrub 一遍(与上传同一道闸,出机器的东西不过两道不放行)。
|
|
199
|
+
"""
|
|
200
|
+
meta = {
|
|
201
|
+
"app": "CodeBee",
|
|
202
|
+
"version": _version(),
|
|
203
|
+
"os": _os_tag(),
|
|
204
|
+
"os_version": platform.platform(),
|
|
205
|
+
"python": platform.python_version(),
|
|
206
|
+
"telemetry_enabled": enabled(),
|
|
207
|
+
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
208
|
+
"note": "此包只含匿名错误元数据与用量统计;已剥离密钥、路径与任务内容。",
|
|
209
|
+
}
|
|
210
|
+
errors = [_sanitize_record(r) for r in errorlog.iter_records(days)]
|
|
211
|
+
buf = io.BytesIO()
|
|
212
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
213
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
214
|
+
z.writestr("codebee-diag-%s/meta.json" % stamp,
|
|
215
|
+
json.dumps(meta, ensure_ascii=False, indent=2))
|
|
216
|
+
z.writestr("codebee-diag-%s/errors-%ddays.jsonl" % (stamp, days),
|
|
217
|
+
"\n".join(json.dumps(r, ensure_ascii=False) for r in errors))
|
|
218
|
+
usage_lines = []
|
|
219
|
+
try:
|
|
220
|
+
from . import usage
|
|
221
|
+
for r in usage._iter_records(days):
|
|
222
|
+
usage_lines.append(json.dumps(r, ensure_ascii=False))
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
z.writestr("codebee-diag-%s/usage-%ddays.jsonl" % (stamp, days),
|
|
226
|
+
"\n".join(usage_lines))
|
|
227
|
+
return buf.getvalue()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------- 一键反馈 Issue(免费通道)
|
|
231
|
+
|
|
232
|
+
# 反馈目标仓库(Issue 预填正文,由用户在浏览器亲手提交——不自动回传)
|
|
233
|
+
ISSUE_URL = "https://github.com/Vercel-By-WXP/CodeBee/issues/new"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def issue_report(days=30, max_groups=8, max_recent=10, detail_chars=120):
|
|
237
|
+
"""本地错误台账 → GitHub Issue 预填摘要。返回 {title, body}。
|
|
238
|
+
|
|
239
|
+
聚合 + 明细全部来自已脱敏的 errorlog 台账,出正文前再 scrub 一道
|
|
240
|
+
(与上传/诊断包同一纪律:出机器的东西过两道不放行)。明细压成单行,
|
|
241
|
+
方便 URL 预填。days=0 表示全部历史。
|
|
242
|
+
"""
|
|
243
|
+
errors = errorlog.iter_records(days)
|
|
244
|
+
groups = {}
|
|
245
|
+
for r in errors:
|
|
246
|
+
key = (str(r.get("reason") or "UNKNOWN"),
|
|
247
|
+
str(r.get("provider") or "-"),
|
|
248
|
+
str(r.get("model") or "-"))
|
|
249
|
+
g = groups.setdefault(key, {"n": 0, "last": ""})
|
|
250
|
+
g["n"] += 1
|
|
251
|
+
if str(r.get("ts") or "") > g["last"]:
|
|
252
|
+
g["last"] = str(r.get("ts") or "")
|
|
253
|
+
top = sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:max_groups]
|
|
254
|
+
version = _version()
|
|
255
|
+
|
|
256
|
+
if top:
|
|
257
|
+
t_reason, t_prov, t_model = top[0][0]
|
|
258
|
+
title = "错误反馈:%s ×%d(v%s / %s)" % (
|
|
259
|
+
t_reason, top[0][1]["n"], version, _os_tag())
|
|
260
|
+
else:
|
|
261
|
+
title = "CodeBee 错误反馈(无自动记录的错误)"
|
|
262
|
+
|
|
263
|
+
lines = [
|
|
264
|
+
"## CodeBee 错误反馈", "",
|
|
265
|
+
"- 版本:v%s(%s)" % (version, _os_tag()),
|
|
266
|
+
"- Python:%s" % platform.python_version(),
|
|
267
|
+
"- 生成时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
268
|
+
"",
|
|
269
|
+
("近 %d 天失败聚合(原因 × 次数)" % days) if days
|
|
270
|
+
else "全部历史失败聚合(原因 × 次数)",
|
|
271
|
+
]
|
|
272
|
+
if top:
|
|
273
|
+
lines.append("| 原因 | 供应商/模型 | 次数 | 最近一次 |")
|
|
274
|
+
lines.append("|---|---|---|---|")
|
|
275
|
+
for (reason, prov, model), g in top:
|
|
276
|
+
lines.append("| %s | %s/%s | %d | %s |" % (reason, prov, model,
|
|
277
|
+
g["n"], g["last"]))
|
|
278
|
+
else:
|
|
279
|
+
lines.append("(本机错误台账为空)")
|
|
280
|
+
lines += ["", "## 最近失败明细(最多 %d 条,已脱敏)" % max_recent]
|
|
281
|
+
if errors:
|
|
282
|
+
for r in errors[-max_recent:]:
|
|
283
|
+
detail = errorlog.scrub_text(r.get("detail") or "", limit=detail_chars)
|
|
284
|
+
detail = detail.replace("\r", " ").replace("\n", " ")
|
|
285
|
+
lines.append("- `%s` %s [%s/%s] %s" % (
|
|
286
|
+
str(r.get("ts") or ""), str(r.get("reason") or "UNKNOWN"),
|
|
287
|
+
str(r.get("provider") or "-"), str(r.get("model") or "-"), detail))
|
|
288
|
+
else:
|
|
289
|
+
lines.append("(无)")
|
|
290
|
+
lines += ["", "> 提示:可在 设置 → 关于与更新 → 导出诊断包 生成 zip 附在本 Issue"
|
|
291
|
+
"(含更完整的脱敏错误记录与用量统计)。", ""]
|
|
292
|
+
return {"title": title, "body": "\n".join(lines)}
|