codebee 0.1.7 → 0.1.9
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 +20 -0
- package/README.md +236 -252
- package/app/core/catalog.py +24 -5
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +48 -11
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +21 -0
- package/app/core/modelhub.py +80 -11
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +420 -88
- package/app/core/runner.py +122 -10
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +180 -139
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/main.py +162 -16
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1012 -604
- package/app/ui/i18n.js +79 -6
- package/app/ui/index.html +161 -80
- package/app/ui/style.css +838 -67
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
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
|
|
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) as resp:
|
|
111
|
+
return int(resp.status or 0)
|
|
112
|
+
except Exception:
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------- 上报循环
|
|
117
|
+
|
|
118
|
+
def _read_cursor():
|
|
119
|
+
try:
|
|
120
|
+
return _cursor_path().read_text(encoding="utf-8").strip()
|
|
121
|
+
except Exception:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_cursor(v):
|
|
126
|
+
try:
|
|
127
|
+
p = _cursor_path()
|
|
128
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
p.write_text(str(v), encoding="utf-8")
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _sanitize_record(r):
|
|
135
|
+
"""上传前的最后一道闸:只挑白名单字段,detail 再过一次 scrub。"""
|
|
136
|
+
keep = ("id", "ts", "day", "category", "reason", "provider", "model",
|
|
137
|
+
"tool", "role", "run_id", "task_id", "step", "exit_code",
|
|
138
|
+
"app_version", "os")
|
|
139
|
+
out = {k: r.get(k) for k in keep if k in r}
|
|
140
|
+
out["detail"] = errorlog.scrub_text(r.get("detail") or "", limit=600)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_once():
|
|
145
|
+
"""一轮上报:版本 ping + 增量错误批量。返回 (ping_ok, uploaded_n)。"""
|
|
146
|
+
if not enabled() or not ENDPOINT:
|
|
147
|
+
return (False, 0)
|
|
148
|
+
ping_ok = _post(ENDPOINT, {
|
|
149
|
+
"kind": "ping", "app": "codebee", "version": _version(),
|
|
150
|
+
"os": _os_tag(), "python": "%d.%d" % platform.python_version_tuple()[:2],
|
|
151
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
152
|
+
}) == 200
|
|
153
|
+
uploaded = 0
|
|
154
|
+
cursor = _read_cursor()
|
|
155
|
+
batch, new_cursor = errorlog.pending_since(cursor, limit=BATCH_LIMIT)
|
|
156
|
+
if batch:
|
|
157
|
+
payload = {"kind": "errors", "records": [_sanitize_record(r) for r in batch]}
|
|
158
|
+
if _post(ENDPOINT, payload) == 200:
|
|
159
|
+
_write_cursor(new_cursor)
|
|
160
|
+
uploaded = len(batch)
|
|
161
|
+
return (ping_ok, uploaded)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _loop():
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
run_once()
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
time.sleep(INTERVAL_S)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_background():
|
|
174
|
+
"""启动上报线程(daemon,首轮延迟 45s)。重复调用安全;未配端点直接休眠。"""
|
|
175
|
+
global _thread_started
|
|
176
|
+
if _thread_started or not ENDPOINT:
|
|
177
|
+
return
|
|
178
|
+
_thread_started = True
|
|
179
|
+
|
|
180
|
+
def _delayed():
|
|
181
|
+
time.sleep(FIRST_DELAY_S)
|
|
182
|
+
try:
|
|
183
|
+
run_once()
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
_loop()
|
|
187
|
+
|
|
188
|
+
threading.Thread(target=_delayed, name="telemetry-up", daemon=True).start()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------- 诊断包(L1)
|
|
192
|
+
|
|
193
|
+
def build_bundle_bytes(days=30):
|
|
194
|
+
"""构建「诊断包」zip 字节流:脱敏错误台账 + 用量台账 + 环境元信息。
|
|
195
|
+
|
|
196
|
+
只装可安全外发的聚合数据——绝不打包 tasks/runs/models.json(后者含密钥)。
|
|
197
|
+
错误记录出包前再 scrub 一遍(与上传同一道闸,出机器的东西不过两道不放行)。
|
|
198
|
+
"""
|
|
199
|
+
meta = {
|
|
200
|
+
"app": "CodeBee",
|
|
201
|
+
"version": _version(),
|
|
202
|
+
"os": _os_tag(),
|
|
203
|
+
"os_version": platform.platform(),
|
|
204
|
+
"python": platform.python_version(),
|
|
205
|
+
"telemetry_enabled": enabled(),
|
|
206
|
+
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
207
|
+
"note": "此包只含匿名错误元数据与用量统计;已剥离密钥、路径与任务内容。",
|
|
208
|
+
}
|
|
209
|
+
errors = [_sanitize_record(r) for r in errorlog.iter_records(days)]
|
|
210
|
+
buf = io.BytesIO()
|
|
211
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
212
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
213
|
+
z.writestr("codebee-diag-%s/meta.json" % stamp,
|
|
214
|
+
json.dumps(meta, ensure_ascii=False, indent=2))
|
|
215
|
+
z.writestr("codebee-diag-%s/errors-%ddays.jsonl" % (stamp, days),
|
|
216
|
+
"\n".join(json.dumps(r, ensure_ascii=False) for r in errors))
|
|
217
|
+
usage_lines = []
|
|
218
|
+
try:
|
|
219
|
+
from . import usage
|
|
220
|
+
for r in usage._iter_records(days):
|
|
221
|
+
usage_lines.append(json.dumps(r, ensure_ascii=False))
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
z.writestr("codebee-diag-%s/usage-%ddays.jsonl" % (stamp, days),
|
|
225
|
+
"\n".join(usage_lines))
|
|
226
|
+
return buf.getvalue()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------- 一键反馈 Issue(免费通道)
|
|
230
|
+
|
|
231
|
+
# 反馈目标仓库(Issue 预填正文,由用户在浏览器亲手提交——不自动回传)
|
|
232
|
+
ISSUE_URL = "https://github.com/Vercel-By-WXP/CodeBee/issues/new"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def issue_report(days=30, max_groups=8, max_recent=10, detail_chars=120):
|
|
236
|
+
"""本地错误台账 → GitHub Issue 预填摘要。返回 {title, body}。
|
|
237
|
+
|
|
238
|
+
聚合 + 明细全部来自已脱敏的 errorlog 台账,出正文前再 scrub 一道
|
|
239
|
+
(与上传/诊断包同一纪律:出机器的东西过两道不放行)。明细压成单行,
|
|
240
|
+
方便 URL 预填。days=0 表示全部历史。
|
|
241
|
+
"""
|
|
242
|
+
errors = errorlog.iter_records(days)
|
|
243
|
+
groups = {}
|
|
244
|
+
for r in errors:
|
|
245
|
+
key = (str(r.get("reason") or "UNKNOWN"),
|
|
246
|
+
str(r.get("provider") or "-"),
|
|
247
|
+
str(r.get("model") or "-"))
|
|
248
|
+
g = groups.setdefault(key, {"n": 0, "last": ""})
|
|
249
|
+
g["n"] += 1
|
|
250
|
+
if str(r.get("ts") or "") > g["last"]:
|
|
251
|
+
g["last"] = str(r.get("ts") or "")
|
|
252
|
+
top = sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:max_groups]
|
|
253
|
+
version = _version()
|
|
254
|
+
|
|
255
|
+
if top:
|
|
256
|
+
t_reason, t_prov, t_model = top[0][0]
|
|
257
|
+
title = "错误反馈:%s ×%d(v%s / %s)" % (
|
|
258
|
+
t_reason, top[0][1]["n"], version, _os_tag())
|
|
259
|
+
else:
|
|
260
|
+
title = "CodeBee 错误反馈(无自动记录的错误)"
|
|
261
|
+
|
|
262
|
+
lines = [
|
|
263
|
+
"## CodeBee 错误反馈", "",
|
|
264
|
+
"- 版本:v%s(%s)" % (version, _os_tag()),
|
|
265
|
+
"- Python:%s" % platform.python_version(),
|
|
266
|
+
"- 生成时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
267
|
+
"",
|
|
268
|
+
("近 %d 天失败聚合(原因 × 次数)" % days) if days
|
|
269
|
+
else "全部历史失败聚合(原因 × 次数)",
|
|
270
|
+
]
|
|
271
|
+
if top:
|
|
272
|
+
lines.append("| 原因 | 供应商/模型 | 次数 | 最近一次 |")
|
|
273
|
+
lines.append("|---|---|---|---|")
|
|
274
|
+
for (reason, prov, model), g in top:
|
|
275
|
+
lines.append("| %s | %s/%s | %d | %s |" % (reason, prov, model,
|
|
276
|
+
g["n"], g["last"]))
|
|
277
|
+
else:
|
|
278
|
+
lines.append("(本机错误台账为空)")
|
|
279
|
+
lines += ["", "## 最近失败明细(最多 %d 条,已脱敏)" % max_recent]
|
|
280
|
+
if errors:
|
|
281
|
+
for r in errors[-max_recent:]:
|
|
282
|
+
detail = errorlog.scrub_text(r.get("detail") or "", limit=detail_chars)
|
|
283
|
+
detail = detail.replace("\r", " ").replace("\n", " ")
|
|
284
|
+
lines.append("- `%s` %s [%s/%s] %s" % (
|
|
285
|
+
str(r.get("ts") or ""), str(r.get("reason") or "UNKNOWN"),
|
|
286
|
+
str(r.get("provider") or "-"), str(r.get("model") or "-"), detail))
|
|
287
|
+
else:
|
|
288
|
+
lines.append("(无)")
|
|
289
|
+
lines += ["", "> 提示:可在 设置 → 关于与更新 → 导出诊断包 生成 zip 附在本 Issue"
|
|
290
|
+
"(含更完整的脱敏错误记录与用量统计)。", ""]
|
|
291
|
+
return {"title": title, "body": "\n".join(lines)}
|
package/app/core/token_meter.py
CHANGED
|
@@ -121,6 +121,24 @@ class TokenMeter:
|
|
|
121
121
|
cap = cap_table.get(m, cap_table.get("", 128_000))
|
|
122
122
|
return self.used(run_id) / max(cap, 1)
|
|
123
123
|
|
|
124
|
+
def capacity(self, model: str = "") -> int:
|
|
125
|
+
"""模型上下文容量(未命中用默认行)。"""
|
|
126
|
+
cap_table = self._capacity()
|
|
127
|
+
return cap_table.get(model or "", cap_table.get("", 128_000))
|
|
128
|
+
|
|
129
|
+
def last_context(self, run_id: str) -> int:
|
|
130
|
+
"""最近一次调用的 input+output+reasoning——下个请求要重发的上下文锚点。
|
|
131
|
+
|
|
132
|
+
used() 是窗口累计(多次调用叠加,系统性偏大,适合做响应式预算闸);
|
|
133
|
+
事前预检判断「这个请求会不会被拒」要用最近一次的真实上下文体量,
|
|
134
|
+
两者口径不同不可混用(借鉴 freebuff base-chat 每步容量重估)。"""
|
|
135
|
+
with self._lock:
|
|
136
|
+
window = self._windows.get(run_id)
|
|
137
|
+
if not window:
|
|
138
|
+
return 0
|
|
139
|
+
_, i, o, r, _ = window[-1]
|
|
140
|
+
return i + o + r
|
|
141
|
+
|
|
124
142
|
def reset(self, run_id: str):
|
|
125
143
|
with self._lock:
|
|
126
144
|
self._windows.pop(run_id, None)
|
package/app/main.py
CHANGED
|
@@ -23,6 +23,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|
|
23
23
|
from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
|
|
24
24
|
from core import paths
|
|
25
25
|
from core import health
|
|
26
|
+
import pick_dialog
|
|
26
27
|
|
|
27
28
|
log = logging.getLogger(__name__)
|
|
28
29
|
|
|
@@ -72,6 +73,13 @@ class RequestBodyError(ValueError):
|
|
|
72
73
|
_SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
73
74
|
".idea", ".vscode", "_attachments"}
|
|
74
75
|
|
|
76
|
+
# 原生「选择文件夹」对话框:Tk 必须活在自家进程的主线程里(HTTP 请求线程里
|
|
77
|
+
# 建 root 在 macOS 上会崩,Windows 上反复建/销毁也不稳),pick_dialog.py
|
|
78
|
+
# 同文件提供父端 ask_directory()(拉起自身为子进程,参数走 stdin、结果走
|
|
79
|
+
# stdout),这里只持锁防重入。
|
|
80
|
+
# 同时只允许一个原生对话框:对话框开着时第二个请求立即拿到 busy,不排队
|
|
81
|
+
_PICK_LOCK = threading.Lock()
|
|
82
|
+
|
|
75
83
|
|
|
76
84
|
class Handler(BaseHTTPRequestHandler):
|
|
77
85
|
server_version = "CodeBee/1.0"
|
|
@@ -264,6 +272,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
264
272
|
except ValueError:
|
|
265
273
|
edays = 90
|
|
266
274
|
return self._json(200, usage.estimate(task_type=ttype, days=edays))
|
|
275
|
+
if path == "/api/diagnostics/bundle":
|
|
276
|
+
# 诊断包(zip):脱敏错误台账 + 用量台账 + 环境元信息,供用户贴 Issue
|
|
277
|
+
from core import telemetry
|
|
278
|
+
data = telemetry.build_bundle_bytes(days=30)
|
|
279
|
+
return self._send(200, data, ctype="application/zip", headers={
|
|
280
|
+
"Content-Disposition":
|
|
281
|
+
'attachment; filename="codebee-diag-%s.zip"'
|
|
282
|
+
% time.strftime("%Y%m%d-%H%M%S")})
|
|
283
|
+
if path == "/api/diagnostics/issue-summary":
|
|
284
|
+
# 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
|
|
285
|
+
from core import telemetry
|
|
286
|
+
return self._json(200, telemetry.issue_report(days=30))
|
|
267
287
|
m = re.match(r"^/api/runs/([^/]+)$", path)
|
|
268
288
|
if m:
|
|
269
289
|
run = store.get_run(m.group(1))
|
|
@@ -436,9 +456,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
436
456
|
# 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
|
|
437
457
|
# 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
|
|
438
458
|
# 否则告警弹框里的按钮在多端场景会静默 423 失败):
|
|
459
|
+
# /api/hooks/run 用自己的令牌鉴权(外部脚本没有设备控制权握手)。
|
|
439
460
|
if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
|
|
440
461
|
"/api/models/test-provider", "/api/models/test-model",
|
|
441
|
-
"/api/models/probe-wire", "/api/models/key-op"):
|
|
462
|
+
"/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run"):
|
|
442
463
|
pass # 落到下方各自路由
|
|
443
464
|
else:
|
|
444
465
|
deny = self._deny_control()
|
|
@@ -446,6 +467,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
446
467
|
return deny
|
|
447
468
|
if path == "/api/tasks":
|
|
448
469
|
return self._api_create_task()
|
|
470
|
+
if path == "/api/hooks/run":
|
|
471
|
+
return self._api_hook_run()
|
|
449
472
|
if path == "/api/health/op":
|
|
450
473
|
# 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
|
|
451
474
|
from core import health
|
|
@@ -469,6 +492,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
469
492
|
if path == "/api/dir/save":
|
|
470
493
|
# 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
|
|
471
494
|
return self._api_dir_save()
|
|
495
|
+
if path == "/api/pick_folder":
|
|
496
|
+
return self._api_pick_folder()
|
|
472
497
|
m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
|
|
473
498
|
if m:
|
|
474
499
|
if m.group(2) == "archive":
|
|
@@ -661,6 +686,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
661
686
|
from core import modelhub
|
|
662
687
|
n, err = modelhub.refresh_models(self._body().get("id") or "")
|
|
663
688
|
return self._json(200, {"ok": bool(n), "count": n, "message": err})
|
|
689
|
+
if path == "/api/models/add":
|
|
690
|
+
# 手工添加模型:厂商列表接口调不通时直接填模型名进列表
|
|
691
|
+
from core import modelhub
|
|
692
|
+
body = self._body()
|
|
693
|
+
n, err = modelhub.add_model_manual(body.get("id") or "",
|
|
694
|
+
body.get("name") or "")
|
|
695
|
+
return self._json(200, {"ok": not err, "count": n, "message": err})
|
|
664
696
|
if path == "/api/models/refresh-all":
|
|
665
697
|
from core import modelhub
|
|
666
698
|
n = modelhub.refresh_all_async()
|
|
@@ -802,12 +834,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
802
834
|
return self._json(400, {
|
|
803
835
|
"error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
|
|
804
836
|
% entry["id"]})
|
|
837
|
+
if op in ("install", "upgrade", "uninstall"):
|
|
838
|
+
# 同条目去重闸:已有进行中的管理操作就把本次请求挂到那个 run 上。
|
|
839
|
+
# 两个同包全局 npm 并发装会互锁成双僵尸(2026-09-18 codex 双开案)
|
|
840
|
+
active = store.active_mgmt_run(entry["id"])
|
|
841
|
+
if active:
|
|
842
|
+
return self._json(200, {"run_id": active["id"], "deduped": True})
|
|
805
843
|
run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
|
|
806
844
|
entry_id=entry["id"], op=op)
|
|
807
845
|
queued, qerr = self._enqueue_run(
|
|
808
846
|
run["id"], None,
|
|
809
847
|
{"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
|
|
810
848
|
if not queued:
|
|
849
|
+
# 入队失败必须落终态:queued 僵尸会永久堵住去重闸
|
|
850
|
+
store.update_run(run["id"], status="failed", error=qerr or "enqueue 失败",
|
|
851
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
811
852
|
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
812
853
|
return self._json(200, {"run_id": run["id"]})
|
|
813
854
|
m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
|
|
@@ -1041,6 +1082,26 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1041
1082
|
parent = "" if p.parent == p else str(p.parent)
|
|
1042
1083
|
return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
|
|
1043
1084
|
|
|
1085
|
+
def _api_pick_folder(self):
|
|
1086
|
+
"""系统原生「选择文件夹」对话框(工作目录「选择…」/点输入框用)。仅限本机:
|
|
1087
|
+
对话框弹在服务所在机器上,远端触发等于替别人开窗。pick_dialog.ask_directory
|
|
1088
|
+
拉起子进程弹真窗口,选中绝对路径直接回给前端回填;用户取消回空 path。
|
|
1089
|
+
机器没有 tkinter 时 fallback=true,前端回落网页目录弹框,不失去选目录能力。"""
|
|
1090
|
+
ip, fw = self._forwarded_ip()
|
|
1091
|
+
if ip not in ("127.0.0.1", "::1") or fw:
|
|
1092
|
+
return self._json(403, {"error": "目录选择仅限本机使用,请手动输入路径"})
|
|
1093
|
+
body = self._body()
|
|
1094
|
+
if not _PICK_LOCK.acquire(blocking=False):
|
|
1095
|
+
return self._json(200, {"path": "", "busy": True})
|
|
1096
|
+
try:
|
|
1097
|
+
path, err, fb = pick_dialog.ask_directory(str(body.get("initial") or ""),
|
|
1098
|
+
str(body.get("title") or "选择文件夹"))
|
|
1099
|
+
finally:
|
|
1100
|
+
_PICK_LOCK.release()
|
|
1101
|
+
if err:
|
|
1102
|
+
return self._json(200, {"path": "", "fallback": fb, "error": err})
|
|
1103
|
+
return self._json(200, {"path": path or ""})
|
|
1104
|
+
|
|
1044
1105
|
def _api_dir_scan(self):
|
|
1045
1106
|
"""侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
|
|
1046
1107
|
|
|
@@ -1243,6 +1304,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1243
1304
|
attachments=saved_paths)
|
|
1244
1305
|
if not msg:
|
|
1245
1306
|
return self._json(400, {"error": "运行不存在或消息非法"})
|
|
1307
|
+
# 终态连载 run 收到递话:自动起答疑轮(op=qa)。此前消息只会躺在信箱里
|
|
1308
|
+
# 无人消费——向已完结的连载任务「下达指令」= 石沉大海(2026-09-18 实案)
|
|
1309
|
+
task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
|
|
1310
|
+
if (task and task.get("serial")
|
|
1311
|
+
and (run.get("status") or "") not in ("queued", "running")):
|
|
1312
|
+
ok, err, new_run = store.retry_task(run["task_id"])
|
|
1313
|
+
if ok:
|
|
1314
|
+
store.update_run(new_run["id"], op="qa", qa_text=text)
|
|
1315
|
+
self._enqueue_run(new_run["id"], run["task_id"],
|
|
1316
|
+
{"kind": "orchestration", "run_id": new_run["id"],
|
|
1317
|
+
"task_id": run["task_id"]})
|
|
1318
|
+
return self._json(200, {"ok": True, "message": msg,
|
|
1319
|
+
"qa_run": new_run["id"]})
|
|
1246
1320
|
return self._json(200, {"ok": True, "message": msg})
|
|
1247
1321
|
|
|
1248
1322
|
def _api_run_timeline(self, run_id):
|
|
@@ -1322,6 +1396,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1322
1396
|
"note": s.get("note") or "",
|
|
1323
1397
|
"run": run_id_of_step,
|
|
1324
1398
|
"log": s.get("log") or "",
|
|
1399
|
+
"followups": s.get("followups") or [],
|
|
1325
1400
|
})
|
|
1326
1401
|
|
|
1327
1402
|
for r in runs:
|
|
@@ -1389,17 +1464,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1389
1464
|
}
|
|
1390
1465
|
|
|
1391
1466
|
def _api_direct_chat(self, run_id):
|
|
1392
|
-
"""
|
|
1467
|
+
"""追话:往已结束的 run 追加一条消息并自动续跑。
|
|
1393
1468
|
|
|
1394
|
-
|
|
1469
|
+
直连任务:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
|
|
1395
1470
|
→ 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
|
|
1396
|
-
|
|
1471
|
+
连载任务:走答疑档(op=qa)——单步只读回答,不再整本重跑(一句
|
|
1472
|
+
「为啥没有第九章」触发全量重评+连环自动续跑,2026-09-18 实案)。
|
|
1473
|
+
运行中的 run 走既有 /messages(轮间注入)。
|
|
1397
1474
|
写接口已在 do_POST 统一做过设备控制。"""
|
|
1398
1475
|
run = store.get_run(run_id)
|
|
1399
1476
|
if not run:
|
|
1400
1477
|
return self._json(404, {"error": "not found"})
|
|
1401
1478
|
task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
|
|
1402
|
-
|
|
1479
|
+
is_serial = bool(task and task.get("serial"))
|
|
1480
|
+
if not task or (task.get("engine") != "direct" and not is_serial):
|
|
1403
1481
|
return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
|
|
1404
1482
|
if (run.get("status") or "") in ("queued", "running"):
|
|
1405
1483
|
return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
|
|
@@ -1424,6 +1502,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1424
1502
|
ok, err, new_run = store.retry_task(task["id"])
|
|
1425
1503
|
if not ok:
|
|
1426
1504
|
return self._json(400, {"error": err or "无法续跑"})
|
|
1505
|
+
if is_serial:
|
|
1506
|
+
# 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
|
|
1507
|
+
# 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡
|
|
1508
|
+
store.update_run(new_run["id"], op="qa", qa_text=text)
|
|
1427
1509
|
queued, qerr = self._enqueue_run(
|
|
1428
1510
|
new_run["id"], task["id"],
|
|
1429
1511
|
{"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
|
|
@@ -1487,12 +1569,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1487
1569
|
except Exception:
|
|
1488
1570
|
pass # 客户端断开是常态,线程随进程退出
|
|
1489
1571
|
|
|
1490
|
-
def
|
|
1491
|
-
|
|
1572
|
+
def _create_and_start(self, body):
|
|
1573
|
+
"""建任务并起跑(UI /api/tasks 与外部 webhook /api/hooks/run 共用一条链)。
|
|
1574
|
+
|
|
1575
|
+
返回 (http_status, response_dict);失败路径都已收口(run/任务状态不会
|
|
1576
|
+
永久卡在排队中)。"""
|
|
1492
1577
|
try:
|
|
1493
1578
|
task = store.create_task(body)
|
|
1494
1579
|
except ValueError as e:
|
|
1495
|
-
return
|
|
1580
|
+
return 400, {"error": str(e)}
|
|
1496
1581
|
run = None
|
|
1497
1582
|
try:
|
|
1498
1583
|
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
@@ -1513,13 +1598,52 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1513
1598
|
store.update_task_status(task["id"], "failed")
|
|
1514
1599
|
except Exception:
|
|
1515
1600
|
log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
|
|
1516
|
-
return
|
|
1601
|
+
return 503, {"error": "运行记录创建失败,请稍后重试"}
|
|
1517
1602
|
queued, qerr = self._enqueue_run(
|
|
1518
1603
|
run["id"], task["id"],
|
|
1519
1604
|
{"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
|
|
1520
1605
|
if not queued:
|
|
1521
|
-
return
|
|
1522
|
-
return
|
|
1606
|
+
return 503, {"error": qerr, "run_id": run["id"]}
|
|
1607
|
+
return 200, {"task_id": task["id"], "run_id": run["id"]}
|
|
1608
|
+
|
|
1609
|
+
def _api_create_task(self):
|
|
1610
|
+
status, resp = self._create_and_start(self._body())
|
|
1611
|
+
return self._json(status, resp)
|
|
1612
|
+
|
|
1613
|
+
def _api_hook_run(self):
|
|
1614
|
+
"""外部触发开任务(webhook,借鉴 emdash/mission-control 的外部集成面)。
|
|
1615
|
+
|
|
1616
|
+
POST /api/hooks/run,头 X-CodeBee-Token;体 {goal 必填, type, workdir,
|
|
1617
|
+
context, title}。令牌取设置 hooks_token(data/settings.json,UI/文件均可
|
|
1618
|
+
配置):已配置则必须精确匹配;未配置仅放行本机回环。任务链与 UI 完全相同。"""
|
|
1619
|
+
try:
|
|
1620
|
+
tok = str(settings.load().get("hooks_token") or "")
|
|
1621
|
+
except Exception:
|
|
1622
|
+
tok = ""
|
|
1623
|
+
given = (self.headers.get("X-CodeBee-Token") or "").strip()
|
|
1624
|
+
if tok:
|
|
1625
|
+
if given != tok:
|
|
1626
|
+
return self._json(401, {"error": "令牌不匹配"})
|
|
1627
|
+
else:
|
|
1628
|
+
host = str(self.client_address[0]) if self.client_address else ""
|
|
1629
|
+
if host not in ("127.0.0.1", "::1", "::ffff:127.0.0.1"):
|
|
1630
|
+
return self._json(403, {
|
|
1631
|
+
"error": "未配置 hooks.token,仅允许本机触发;请先在设置中配置令牌"})
|
|
1632
|
+
raw = self._body()
|
|
1633
|
+
payload = {}
|
|
1634
|
+
for k, cap in (("goal", 4000), ("context", 4000), ("type", 40),
|
|
1635
|
+
("workdir", 300), ("title", 80)):
|
|
1636
|
+
v = str(raw.get(k) or "").strip()
|
|
1637
|
+
if v:
|
|
1638
|
+
payload[k] = v[:cap]
|
|
1639
|
+
if not payload.get("goal"):
|
|
1640
|
+
return self._json(400, {"error": "goal 必填"})
|
|
1641
|
+
if not payload.get("type"):
|
|
1642
|
+
payload["type"] = "direct"
|
|
1643
|
+
if not payload.get("title"):
|
|
1644
|
+
payload["title"] = payload["goal"][:30]
|
|
1645
|
+
status, resp = self._create_and_start(payload)
|
|
1646
|
+
return self._json(status, resp)
|
|
1523
1647
|
|
|
1524
1648
|
def _enqueue_run(self, run_id, task_id, job):
|
|
1525
1649
|
"""入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
|
|
@@ -1666,13 +1790,27 @@ def main():
|
|
|
1666
1790
|
n_rc = store.recover_orphaned_runs()
|
|
1667
1791
|
if n_rc:
|
|
1668
1792
|
print("[CodeBee] 崩溃恢复:%d 个遗留运行标记为 failed(interrupted at startup)" % n_rc)
|
|
1793
|
+
n_mg = store.recover_interrupted_mgmt()
|
|
1794
|
+
if n_mg:
|
|
1795
|
+
print("[CodeBee] 崩溃恢复:%d 个遗留管理操作标记为 failed(interrupted at startup)" % n_mg)
|
|
1669
1796
|
try:
|
|
1797
|
+
# 孤儿 CLI 清扫走后台线程:PowerShell Get-CimInstance 在部分机器上会慢满
|
|
1798
|
+
# timeout(真实装机 60s,启动被白拖一分钟且无任何提示——看门狗堆栈抓到)。
|
|
1799
|
+
# 清扫是尽力而为的旁路;60s timeout 保证它终会结束,但不能挡服务就绪。
|
|
1670
1800
|
from core import manager as _mgr
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1801
|
+
|
|
1802
|
+
def _sweep_async():
|
|
1803
|
+
try:
|
|
1804
|
+
n_z = _mgr.sweep_orphan_cli_processes()
|
|
1805
|
+
if n_z:
|
|
1806
|
+
# 服务重启孤儿化的 CLI 孙进程:僵尸 opencode 会劫持后续会话
|
|
1807
|
+
# 的项目根(2026-09-17 mo-so「工作目录是 Temp」真凶)
|
|
1808
|
+
print("[CodeBee] 后台清扫:%d 个孤儿 CLI 进程(opencode/codex/kimi)"
|
|
1809
|
+
% n_z, flush=True)
|
|
1810
|
+
except Exception:
|
|
1811
|
+
pass
|
|
1812
|
+
threading.Thread(target=_sweep_async, name="orphan-sweep",
|
|
1813
|
+
daemon=True).start()
|
|
1676
1814
|
except Exception:
|
|
1677
1815
|
pass
|
|
1678
1816
|
from core import bookmeta
|
|
@@ -1684,11 +1822,19 @@ def main():
|
|
|
1684
1822
|
settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
|
|
1685
1823
|
except Exception:
|
|
1686
1824
|
pass
|
|
1825
|
+
try:
|
|
1826
|
+
from core import telemetry
|
|
1827
|
+
telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
|
|
1828
|
+
except Exception:
|
|
1829
|
+
pass
|
|
1687
1830
|
_step("正在启动任务队列…")
|
|
1688
1831
|
jobs.start_worker()
|
|
1689
1832
|
n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
|
|
1690
1833
|
if n_resume:
|
|
1691
1834
|
print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
|
|
1835
|
+
n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
|
|
1836
|
+
if n_rq:
|
|
1837
|
+
print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
|
|
1692
1838
|
_step("正在启动自动化调度…")
|
|
1693
1839
|
n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
|
|
1694
1840
|
if n_auto:
|