codebee 0.1.6 → 0.1.8
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 +18 -0
- package/README.md +415 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +45 -3
- 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 +55 -61
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +101 -11
- package/app/core/modelhub.py +2952 -2896
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +425 -99
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +148 -13
- package/app/core/selfupdate.py +47 -16
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +160 -28
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/core/usage.py +51 -1
- package/app/main.py +379 -37
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1006 -266
- package/app/ui/i18n.js +107 -7
- package/app/ui/index.html +148 -55
- package/app/ui/style.css +1816 -2
- 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
|
|
@@ -22,6 +23,9 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|
|
22
23
|
from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
|
|
23
24
|
from core import paths
|
|
24
25
|
from core import health
|
|
26
|
+
import pick_dialog
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
25
29
|
|
|
26
30
|
MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
27
31
|
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
|
|
@@ -48,10 +52,34 @@ def _utf8_bytes(data):
|
|
|
48
52
|
|
|
49
53
|
PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
|
|
50
54
|
|
|
55
|
+
# 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
|
|
56
|
+
# 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
|
|
57
|
+
# 独立上传接口)。
|
|
58
|
+
MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
59
|
+
# 附件本体用 base64 包装:24 MiB Office 文件编码后约 32 MiB,再留少量 JSON
|
|
60
|
+
# 开销。该上限仍会在 attachments.save_pending 中按扩展名再次精确校验。
|
|
61
|
+
MAX_ATTACHMENT_BODY_BYTES = 34 * 1024 * 1024
|
|
62
|
+
_BODY_UNSET = object()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RequestBodyError(ValueError):
|
|
66
|
+
"""客户端请求体不可解析;status 用于把错误稳定映射为 400/413。"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message, status=400):
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.status = status
|
|
71
|
+
|
|
51
72
|
# 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
|
|
52
73
|
_SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
53
74
|
".idea", ".vscode", "_attachments"}
|
|
54
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
|
+
|
|
55
83
|
|
|
56
84
|
class Handler(BaseHTTPRequestHandler):
|
|
57
85
|
server_version = "CodeBee/1.0"
|
|
@@ -79,13 +107,38 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
79
107
|
self._send(code, json.dumps(obj, ensure_ascii=False))
|
|
80
108
|
|
|
81
109
|
def _body(self):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
110
|
+
cached = getattr(self, "_parsed_body", _BODY_UNSET)
|
|
111
|
+
if cached is not _BODY_UNSET:
|
|
112
|
+
return cached
|
|
113
|
+
raw_len = self.headers.get("Content-Length")
|
|
114
|
+
if not raw_len:
|
|
115
|
+
body = {}
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
n = int(raw_len)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
raise RequestBodyError("Content-Length 无效")
|
|
121
|
+
if n < 0:
|
|
122
|
+
raise RequestBodyError("Content-Length 无效")
|
|
123
|
+
limit = getattr(self, "_body_limit", MAX_BODY_BYTES)
|
|
124
|
+
if n > limit:
|
|
125
|
+
raise RequestBodyError("请求体过大(最大 %d MiB)" % (limit // (1024 * 1024)), 413)
|
|
126
|
+
if n == 0:
|
|
127
|
+
body = {}
|
|
128
|
+
else:
|
|
129
|
+
data = self.rfile.read(n)
|
|
130
|
+
try:
|
|
131
|
+
text = data.decode("utf-8")
|
|
132
|
+
except UnicodeDecodeError:
|
|
133
|
+
raise RequestBodyError("请求体必须使用 UTF-8 编码")
|
|
134
|
+
try:
|
|
135
|
+
body = json.loads(text)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
raise RequestBodyError("请求体不是有效的 JSON")
|
|
138
|
+
if not isinstance(body, dict):
|
|
139
|
+
raise RequestBodyError("JSON 请求体顶层必须是对象")
|
|
140
|
+
self._parsed_body = body
|
|
141
|
+
return body
|
|
89
142
|
|
|
90
143
|
# ------------------------------------------------------------ 远程访问
|
|
91
144
|
def _forwarded_ip(self):
|
|
@@ -210,6 +263,27 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
210
263
|
except ValueError:
|
|
211
264
|
days = 30
|
|
212
265
|
return self._json(200, usage.summary(days=days))
|
|
266
|
+
if path == "/api/usage/estimate":
|
|
267
|
+
from core import usage
|
|
268
|
+
q = parse_qs(urlparse(self.path).query)
|
|
269
|
+
ttype = (q.get("type") or [""])[0][:32]
|
|
270
|
+
try:
|
|
271
|
+
edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
|
|
272
|
+
except ValueError:
|
|
273
|
+
edays = 90
|
|
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))
|
|
213
287
|
m = re.match(r"^/api/runs/([^/]+)$", path)
|
|
214
288
|
if m:
|
|
215
289
|
run = store.get_run(m.group(1))
|
|
@@ -366,6 +440,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
366
440
|
m = None
|
|
367
441
|
if not self._authed():
|
|
368
442
|
return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
|
|
443
|
+
# 所有写接口共用一次严格解析;后续路由再次调用 _body() 时直接取缓存。
|
|
444
|
+
# 这样 malformed JSON 不会被当成空对象继续执行,也避免同一请求重复读流。
|
|
445
|
+
self._body_limit = (MAX_ATTACHMENT_BODY_BYTES if path == "/api/attachments"
|
|
446
|
+
else MAX_BODY_BYTES)
|
|
447
|
+
try:
|
|
448
|
+
self._body()
|
|
449
|
+
except RequestBodyError as e:
|
|
450
|
+
return self._json(e.status, {"error": str(e)})
|
|
369
451
|
if path == "/api/control":
|
|
370
452
|
return self._api_control()
|
|
371
453
|
if path == "/api/control/heartbeat":
|
|
@@ -374,9 +456,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
374
456
|
# 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
|
|
375
457
|
# 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
|
|
376
458
|
# 否则告警弹框里的按钮在多端场景会静默 423 失败):
|
|
459
|
+
# /api/hooks/run 用自己的令牌鉴权(外部脚本没有设备控制权握手)。
|
|
377
460
|
if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
|
|
378
461
|
"/api/models/test-provider", "/api/models/test-model",
|
|
379
|
-
"/api/models/probe-wire", "/api/models/key-op"):
|
|
462
|
+
"/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run"):
|
|
380
463
|
pass # 落到下方各自路由
|
|
381
464
|
else:
|
|
382
465
|
deny = self._deny_control()
|
|
@@ -384,6 +467,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
384
467
|
return deny
|
|
385
468
|
if path == "/api/tasks":
|
|
386
469
|
return self._api_create_task()
|
|
470
|
+
if path == "/api/hooks/run":
|
|
471
|
+
return self._api_hook_run()
|
|
387
472
|
if path == "/api/health/op":
|
|
388
473
|
# 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
|
|
389
474
|
from core import health
|
|
@@ -407,6 +492,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
407
492
|
if path == "/api/dir/save":
|
|
408
493
|
# 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
|
|
409
494
|
return self._api_dir_save()
|
|
495
|
+
if path == "/api/pick_folder":
|
|
496
|
+
return self._api_pick_folder()
|
|
410
497
|
m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
|
|
411
498
|
if m:
|
|
412
499
|
if m.group(2) == "archive":
|
|
@@ -416,7 +503,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
416
503
|
ok, err, run = store.retry_task(m.group(1))
|
|
417
504
|
if not ok:
|
|
418
505
|
return self._json(400, {"error": err})
|
|
419
|
-
|
|
506
|
+
queued, qerr = self._enqueue_run(
|
|
507
|
+
run["id"], m.group(1),
|
|
508
|
+
{"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
|
|
509
|
+
if not queued:
|
|
510
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
420
511
|
return self._json(200, {"ok": True, "run_id": run["id"]})
|
|
421
512
|
elif m.group(2) == "continue":
|
|
422
513
|
# 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
|
|
@@ -424,11 +515,36 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
424
515
|
m.group(1), (self._body() or {}).get("chapters"))
|
|
425
516
|
if not ok:
|
|
426
517
|
return self._json(400, {"error": err})
|
|
427
|
-
run =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
518
|
+
run = None
|
|
519
|
+
try:
|
|
520
|
+
run = store.create_run("orchestration", new_task["title"],
|
|
521
|
+
task_id=new_task["id"])
|
|
522
|
+
store.update_task_status(new_task["id"], "queued")
|
|
523
|
+
except Exception:
|
|
524
|
+
# continue_task has already persisted the new task. If run
|
|
525
|
+
# initialization fails, close whichever records exist so a
|
|
526
|
+
# retry is possible and no task remains queued forever.
|
|
527
|
+
log.exception("续写运行初始化失败 task=%s run=%s",
|
|
528
|
+
new_task.get("id"), (run or {}).get("id"))
|
|
529
|
+
if run:
|
|
530
|
+
try:
|
|
531
|
+
store.update_run(
|
|
532
|
+
run["id"], status="failed",
|
|
533
|
+
error="运行记录创建失败,请稍后重试",
|
|
534
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
535
|
+
except Exception:
|
|
536
|
+
log.exception("续写运行失败收口失败 run=%s", run.get("id"))
|
|
537
|
+
try:
|
|
538
|
+
store.update_task_status(new_task["id"], "failed")
|
|
539
|
+
except Exception:
|
|
540
|
+
log.exception("续写任务失败收口失败 task=%s", new_task.get("id"))
|
|
541
|
+
return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
|
|
542
|
+
queued, qerr = self._enqueue_run(
|
|
543
|
+
run["id"], new_task["id"],
|
|
544
|
+
{"kind": "orchestration",
|
|
545
|
+
"run_id": run["id"], "task_id": new_task["id"]})
|
|
546
|
+
if not queued:
|
|
547
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
432
548
|
return self._json(200, {"ok": True, "task_id": new_task["id"],
|
|
433
549
|
"run_id": run["id"]})
|
|
434
550
|
elif m.group(2) == "rename":
|
|
@@ -508,7 +624,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
508
624
|
"checking": manager.updates_checking()})
|
|
509
625
|
if path == "/api/selfupdate/apply":
|
|
510
626
|
from core import selfupdate
|
|
511
|
-
|
|
627
|
+
try:
|
|
628
|
+
res = selfupdate.apply_upgrade()
|
|
629
|
+
except Exception:
|
|
630
|
+
log.exception("自更新任务创建失败")
|
|
631
|
+
return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
|
|
512
632
|
return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
|
|
513
633
|
if path == "/api/selfupdate/restart":
|
|
514
634
|
from core import selfupdate
|
|
@@ -566,6 +686,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
566
686
|
from core import modelhub
|
|
567
687
|
n, err = modelhub.refresh_models(self._body().get("id") or "")
|
|
568
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})
|
|
569
696
|
if path == "/api/models/refresh-all":
|
|
570
697
|
from core import modelhub
|
|
571
698
|
n = modelhub.refresh_all_async()
|
|
@@ -707,9 +834,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
707
834
|
return self._json(400, {
|
|
708
835
|
"error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
|
|
709
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})
|
|
710
843
|
run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
|
|
711
844
|
entry_id=entry["id"], op=op)
|
|
712
|
-
|
|
845
|
+
queued, qerr = self._enqueue_run(
|
|
846
|
+
run["id"], None,
|
|
847
|
+
{"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
|
|
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"))
|
|
852
|
+
return self._json(503, {"error": qerr, "run_id": run["id"]})
|
|
713
853
|
return self._json(200, {"run_id": run["id"]})
|
|
714
854
|
m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
|
|
715
855
|
if m:
|
|
@@ -942,6 +1082,26 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
942
1082
|
parent = "" if p.parent == p else str(p.parent)
|
|
943
1083
|
return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
|
|
944
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
|
+
|
|
945
1105
|
def _api_dir_scan(self):
|
|
946
1106
|
"""侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
|
|
947
1107
|
|
|
@@ -1144,6 +1304,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1144
1304
|
attachments=saved_paths)
|
|
1145
1305
|
if not msg:
|
|
1146
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"]})
|
|
1147
1320
|
return self._json(200, {"ok": True, "message": msg})
|
|
1148
1321
|
|
|
1149
1322
|
def _api_run_timeline(self, run_id):
|
|
@@ -1202,11 +1375,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1202
1375
|
"id": m.get("id"),
|
|
1203
1376
|
})
|
|
1204
1377
|
|
|
1205
|
-
def _emit_step(s):
|
|
1378
|
+
def _emit_step(s, run_id_of_step):
|
|
1206
1379
|
# 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
|
|
1207
1380
|
# 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
|
|
1208
1381
|
# 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
|
|
1209
|
-
# 前端显示「正在执行」占位。
|
|
1382
|
+
# 前端显示「正在执行」占位。log/run 随项下发:气泡上「执行过程」入口
|
|
1383
|
+
# 要按归属 run 打开该步日志(时间线是任务级跨 run 回放,不能拿当前
|
|
1384
|
+
# run id 想当然)。
|
|
1210
1385
|
body = s.get("output") or ""
|
|
1211
1386
|
if not body:
|
|
1212
1387
|
body = s.get("summary") or ""
|
|
@@ -1219,6 +1394,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1219
1394
|
"status": s.get("status") or "",
|
|
1220
1395
|
"text": body,
|
|
1221
1396
|
"note": s.get("note") or "",
|
|
1397
|
+
"run": run_id_of_step,
|
|
1398
|
+
"log": s.get("log") or "",
|
|
1399
|
+
"followups": s.get("followups") or [],
|
|
1222
1400
|
})
|
|
1223
1401
|
|
|
1224
1402
|
for r in runs:
|
|
@@ -1233,31 +1411,73 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1233
1411
|
_emit_msg(m)
|
|
1234
1412
|
for s in steps:
|
|
1235
1413
|
if (s.get("status") or "") == "done":
|
|
1236
|
-
_emit_step(s)
|
|
1414
|
+
_emit_step(s, r.get("id") or run_id)
|
|
1237
1415
|
for m in msgs:
|
|
1238
1416
|
if not m.get("consumed"):
|
|
1239
1417
|
_emit_msg(m)
|
|
1240
1418
|
for s in steps:
|
|
1241
1419
|
if (s.get("status") or "") != "done":
|
|
1242
|
-
_emit_step(s)
|
|
1420
|
+
_emit_step(s, r.get("id") or run_id)
|
|
1243
1421
|
return self._json(200, {
|
|
1244
1422
|
"run_id": run_id, "status": run.get("status") or "",
|
|
1245
1423
|
"engine": engine,
|
|
1246
1424
|
"items": items,
|
|
1425
|
+
"result": self._direct_result(runs[-1] if runs else run, engine),
|
|
1247
1426
|
})
|
|
1248
1427
|
|
|
1428
|
+
def _direct_result(self, latest, engine):
|
|
1429
|
+
"""对话页「执行结果」卡的数据:最新 run 到终态后给出确定性摘要——
|
|
1430
|
+
成没成、跑多久、谁执行的、产出了哪些文件。模型最后一轮回答可能只是
|
|
1431
|
+
寒暄/追问(用户反馈:输入"1"跑完 55 秒只见一句"消息可能发错了"),
|
|
1432
|
+
执行结果不能依赖模型自觉交代,由产品明示。"""
|
|
1433
|
+
if engine != "direct" or not latest:
|
|
1434
|
+
return None
|
|
1435
|
+
st = latest.get("status") or ""
|
|
1436
|
+
if st not in ("done", "failed", "cancelled", "timeout"):
|
|
1437
|
+
return None
|
|
1438
|
+
verdict = latest.get("verdict") or {}
|
|
1439
|
+
route = latest.get("route") or {}
|
|
1440
|
+
|
|
1441
|
+
def _sec(a, b):
|
|
1442
|
+
try:
|
|
1443
|
+
return max(0, int(time.mktime(time.strptime(b, "%Y-%m-%d %H:%M:%S")) -
|
|
1444
|
+
time.mktime(time.strptime(a, "%Y-%m-%d %H:%M:%S"))))
|
|
1445
|
+
except Exception:
|
|
1446
|
+
return None
|
|
1447
|
+
t0 = latest.get("started_at") or latest.get("created_at")
|
|
1448
|
+
wd, files = "", []
|
|
1449
|
+
try:
|
|
1450
|
+
wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
|
|
1451
|
+
if files and not store.task_step_count(latest.get("task_id") or ""):
|
|
1452
|
+
files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
|
|
1453
|
+
except Exception:
|
|
1454
|
+
wd, files = "", []
|
|
1455
|
+
return {
|
|
1456
|
+
"status": st,
|
|
1457
|
+
"error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
|
|
1458
|
+
"executor": route.get("implementer") or verdict.get("impl") or "",
|
|
1459
|
+
"turns": verdict.get("turns") or 0,
|
|
1460
|
+
"duration_s": _sec(t0, latest.get("ended_at") or "")
|
|
1461
|
+
if t0 and latest.get("ended_at") else None,
|
|
1462
|
+
"workdir": wd,
|
|
1463
|
+
"files": files,
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1249
1466
|
def _api_direct_chat(self, run_id):
|
|
1250
|
-
"""
|
|
1467
|
+
"""追话:往已结束的 run 追加一条消息并自动续跑。
|
|
1251
1468
|
|
|
1252
|
-
|
|
1469
|
+
直连任务:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
|
|
1253
1470
|
→ 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
|
|
1254
|
-
|
|
1471
|
+
连载任务:走答疑档(op=qa)——单步只读回答,不再整本重跑(一句
|
|
1472
|
+
「为啥没有第九章」触发全量重评+连环自动续跑,2026-09-18 实案)。
|
|
1473
|
+
运行中的 run 走既有 /messages(轮间注入)。
|
|
1255
1474
|
写接口已在 do_POST 统一做过设备控制。"""
|
|
1256
1475
|
run = store.get_run(run_id)
|
|
1257
1476
|
if not run:
|
|
1258
1477
|
return self._json(404, {"error": "not found"})
|
|
1259
1478
|
task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
|
|
1260
|
-
|
|
1479
|
+
is_serial = bool(task and task.get("serial"))
|
|
1480
|
+
if not task or (task.get("engine") != "direct" and not is_serial):
|
|
1261
1481
|
return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
|
|
1262
1482
|
if (run.get("status") or "") in ("queued", "running"):
|
|
1263
1483
|
return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
|
|
@@ -1282,7 +1502,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1282
1502
|
ok, err, new_run = store.retry_task(task["id"])
|
|
1283
1503
|
if not ok:
|
|
1284
1504
|
return self._json(400, {"error": err or "无法续跑"})
|
|
1285
|
-
|
|
1505
|
+
if is_serial:
|
|
1506
|
+
# 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
|
|
1507
|
+
# 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡
|
|
1508
|
+
store.update_run(new_run["id"], op="qa", qa_text=text)
|
|
1509
|
+
queued, qerr = self._enqueue_run(
|
|
1510
|
+
new_run["id"], task["id"],
|
|
1511
|
+
{"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
|
|
1512
|
+
if not queued:
|
|
1513
|
+
return self._json(503, {"error": qerr, "run_id": new_run["id"]})
|
|
1286
1514
|
return self._json(200, {"ok": True, "run_id": new_run["id"]})
|
|
1287
1515
|
|
|
1288
1516
|
def _api_retract_message(self, run_id):
|
|
@@ -1341,16 +1569,108 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1341
1569
|
except Exception:
|
|
1342
1570
|
pass # 客户端断开是常态,线程随进程退出
|
|
1343
1571
|
|
|
1344
|
-
def
|
|
1345
|
-
|
|
1572
|
+
def _create_and_start(self, body):
|
|
1573
|
+
"""建任务并起跑(UI /api/tasks 与外部 webhook /api/hooks/run 共用一条链)。
|
|
1574
|
+
|
|
1575
|
+
返回 (http_status, response_dict);失败路径都已收口(run/任务状态不会
|
|
1576
|
+
永久卡在排队中)。"""
|
|
1346
1577
|
try:
|
|
1347
1578
|
task = store.create_task(body)
|
|
1348
1579
|
except ValueError as e:
|
|
1349
|
-
return
|
|
1350
|
-
run =
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1580
|
+
return 400, {"error": str(e)}
|
|
1581
|
+
run = None
|
|
1582
|
+
try:
|
|
1583
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
1584
|
+
store.update_task_status(task["id"], "queued")
|
|
1585
|
+
except Exception:
|
|
1586
|
+
# create_run 已落盘后,update_task_status 仍可能因磁盘/JSON 错误失败。
|
|
1587
|
+
# 这时必须把已经存在的 run 收口,否则 UI 会永久显示「排队中」。
|
|
1588
|
+
log.exception("创建任务运行记录失败 task=%s run=%s", task.get("id"),
|
|
1589
|
+
(run or {}).get("id"))
|
|
1590
|
+
if run:
|
|
1591
|
+
try:
|
|
1592
|
+
store.update_run(run["id"], status="failed",
|
|
1593
|
+
error="运行记录初始化失败",
|
|
1594
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
1595
|
+
except Exception:
|
|
1596
|
+
log.exception("收口失败的运行记录失败 run=%s", run.get("id"))
|
|
1597
|
+
try:
|
|
1598
|
+
store.update_task_status(task["id"], "failed")
|
|
1599
|
+
except Exception:
|
|
1600
|
+
log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
|
|
1601
|
+
return 503, {"error": "运行记录创建失败,请稍后重试"}
|
|
1602
|
+
queued, qerr = self._enqueue_run(
|
|
1603
|
+
run["id"], task["id"],
|
|
1604
|
+
{"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
|
|
1605
|
+
if not queued:
|
|
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)
|
|
1647
|
+
|
|
1648
|
+
def _enqueue_run(self, run_id, task_id, job):
|
|
1649
|
+
"""入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
|
|
1650
|
+
try:
|
|
1651
|
+
jobs.enqueue(job)
|
|
1652
|
+
return True, ""
|
|
1653
|
+
except Exception:
|
|
1654
|
+
# 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
|
|
1655
|
+
# 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
|
|
1656
|
+
log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
|
|
1657
|
+
err = "任务入队失败,请稍后重试"
|
|
1658
|
+
try:
|
|
1659
|
+
closed = store.update_run(run_id, status="failed", error=err,
|
|
1660
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
1661
|
+
if closed is None:
|
|
1662
|
+
# The run may have been removed between creation and enqueue
|
|
1663
|
+
# (for example, an operator cleared history concurrently).
|
|
1664
|
+
# Still force the task out of queued so the UI cannot wait
|
|
1665
|
+
# forever on a record that no longer exists.
|
|
1666
|
+
raise RuntimeError("运行记录不存在")
|
|
1667
|
+
except Exception:
|
|
1668
|
+
if task_id:
|
|
1669
|
+
try:
|
|
1670
|
+
store.update_task_status(task_id, "failed")
|
|
1671
|
+
except Exception:
|
|
1672
|
+
pass
|
|
1673
|
+
return False, err
|
|
1354
1674
|
|
|
1355
1675
|
def _api_set_preference(self):
|
|
1356
1676
|
body = self._body()
|
|
@@ -1470,13 +1790,27 @@ def main():
|
|
|
1470
1790
|
n_rc = store.recover_orphaned_runs()
|
|
1471
1791
|
if n_rc:
|
|
1472
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)
|
|
1473
1796
|
try:
|
|
1797
|
+
# 孤儿 CLI 清扫走后台线程:PowerShell Get-CimInstance 在部分机器上会慢满
|
|
1798
|
+
# timeout(真实装机 60s,启动被白拖一分钟且无任何提示——看门狗堆栈抓到)。
|
|
1799
|
+
# 清扫是尽力而为的旁路;60s timeout 保证它终会结束,但不能挡服务就绪。
|
|
1474
1800
|
from core import manager as _mgr
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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()
|
|
1480
1814
|
except Exception:
|
|
1481
1815
|
pass
|
|
1482
1816
|
from core import bookmeta
|
|
@@ -1488,11 +1822,19 @@ def main():
|
|
|
1488
1822
|
settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
|
|
1489
1823
|
except Exception:
|
|
1490
1824
|
pass
|
|
1825
|
+
try:
|
|
1826
|
+
from core import telemetry
|
|
1827
|
+
telemetry.start_background() # 匿名错误回传+版本 ping(默认开可关;未配端点自动休眠,延迟 45s 不挡启动)
|
|
1828
|
+
except Exception:
|
|
1829
|
+
pass
|
|
1491
1830
|
_step("正在启动任务队列…")
|
|
1492
1831
|
jobs.start_worker()
|
|
1493
1832
|
n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
|
|
1494
1833
|
if n_resume:
|
|
1495
1834
|
print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
|
|
1835
|
+
n_rq = jobs.requeue_pending() # 启动补队:队列在内存里,重启会让排队项变僵尸
|
|
1836
|
+
if n_rq:
|
|
1837
|
+
print("[CodeBee] 已重新入队 %d 个遗留排队运行" % n_rq)
|
|
1496
1838
|
_step("正在启动自动化调度…")
|
|
1497
1839
|
n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
|
|
1498
1840
|
if n_auto:
|