codebee 0.1.69 → 0.1.70
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 +37 -0
- package/README.md +4 -3
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/benchstore.py +95 -0
- package/app/core/bookmeta.py +9 -4
- package/app/core/builtin_agent.py +472 -33
- package/app/core/catalog.py +7 -1
- package/app/core/dispatch.py +20 -3
- package/app/core/error_codes.py +11 -2
- package/app/core/evalbench.py +720 -0
- package/app/core/flows.py +228 -3
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +21 -0
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +118 -23
- package/app/core/novel_quality.py +189 -0
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/publish/browser.py +129 -13
- package/app/core/publish/fanqie.py +6 -0
- package/app/core/publish/flow.py +154 -5
- package/app/core/publish/flows-fanqie-calibrated.json +11 -7
- package/app/core/publish/ledger.py +29 -3
- package/app/core/publish/manager.py +134 -2
- package/app/core/publish/qimao.py +5 -0
- package/app/core/remotesync.py +121 -0
- package/app/core/runner.py +80 -5
- package/app/core/settings.py +39 -2
- package/app/core/settings_schema.py +8 -0
- package/app/core/store.py +30 -8
- package/app/core/usage.py +187 -14
- package/app/core/zentao.py +153 -4
- package/app/current-browser.png +0 -0
- package/app/main.py +279 -15
- package/app/mcp_server.py +175 -0
- package/app/pet.py +82 -21
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1380 -73
- package/app/ui/i18n.js +248 -6
- package/app/ui/index.html +277 -13
- package/app/ui/style.css +786 -0
- package/package.json +2 -1
|
@@ -452,8 +452,9 @@ def create_book_async(task_id, plat, auto_submit=False):
|
|
|
452
452
|
metadata={"platform": plat, "action": "create_book"})
|
|
453
453
|
ledger.record(plat, "create_book", task_id=task_id, title=book_name,
|
|
454
454
|
book_id=book_id, ok=bool(book_id),
|
|
455
|
-
error=("
|
|
456
|
-
|
|
455
|
+
error=((("" if book_id else
|
|
456
|
+
"建书结果未知:未取得远端 book_id;请先对账\n")
|
|
457
|
+
+ "\n".join(logs))[:2000] or None),
|
|
457
458
|
shot=str(ledger.shot_path(plat, task_id, "")),
|
|
458
459
|
operation_id=operation_id, operation_status=operation_status,
|
|
459
460
|
remote_receipt=book_id)
|
|
@@ -489,6 +490,13 @@ def register_book(task_id, plat, title, book_id=""):
|
|
|
489
490
|
title = (title or "").strip()
|
|
490
491
|
if not title:
|
|
491
492
|
return False, "作品名必填(发章按作品名在平台找书)"
|
|
493
|
+
# 换绑另一本书(book_id/title 变了)时旧书的校准数作废,防张冠李戴
|
|
494
|
+
old = ledger.book_for(task_id, plat) or {}
|
|
495
|
+
if old and (old.get("book_id") != str(book_id or "").strip()
|
|
496
|
+
or old.get("title") != title[:120]):
|
|
497
|
+
ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
|
|
498
|
+
remote_review=None, remote_rejected=None,
|
|
499
|
+
remote_synced_at=None, remote_error=None)
|
|
492
500
|
ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
|
|
493
501
|
"title": title[:120]})
|
|
494
502
|
return True, ""
|
|
@@ -684,6 +692,130 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
|
|
|
684
692
|
return True, ""
|
|
685
693
|
|
|
686
694
|
|
|
695
|
+
# ------------------------------------------------ 已发章数校准(平台实况对账)
|
|
696
|
+
# 台账只记得 CodeBee 自己发成功的章:用户在平台窗口里手工补交/平台驳回/
|
|
697
|
+
# 台账重复记录都会让「已发 N」漂移(2026-09-28 实案:番茄平台 8 章台账 7)。
|
|
698
|
+
# 校准 = 打开章节管理页数行,按状态关键词分桶后写回 books.json 的 remote_*。
|
|
699
|
+
|
|
700
|
+
_STATUS_PUB = "已发布"
|
|
701
|
+
_STATUS_REVIEW = ("待审核", "审核中", "排队")
|
|
702
|
+
_STATUS_BAD = ("未通过", "驳回")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def bucket_chapter_rows(rows):
|
|
706
|
+
"""章节管理页的行文本 → {total, published, review, rejected}。
|
|
707
|
+
|
|
708
|
+
行里含状态关键词才计入对应桶;total=识别出的章节数据行数。"""
|
|
709
|
+
out = {"total": 0, "published": 0, "review": 0, "rejected": 0}
|
|
710
|
+
for t in rows or []:
|
|
711
|
+
t = str(t)
|
|
712
|
+
if not t:
|
|
713
|
+
continue
|
|
714
|
+
if "章节名称" in t: # 表头兜底(正常到不了这)
|
|
715
|
+
continue
|
|
716
|
+
out["total"] += 1
|
|
717
|
+
if any(k in t for k in _STATUS_BAD):
|
|
718
|
+
out["rejected"] += 1
|
|
719
|
+
elif _STATUS_PUB in t:
|
|
720
|
+
out["published"] += 1
|
|
721
|
+
elif any(k in t for k in _STATUS_REVIEW):
|
|
722
|
+
out["review"] += 1
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def sync_published(task_id, plat, manual=False):
|
|
727
|
+
"""同步执行一次校准(manager.sync_published_async 的线程体)。
|
|
728
|
+
|
|
729
|
+
manual=False(打开作品页自动触发)只 attach 在跑的浏览器实例,绝不
|
|
730
|
+
静默 launch 新窗口吓人;manual=True(用户点「校准」按钮)才允许
|
|
731
|
+
attach-or-launch。识别不到章节行(改版/未登录/分页)时只记 note
|
|
732
|
+
不覆写 remote_*——宁可显示旧数也不把 0 当真相。"""
|
|
733
|
+
from .browser import Browser
|
|
734
|
+
book = ledger.book_for(task_id, plat)
|
|
735
|
+
if not book:
|
|
736
|
+
return False, "该任务未在此平台登记作品"
|
|
737
|
+
if _st(plat).get("status") == "busy":
|
|
738
|
+
return False, "该平台有操作正在进行中"
|
|
739
|
+
mod = PLATFORMS[plat]
|
|
740
|
+
js = mod.CONFIG.get("count_rows_js")
|
|
741
|
+
if not js:
|
|
742
|
+
return False, "该平台未配置章节计数"
|
|
743
|
+
try:
|
|
744
|
+
if manual:
|
|
745
|
+
b, page = _open_page(plat)
|
|
746
|
+
else:
|
|
747
|
+
port = _st(plat).get("port") or 0
|
|
748
|
+
b = Browser.attach(int(port)) # 实例已死 → 异常 → 只记原因
|
|
749
|
+
page = b.first_page(create=True)
|
|
750
|
+
except Exception as e:
|
|
751
|
+
return False, "浏览器未连接,点「连接平台」后再校准"
|
|
752
|
+
try:
|
|
753
|
+
page.navigate(mod.chapter_manage_url(book), timeout=40)
|
|
754
|
+
time.sleep(4.0) # SPA 表格慢渲染
|
|
755
|
+
url = str(page.url() or "")
|
|
756
|
+
if any(m in url for m in mod.CONFIG["login_url_marks"]) \
|
|
757
|
+
or url.startswith("chrome-error://"):
|
|
758
|
+
return False, "平台登录态已失效,请重连后校准"
|
|
759
|
+
rows = page.call(js) or []
|
|
760
|
+
st = bucket_chapter_rows(rows)
|
|
761
|
+
if st["total"] <= 0:
|
|
762
|
+
return False, "章节管理页未识别到章节列表(可能改版)"
|
|
763
|
+
ledger.update_book(task_id, plat,
|
|
764
|
+
remote_total=st["total"],
|
|
765
|
+
remote_published=st["published"],
|
|
766
|
+
remote_review=st["review"],
|
|
767
|
+
remote_rejected=st["rejected"],
|
|
768
|
+
remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
769
|
+
return True, ""
|
|
770
|
+
except Exception as e:
|
|
771
|
+
return False, "校准失败:%s" % str(e)[:160]
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _sync_preflight(task_id, plat, manual):
|
|
775
|
+
"""校准前置检查(同步、不碰浏览器页面):登记在案/没在忙/平台支持;
|
|
776
|
+
自动触发(manual=False)时还要求浏览器实例活着——绝不静默 launch。"""
|
|
777
|
+
from .browser import Browser
|
|
778
|
+
if plat not in PLATFORMS:
|
|
779
|
+
return False, "未知平台"
|
|
780
|
+
if not ledger.book_for(task_id, plat):
|
|
781
|
+
return False, "该任务未在此平台登记作品"
|
|
782
|
+
if _st(plat).get("status") == "busy":
|
|
783
|
+
return False, "该平台有操作正在进行中"
|
|
784
|
+
if not PLATFORMS[plat].CONFIG.get("count_rows_js"):
|
|
785
|
+
return False, "该平台未配置章节计数"
|
|
786
|
+
if not manual:
|
|
787
|
+
port = _st(plat).get("port") or 0
|
|
788
|
+
try:
|
|
789
|
+
Browser.attach(int(port))
|
|
790
|
+
except Exception:
|
|
791
|
+
return False, "浏览器未连接,点「连接平台」后再校准"
|
|
792
|
+
return True, ""
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def sync_published_async(task_id, plat, manual=False):
|
|
796
|
+
"""校准进后台线程(浏览器操作绝不能堵 HTTP 线程)。
|
|
797
|
+
|
|
798
|
+
返回 (ok, err):预检失败时线程都不起,接口即时回报 skipped 原因。"""
|
|
799
|
+
ok, err = _sync_preflight(task_id, plat, manual)
|
|
800
|
+
if not ok:
|
|
801
|
+
return False, err
|
|
802
|
+
|
|
803
|
+
def run():
|
|
804
|
+
ok2, err2 = sync_published(task_id, plat, manual=manual)
|
|
805
|
+
if not ok2:
|
|
806
|
+
ledger.update_book(task_id, plat, remote_error=err2,
|
|
807
|
+
remote_synced_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
808
|
+
|
|
809
|
+
threading.Thread(target=run, daemon=True,
|
|
810
|
+
name="pub-sync-%s" % plat).start()
|
|
811
|
+
return True, ""
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def published_local(task_id, plat):
|
|
815
|
+
"""本地台账口径的已发章数(去重后的章号数,记录数会因重试虚高)。"""
|
|
816
|
+
return len(ledger.published_chapters(task_id, plat))
|
|
817
|
+
|
|
818
|
+
|
|
687
819
|
def history(task_id=None, plat=None, limit=50):
|
|
688
820
|
return ledger.recent(task_id=task_id, platform=plat, limit=limit)
|
|
689
821
|
|
|
@@ -19,6 +19,11 @@ CONFIG = {
|
|
|
19
19
|
# 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
|
|
20
20
|
"min_chapter_chars": 1000,
|
|
21
21
|
"login_url_marks": ["login", "signin", "passport", "sso"],
|
|
22
|
+
# 章节管理页计数(2026-09-28 实机校准):Element UI 表格,.el-table__row
|
|
23
|
+
# 只中数据行(表头在独立的 header-wrapper 表里),与发章流程的等待选择器同源
|
|
24
|
+
"count_rows_js":
|
|
25
|
+
"()=>[...document.querySelectorAll('.el-table__row')]"
|
|
26
|
+
".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
CREATE_BOOK = [
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""备份包远程推送(WebDAV / S3 兼容 PUT 端点):data/ 不在 git 里、磁盘一坏
|
|
3
|
+
全没——本机产品额外给备份包一条出站活路。
|
|
4
|
+
|
|
5
|
+
L0 自由层(读 settings + 依赖 runner 起进程都合法)。curl -T 流式上传:
|
|
6
|
+
备份包可能 GB 级(含工作目录),绝不能整包读进内存。密钥只存本机
|
|
7
|
+
settings.json,推送时才带 Basic auth 发给用户自己配的端点。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from . import runner # L0→L2 合法方向(小层号依赖大层号);模块级绑定便于测试打桩
|
|
14
|
+
|
|
15
|
+
_MAX_TIMEOUT_S = 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cfg():
|
|
19
|
+
try:
|
|
20
|
+
from . import settings
|
|
21
|
+
s = settings.load()
|
|
22
|
+
return {"enabled": bool(s.get("backup_remote_enabled")),
|
|
23
|
+
"url": str(s.get("backup_remote_url") or "").strip().rstrip("/"),
|
|
24
|
+
"user": str(s.get("backup_remote_user") or "").strip(),
|
|
25
|
+
"pass": str(s.get("backup_remote_pass") or "")}
|
|
26
|
+
except Exception:
|
|
27
|
+
return {"enabled": False, "url": "", "user": "", "pass": ""}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def push_remote(zip_path, timeout_s=1800):
|
|
31
|
+
"""把备份包 PUT 到远程端点。返回 {"ok","skipped","status","error"}。"""
|
|
32
|
+
cfg = _cfg()
|
|
33
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
34
|
+
return {"ok": False, "skipped": True, "status": 0, "error": "未启用远程备份"}
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
p = Path(zip_path)
|
|
37
|
+
if not p.is_file():
|
|
38
|
+
return {"ok": False, "skipped": False, "status": 0, "error": "备份包不存在"}
|
|
39
|
+
argv = ["curl", "-sS", "-f", "-T", str(p), "--max-time", str(max(60, timeout_s)),
|
|
40
|
+
cfg["url"] + "/" + p.name]
|
|
41
|
+
if cfg["user"] or cfg["pass"]:
|
|
42
|
+
argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
|
|
43
|
+
try:
|
|
44
|
+
r = runner.run_process(argv=argv, timeout=timeout_s + 100)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"ok": False, "skipped": False, "status": 0, "error": str(e)[:200]}
|
|
47
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
48
|
+
return {"ok": bool(r.get("ok")), "skipped": False,
|
|
49
|
+
"status": 200 if r.get("ok") else 0,
|
|
50
|
+
"error": "" if r.get("ok") else (err[-1] if err else "上传失败")[:200]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_remote():
|
|
54
|
+
"""连通性测试:PUT 一个小探针文件(同端点同凭据)。"""
|
|
55
|
+
import os
|
|
56
|
+
import tempfile
|
|
57
|
+
fd, p = tempfile.mkstemp(prefix="codebee-remote-", suffix=".txt")
|
|
58
|
+
try:
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
60
|
+
f.write("CodeBee remote backup probe")
|
|
61
|
+
return push_remote(p, timeout_s=60)
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
os.unlink(p)
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _curl_common_args():
|
|
70
|
+
cfg = _cfg()
|
|
71
|
+
argv = ["curl", "-sS", "-f", "--max-time", "120"]
|
|
72
|
+
if cfg["user"] or cfg["pass"]:
|
|
73
|
+
argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
|
|
74
|
+
return cfg, argv
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_remote():
|
|
78
|
+
"""列远程端点上的备份包(WebDAV PROPFIND,其他端点回落 GET 目录页)。
|
|
79
|
+
返回 (文件名列表, 错误)。仅支持 push 时同款 URL 前缀。"""
|
|
80
|
+
cfg, argv = _curl_common_args()
|
|
81
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
82
|
+
return None, "未启用远程备份"
|
|
83
|
+
r = runner.run_process(
|
|
84
|
+
argv=argv + ["-X", "PROPFIND", "-H",
|
|
85
|
+
"Content-Type: application/xml", "-H", "Depth: 1",
|
|
86
|
+
"--data-binary", "", cfg["url"] + "/"], timeout=150)
|
|
87
|
+
if not r.get("ok"):
|
|
88
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
89
|
+
return None, (err[-1] if err else "列举失败(端点可能不支持 PROPFIND)")[:200]
|
|
90
|
+
import re as _re
|
|
91
|
+
text = str(r.get("stdout") or "")
|
|
92
|
+
names = sorted({m for m in _re.findall(
|
|
93
|
+
r"codebee-backup-\d{8}-\d{6}\.zip", text)}, reverse=True)
|
|
94
|
+
return names, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pull_remote(name):
|
|
98
|
+
"""从远程端点拉备份包到本机 imports 目录(走现有导入向导)。
|
|
99
|
+
name 必须精确匹配 codebee-backup-*.zip 形态(防路径注入)。
|
|
100
|
+
返回 (本地路径, 错误)。"""
|
|
101
|
+
import re as _re
|
|
102
|
+
name = str(name or "").strip()
|
|
103
|
+
if not _re.match(r"^codebee-backup-\d{8}-\d{6}\.zip$", name):
|
|
104
|
+
return None, "非法的备份包名"
|
|
105
|
+
cfg, argv = _curl_common_args()
|
|
106
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
107
|
+
return None, "未启用远程备份"
|
|
108
|
+
from . import paths
|
|
109
|
+
dest_dir = paths.DATA_DIR / "imports"
|
|
110
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
dest = dest_dir / name
|
|
112
|
+
r = runner.run_process(
|
|
113
|
+
argv=argv + ["-o", str(dest), cfg["url"] + "/" + name], timeout=1900)
|
|
114
|
+
if not r.get("ok") or not dest.is_file() or dest.stat().st_size == 0:
|
|
115
|
+
try:
|
|
116
|
+
dest.unlink()
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
120
|
+
return None, (err[-1] if err else "拉取失败")[:200]
|
|
121
|
+
return str(dest), None
|
package/app/core/runner.py
CHANGED
|
@@ -631,7 +631,7 @@ def _normalize_abort_markers(value):
|
|
|
631
631
|
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
632
632
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
633
633
|
stall_timeout=0, repeat_abort=None, deadline=None,
|
|
634
|
-
abort_markers=None, activity_timeout=0):
|
|
634
|
+
abort_markers=None, activity_timeout=0, audit_notes=None):
|
|
635
635
|
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
636
636
|
|
|
637
637
|
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
@@ -652,6 +652,10 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
652
652
|
到点会杀掉进程树并返回 deadline_exceeded=True,调用方可把整个任务收口
|
|
653
653
|
为 timeout,而不是把它误记成普通供应商失败。
|
|
654
654
|
|
|
655
|
+
audit_notes:可选字符串列表,逐行追加在审计头命令行下方——用于把
|
|
656
|
+
argv/env 里看不出的运行形态钉进日志(如 claude 凭据=绑定链注入/本机默认),
|
|
657
|
+
事后诊断不用靠猜。只写形态名,绝不写密钥值。
|
|
658
|
+
|
|
655
659
|
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled,
|
|
656
660
|
deadline_exceeded, abort_marker};杀树后未能确认整树清空时额外带
|
|
657
661
|
quiescence_unknown=True(孙进程可能仍在,诊断用,不改变终态语义)。
|
|
@@ -695,6 +699,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
695
699
|
try:
|
|
696
700
|
head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
697
701
|
"$ " + " ".join(str(a) for a in argv)]
|
|
702
|
+
for _note in (audit_notes or []):
|
|
703
|
+
if _note:
|
|
704
|
+
head.append("· " + str(_note))
|
|
698
705
|
if stdin_text:
|
|
699
706
|
capped = stdin_text[:12000]
|
|
700
707
|
head.append("--- 指令(%d 字符%s)---" % (
|
|
@@ -1063,6 +1070,64 @@ def _claude_drift_note(att_model, parsed):
|
|
|
1063
1070
|
return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
|
|
1064
1071
|
|
|
1065
1072
|
|
|
1073
|
+
_LOGIN_FIX_HINTS = {
|
|
1074
|
+
"claude": ("① 绑定页为 claude-code 配置供应商链(如 Bigmodel anthropic 面);"
|
|
1075
|
+
"② 在 ~/.claude/settings.json 的 env 里配 ANTHROPIC_BASE_URL"
|
|
1076
|
+
" + ANTHROPIC_AUTH_TOKEN"),
|
|
1077
|
+
"codex": ("① 绑定页为 codex 配置供应商链;② 本机执行 codex login 完成登录"),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _login_hint(kind, error_code, error_text):
|
|
1082
|
+
"""凭据注入型 CLI 撞登录闸门的修复指引;非缺凭据或非登录措辞返回空。
|
|
1083
|
+
|
|
1084
|
+
「无链回落本机默认」时 runner 不注入任何凭据(_resolve_attempts 的 env
|
|
1085
|
+
恒空),本机 CLI 又没登录 → 每步必死还烧满退避。把修法直接写进错误串,
|
|
1086
|
+
别让一句「Not logged in」冒充死因(2026-09-25 Mac 端 claude 实案:init
|
|
1087
|
+
JSON 里 apiKeySource=none,链上零候选可换将)。措辞闸保持窄词:
|
|
1088
|
+
login failed / invalid credentials 是 AUTH 语义,不进缺凭据指引。"""
|
|
1089
|
+
if error_code_value(error_code) != ErrorCode.MISSING_CREDENTIAL:
|
|
1090
|
+
return ""
|
|
1091
|
+
low = str(error_text or "").lower()
|
|
1092
|
+
if ("not logged in" not in low and "please run /login" not in low
|
|
1093
|
+
and "please log in" not in low):
|
|
1094
|
+
return ""
|
|
1095
|
+
fix = _LOGIN_FIX_HINTS.get(kind) or (
|
|
1096
|
+
"① 绑定页为该 CLI 配置供应商链(带密钥的供应商条目);"
|
|
1097
|
+
"② 完成该 CLI 的本机登录或在其自家配置里注入 API Key")
|
|
1098
|
+
return (";%s CLI 未登录且本步未注入绑定链凭据,修复二选一:%s"
|
|
1099
|
+
% (kind, fix))
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _cred_audit_note(kind, att, argv):
|
|
1103
|
+
"""步骤审计头的凭据模式行(run_process audit_notes 用,所有 CLI 通用)。
|
|
1104
|
+
|
|
1105
|
+
注入真值按 kind 分流:claude 只认 argv 里的 --settings(_build_call 只在
|
|
1106
|
+
env 带 ANTHROPIC_* 时落它——ORCH_API_KEY 这类别家约定的键对 claude 无效,
|
|
1107
|
+
不得按 env 非空误报「绑定链注入」);其余 kind 的 env 直接传给进程,
|
|
1108
|
+
env 非空即有效注入。条目级 no_cred 标记(resolve_binding 给纯模型条目
|
|
1109
|
+
打标)独立点名。只写形态名与变量名,密钥值绝不入日志。
|
|
1110
|
+
2026-09-25 Mac 实案:纯模型条目零注入,进程死在登录闸门后只能靠 init
|
|
1111
|
+
JSON 的 apiKeySource=none 反推死因;起跑就把凭据模式钉进日志头,且不限
|
|
1112
|
+
claude——codex/kimi/qwen 等 generic 的零凭据起跑同样一眼可读。"""
|
|
1113
|
+
if kind == "claude":
|
|
1114
|
+
if any(str(a) == "--settings" for a in (argv or [])):
|
|
1115
|
+
return "凭据=绑定链(一次性 --settings 注入 ANTHROPIC_*)"
|
|
1116
|
+
if att.get("no_cred"):
|
|
1117
|
+
return ("凭据=无注入(链上纯模型条目零凭据,只传 -m 走本机 claude "
|
|
1118
|
+
"登录态;未登录则 -p 必死在登录闸门)")
|
|
1119
|
+
return ("凭据=本机默认(未注入 ANTHROPIC_*,走本机 claude 登录态;"
|
|
1120
|
+
"未登录则 -p 必死在登录闸门)")
|
|
1121
|
+
if att.get("no_cred"):
|
|
1122
|
+
return ("凭据=无注入(链上纯模型条目零凭据,只传 -m 走本机 CLI 登录态;"
|
|
1123
|
+
"本机未登录/未配置则必死在凭据闸门)")
|
|
1124
|
+
env = att.get("env") or {}
|
|
1125
|
+
if env:
|
|
1126
|
+
return "凭据=绑定链注入(env: %s)" % ",".join(sorted(env))
|
|
1127
|
+
return ("凭据=本机默认(本步未注入任何凭据 env,走 CLI 本机登录态/配置;"
|
|
1128
|
+
"未登录则必死在登录/凭据闸门)")
|
|
1129
|
+
|
|
1130
|
+
|
|
1066
1131
|
def _log_note(log_path, text):
|
|
1067
1132
|
if not log_path or not text:
|
|
1068
1133
|
return
|
|
@@ -1189,9 +1254,11 @@ def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_ou
|
|
|
1189
1254
|
classified = classify_error_text(detail)
|
|
1190
1255
|
if classified is not None:
|
|
1191
1256
|
return classified
|
|
1192
|
-
# claude 解析失败(进程 ok
|
|
1257
|
+
# claude 解析失败(进程 ok 但无 result 事件):先按 stdout 认错误——未登录
|
|
1258
|
+
# 闸门只吐一行「Not logged in · Please run /login」就退出,认不出才是
|
|
1259
|
+
# 真正的解析失败
|
|
1193
1260
|
if kind == "claude" and parsed is None:
|
|
1194
|
-
return ErrorCode.PARSE_FAIL
|
|
1261
|
+
return classify_error_text(res.get("stdout") or "") or ErrorCode.PARSE_FAIL
|
|
1195
1262
|
# claude 明确 is_error
|
|
1196
1263
|
if parsed and parsed.get("is_error"):
|
|
1197
1264
|
return ErrorCode.VENDOR_REFUSAL
|
|
@@ -1226,7 +1293,8 @@ def _resolve_attempts(agent):
|
|
|
1226
1293
|
"own_cp": "codex_provider" in e,
|
|
1227
1294
|
"codex_provider": e.get("codex_provider"),
|
|
1228
1295
|
"provider_id": e.get("provider_id") or "",
|
|
1229
|
-
"key_id": e.get("key_id") or ""
|
|
1296
|
+
"key_id": e.get("key_id") or "",
|
|
1297
|
+
"no_cred": bool(e.get("no_cred"))} for e in chain]
|
|
1230
1298
|
base_model = agent.get("model")
|
|
1231
1299
|
fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
|
|
1232
1300
|
models_to_try = ([base_model] if base_model else []) + fb
|
|
@@ -1693,6 +1761,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1693
1761
|
att["model"], prompt,
|
|
1694
1762
|
images=images if kind == "codex" else None,
|
|
1695
1763
|
workdir=workdir)
|
|
1764
|
+
# 凭据模式起跑钉进审计头(所有 CLI):注入真值按 kind 分流,
|
|
1765
|
+
# claude 以 argv 的 --settings 为准,见 _cred_audit_note
|
|
1766
|
+
audit_notes = [_cred_audit_note(kind, att, argv)]
|
|
1696
1767
|
try:
|
|
1697
1768
|
repeat_guard = (("Reconnecting...", 2)
|
|
1698
1769
|
if kind == "codex" else None)
|
|
@@ -1715,7 +1786,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1715
1786
|
log_path=log_path, stall_timeout=stall_t,
|
|
1716
1787
|
activity_timeout=activity_t,
|
|
1717
1788
|
repeat_abort=repeat_guard,
|
|
1718
|
-
abort_markers=stream_abort_markers
|
|
1789
|
+
abort_markers=stream_abort_markers,
|
|
1790
|
+
audit_notes=audit_notes)
|
|
1719
1791
|
finally:
|
|
1720
1792
|
# 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
|
|
1721
1793
|
for tf in tmp_files:
|
|
@@ -1796,6 +1868,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1796
1868
|
if _permission_error(out["error"])
|
|
1797
1869
|
else classify_error_text(out["error"])
|
|
1798
1870
|
or _classify_failure(res, kind=kind))
|
|
1871
|
+
out["error"] += _login_hint(kind, out["error_code"],
|
|
1872
|
+
out["error"])
|
|
1799
1873
|
break
|
|
1800
1874
|
if kind == "codex":
|
|
1801
1875
|
out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
|
|
@@ -1830,6 +1904,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1830
1904
|
out["ok"] = False
|
|
1831
1905
|
out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
|
|
1832
1906
|
out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
|
|
1907
|
+
out["error"] += _login_hint("claude", out["error_code"], out["error"])
|
|
1833
1908
|
break
|
|
1834
1909
|
out["text"] = parsed["text"]
|
|
1835
1910
|
out["cost_usd"] = parsed["cost_usd"]
|
package/app/core/settings.py
CHANGED
|
@@ -21,9 +21,24 @@ _FILE = paths.DATA_DIR / "settings.json"
|
|
|
21
21
|
# 运行时出现(空闲 90s 隐身)。
|
|
22
22
|
# cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
|
|
23
23
|
# 运行过程日志/发布截图/bak 残留等超期自动清理;retention 为保留天数。
|
|
24
|
+
# 个人推送通道(core/notify.py 读取):Bark(iOS)/ ntfy(自托管友好)/
|
|
25
|
+
# Server酱(微信)/ Telegram Bot。密钥只存本机 settings.json,不出网(推送
|
|
26
|
+
# 时才带着发给对应服务)。bark_server 留空回落官方 api.day.app。
|
|
24
27
|
DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
25
28
|
"telemetry_errors": True, "publish_daily_cap": 10,
|
|
26
29
|
"publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
|
|
30
|
+
"notify_bark_server": "", "notify_bark_key": "",
|
|
31
|
+
"notify_ntfy_topic": "", "notify_serverchan_key": "",
|
|
32
|
+
"notify_telegram_token": "", "notify_telegram_chat_id": "",
|
|
33
|
+
# 评测台定时回归(evalbench.fire_due,automation tick 调用):
|
|
34
|
+
# enabled=False 关;days 间隔天数控节流;mcp_servers=内置智能体的
|
|
35
|
+
# MCP 服务器清单(JSON 数组字符串,stdio 形态)
|
|
36
|
+
"bench_auto_enabled": False, "bench_auto_days": 7,
|
|
37
|
+
"mcp_servers": "",
|
|
38
|
+
# 备份远程通道(remotesync):导出成功后自动 PUT 到 WebDAV/S3 兼容
|
|
39
|
+
# 端点(Basic auth);密钥只存本机
|
|
40
|
+
"backup_remote_enabled": False, "backup_remote_url": "",
|
|
41
|
+
"backup_remote_user": "", "backup_remote_pass": "",
|
|
27
42
|
"pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
|
|
28
43
|
"cleanup_enabled": True, "cleanup_retention_days": 14,
|
|
29
44
|
# claude_config_sync:打开/运行前防线是否直写 ~/.claude/settings.json。
|
|
@@ -40,8 +55,10 @@ MIN_WORKERS, MAX_WORKERS = 1, 24
|
|
|
40
55
|
|
|
41
56
|
|
|
42
57
|
def builtin_workdir():
|
|
43
|
-
"""
|
|
44
|
-
|
|
58
|
+
"""内置默认保存路径:数据目录内的 workspace/(CodeBee 自己的地盘,
|
|
59
|
+
启动 ensure_dirs 即创建;老版本落在数据目录同级——Windows 上是
|
|
60
|
+
AppData\\Roaming\\workspace,平时不存在点开即报错,已废弃)。"""
|
|
61
|
+
return str(paths.DATA_DIR / "workspace")
|
|
45
62
|
|
|
46
63
|
|
|
47
64
|
def default_workdir():
|
|
@@ -118,6 +135,26 @@ def save(patch):
|
|
|
118
135
|
cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
|
|
119
136
|
if "notify_base_url" in patch:
|
|
120
137
|
cur["notify_base_url"] = str(patch.get("notify_base_url") or "").strip()[:200]
|
|
138
|
+
for _nk, _nmax in (("notify_bark_server", 200), ("notify_bark_key", 128),
|
|
139
|
+
("notify_ntfy_topic", 200), ("notify_serverchan_key", 128),
|
|
140
|
+
("notify_telegram_token", 128), ("notify_telegram_chat_id", 64)):
|
|
141
|
+
if _nk in patch:
|
|
142
|
+
cur[_nk] = str(patch.get(_nk) or "").strip()[:_nmax]
|
|
143
|
+
if "bench_auto_enabled" in patch:
|
|
144
|
+
cur["bench_auto_enabled"] = bool(patch.get("bench_auto_enabled"))
|
|
145
|
+
if "bench_auto_days" in patch:
|
|
146
|
+
try:
|
|
147
|
+
cur["bench_auto_days"] = max(1, min(90, int(patch.get("bench_auto_days"))))
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return cur, "bench_auto_days 必须是 1-90 的整数"
|
|
150
|
+
if "mcp_servers" in patch:
|
|
151
|
+
cur["mcp_servers"] = str(patch.get("mcp_servers") or "").strip()[:4000]
|
|
152
|
+
if "backup_remote_enabled" in patch:
|
|
153
|
+
cur["backup_remote_enabled"] = bool(patch.get("backup_remote_enabled"))
|
|
154
|
+
for _rk, _rmax in (("backup_remote_url", 300), ("backup_remote_user", 128),
|
|
155
|
+
("backup_remote_pass", 128)):
|
|
156
|
+
if _rk in patch:
|
|
157
|
+
cur[_rk] = str(patch.get(_rk) or "").strip()[:_rmax]
|
|
121
158
|
if "pet_enabled" in patch:
|
|
122
159
|
cur["pet_enabled"] = bool(patch.get("pet_enabled"))
|
|
123
160
|
if "pet_mode" in patch:
|
|
@@ -248,6 +248,14 @@ def register_default_namespaces():
|
|
|
248
248
|
FieldDef("max_tokens_per_run", "int", 0,
|
|
249
249
|
"单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
|
|
250
250
|
clamp=(0, 100_000_000)),
|
|
251
|
+
# 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
|
|
252
|
+
# 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
|
|
253
|
+
FieldDef("daily_cost_yuan", "float", 0.0,
|
|
254
|
+
"每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
255
|
+
clamp=(0, 1_000_000)),
|
|
256
|
+
FieldDef("monthly_cost_yuan", "float", 0.0,
|
|
257
|
+
"每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
|
|
258
|
+
clamp=(0, 1_000_000)),
|
|
251
259
|
],
|
|
252
260
|
validate=lambda v: None,
|
|
253
261
|
)
|
package/app/core/store.py
CHANGED
|
@@ -18,7 +18,7 @@ import threading
|
|
|
18
18
|
import time
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from . import artifacts, paths, runner, tracing, volumes
|
|
21
|
+
from . import artifacts, audit, paths, runner, tracing, volumes
|
|
22
22
|
|
|
23
23
|
LOCK = threading.RLock()
|
|
24
24
|
_TASKS = {}
|
|
@@ -148,7 +148,7 @@ def create_task(payload):
|
|
|
148
148
|
raise ValueError("目标描述不能为空")
|
|
149
149
|
title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
|
|
150
150
|
if not workdir:
|
|
151
|
-
# 未指定目录 → 用「默认保存路径」(设置里可改;内置回落
|
|
151
|
+
# 未指定目录 → 用「默认保存路径」(设置里可改;内置回落 <数据目录>/workspace)。
|
|
152
152
|
# 默认路径允许自动创建;用户显式给的目录仍必须已存在。
|
|
153
153
|
from . import settings as settings_mod
|
|
154
154
|
workdir = settings_mod.default_workdir()
|
|
@@ -487,8 +487,8 @@ def get_task(task_id):
|
|
|
487
487
|
return None
|
|
488
488
|
|
|
489
489
|
|
|
490
|
-
def list_tasks(limit=
|
|
491
|
-
"""archived=None
|
|
490
|
+
def list_tasks(limit=None, archived=None):
|
|
491
|
+
"""按 id 倒序列任务。archived 筛选归档态;limit=None 返回全部。"""
|
|
492
492
|
with LOCK:
|
|
493
493
|
ids = sorted(_TASKS.keys(), reverse=True)
|
|
494
494
|
out = []
|
|
@@ -497,7 +497,7 @@ def list_tasks(limit=100, archived=None):
|
|
|
497
497
|
if archived is not None and bool(t.get("archived")) != archived:
|
|
498
498
|
continue
|
|
499
499
|
out.append(t)
|
|
500
|
-
if len(out) >= limit:
|
|
500
|
+
if limit is not None and len(out) >= limit:
|
|
501
501
|
break
|
|
502
502
|
return out
|
|
503
503
|
|
|
@@ -1435,8 +1435,12 @@ def archive_task(task_id, archived=True):
|
|
|
1435
1435
|
return True, ""
|
|
1436
1436
|
|
|
1437
1437
|
|
|
1438
|
-
def delete_task(task_id):
|
|
1439
|
-
"""删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。
|
|
1438
|
+
def delete_task(task_id, actor=""):
|
|
1439
|
+
"""删除任务及其全部运行记录(含日志与报告目录)。返回 (ok, 错误信息)。
|
|
1440
|
+
|
|
1441
|
+
不可逆且无回收站,故落审计(谁/何时/删了什么——2026-09-28 连载根任务
|
|
1442
|
+
被删案:事后无人能回答这两问)。连载链的前序任务被后续任务 serial.continues
|
|
1443
|
+
依赖,删了会把整条链的「继续连载/断点续跑」地基抽掉——这类任务只许归档。"""
|
|
1440
1444
|
if not _valid_id(task_id):
|
|
1441
1445
|
return False, "非法的任务 ID"
|
|
1442
1446
|
with LOCK:
|
|
@@ -1445,6 +1449,11 @@ def delete_task(task_id):
|
|
|
1445
1449
|
return False, "任务不存在"
|
|
1446
1450
|
if task.get("status") in ("queued", "running"):
|
|
1447
1451
|
return False, "运行中的任务不能删除,请先取消"
|
|
1452
|
+
if task.get("type") == "serial_novel":
|
|
1453
|
+
for other in _TASKS.values():
|
|
1454
|
+
if other.get("id") != task_id and \
|
|
1455
|
+
(other.get("serial") or {}).get("continues") == task_id:
|
|
1456
|
+
return False, "该任务是连载链的前序任务(后续任务依赖它),请用「归档」隐藏而不是删除"
|
|
1448
1457
|
run_ids = []
|
|
1449
1458
|
for rid, r in _RUNS.items():
|
|
1450
1459
|
if r.get("task_id") != task_id:
|
|
@@ -1458,6 +1467,15 @@ def delete_task(task_id):
|
|
|
1458
1467
|
for rid in run_ids:
|
|
1459
1468
|
_schedule_run_dir_cleanup(rid)
|
|
1460
1469
|
(paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
|
|
1470
|
+
audit.record("task_delete", {
|
|
1471
|
+
"task_id": task_id,
|
|
1472
|
+
"title": task.get("title") or "",
|
|
1473
|
+
"type": task.get("type") or "",
|
|
1474
|
+
"status": task.get("status") or "",
|
|
1475
|
+
"workdir": task.get("workdir") or "",
|
|
1476
|
+
"run_ids": run_ids,
|
|
1477
|
+
"actor": actor or "local",
|
|
1478
|
+
})
|
|
1461
1479
|
bump_state()
|
|
1462
1480
|
return True, ""
|
|
1463
1481
|
|
|
@@ -1880,7 +1898,7 @@ def clear_stream_state(run_id=None, n=None):
|
|
|
1880
1898
|
|
|
1881
1899
|
def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
1882
1900
|
cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
|
|
1883
|
-
followups=None, thinking=None):
|
|
1901
|
+
followups=None, thinking=None, partial=False):
|
|
1884
1902
|
with LOCK:
|
|
1885
1903
|
run = _RUNS.get(run_id)
|
|
1886
1904
|
if not run:
|
|
@@ -1911,6 +1929,10 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
|
1911
1929
|
s["thinking"] = runner.strip_ansi(str(thinking))[:LIVE_TEXT_MAX]
|
|
1912
1930
|
if followups:
|
|
1913
1931
|
s["followups"] = list(followups)[:3]
|
|
1932
|
+
if partial:
|
|
1933
|
+
s["partial"] = True
|
|
1934
|
+
else:
|
|
1935
|
+
s.pop("partial", None)
|
|
1914
1936
|
# 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
|
|
1915
1937
|
# 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
|
|
1916
1938
|
s.pop("stream", None)
|