codebee 0.1.5 → 0.1.7
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 +16 -0
- package/README.md +431 -422
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +23 -0
- package/app/core/health.py +12 -1
- package/app/core/jobs.py +10 -2
- package/app/core/manager.py +87 -10
- package/app/core/modelhub.py +2883 -2845
- package/app/core/pipeline.py +120 -60
- package/app/core/registry.py +4 -0
- package/app/core/remote.py +310 -303
- package/app/core/router.py +5 -4
- package/app/core/runner.py +38 -6
- package/app/core/selfupdate.py +53 -16
- package/app/core/store.py +138 -47
- package/app/core/usage.py +51 -1
- package/app/main.py +219 -23
- package/app/ui/app.js +687 -222
- package/app/ui/i18n.js +43 -6
- package/app/ui/icons/bee.svg +79 -0
- package/app/ui/index.html +59 -46
- package/app/ui/style.css +1229 -328
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
9
|
import json
|
|
10
|
+
import logging
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
12
13
|
import socket
|
|
@@ -23,6 +24,8 @@ from core import automation, catalog, flows, jobs, manager, market, market_remot
|
|
|
23
24
|
from core import paths
|
|
24
25
|
from core import health
|
|
25
26
|
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
26
29
|
MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
27
30
|
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
|
|
28
31
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
@@ -48,6 +51,23 @@ def _utf8_bytes(data):
|
|
|
48
51
|
|
|
49
52
|
PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
|
|
50
53
|
|
|
54
|
+
# 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
|
|
55
|
+
# 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
|
|
56
|
+
# 独立上传接口)。
|
|
57
|
+
MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
58
|
+
# 附件本体用 base64 包装:24 MiB Office 文件编码后约 32 MiB,再留少量 JSON
|
|
59
|
+
# 开销。该上限仍会在 attachments.save_pending 中按扩展名再次精确校验。
|
|
60
|
+
MAX_ATTACHMENT_BODY_BYTES = 34 * 1024 * 1024
|
|
61
|
+
_BODY_UNSET = object()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RequestBodyError(ValueError):
|
|
65
|
+
"""客户端请求体不可解析;status 用于把错误稳定映射为 400/413。"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, message, status=400):
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.status = status
|
|
70
|
+
|
|
51
71
|
# 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
|
|
52
72
|
_SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
53
73
|
".idea", ".vscode", "_attachments"}
|
|
@@ -79,13 +99,38 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
79
99
|
self._send(code, json.dumps(obj, ensure_ascii=False))
|
|
80
100
|
|
|
81
101
|
def _body(self):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
cached = getattr(self, "_parsed_body", _BODY_UNSET)
|
|
103
|
+
if cached is not _BODY_UNSET:
|
|
104
|
+
return cached
|
|
105
|
+
raw_len = self.headers.get("Content-Length")
|
|
106
|
+
if not raw_len:
|
|
107
|
+
body = {}
|
|
108
|
+
else:
|
|
109
|
+
try:
|
|
110
|
+
n = int(raw_len)
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
raise RequestBodyError("Content-Length 无效")
|
|
113
|
+
if n < 0:
|
|
114
|
+
raise RequestBodyError("Content-Length 无效")
|
|
115
|
+
limit = getattr(self, "_body_limit", MAX_BODY_BYTES)
|
|
116
|
+
if n > limit:
|
|
117
|
+
raise RequestBodyError("请求体过大(最大 %d MiB)" % (limit // (1024 * 1024)), 413)
|
|
118
|
+
if n == 0:
|
|
119
|
+
body = {}
|
|
120
|
+
else:
|
|
121
|
+
data = self.rfile.read(n)
|
|
122
|
+
try:
|
|
123
|
+
text = data.decode("utf-8")
|
|
124
|
+
except UnicodeDecodeError:
|
|
125
|
+
raise RequestBodyError("请求体必须使用 UTF-8 编码")
|
|
126
|
+
try:
|
|
127
|
+
body = json.loads(text)
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
raise RequestBodyError("请求体不是有效的 JSON")
|
|
130
|
+
if not isinstance(body, dict):
|
|
131
|
+
raise RequestBodyError("JSON 请求体顶层必须是对象")
|
|
132
|
+
self._parsed_body = body
|
|
133
|
+
return body
|
|
89
134
|
|
|
90
135
|
# ------------------------------------------------------------ 远程访问
|
|
91
136
|
def _forwarded_ip(self):
|
|
@@ -210,6 +255,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
210
255
|
except ValueError:
|
|
211
256
|
days = 30
|
|
212
257
|
return self._json(200, usage.summary(days=days))
|
|
258
|
+
if path == "/api/usage/estimate":
|
|
259
|
+
from core import usage
|
|
260
|
+
q = parse_qs(urlparse(self.path).query)
|
|
261
|
+
ttype = (q.get("type") or [""])[0][:32]
|
|
262
|
+
try:
|
|
263
|
+
edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
|
|
264
|
+
except ValueError:
|
|
265
|
+
edays = 90
|
|
266
|
+
return self._json(200, usage.estimate(task_type=ttype, days=edays))
|
|
213
267
|
m = re.match(r"^/api/runs/([^/]+)$", path)
|
|
214
268
|
if m:
|
|
215
269
|
run = store.get_run(m.group(1))
|
|
@@ -366,6 +420,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
366
420
|
m = None
|
|
367
421
|
if not self._authed():
|
|
368
422
|
return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
|
|
423
|
+
# 所有写接口共用一次严格解析;后续路由再次调用 _body() 时直接取缓存。
|
|
424
|
+
# 这样 malformed JSON 不会被当成空对象继续执行,也避免同一请求重复读流。
|
|
425
|
+
self._body_limit = (MAX_ATTACHMENT_BODY_BYTES if path == "/api/attachments"
|
|
426
|
+
else MAX_BODY_BYTES)
|
|
427
|
+
try:
|
|
428
|
+
self._body()
|
|
429
|
+
except RequestBodyError as e:
|
|
430
|
+
return self._json(e.status, {"error": str(e)})
|
|
369
431
|
if path == "/api/control":
|
|
370
432
|
return self._api_control()
|
|
371
433
|
if path == "/api/control/heartbeat":
|
|
@@ -416,7 +478,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
416
478
|
ok, err, run = store.retry_task(m.group(1))
|
|
417
479
|
if not ok:
|
|
418
480
|
return self._json(400, {"error": err})
|
|
419
|
-
|
|
481
|
+
queued, qerr = self._enqueue_run(
|
|
482
|
+
run["id"], m.group(1),
|
|
483
|
+
{"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
|
|
484
|
+
if not queued:
|
|
485
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
420
486
|
return self._json(200, {"ok": True, "run_id": run["id"]})
|
|
421
487
|
elif m.group(2) == "continue":
|
|
422
488
|
# 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
|
|
@@ -424,11 +490,36 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
424
490
|
m.group(1), (self._body() or {}).get("chapters"))
|
|
425
491
|
if not ok:
|
|
426
492
|
return self._json(400, {"error": err})
|
|
427
|
-
run =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
493
|
+
run = None
|
|
494
|
+
try:
|
|
495
|
+
run = store.create_run("orchestration", new_task["title"],
|
|
496
|
+
task_id=new_task["id"])
|
|
497
|
+
store.update_task_status(new_task["id"], "queued")
|
|
498
|
+
except Exception:
|
|
499
|
+
# continue_task has already persisted the new task. If run
|
|
500
|
+
# initialization fails, close whichever records exist so a
|
|
501
|
+
# retry is possible and no task remains queued forever.
|
|
502
|
+
log.exception("续写运行初始化失败 task=%s run=%s",
|
|
503
|
+
new_task.get("id"), (run or {}).get("id"))
|
|
504
|
+
if run:
|
|
505
|
+
try:
|
|
506
|
+
store.update_run(
|
|
507
|
+
run["id"], status="failed",
|
|
508
|
+
error="运行记录创建失败,请稍后重试",
|
|
509
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
510
|
+
except Exception:
|
|
511
|
+
log.exception("续写运行失败收口失败 run=%s", run.get("id"))
|
|
512
|
+
try:
|
|
513
|
+
store.update_task_status(new_task["id"], "failed")
|
|
514
|
+
except Exception:
|
|
515
|
+
log.exception("续写任务失败收口失败 task=%s", new_task.get("id"))
|
|
516
|
+
return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
|
|
517
|
+
queued, qerr = self._enqueue_run(
|
|
518
|
+
run["id"], new_task["id"],
|
|
519
|
+
{"kind": "orchestration",
|
|
520
|
+
"run_id": run["id"], "task_id": new_task["id"]})
|
|
521
|
+
if not queued:
|
|
522
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
432
523
|
return self._json(200, {"ok": True, "task_id": new_task["id"],
|
|
433
524
|
"run_id": run["id"]})
|
|
434
525
|
elif m.group(2) == "rename":
|
|
@@ -508,7 +599,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
508
599
|
"checking": manager.updates_checking()})
|
|
509
600
|
if path == "/api/selfupdate/apply":
|
|
510
601
|
from core import selfupdate
|
|
511
|
-
|
|
602
|
+
try:
|
|
603
|
+
res = selfupdate.apply_upgrade()
|
|
604
|
+
except Exception:
|
|
605
|
+
log.exception("自更新任务创建失败")
|
|
606
|
+
return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
|
|
512
607
|
return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
|
|
513
608
|
if path == "/api/selfupdate/restart":
|
|
514
609
|
from core import selfupdate
|
|
@@ -709,7 +804,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
709
804
|
% entry["id"]})
|
|
710
805
|
run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
|
|
711
806
|
entry_id=entry["id"], op=op)
|
|
712
|
-
|
|
807
|
+
queued, qerr = self._enqueue_run(
|
|
808
|
+
run["id"], None,
|
|
809
|
+
{"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
|
|
810
|
+
if not queued:
|
|
811
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
713
812
|
return self._json(200, {"run_id": run["id"]})
|
|
714
813
|
m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
|
|
715
814
|
if m:
|
|
@@ -1202,11 +1301,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1202
1301
|
"id": m.get("id"),
|
|
1203
1302
|
})
|
|
1204
1303
|
|
|
1205
|
-
def _emit_step(s):
|
|
1304
|
+
def _emit_step(s, run_id_of_step):
|
|
1206
1305
|
# 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
|
|
1207
1306
|
# 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
|
|
1208
1307
|
# 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
|
|
1209
|
-
# 前端显示「正在执行」占位。
|
|
1308
|
+
# 前端显示「正在执行」占位。log/run 随项下发:气泡上「执行过程」入口
|
|
1309
|
+
# 要按归属 run 打开该步日志(时间线是任务级跨 run 回放,不能拿当前
|
|
1310
|
+
# run id 想当然)。
|
|
1210
1311
|
body = s.get("output") or ""
|
|
1211
1312
|
if not body:
|
|
1212
1313
|
body = s.get("summary") or ""
|
|
@@ -1219,6 +1320,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1219
1320
|
"status": s.get("status") or "",
|
|
1220
1321
|
"text": body,
|
|
1221
1322
|
"note": s.get("note") or "",
|
|
1323
|
+
"run": run_id_of_step,
|
|
1324
|
+
"log": s.get("log") or "",
|
|
1222
1325
|
})
|
|
1223
1326
|
|
|
1224
1327
|
for r in runs:
|
|
@@ -1233,19 +1336,58 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1233
1336
|
_emit_msg(m)
|
|
1234
1337
|
for s in steps:
|
|
1235
1338
|
if (s.get("status") or "") == "done":
|
|
1236
|
-
_emit_step(s)
|
|
1339
|
+
_emit_step(s, r.get("id") or run_id)
|
|
1237
1340
|
for m in msgs:
|
|
1238
1341
|
if not m.get("consumed"):
|
|
1239
1342
|
_emit_msg(m)
|
|
1240
1343
|
for s in steps:
|
|
1241
1344
|
if (s.get("status") or "") != "done":
|
|
1242
|
-
_emit_step(s)
|
|
1345
|
+
_emit_step(s, r.get("id") or run_id)
|
|
1243
1346
|
return self._json(200, {
|
|
1244
1347
|
"run_id": run_id, "status": run.get("status") or "",
|
|
1245
1348
|
"engine": engine,
|
|
1246
1349
|
"items": items,
|
|
1350
|
+
"result": self._direct_result(runs[-1] if runs else run, engine),
|
|
1247
1351
|
})
|
|
1248
1352
|
|
|
1353
|
+
def _direct_result(self, latest, engine):
|
|
1354
|
+
"""对话页「执行结果」卡的数据:最新 run 到终态后给出确定性摘要——
|
|
1355
|
+
成没成、跑多久、谁执行的、产出了哪些文件。模型最后一轮回答可能只是
|
|
1356
|
+
寒暄/追问(用户反馈:输入"1"跑完 55 秒只见一句"消息可能发错了"),
|
|
1357
|
+
执行结果不能依赖模型自觉交代,由产品明示。"""
|
|
1358
|
+
if engine != "direct" or not latest:
|
|
1359
|
+
return None
|
|
1360
|
+
st = latest.get("status") or ""
|
|
1361
|
+
if st not in ("done", "failed", "cancelled", "timeout"):
|
|
1362
|
+
return None
|
|
1363
|
+
verdict = latest.get("verdict") or {}
|
|
1364
|
+
route = latest.get("route") or {}
|
|
1365
|
+
|
|
1366
|
+
def _sec(a, b):
|
|
1367
|
+
try:
|
|
1368
|
+
return max(0, int(time.mktime(time.strptime(b, "%Y-%m-%d %H:%M:%S")) -
|
|
1369
|
+
time.mktime(time.strptime(a, "%Y-%m-%d %H:%M:%S"))))
|
|
1370
|
+
except Exception:
|
|
1371
|
+
return None
|
|
1372
|
+
t0 = latest.get("started_at") or latest.get("created_at")
|
|
1373
|
+
wd, files = "", []
|
|
1374
|
+
try:
|
|
1375
|
+
wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
|
|
1376
|
+
if files and not store.task_step_count(latest.get("task_id") or ""):
|
|
1377
|
+
files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
|
|
1378
|
+
except Exception:
|
|
1379
|
+
wd, files = "", []
|
|
1380
|
+
return {
|
|
1381
|
+
"status": st,
|
|
1382
|
+
"error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
|
|
1383
|
+
"executor": route.get("implementer") or verdict.get("impl") or "",
|
|
1384
|
+
"turns": verdict.get("turns") or 0,
|
|
1385
|
+
"duration_s": _sec(t0, latest.get("ended_at") or "")
|
|
1386
|
+
if t0 and latest.get("ended_at") else None,
|
|
1387
|
+
"workdir": wd,
|
|
1388
|
+
"files": files,
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1249
1391
|
def _api_direct_chat(self, run_id):
|
|
1250
1392
|
"""直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
|
|
1251
1393
|
|
|
@@ -1282,7 +1424,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1282
1424
|
ok, err, new_run = store.retry_task(task["id"])
|
|
1283
1425
|
if not ok:
|
|
1284
1426
|
return self._json(400, {"error": err or "无法续跑"})
|
|
1285
|
-
|
|
1427
|
+
queued, qerr = self._enqueue_run(
|
|
1428
|
+
new_run["id"], task["id"],
|
|
1429
|
+
{"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
|
|
1430
|
+
if not queued:
|
|
1431
|
+
return self._json(503, {"error": qerr, "run_id": new_run["id"]})
|
|
1286
1432
|
return self._json(200, {"ok": True, "run_id": new_run["id"]})
|
|
1287
1433
|
|
|
1288
1434
|
def _api_retract_message(self, run_id):
|
|
@@ -1347,11 +1493,61 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1347
1493
|
task = store.create_task(body)
|
|
1348
1494
|
except ValueError as e:
|
|
1349
1495
|
return self._json(400, {"error": str(e)})
|
|
1350
|
-
run =
|
|
1351
|
-
|
|
1352
|
-
|
|
1496
|
+
run = None
|
|
1497
|
+
try:
|
|
1498
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
1499
|
+
store.update_task_status(task["id"], "queued")
|
|
1500
|
+
except Exception:
|
|
1501
|
+
# create_run 已落盘后,update_task_status 仍可能因磁盘/JSON 错误失败。
|
|
1502
|
+
# 这时必须把已经存在的 run 收口,否则 UI 会永久显示「排队中」。
|
|
1503
|
+
log.exception("创建任务运行记录失败 task=%s run=%s", task.get("id"),
|
|
1504
|
+
(run or {}).get("id"))
|
|
1505
|
+
if run:
|
|
1506
|
+
try:
|
|
1507
|
+
store.update_run(run["id"], status="failed",
|
|
1508
|
+
error="运行记录初始化失败",
|
|
1509
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
1510
|
+
except Exception:
|
|
1511
|
+
log.exception("收口失败的运行记录失败 run=%s", run.get("id"))
|
|
1512
|
+
try:
|
|
1513
|
+
store.update_task_status(task["id"], "failed")
|
|
1514
|
+
except Exception:
|
|
1515
|
+
log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
|
|
1516
|
+
return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
|
|
1517
|
+
queued, qerr = self._enqueue_run(
|
|
1518
|
+
run["id"], task["id"],
|
|
1519
|
+
{"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
|
|
1520
|
+
if not queued:
|
|
1521
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
1353
1522
|
return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
|
|
1354
1523
|
|
|
1524
|
+
def _enqueue_run(self, run_id, task_id, job):
|
|
1525
|
+
"""入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
|
|
1526
|
+
try:
|
|
1527
|
+
jobs.enqueue(job)
|
|
1528
|
+
return True, ""
|
|
1529
|
+
except Exception:
|
|
1530
|
+
# 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
|
|
1531
|
+
# 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
|
|
1532
|
+
log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
|
|
1533
|
+
err = "任务入队失败,请稍后重试"
|
|
1534
|
+
try:
|
|
1535
|
+
closed = store.update_run(run_id, status="failed", error=err,
|
|
1536
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
1537
|
+
if closed is None:
|
|
1538
|
+
# The run may have been removed between creation and enqueue
|
|
1539
|
+
# (for example, an operator cleared history concurrently).
|
|
1540
|
+
# Still force the task out of queued so the UI cannot wait
|
|
1541
|
+
# forever on a record that no longer exists.
|
|
1542
|
+
raise RuntimeError("运行记录不存在")
|
|
1543
|
+
except Exception:
|
|
1544
|
+
if task_id:
|
|
1545
|
+
try:
|
|
1546
|
+
store.update_task_status(task_id, "failed")
|
|
1547
|
+
except Exception:
|
|
1548
|
+
pass
|
|
1549
|
+
return False, err
|
|
1550
|
+
|
|
1355
1551
|
def _api_set_preference(self):
|
|
1356
1552
|
body = self._body()
|
|
1357
1553
|
agent_id = body.get("agent_id")
|