codebee 0.1.70 → 0.1.72
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 +15 -0
- package/README.md +3 -4
- package/app/core/builtin_agent.py +7 -0
- package/app/core/evalbench.py +119 -3
- package/app/core/jobs.py +21 -0
- package/app/core/market.py +88 -2
- package/app/core/market_remote.py +16 -0
- package/app/core/modelhub.py +3 -2
- package/app/core/pipeline.py +102 -30
- package/app/core/prompt_guard.py +78 -0
- package/app/core/publish/auto.py +43 -14
- package/app/core/publish/ledger.py +90 -12
- package/app/core/publish/manager.py +121 -14
- package/app/core/quality_gate.py +194 -0
- package/app/core/settings.py +3 -2
- package/app/core/skill_scan.py +17 -0
- package/app/core/skills.py +58 -8
- package/app/core/store.py +51 -0
- package/app/core/zentao.py +135 -57
- package/app/main.py +124 -14
- package/app/pet.py +1 -1
- package/app/ui/app.js +269 -58
- package/app/ui/i18n.js +25 -1
- package/app/ui/index.html +5 -0
- package/app/ui/style.css +7051 -6965
- package/package.json +3 -2
package/app/core/zentao.py
CHANGED
|
@@ -32,11 +32,11 @@ backend | frontend | both | not_ours | unknown。
|
|
|
32
32
|
纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
|
|
33
33
|
非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
|
|
34
34
|
unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
|
|
35
|
-
修复任务失败 →
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
修复任务失败 → 不评论不转派(#27828 案定口径:失败不是修复结论,写回
|
|
36
|
+
禅道是拿工具故障/中间态打扰业务流,也绝不当甩锅转派)——
|
|
37
|
+
只记台账+群通知,回炉 retry 状态等下轮扫描自动重试(上限
|
|
38
|
+
FIX_MAX_ATTEMPTS 次),到限置 failed 终态等人工;禅道页
|
|
39
|
+
「立即处理」(retry_claim)随时可手工重触发。
|
|
40
40
|
|
|
41
41
|
落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
|
|
42
42
|
走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
|
|
@@ -94,6 +94,7 @@ HTTP_TIMEOUT = 15
|
|
|
94
94
|
PAGE_LIMIT = 100
|
|
95
95
|
MAX_BUGS = 500
|
|
96
96
|
RESOLVE_MAX_ATTEMPTS = 3
|
|
97
|
+
FIX_MAX_ATTEMPTS = 3 # 修复失败自动重试上限(retry 状态下轮扫描重试计数)
|
|
97
98
|
RETRY_DELAY_MIN = 30
|
|
98
99
|
INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
|
|
99
100
|
INTERVAL_DEFAULT = 5
|
|
@@ -1684,22 +1685,14 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
|
|
|
1684
1685
|
return "\n".join(lines)
|
|
1685
1686
|
|
|
1686
1687
|
|
|
1687
|
-
def
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
|
|
1691
|
-
if tri.get("side"):
|
|
1692
|
-
lines.append("排查结论:%s(%s)" % (tri.get("side"), tri.get("reason") or ""))
|
|
1688
|
+
def _fail_brief(failed_tasks, runs):
|
|
1689
|
+
"""失败端一句话摘要:『后端:原因;前端:原因』(群通知/台账共用)。"""
|
|
1690
|
+
parts = []
|
|
1693
1691
|
for t in failed_tasks:
|
|
1694
1692
|
r = runs.get(t.get("run_id")) or {}
|
|
1695
1693
|
why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
lines.append("(失败原因是 CodeBee 工具自身异常,不代表修复结论;"
|
|
1699
|
-
"请人工排查 CodeBee 或续跑修复任务)")
|
|
1700
|
-
lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
|
|
1701
|
-
% "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
|
|
1702
|
-
return "\n".join(lines)
|
|
1694
|
+
parts.append("%s:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:200]))
|
|
1695
|
+
return ";".join(parts)[:300]
|
|
1703
1696
|
|
|
1704
1697
|
|
|
1705
1698
|
# ---------------------------------------------------------------- 禅道写回动作
|
|
@@ -2022,25 +2015,32 @@ def _finish_ok(claim, cfg):
|
|
|
2022
2015
|
# 0.1.63 _TRANSIENT 缩进事故实测形态)或带 Python traceback。这不是「修不动
|
|
2023
2016
|
# bug」——转派给端负责人等于拿自家工具故障甩锅。冒号形态(TimeoutError: x)
|
|
2024
2017
|
# 多为 CLI/被测仓的真实输出,不算内部崩溃。
|
|
2018
|
+
# 自家工具链故障形态:评审器没跑起来(评审未执行)——pipeline 对评审 CLI
|
|
2019
|
+
# 启动失败的包装错误,同样不是「修不动」的结论。
|
|
2025
2020
|
_CRASH_REPR_RE = re.compile(r"^[A-Za-z_]\w*(?:Error|Exception|Interrupt)\(")
|
|
2026
2021
|
|
|
2022
|
+
_TOOL_FAIL_MARKERS = ("评审器故障(评审未执行)", "评审器执行失败(评审未执行)")
|
|
2023
|
+
|
|
2027
2024
|
|
|
2028
2025
|
def _looks_internal_crash(err):
|
|
2029
2026
|
e = str(err or "").strip()
|
|
2030
2027
|
if not e:
|
|
2031
2028
|
return False
|
|
2029
|
+
if any(m in e for m in _TOOL_FAIL_MARKERS):
|
|
2030
|
+
return True
|
|
2032
2031
|
return ("Traceback (most recent call last" in e
|
|
2033
2032
|
or _CRASH_REPR_RE.match(e) is not None)
|
|
2034
2033
|
|
|
2035
2034
|
|
|
2036
2035
|
def _finish_failed(claim, cfg):
|
|
2037
|
-
"""
|
|
2036
|
+
"""我方任一任务失败:不评论不转派(#27828 案定口径),只记台账+群通知。
|
|
2038
2037
|
|
|
2039
|
-
CodeBee
|
|
2040
|
-
|
|
2038
|
+
失败不是修复结论——无论是 CodeBee 工具链故障(评审器起不来/内部崩溃)
|
|
2039
|
+
还是修复未通过,写回禅道都是拿中间态打扰业务流,转派更是甩锅。失败回炉
|
|
2040
|
+
retry 状态,下轮扫描自动重试(只重建失败端,上限 FIX_MAX_ATTEMPTS 次);
|
|
2041
|
+
到限置 failed 终态等人工,禅道页「立即处理」随时可手工重触发。
|
|
2041
2042
|
"""
|
|
2042
2043
|
bid = str(claim.get("bug_id"))
|
|
2043
|
-
profile = _profile_for(cfg, claim.get("product")) or {}
|
|
2044
2044
|
tasks = claim.get("tasks") or []
|
|
2045
2045
|
runs = {t.get("run_id"): store.get_run(t.get("run_id") or "") for t in tasks}
|
|
2046
2046
|
failed = [t for t in tasks
|
|
@@ -2048,36 +2048,27 @@ def _finish_failed(claim, cfg):
|
|
|
2048
2048
|
("queued", "running", "done")]
|
|
2049
2049
|
internal = any(_looks_internal_crash((runs.get(t.get("run_id")) or {}).get("error"))
|
|
2050
2050
|
for t in failed)
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
|
|
2073
|
-
note = "修复失败,评论未送达:%s" % e
|
|
2074
|
-
state = "commented"
|
|
2075
|
-
_set_claim(bid, state=state, note=note)
|
|
2076
|
-
why = ("CodeBee 内部异常,未转派" if internal
|
|
2077
|
-
else ("已转派 " + target if target else "负责人未配置,未转派"))
|
|
2078
|
-
_notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n修复任务:%s"
|
|
2079
|
-
% (bid, why,
|
|
2080
|
-
claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
|
|
2051
|
+
why = _fail_brief(failed, runs)
|
|
2052
|
+
kind = "CodeBee 工具链异常" if internal else "修复未通过"
|
|
2053
|
+
task_ids = "、".join(t.get("task_id") or "?" for t in tasks)
|
|
2054
|
+
attempts = int(claim.get("attempts") or 0) + 1
|
|
2055
|
+
if attempts <= FIX_MAX_ATTEMPTS:
|
|
2056
|
+
_set_claim(bid, attempts=attempts, state="retry",
|
|
2057
|
+
note="修复失败(%s:%s),第 %d/%d 次重试将于下轮扫描自动进行"
|
|
2058
|
+
% (kind, why, attempts, FIX_MAX_ATTEMPTS))
|
|
2059
|
+
if attempts == 1: # 中间次只记台账不刷屏,成败都有下文(重试/终态)再报
|
|
2060
|
+
_notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n失败:%s\n"
|
|
2061
|
+
"第 %d/%d 次重试将于下轮扫描自动进行,也可到禅道页点「立即处理」。\n"
|
|
2062
|
+
"修复任务:%s"
|
|
2063
|
+
% (bid, kind, claim.get("title") or "", why,
|
|
2064
|
+
attempts, FIX_MAX_ATTEMPTS, task_ids))
|
|
2065
|
+
return
|
|
2066
|
+
_set_claim(bid, attempts=attempts, state="failed",
|
|
2067
|
+
note="连续 %d 次修复失败(%s:%s),已停止自动重试,请人工接管"
|
|
2068
|
+
% (attempts, kind, why))
|
|
2069
|
+
_notify("🐛❌ 禅道 Bug #%s 连续 %d 次自动修复未成功(%s),已停止重试,"
|
|
2070
|
+
"请人工接管(禅道页可「立即处理」重新触发)\n%s\n失败:%s\n修复任务:%s"
|
|
2071
|
+
% (bid, attempts, kind, claim.get("title") or "", why, task_ids))
|
|
2081
2072
|
|
|
2082
2073
|
|
|
2083
2074
|
def _refresh_claim_runs(claim):
|
|
@@ -2258,8 +2249,88 @@ def _route_one(bug, profile, cfg, notify=True):
|
|
|
2258
2249
|
return "fixing"
|
|
2259
2250
|
|
|
2260
2251
|
|
|
2252
|
+
def _retry_one(bug, claim, profile, cfg):
|
|
2253
|
+
"""重试一个 retry 挂单:只重建失败的修复任务(成功端保留),attempts 累加
|
|
2254
|
+
不清零(上限计数跨重试累计)。建任务失败 → need_manual 留人工。"""
|
|
2255
|
+
bid = str(bug.get("id") or "")
|
|
2256
|
+
tasks = []
|
|
2257
|
+
try:
|
|
2258
|
+
for t in claim.get("tasks") or []:
|
|
2259
|
+
r = store.get_run(t.get("run_id") or "") or {}
|
|
2260
|
+
if str(r.get("status") or "") in ("queued", "running", "done"):
|
|
2261
|
+
tasks.append(dict(t))
|
|
2262
|
+
continue
|
|
2263
|
+
task, run = _launch_fix(bug, profile, t.get("side"), cfg)
|
|
2264
|
+
tasks.append({"side": t.get("side"), "task_id": task["id"],
|
|
2265
|
+
"run_id": run["id"], "state": "fixing"})
|
|
2266
|
+
except Exception as e:
|
|
2267
|
+
log.warning("zentao: bug %s 重试建任务失败:%s", bid, e)
|
|
2268
|
+
_set_claim(bid, state="need_manual", note="重试建任务失败:%s" % e)
|
|
2269
|
+
_notify("🐛⚠️ 禅道 Bug #%s 重试建任务失败,留人工:%s" % (bid, e))
|
|
2270
|
+
return
|
|
2271
|
+
_set_claim(bid, tasks=tasks, state="fixing",
|
|
2272
|
+
note="第 %d 次重试进行中" % max(1, int(claim.get("attempts") or 1)))
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
# 「立即处理」可重触发的状态:fixing(在跑)/resolved/transferred(已闭环)/
|
|
2276
|
+
# done_manual(修完等人工确认,重跑会重复修复)不行,其余都行。
|
|
2277
|
+
_RETRYABLE_STATES = ("retry", "failed", "escalated", "commented",
|
|
2278
|
+
"need_manual", "merge_failed", "resolve_failed", "lost")
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def retry_claim(bid):
|
|
2282
|
+
"""手工「立即处理」:不等下轮扫描,立即重新触发一个失败/留人工的修复。
|
|
2283
|
+
|
|
2284
|
+
按状态分流:need_manual 重新排查认领;merge_failed/resolve_failed 置回
|
|
2285
|
+
fixing 交给对账重试合并/回写;其余(retry/failed/历史 escalated 等)重建
|
|
2286
|
+
失败端任务。归档的不拒——手工触发即用户明确意图,顺手取消归档。
|
|
2287
|
+
返回 (claim, 动作文案);不存在/状态不允许抛 ZenError。
|
|
2288
|
+
"""
|
|
2289
|
+
bid = str(bid)
|
|
2290
|
+
_ensure_loaded()
|
|
2291
|
+
with _LOCK:
|
|
2292
|
+
cfg = dict(_cfg())
|
|
2293
|
+
c = _STATE["claims"].get(bid)
|
|
2294
|
+
if c is None:
|
|
2295
|
+
raise ZenError("修复记录 #%s 不存在" % bid)
|
|
2296
|
+
state = str(c.get("state") or "")
|
|
2297
|
+
if state == "fixing":
|
|
2298
|
+
raise ZenError("Bug #%s 修复进行中,无需重触发" % bid)
|
|
2299
|
+
if state == "resolved":
|
|
2300
|
+
raise ZenError("Bug #%s 已解决,无需重触发" % bid)
|
|
2301
|
+
if state == "transferred":
|
|
2302
|
+
raise ZenError("Bug #%s 已转派他人,不能重触发" % bid)
|
|
2303
|
+
if state == "done_manual":
|
|
2304
|
+
raise ZenError("Bug #%s 修复已完成,等人工确认合并/解决即可" % bid)
|
|
2305
|
+
product = c.get("product")
|
|
2306
|
+
profile = _profile_for(cfg, product)
|
|
2307
|
+
if profile is None:
|
|
2308
|
+
raise ZenError("Bug #%s 的产品档案已不存在,无法重触发" % bid)
|
|
2309
|
+
bug = _call("GET", "/bugs/%s" % bid, cfg=cfg) # ZenError 往上抛(连不上别硬触)
|
|
2310
|
+
if str(bug.get("status") or "") not in ("active", ""):
|
|
2311
|
+
raise ZenError("Bug #%s 禅道侧状态已是 %s,无需修复" % (bid, bug.get("status")))
|
|
2312
|
+
if state == "need_manual":
|
|
2313
|
+
_route_one(bug, profile, cfg, notify=False)
|
|
2314
|
+
msg = "已重新排查认领"
|
|
2315
|
+
elif state in ("merge_failed", "resolve_failed"):
|
|
2316
|
+
_set_claim(bid, state="fixing", note="手工重触发:等待对账重试合并/回写")
|
|
2317
|
+
msg = "已置回修复中,等待对账重试合并/回写"
|
|
2318
|
+
else:
|
|
2319
|
+
# failed/retry/历史遗留 escalated/commented/lost:清零计数重新给满重试额度
|
|
2320
|
+
with _LOCK:
|
|
2321
|
+
cur = dict(_STATE["claims"].get(bid) or {})
|
|
2322
|
+
cur["attempts"] = 0
|
|
2323
|
+
_retry_one(bug, cur, profile, cfg)
|
|
2324
|
+
msg = "已重新拉起修复任务(重试额度已重置)"
|
|
2325
|
+
_set_claim(bid, attempts=0, archived=False, archived_by="",
|
|
2326
|
+
archived_at="", archive_reason="")
|
|
2327
|
+
with _LOCK:
|
|
2328
|
+
out = dict(_STATE["claims"].get(bid) or {})
|
|
2329
|
+
return out, msg
|
|
2330
|
+
|
|
2331
|
+
|
|
2261
2332
|
def _scan(cfg):
|
|
2262
|
-
"""按产品档案逐个拉 bug:认领新 bug + 重试 need_manual 的存量 + 归档对账。
|
|
2333
|
+
"""按产品档案逐个拉 bug:认领新 bug + 重试 need_manual/retry 的存量 + 归档对账。
|
|
2263
2334
|
返回认领数。"""
|
|
2264
2335
|
claimed = 0
|
|
2265
2336
|
profiles = _profiles(cfg)
|
|
@@ -2271,7 +2342,8 @@ def _scan(cfg):
|
|
|
2271
2342
|
with _LOCK:
|
|
2272
2343
|
seen = set(_STATE["claims"].keys())
|
|
2273
2344
|
retry_ids = [k for k, c in _STATE["claims"].items()
|
|
2274
|
-
if c.get("state")
|
|
2345
|
+
if c.get("state") in ("need_manual", "retry")
|
|
2346
|
+
and not c.get("archived")
|
|
2275
2347
|
and k in by_id
|
|
2276
2348
|
and str(by_id[k].get("status") or "") == "active"]
|
|
2277
2349
|
for bug in bugs:
|
|
@@ -2280,12 +2352,18 @@ def _scan(cfg):
|
|
|
2280
2352
|
continue
|
|
2281
2353
|
_route_one(bug, profile, cfg)
|
|
2282
2354
|
claimed += 1
|
|
2283
|
-
# need_manual
|
|
2355
|
+
# need_manual 重排查 / retry 失败端重试(bug 仍激活才出现在列表里)
|
|
2284
2356
|
for bid in retry_ids:
|
|
2285
2357
|
with _LOCK:
|
|
2286
|
-
|
|
2358
|
+
c = _STATE["claims"].get(bid) or {}
|
|
2359
|
+
st = str(c.get("state") or "")
|
|
2360
|
+
if st not in ("need_manual", "retry"):
|
|
2287
2361
|
continue
|
|
2288
|
-
|
|
2362
|
+
claim = dict(c)
|
|
2363
|
+
if st == "need_manual":
|
|
2364
|
+
_route_one(by_id[bid], profile, cfg, notify=False)
|
|
2365
|
+
else:
|
|
2366
|
+
_retry_one(by_id[bid], claim, profile, cfg)
|
|
2289
2367
|
# 归档对账:走到这里说明本产品列表拉取成功,消失判定才可信
|
|
2290
2368
|
try:
|
|
2291
2369
|
_archive_sweep(cfg, profile, by_id)
|
package/app/main.py
CHANGED
|
@@ -220,6 +220,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
220
220
|
|
|
221
221
|
# ------------------------------------------------------------ 路由
|
|
222
222
|
def do_GET(self):
|
|
223
|
+
# 顶层兜底:任何路由裸抛以前=socketserver 直接掐断连接、零日志,
|
|
224
|
+
# 浏览器只见 "Failed to fetch"(2026-09-29 store 局部名遮蔽实案)。
|
|
225
|
+
# 现在统一落日志并回 500 JSON;对端已断开时无从回话,原样上抛。
|
|
226
|
+
try:
|
|
227
|
+
return self._route_get()
|
|
228
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError,
|
|
229
|
+
socket.timeout):
|
|
230
|
+
raise
|
|
231
|
+
except Exception:
|
|
232
|
+
log.exception("GET %s 处理异常", getattr(self, "path", "?"))
|
|
233
|
+
self._safe_500()
|
|
234
|
+
|
|
235
|
+
def _route_get(self):
|
|
223
236
|
path = urlparse(self.path).path
|
|
224
237
|
m = None
|
|
225
238
|
# 静态页不设防(无敏感信息):远程裸地址打开时由前端令牌门引导输入
|
|
@@ -274,7 +287,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
274
287
|
return self._json(200, {"versions": flows.flow_versions(fid)})
|
|
275
288
|
if path == "/api/skills":
|
|
276
289
|
from core import skills
|
|
277
|
-
|
|
290
|
+
v = skills.view()
|
|
291
|
+
try:
|
|
292
|
+
# 技能行为卡(ClawHub Skill Card 模式):市场安装的包按文件
|
|
293
|
+
# 名关联拼卡——声明/来源/指纹/安检一次可见。skills(L1) 不能
|
|
294
|
+
# 反向 import market(成环),拼接放路由层。
|
|
295
|
+
from core import market as _market
|
|
296
|
+
reg = _market._load_registry().get("installed") or {}
|
|
297
|
+
by_file = {str(r.get("file") or ""): pid
|
|
298
|
+
for pid, r in reg.items() if isinstance(r, dict)}
|
|
299
|
+
for p in v.get("packs") or []:
|
|
300
|
+
pid = by_file.get("market-%s.md" % p.get("id"))
|
|
301
|
+
if pid:
|
|
302
|
+
p["card"] = _market.skill_card(pid)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
return self._json(200, v)
|
|
278
306
|
if path == "/api/knowledge":
|
|
279
307
|
from core import knowledge
|
|
280
308
|
return self._json(200, knowledge.view())
|
|
@@ -296,6 +324,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
296
324
|
if path == "/api/evalbench":
|
|
297
325
|
from core import evalbench
|
|
298
326
|
return self._json(200, evalbench.state())
|
|
327
|
+
if path == "/api/evalbench/report":
|
|
328
|
+
# 评测报告导出(Markdown,浏览器下载)
|
|
329
|
+
from core import evalbench
|
|
330
|
+
return self._send(200, evalbench.report_markdown().encode("utf-8"),
|
|
331
|
+
ctype="text/markdown; charset=utf-8",
|
|
332
|
+
headers={"Content-Disposition":
|
|
333
|
+
'attachment; filename="codebee-bench-%s.md"'
|
|
334
|
+
% time.strftime("%Y%m%d-%H%M%S")})
|
|
299
335
|
if path == "/api/catalog":
|
|
300
336
|
return self._json(200, {"catalog": manager.catalog_view(),
|
|
301
337
|
"checking": manager.updates_checking()})
|
|
@@ -520,11 +556,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
520
556
|
return self._json(200, {"path": fp, "text": text})
|
|
521
557
|
m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
|
|
522
558
|
if m:
|
|
523
|
-
#
|
|
559
|
+
# 作品信息(番茄/七猫建书表单资料):读取生成状态与结果。
|
|
560
|
+
# 连载链沿链继承(都是用第一个):续写批次读到的就是首批的结果。
|
|
524
561
|
task = store.get_task(m.group(1))
|
|
525
562
|
if not task:
|
|
526
563
|
return self._json(404, {"error": "not found"})
|
|
527
|
-
return self._json(200, {"book_meta":
|
|
564
|
+
return self._json(200, {"book_meta": {
|
|
565
|
+
p: store.inherited_book_meta(task, p)
|
|
566
|
+
for p in ("fanqie", "qimao")
|
|
567
|
+
if store.inherited_book_meta(task, p)}})
|
|
528
568
|
if path == "/api/publish":
|
|
529
569
|
# 一键发布:平台连接状态 + 最近台账(详情页发布面板)
|
|
530
570
|
from core.publish import manager as pub
|
|
@@ -536,7 +576,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
536
576
|
from core.publish import manager as pub
|
|
537
577
|
from core.publish import ledger as pub_ledger
|
|
538
578
|
tid = m.group(1)
|
|
539
|
-
|
|
579
|
+
# 绑定沿连载链继承(都是用第一个):续写批次也要看到这本书的登记
|
|
580
|
+
books = pub_ledger.books_for(tid)
|
|
540
581
|
return self._json(200, {"history": pub.history(task_id=tid, limit=50),
|
|
541
582
|
"books": books,
|
|
542
583
|
"published": {p: pub.published_local(tid, p)
|
|
@@ -667,6 +708,24 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
667
708
|
return self._json(404, {"error": "not found"})
|
|
668
709
|
|
|
669
710
|
def do_POST(self):
|
|
711
|
+
# 同 do_GET:路由裸抛统一落日志并回 500,不再静默掐连接
|
|
712
|
+
try:
|
|
713
|
+
return self._route_post()
|
|
714
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError,
|
|
715
|
+
socket.timeout):
|
|
716
|
+
raise
|
|
717
|
+
except Exception:
|
|
718
|
+
log.exception("POST %s 处理异常", getattr(self, "path", "?"))
|
|
719
|
+
self._safe_500()
|
|
720
|
+
|
|
721
|
+
def _safe_500(self):
|
|
722
|
+
# 响应头可能已发出(流式路由半途抛),二次失败只能放弃
|
|
723
|
+
try:
|
|
724
|
+
self._json(500, {"error": "服务内部错误,详情见服务端日志"})
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
|
|
728
|
+
def _route_post(self):
|
|
670
729
|
path = urlparse(self.path).path
|
|
671
730
|
m = None
|
|
672
731
|
if not self._authed():
|
|
@@ -906,6 +965,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
906
965
|
m = re.match(r"^/api/publish/task/([^/]+)/(create-book|chapter|auto-publish)$", path)
|
|
907
966
|
if m:
|
|
908
967
|
return self._api_publish_task_op(m.group(1), m.group(2))
|
|
968
|
+
m = re.match(r"^/api/publish/task/([^/]+)/confirm-chapter$", path)
|
|
969
|
+
if m:
|
|
970
|
+
from core.publish import manager as pub
|
|
971
|
+
body = self._body() or {}
|
|
972
|
+
platform = (body.get("platform") or "").strip()
|
|
973
|
+
ok, err = pub.confirm_manual_chapter(
|
|
974
|
+
m.group(1), platform, body.get("chapter_no"))
|
|
975
|
+
return self._json(400, {"error": err or "确认失败"}) \
|
|
976
|
+
if not ok else self._json(200, {"ok": True})
|
|
909
977
|
m = re.match(r"^/api/publish/task/([^/]+)/register-book$", path)
|
|
910
978
|
if m:
|
|
911
979
|
return self._api_publish_register_book(m.group(1))
|
|
@@ -918,14 +986,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
918
986
|
# manual=true(用户点「校准」)允许 attach-or-launch;自动触发
|
|
919
987
|
# 只 attach 在跑实例,绝不因打开页面弹出新浏览器窗口。
|
|
920
988
|
from core.publish import manager as pub
|
|
921
|
-
from core import store
|
|
922
989
|
from core.publish import ledger as pub_ledger
|
|
990
|
+
# 注意:这里绝不能再 `from core import store`——函数内 import 会把
|
|
991
|
+
# store 变成 do_POST 整个函数的局部名,同函数里其余路由(新建任务/
|
|
992
|
+
# 取消/升级全部等)一读 store 就是 UnboundLocalError,连接被静默
|
|
993
|
+
# 掐断,前端只见 "Failed to fetch"(0.1.70 实案,bisect 定位)。
|
|
994
|
+
# store 已在模块顶层导入,直接用。
|
|
923
995
|
tid = m.group(1)
|
|
924
996
|
if not store.get_task(tid):
|
|
925
997
|
return self._json(404, {"error": "任务不存在"})
|
|
926
998
|
body = self._body() or {}
|
|
927
999
|
plat = str(body.get("platform") or "").strip()
|
|
928
|
-
|
|
1000
|
+
# 绑定沿连载链继承(与 history 端点同口径):续写批次发起校准也能
|
|
1001
|
+
# 落到这本书的账上,否则前端看到继承绑定、这里却报「未登记」互相打架
|
|
1002
|
+
books = pub_ledger.books_for(tid)
|
|
929
1003
|
if plat and plat not in books:
|
|
930
1004
|
return self._json(400, {"error": "该平台未登记作品"})
|
|
931
1005
|
plats = [plat] if plat else list(books)
|
|
@@ -1533,6 +1607,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1533
1607
|
except zentao.ZenError as e:
|
|
1534
1608
|
return self._json(404, {"error": str(e)})
|
|
1535
1609
|
return self._json(200, {"ok": True, "claim": c})
|
|
1610
|
+
if path == "/api/zentao/claims/retry":
|
|
1611
|
+
from core import zentao
|
|
1612
|
+
body = self._body() or {}
|
|
1613
|
+
try:
|
|
1614
|
+
c, msg = zentao.retry_claim(body.get("bug_id"))
|
|
1615
|
+
except zentao.ZenError as e:
|
|
1616
|
+
return self._json(409, {"error": str(e)})
|
|
1617
|
+
return self._json(200, {"ok": True, "claim": c, "message": msg})
|
|
1536
1618
|
if path == "/api/zentao/modules":
|
|
1537
1619
|
from core import zentao
|
|
1538
1620
|
pid = (self._body() or {}).get("product")
|
|
@@ -1679,7 +1761,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1679
1761
|
if platform not in bookmeta.PLATFORMS:
|
|
1680
1762
|
return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
|
|
1681
1763
|
if not bookmeta.needs_book_meta(task):
|
|
1682
|
-
|
|
1764
|
+
# 生成权留在首批(一本书一份资料):指路根任务,别让用户在续写批次上撞墙
|
|
1765
|
+
root = next((store.get_task(t) for t
|
|
1766
|
+
in store.serial_chain_ids(task_id) if t != task_id), None)
|
|
1767
|
+
return self._json(400, {"error": (
|
|
1768
|
+
"开书资料在连载首批任务《%s》上生成与维护,请到首批任务的「作品信息」页操作"
|
|
1769
|
+
% ((root or {}).get("title") or "首批")
|
|
1770
|
+
if root else "只有连载首批任务需要作品信息;续写批次沿用第一批的开书资料")})
|
|
1683
1771
|
if task.get("status") in ("queued", "running"):
|
|
1684
1772
|
return self._json(400, {"error": "任务正在运行,请等本轮结束后再生成作品信息"})
|
|
1685
1773
|
cur = ((task.get("book_meta") or {}).get(platform) or {})
|
|
@@ -1724,8 +1812,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1724
1812
|
if platform not in ("fanqie", "qimao"):
|
|
1725
1813
|
return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
|
|
1726
1814
|
auto_submit = bool(body.get("auto_submit"))
|
|
1815
|
+
force = bool(body.get("force"))
|
|
1816
|
+
force_confirmed = bool(body.get("force_confirmed"))
|
|
1817
|
+
force_reason = str(body.get("force_reason") or "").strip()
|
|
1727
1818
|
if op == "create-book":
|
|
1728
|
-
|
|
1819
|
+
kwargs = ({"force": force, "force_confirmed": force_confirmed,
|
|
1820
|
+
"force_reason": force_reason}
|
|
1821
|
+
if force or force_confirmed or force_reason else {})
|
|
1822
|
+
ok, err = pub.create_book_async(task_id, platform, auto_submit, **kwargs)
|
|
1729
1823
|
elif op == "auto-publish":
|
|
1730
1824
|
# 定时发布配置(P2.5):enabled=false 也落(保留 time 供再开);
|
|
1731
1825
|
# 校验/归一在 auto.norm_auto_publish,语义见 auto.py 头注
|
|
@@ -1754,9 +1848,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1754
1848
|
fp.relative_to(_P(wd).resolve())
|
|
1755
1849
|
except (OSError, ValueError):
|
|
1756
1850
|
return self._json(400, {"error": "章节文件必须在任务工作目录内"})
|
|
1757
|
-
|
|
1851
|
+
kwargs = ({"force": force, "force_confirmed": force_confirmed,
|
|
1852
|
+
"force_reason": force_reason}
|
|
1853
|
+
if force or force_confirmed or force_reason else {})
|
|
1854
|
+
ok, err = pub.upload_chapter_async(task_id, platform, str(fp), auto_submit,
|
|
1855
|
+
**kwargs)
|
|
1758
1856
|
if not ok:
|
|
1759
|
-
|
|
1857
|
+
payload = {"error": err or "操作失败"}
|
|
1858
|
+
if str(err or "").startswith("质量门禁拦截:"):
|
|
1859
|
+
payload["quality_gate"] = {"status": "blocked", "message": err}
|
|
1860
|
+
return self._json(400, payload)
|
|
1760
1861
|
return self._json(200, {"ok": True, "started": True})
|
|
1761
1862
|
|
|
1762
1863
|
def _api_publish_register_book(self, task_id):
|
|
@@ -1786,10 +1887,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1786
1887
|
platform = (body.get("platform") or "").strip()
|
|
1787
1888
|
if platform not in ("fanqie", "qimao"):
|
|
1788
1889
|
return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
|
|
1890
|
+
force = bool(body.get("force"))
|
|
1891
|
+
force_confirmed = bool(body.get("force_confirmed"))
|
|
1892
|
+
force_reason = str(body.get("force_reason") or "").strip()
|
|
1789
1893
|
ok, err = pub_auto.publish_pending_async(
|
|
1790
|
-
task_id, platform, auto_submit=bool(body.get("auto_submit"))
|
|
1894
|
+
task_id, platform, auto_submit=bool(body.get("auto_submit")),
|
|
1895
|
+
**({"force": force, "force_confirmed": force_confirmed,
|
|
1896
|
+
"force_reason": force_reason}
|
|
1897
|
+
if force or force_confirmed or force_reason else {}))
|
|
1791
1898
|
if not ok:
|
|
1792
|
-
|
|
1899
|
+
payload = {"error": err or "操作失败"}
|
|
1900
|
+
if str(err or "").startswith("质量门禁拦截:"):
|
|
1901
|
+
payload["quality_gate"] = {"status": "blocked", "message": err}
|
|
1902
|
+
return self._json(400, payload)
|
|
1793
1903
|
return self._json(200, {"ok": True, "started": True})
|
|
1794
1904
|
|
|
1795
1905
|
def _api_task_side(self, task_id):
|
|
@@ -1916,7 +2026,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1916
2026
|
# 服务换了个进程(升级重启)——旧蜜蜂自行退场让新服务 spawn 新蜜蜂
|
|
1917
2027
|
# (旧形象常驻的根因:同端口轮询永远正常,旧进程从不自离)。
|
|
1918
2028
|
"boot": _BOOT_TS,
|
|
1919
|
-
"settings": {"pet_enabled": bool(st.get("pet_enabled",
|
|
2029
|
+
"settings": {"pet_enabled": bool(st.get("pet_enabled", False)),
|
|
1920
2030
|
"pet_mode": str(st.get("pet_mode") or "always"),
|
|
1921
2031
|
"pet_skin": str(st.get("pet_skin") or "plush")},
|
|
1922
2032
|
"workers": {"running": n_run, "queued": n_q},
|
|
@@ -2878,7 +2988,7 @@ def _start_pet_keeper(port):
|
|
|
2878
2988
|
def _loop():
|
|
2879
2989
|
while True:
|
|
2880
2990
|
try:
|
|
2881
|
-
if settings.load().get("pet_enabled",
|
|
2991
|
+
if settings.load().get("pet_enabled", False):
|
|
2882
2992
|
if _PET_PROC is None or _PET_PROC.poll() is not None:
|
|
2883
2993
|
# 全机唯一蜜蜂:别的服务实例(不同数据目录)已养蜂时
|
|
2884
2994
|
# 不再拉起,否则桌面会出现多只(用户实测踩坑)
|
package/app/pet.py
CHANGED
|
@@ -89,7 +89,7 @@ def parse_snapshot(raw):
|
|
|
89
89
|
# boot:服务进程启动标识(值变化=服务换人,本宠让位给新服务的蜜蜂)
|
|
90
90
|
"boot": raw.get("boot") or None,
|
|
91
91
|
"settings": {
|
|
92
|
-
"pet_enabled": bool(st.get("pet_enabled",
|
|
92
|
+
"pet_enabled": bool(st.get("pet_enabled", False)),
|
|
93
93
|
"pet_mode": str(st.get("pet_mode") or "always"),
|
|
94
94
|
"pet_skin": str(st.get("pet_skin") or DEFAULT_SKIN),
|
|
95
95
|
},
|