codebee 0.1.95 → 0.1.97

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,384 +1,384 @@
1
- # -*- coding: utf-8 -*-
2
- """Tutti 自更新:版本读取 / 新版本检测 / 一键升级 / 就地重启。
3
-
4
- 安装模式判定(mode):
5
- npm — 运行副本位于 node_modules 下且带 package.json(npm 全局安装的包),
6
- 可查新、可升级、可重启;
7
- repo — 带 .git 的开发仓库:**永不自动升级**(会覆盖开发中的代码),提示走 git pull;
8
- other — 裸源码拷贝,提示手动替换。
9
-
10
- 升级 = 在标准 mgmt run 里跑 `npm install -g codebee@latest`(日志实时落盘、
11
- SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进内存不受影响,装完后由
12
- 自动重启换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双
13
- LISTEN 同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
14
-
15
- 安全:两处子进程命令的 argv 均为**行内字面量列表**(可执行文件与全部参数不来自任何
16
- 外部输入;重启仅透传 argparse 校验过的整型端口),shell 全程 False,绝不拼接用户输入。
17
- 改发布名时(见 test_selfupdate 与 package.json 的一致性断言)同步改 _PKG_NAME。
18
- """
19
- from __future__ import annotations
20
-
21
- import json
22
- import logging
23
- import os
24
- import re
25
- import socket
26
- import subprocess
27
- import sys
28
- import threading
29
- import time
30
- from pathlib import Path
31
-
32
- from . import paths, runner
33
-
34
- log = logging.getLogger(__name__)
35
-
36
- _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
37
- _UPDATE_TTL = 600 # 查新结果缓存(秒)
38
- _LOCK = threading.Lock()
39
- _CHECK_CACHE = {"ts": 0.0, "result": None}
40
-
41
-
42
- def package_version():
43
- """读本包 package.json 的 version;读不到返回 ''(开发仓库未同步版本号时)。"""
44
- try:
45
- pj = (paths.ROOT / "package.json").resolve()
46
- return str(json.loads(pj.read_text(encoding="utf-8")).get("version") or "")
47
- except Exception:
48
- return ""
49
-
50
-
51
- def install_mode():
52
- """npm / repo / source / other(见模块 docstring)。"""
53
- try:
54
- parts = [p.lower() for p in paths.ROOT.resolve().parts]
55
- except Exception:
56
- return "other"
57
- has_pkg = (paths.ROOT / "package.json").is_file()
58
- if "node_modules" in parts and has_pkg:
59
- return "npm"
60
- if (paths.ROOT / ".git").exists():
61
- return "repo"
62
- return "source" if has_pkg else "other"
63
-
64
-
65
- def _ver_tuple(s):
66
- return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
67
-
68
-
69
- _RELNOTES_RE = re.compile(
70
- r"<!--\s*relnotes:start\s*-->(.*?)<!--\s*relnotes:end\s*-->", re.S)
71
-
72
-
73
- def _relnotes(readme):
74
- """README 里 relnotes 标记段的内容(发布前手工更新,见 CHANGELOG.md 头部说明)。"""
75
- m = _RELNOTES_RE.search(str(readme or ""))
76
- return m.group(1).strip() if m else ""
77
-
78
-
79
- def _npm_argv(*args):
80
- """npm 命令 argv:Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑。"""
81
- if os.name == "nt":
82
- return ["cmd", "/c", "npm"] + list(args)
83
- return ["npm"] + list(args)
84
-
85
-
86
- def _npm_meta():
87
- """npm view <pkg> --json;返回 (latest, relnotes, err)。
88
-
89
- relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
90
- GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
91
- readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
92
- r = runner.run_process(
93
- argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
94
- if not r["ok"]:
95
- return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
96
- ver, notes = "", ""
97
- try:
98
- meta = json.loads(r["stdout"] or "{}")
99
- if isinstance(meta, dict):
100
- ver = str(meta.get("version") or "")
101
- notes = _relnotes(meta.get("readme") or "")
102
- except Exception:
103
- pass
104
- if not ver: # 旧 npm --json 失败时退回纯文本解析
105
- m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
106
- ver = m.group(0) if m else ""
107
- if ver and not notes:
108
- r2 = runner.run_process(
109
- argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
110
- if r2["ok"]:
111
- notes = _relnotes(r2["stdout"] or "")
112
- return ver, notes, ("" if ver else "npm 输出无法解析")
113
-
114
-
115
- def changelog_section(version):
116
- """本地 CHANGELOG.md 中 version 对应小节(升级重启后「本次更新内容」用)。"""
117
- v = re.escape(str(version or "").lstrip("v"))
118
- m = re.search(r"(?ms)^## v?%s\b[^\n]*$(.*?)(?=^## |\Z)" % v,
119
- _read_changelog())
120
- if not m:
121
- return ""
122
- body = m.group(0).strip()
123
- return body
124
-
125
-
126
- def _read_changelog():
127
- try:
128
- return (paths.ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
129
- except Exception:
130
- return ""
131
-
132
-
133
- def check(force=False):
134
- """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note,
135
- notes, whatsnew}。notes=远端新版本的更新内容(npm readme relnotes 段);
136
- whatsnew=当前版本在本地 CHANGELOG.md 的小节(升级重启后弹「本次更新内容」)。"""
137
- mode = install_mode()
138
- cur = package_version()
139
- out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
140
- "note": "", "notes": "", "whatsnew": changelog_section(cur)}
141
- if mode != "npm":
142
- out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
143
- if mode == "repo" else "非 npm 安装,无法自动更新")
144
- return out
145
- with _LOCK:
146
- if not force and _CHECK_CACHE["result"] and \
147
- time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
148
- return dict(_CHECK_CACHE["result"])
149
- latest, notes, err = _npm_meta()
150
- out["latest"] = latest
151
- out["notes"] = notes
152
- if err:
153
- out["note"] = "查询新版本失败:" + err
154
- elif latest and cur:
155
- out["has_update"] = _ver_tuple(latest) > _ver_tuple(cur)
156
- else:
157
- out["note"] = "无法比较版本(本地或 registry 版本号缺失)"
158
- with _LOCK:
159
- _CHECK_CACHE["ts"] = time.time()
160
- _CHECK_CACHE["result"] = dict(out)
161
- return out
162
-
163
-
164
- _PENDING_PORT = None # apply_upgrade 记下的服务端口,升级成功后自动重启用
165
-
166
-
167
- def apply_upgrade(port=None):
168
- """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。
169
-
170
- port=服务端口:升级成功且版本真变时会自动就地重启(用户拍板 2026-09-21:
171
- 升级完不该再要求手动点「重启服务生效」——旧进程滞留是 unknown api/界面
172
- 闪烁/老宠物一类「升级了没生效」事故的总根子)。拿不到端口或运行环境不
173
- 具备时才跳过自动重启。"""
174
- global _PENDING_PORT
175
- try:
176
- _PENDING_PORT = int(port) if port else None
177
- except (TypeError, ValueError):
178
- _PENDING_PORT = None
179
- if install_mode() != "npm":
180
- return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
181
- from . import store, jobs
182
- capacity = jobs.capacity_status()
183
- if not capacity["accepting"]:
184
- if capacity["restarting"]:
185
- return {"error": "服务正在完成升级重启,请稍后再试"}
186
- return {"error": "当前有 %d 个任务运行,已达并发保护上限(%d);"
187
- "升级未启动且不会排队,请在任务结束后重试"
188
- % (capacity["active"], capacity["limit"])}
189
- try:
190
- run = store.create_run(
191
- "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
192
- entry_id="__self__", op="selfupgrade")
193
- except Exception:
194
- log.exception("selfupdate: 创建升级运行记录失败")
195
- return {"error": "升级任务创建失败,请稍后重试"}
196
- try:
197
- jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
198
- except Exception as exc:
199
- # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
200
- if isinstance(exc, jobs.JobsBusyError):
201
- message = "升级未启动:%s" % str(exc)
202
- log.warning("selfupdate: 升级任务因并发满载未启动 run=%s: %s",
203
- run["id"], exc)
204
- else:
205
- log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
206
- # 仅并发保护类错误可直接展示;其他内部异常继续隐藏实现细节。
207
- message = "升级任务启动失败,本次未排队,请稍后重试"
208
- try:
209
- store.update_run(run["id"], status="failed",
210
- error=message,
211
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
212
- except Exception:
213
- log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
214
- return {"error": message, "run_id": run["id"]}
215
- return {"run_id": run["id"]}
216
-
217
-
218
- _LOCKED_RE = re.compile(r"\b(EBUSY|EPERM)\b")
219
- _RETRY_DELAYS = (5, 15) # 目录被占用时自动重试前的等待秒数(暂时性占用多在此窗口内释放)
220
-
221
-
222
- def _locked_error(res):
223
- """npm 失败输出是否为「包目录被占用」类(EBUSY/EPERM)——值得等一等重试。"""
224
- blob = ((res or {}).get("stderr") or "") + ((res or {}).get("stdout") or "")
225
- return bool(_LOCKED_RE.search(blob[-4000:]))
226
-
227
-
228
- def _log_note(log_path, text):
229
- """向步骤日志追加一行进度说明(run_process 以 append 模式写同一文件)。"""
230
- if not log_path:
231
- return
232
- try:
233
- with open(log_path, "ab") as fh:
234
- fh.write(("\n===== %s =====\n" % text).encode("utf-8", "replace"))
235
- except Exception:
236
- pass
237
-
238
-
239
- def _maybe_auto_relaunch(old_pkg, log_path):
240
- """升级成功后的自动重启:
241
- ①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
242
- ③等当前用户任务自然结束后再原子停止接单——正在干活的任务不能被升级
243
- 重启打断,也不再要求用户找到一个可能不存在的手动重启按钮。"""
244
- import threading
245
- from . import jobs
246
- if not _PENDING_PORT:
247
- _log_note(log_path, "未记录服务端口,跳过自动重启——请在版本页手动重启生效")
248
- return
249
- new_pkg = package_version()
250
- if not old_pkg or new_pkg == old_pkg:
251
- _log_note(log_path, "版本未变化(%s),无需重启" % (new_pkg or "?"))
252
- return
253
- port = _PENDING_PORT
254
-
255
- def _go():
256
- drain_started = False
257
- wait_noted = False
258
- try:
259
- time.sleep(3.0) # 留出日志收尾/浏览器看到「升级完成」的窗口
260
- while not drain_started:
261
- if (not wait_noted and
262
- (getattr(jobs, "_alive", 0) > 1 or
263
- getattr(jobs, "_chat_alive", 0) > 0)):
264
- _log_note(log_path, "等待当前任务完成后自动重启服务…")
265
- wait_noted = True
266
- drain_started = jobs.begin_restart_drain()
267
- if not drain_started:
268
- jobs.wait_for_idle(timeout=1.0)
269
- _log_note(log_path, "自动重启服务以应用新版本 %s …" % new_pkg)
270
- if relaunch(port):
271
- self_quit()
272
- except Exception:
273
- log.exception("selfupdate: 自动重启失败")
274
- finally:
275
- # 正常 self_quit 会直接结束进程;若拉起失败、异常或测试替身返回,必须
276
- # 释放停止接单闸,避免当前实例永久拒绝新任务。
277
- if drain_started:
278
- jobs.cancel_restart_drain()
279
- threading.Thread(target=_go, name="selfupdate-relaunch",
280
- daemon=True).start()
281
-
282
-
283
- def run_upgrade(run_id, log_path, cancel_event=None):
284
- """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
285
-
286
- 包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
287
- 杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
288
- _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。
289
- 成功且版本真变时自动重启服务(_maybe_auto_relaunch,守卫见其 docstring)。"""
290
- old_pkg = package_version()
291
- res = {}
292
- for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
293
- if cancel_event is not None and cancel_event.is_set():
294
- return {"ok": False, "exit_code": None, "error": "用户主动取消",
295
- "cancelled": True}
296
- if delay:
297
- _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
298
- % (delay, attempt, len(_RETRY_DELAYS)))
299
- if cancel_event is not None and cancel_event.wait(delay):
300
- return {"ok": False, "exit_code": None, "error": "用户主动取消",
301
- "cancelled": True}
302
- if cancel_event is None:
303
- time.sleep(delay)
304
- res = runner.run_process(
305
- argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
306
- # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
307
- # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
308
- cwd=str(Path.home()), timeout=900, log_path=log_path,
309
- cancel_event=cancel_event)
310
- if res.get("cancelled"):
311
- return {"ok": False, "exit_code": res.get("exit_code"),
312
- "error": "用户主动取消", "cancelled": True}
313
- if res["ok"] or not _locked_error(res):
314
- break
315
- if res["ok"]:
316
- with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
317
- _CHECK_CACHE["result"] = None
318
- _maybe_auto_relaunch(old_pkg, log_path)
319
- return {"ok": True, "exit_code": res["exit_code"], "error": ""}
320
- stderr = res["stderr"] or ""
321
- if _locked_error(res):
322
- err = ("升级失败:codebee 安装目录被其他程序占用(已自动重试 %d 次未恢复)。"
323
- "常见占用:打开包目录的资源管理器窗口/终端、杀毒或索引扫描。"
324
- "请关闭相关窗口后回版本页重试;仍不行可退出 CodeBee 后手动执行 "
325
- "npm install -g %s@latest。" % (len(_RETRY_DELAYS), _PKG_NAME))
326
- else:
327
- # npm 的进度条/颜色转义与中文 Windows 的 GBK 输出都进过这里,先洗再用
328
- err = runner.clean_cli_text(stderr)[-800:] or "退出码 %s" % res["exit_code"]
329
- return {"ok": False, "exit_code": res["exit_code"], "error": err}
330
-
331
-
332
- def _port_free(port):
333
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
334
- try:
335
- s.settimeout(0.3)
336
- return s.connect_ex(("127.0.0.1", int(port))) != 0
337
- except Exception:
338
- return True
339
- finally:
340
- s.close()
341
-
342
-
343
- def wait_port_before_bind(port):
344
- """新进程入口(--wait-port):轮询直到旧实例释放端口(最多 ~30 秒)。
345
- 超时也放行——旧进程若没退,bind 失败自会报错,不会出现双实例串流。"""
346
- for _ in range(60):
347
- if _port_free(port):
348
- return
349
- time.sleep(0.5)
350
-
351
-
352
- def relaunch(port):
353
- """就地重启:拉起新实例(--wait-port 等新端口可 bind 时旧实例已自退)。
354
- 调用方发送完重启响应后应 self_quit()。port 经 int() 强校验,argv 全字面量。"""
355
- port = int(port)
356
- if port < 1 or port > 65535:
357
- return False
358
- from .manager import tmp_data_no_home_write
359
- if tmp_data_no_home_write():
360
- # 测试实例防毒闸的延伸:TUTTI_DATA 在临时目录的进程绝不拉起真实
361
- # 端口服务——relaunch 的目标端口往往是 8765(真实服务所在),
362
- # 新实例启动清场会把生产服务当「自家旧实例」杀掉(2026-09-23 实案)
363
- log.warning("relaunch 拒绝:测试实例(TUTTI_DATA 在临时目录)"
364
- "不得拉起真实端口服务")
365
- return False
366
- subprocess.Popen(
367
- [sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
368
- "--wait-port", "--no-browser"],
369
- # 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
370
- cwd=str(Path.home()),
371
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
372
- close_fds=True,
373
- creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
374
- return True
375
-
376
-
377
- def self_quit():
378
- """旧进程体面自退(HTTP 响应已发出后调用)。"""
379
- try:
380
- from . import remote
381
- remote.stop_quick_tunnel()
382
- except Exception:
383
- pass
384
- os._exit(0)
1
+ # -*- coding: utf-8 -*-
2
+ """Tutti 自更新:版本读取 / 新版本检测 / 一键升级 / 就地重启。
3
+
4
+ 安装模式判定(mode):
5
+ npm — 运行副本位于 node_modules 下且带 package.json(npm 全局安装的包),
6
+ 可查新、可升级、可重启;
7
+ repo — 带 .git 的开发仓库:**永不自动升级**(会覆盖开发中的代码),提示走 git pull;
8
+ other — 裸源码拷贝,提示手动替换。
9
+
10
+ 升级 = 在标准 mgmt run 里跑 `npm install -g codebee@latest`(日志实时落盘、
11
+ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进内存不受影响,装完后由
12
+ 自动重启换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双
13
+ LISTEN 同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
14
+
15
+ 安全:两处子进程命令的 argv 均为**行内字面量列表**(可执行文件与全部参数不来自任何
16
+ 外部输入;重启仅透传 argparse 校验过的整型端口),shell 全程 False,绝不拼接用户输入。
17
+ 改发布名时(见 test_selfupdate 与 package.json 的一致性断言)同步改 _PKG_NAME。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ import re
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ import time
30
+ from pathlib import Path
31
+
32
+ from . import paths, runner
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
37
+ _UPDATE_TTL = 600 # 查新结果缓存(秒)
38
+ _LOCK = threading.Lock()
39
+ _CHECK_CACHE = {"ts": 0.0, "result": None}
40
+
41
+
42
+ def package_version():
43
+ """读本包 package.json 的 version;读不到返回 ''(开发仓库未同步版本号时)。"""
44
+ try:
45
+ pj = (paths.ROOT / "package.json").resolve()
46
+ return str(json.loads(pj.read_text(encoding="utf-8")).get("version") or "")
47
+ except Exception:
48
+ return ""
49
+
50
+
51
+ def install_mode():
52
+ """npm / repo / source / other(见模块 docstring)。"""
53
+ try:
54
+ parts = [p.lower() for p in paths.ROOT.resolve().parts]
55
+ except Exception:
56
+ return "other"
57
+ has_pkg = (paths.ROOT / "package.json").is_file()
58
+ if "node_modules" in parts and has_pkg:
59
+ return "npm"
60
+ if (paths.ROOT / ".git").exists():
61
+ return "repo"
62
+ return "source" if has_pkg else "other"
63
+
64
+
65
+ def _ver_tuple(s):
66
+ return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
67
+
68
+
69
+ _RELNOTES_RE = re.compile(
70
+ r"<!--\s*relnotes:start\s*-->(.*?)<!--\s*relnotes:end\s*-->", re.S)
71
+
72
+
73
+ def _relnotes(readme):
74
+ """README 里 relnotes 标记段的内容(发布前手工更新,见 CHANGELOG.md 头部说明)。"""
75
+ m = _RELNOTES_RE.search(str(readme or ""))
76
+ return m.group(1).strip() if m else ""
77
+
78
+
79
+ def _npm_argv(*args):
80
+ """npm 命令 argv:Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑。"""
81
+ if os.name == "nt":
82
+ return ["cmd", "/c", "npm"] + list(args)
83
+ return ["npm"] + list(args)
84
+
85
+
86
+ def _npm_meta():
87
+ """npm view <pkg> --json;返回 (latest, relnotes, err)。
88
+
89
+ relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
90
+ GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
91
+ readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
92
+ r = runner.run_process(
93
+ argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
94
+ if not r["ok"]:
95
+ return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
96
+ ver, notes = "", ""
97
+ try:
98
+ meta = json.loads(r["stdout"] or "{}")
99
+ if isinstance(meta, dict):
100
+ ver = str(meta.get("version") or "")
101
+ notes = _relnotes(meta.get("readme") or "")
102
+ except Exception:
103
+ pass
104
+ if not ver: # 旧 npm --json 失败时退回纯文本解析
105
+ m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
106
+ ver = m.group(0) if m else ""
107
+ if ver and not notes:
108
+ r2 = runner.run_process(
109
+ argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
110
+ if r2["ok"]:
111
+ notes = _relnotes(r2["stdout"] or "")
112
+ return ver, notes, ("" if ver else "npm 输出无法解析")
113
+
114
+
115
+ def changelog_section(version):
116
+ """本地 CHANGELOG.md 中 version 对应小节(升级重启后「本次更新内容」用)。"""
117
+ v = re.escape(str(version or "").lstrip("v"))
118
+ m = re.search(r"(?ms)^## v?%s\b[^\n]*$(.*?)(?=^## |\Z)" % v,
119
+ _read_changelog())
120
+ if not m:
121
+ return ""
122
+ body = m.group(0).strip()
123
+ return body
124
+
125
+
126
+ def _read_changelog():
127
+ try:
128
+ return (paths.ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
129
+ except Exception:
130
+ return ""
131
+
132
+
133
+ def check(force=False):
134
+ """GET /api/selfupdate 载荷:{mode, current, latest, has_update, note,
135
+ notes, whatsnew}。notes=远端新版本的更新内容(npm readme relnotes 段);
136
+ whatsnew=当前版本在本地 CHANGELOG.md 的小节(升级重启后弹「本次更新内容」)。"""
137
+ mode = install_mode()
138
+ cur = package_version()
139
+ out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
140
+ "note": "", "notes": "", "whatsnew": changelog_section(cur)}
141
+ if mode != "npm":
142
+ out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
143
+ if mode == "repo" else "非 npm 安装,无法自动更新")
144
+ return out
145
+ with _LOCK:
146
+ if not force and _CHECK_CACHE["result"] and \
147
+ time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
148
+ return dict(_CHECK_CACHE["result"])
149
+ latest, notes, err = _npm_meta()
150
+ out["latest"] = latest
151
+ out["notes"] = notes
152
+ if err:
153
+ out["note"] = "查询新版本失败:" + err
154
+ elif latest and cur:
155
+ out["has_update"] = _ver_tuple(latest) > _ver_tuple(cur)
156
+ else:
157
+ out["note"] = "无法比较版本(本地或 registry 版本号缺失)"
158
+ with _LOCK:
159
+ _CHECK_CACHE["ts"] = time.time()
160
+ _CHECK_CACHE["result"] = dict(out)
161
+ return out
162
+
163
+
164
+ _PENDING_PORT = None # apply_upgrade 记下的服务端口,升级成功后自动重启用
165
+
166
+
167
+ def apply_upgrade(port=None):
168
+ """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。
169
+
170
+ port=服务端口:升级成功且版本真变时会自动就地重启(用户拍板 2026-09-21:
171
+ 升级完不该再要求手动点「重启服务生效」——旧进程滞留是 unknown api/界面
172
+ 闪烁/老宠物一类「升级了没生效」事故的总根子)。拿不到端口或运行环境不
173
+ 具备时才跳过自动重启。"""
174
+ global _PENDING_PORT
175
+ try:
176
+ _PENDING_PORT = int(port) if port else None
177
+ except (TypeError, ValueError):
178
+ _PENDING_PORT = None
179
+ if install_mode() != "npm":
180
+ return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
181
+ from . import store, jobs
182
+ capacity = jobs.capacity_status()
183
+ if not capacity["accepting"]:
184
+ if capacity["restarting"]:
185
+ return {"error": "服务正在完成升级重启,请稍后再试"}
186
+ return {"error": "当前有 %d 个任务运行,已达并发保护上限(%d);"
187
+ "升级未启动且不会排队,请在任务结束后重试"
188
+ % (capacity["active"], capacity["limit"])}
189
+ try:
190
+ run = store.create_run(
191
+ "mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
192
+ entry_id="__self__", op="selfupgrade")
193
+ except Exception:
194
+ log.exception("selfupdate: 创建升级运行记录失败")
195
+ return {"error": "升级任务创建失败,请稍后重试"}
196
+ try:
197
+ jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
198
+ except Exception as exc:
199
+ # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
200
+ if isinstance(exc, jobs.JobsBusyError):
201
+ message = "升级未启动:%s" % str(exc)
202
+ log.warning("selfupdate: 升级任务因并发满载未启动 run=%s: %s",
203
+ run["id"], exc)
204
+ else:
205
+ log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
206
+ # 仅并发保护类错误可直接展示;其他内部异常继续隐藏实现细节。
207
+ message = "升级任务启动失败,本次未排队,请稍后重试"
208
+ try:
209
+ store.update_run(run["id"], status="failed",
210
+ error=message,
211
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
212
+ except Exception:
213
+ log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
214
+ return {"error": message, "run_id": run["id"]}
215
+ return {"run_id": run["id"]}
216
+
217
+
218
+ _LOCKED_RE = re.compile(r"\b(EBUSY|EPERM)\b")
219
+ _RETRY_DELAYS = (5, 15) # 目录被占用时自动重试前的等待秒数(暂时性占用多在此窗口内释放)
220
+
221
+
222
+ def _locked_error(res):
223
+ """npm 失败输出是否为「包目录被占用」类(EBUSY/EPERM)——值得等一等重试。"""
224
+ blob = ((res or {}).get("stderr") or "") + ((res or {}).get("stdout") or "")
225
+ return bool(_LOCKED_RE.search(blob[-4000:]))
226
+
227
+
228
+ def _log_note(log_path, text):
229
+ """向步骤日志追加一行进度说明(run_process 以 append 模式写同一文件)。"""
230
+ if not log_path:
231
+ return
232
+ try:
233
+ with open(log_path, "ab") as fh:
234
+ fh.write(("\n===== %s =====\n" % text).encode("utf-8", "replace"))
235
+ except Exception:
236
+ pass
237
+
238
+
239
+ def _maybe_auto_relaunch(old_pkg, log_path):
240
+ """升级成功后的自动重启:
241
+ ①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
242
+ ③等当前用户任务自然结束后再原子停止接单——正在干活的任务不能被升级
243
+ 重启打断,也不再要求用户找到一个可能不存在的手动重启按钮。"""
244
+ import threading
245
+ from . import jobs
246
+ if not _PENDING_PORT:
247
+ _log_note(log_path, "未记录服务端口,跳过自动重启——请在版本页手动重启生效")
248
+ return
249
+ new_pkg = package_version()
250
+ if not old_pkg or new_pkg == old_pkg:
251
+ _log_note(log_path, "版本未变化(%s),无需重启" % (new_pkg or "?"))
252
+ return
253
+ port = _PENDING_PORT
254
+
255
+ def _go():
256
+ drain_started = False
257
+ wait_noted = False
258
+ try:
259
+ time.sleep(3.0) # 留出日志收尾/浏览器看到「升级完成」的窗口
260
+ while not drain_started:
261
+ if (not wait_noted and
262
+ (getattr(jobs, "_alive", 0) > 1 or
263
+ getattr(jobs, "_chat_alive", 0) > 0)):
264
+ _log_note(log_path, "等待当前任务完成后自动重启服务…")
265
+ wait_noted = True
266
+ drain_started = jobs.begin_restart_drain()
267
+ if not drain_started:
268
+ jobs.wait_for_idle(timeout=1.0)
269
+ _log_note(log_path, "自动重启服务以应用新版本 %s …" % new_pkg)
270
+ if relaunch(port):
271
+ self_quit()
272
+ except Exception:
273
+ log.exception("selfupdate: 自动重启失败")
274
+ finally:
275
+ # 正常 self_quit 会直接结束进程;若拉起失败、异常或测试替身返回,必须
276
+ # 释放停止接单闸,避免当前实例永久拒绝新任务。
277
+ if drain_started:
278
+ jobs.cancel_restart_drain()
279
+ threading.Thread(target=_go, name="selfupdate-relaunch",
280
+ daemon=True).start()
281
+
282
+
283
+ def run_upgrade(run_id, log_path, cancel_event=None):
284
+ """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
285
+
286
+ 包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
287
+ 杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
288
+ _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。
289
+ 成功且版本真变时自动重启服务(_maybe_auto_relaunch,守卫见其 docstring)。"""
290
+ old_pkg = package_version()
291
+ res = {}
292
+ for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
293
+ if cancel_event is not None and cancel_event.is_set():
294
+ return {"ok": False, "exit_code": None, "error": "用户主动取消",
295
+ "cancelled": True}
296
+ if delay:
297
+ _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
298
+ % (delay, attempt, len(_RETRY_DELAYS)))
299
+ if cancel_event is not None and cancel_event.wait(delay):
300
+ return {"ok": False, "exit_code": None, "error": "用户主动取消",
301
+ "cancelled": True}
302
+ if cancel_event is None:
303
+ time.sleep(delay)
304
+ res = runner.run_process(
305
+ argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
306
+ # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
307
+ # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
308
+ cwd=str(Path.home()), timeout=900, log_path=log_path,
309
+ cancel_event=cancel_event)
310
+ if res.get("cancelled"):
311
+ return {"ok": False, "exit_code": res.get("exit_code"),
312
+ "error": "用户主动取消", "cancelled": True}
313
+ if res["ok"] or not _locked_error(res):
314
+ break
315
+ if res["ok"]:
316
+ with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
317
+ _CHECK_CACHE["result"] = None
318
+ _maybe_auto_relaunch(old_pkg, log_path)
319
+ return {"ok": True, "exit_code": res["exit_code"], "error": ""}
320
+ stderr = res["stderr"] or ""
321
+ if _locked_error(res):
322
+ err = ("升级失败:codebee 安装目录被其他程序占用(已自动重试 %d 次未恢复)。"
323
+ "常见占用:打开包目录的资源管理器窗口/终端、杀毒或索引扫描。"
324
+ "请关闭相关窗口后回版本页重试;仍不行可退出 CodeBee 后手动执行 "
325
+ "npm install -g %s@latest。" % (len(_RETRY_DELAYS), _PKG_NAME))
326
+ else:
327
+ # npm 的进度条/颜色转义与中文 Windows 的 GBK 输出都进过这里,先洗再用
328
+ err = runner.clean_cli_text(stderr)[-800:] or "退出码 %s" % res["exit_code"]
329
+ return {"ok": False, "exit_code": res["exit_code"], "error": err}
330
+
331
+
332
+ def _port_free(port):
333
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
334
+ try:
335
+ s.settimeout(0.3)
336
+ return s.connect_ex(("127.0.0.1", int(port))) != 0
337
+ except Exception:
338
+ return True
339
+ finally:
340
+ s.close()
341
+
342
+
343
+ def wait_port_before_bind(port):
344
+ """新进程入口(--wait-port):轮询直到旧实例释放端口(最多 ~30 秒)。
345
+ 超时也放行——旧进程若没退,bind 失败自会报错,不会出现双实例串流。"""
346
+ for _ in range(60):
347
+ if _port_free(port):
348
+ return
349
+ time.sleep(0.5)
350
+
351
+
352
+ def relaunch(port):
353
+ """就地重启:拉起新实例(--wait-port 等新端口可 bind 时旧实例已自退)。
354
+ 调用方发送完重启响应后应 self_quit()。port 经 int() 强校验,argv 全字面量。"""
355
+ port = int(port)
356
+ if port < 1 or port > 65535:
357
+ return False
358
+ from .manager import tmp_data_no_home_write
359
+ if tmp_data_no_home_write():
360
+ # 测试实例防毒闸的延伸:TUTTI_DATA 在临时目录的进程绝不拉起真实
361
+ # 端口服务——relaunch 的目标端口往往是 8765(真实服务所在),
362
+ # 新实例启动清场会把生产服务当「自家旧实例」杀掉(2026-09-23 实案)
363
+ log.warning("relaunch 拒绝:测试实例(TUTTI_DATA 在临时目录)"
364
+ "不得拉起真实端口服务")
365
+ return False
366
+ subprocess.Popen(
367
+ [sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
368
+ "--wait-port", "--no-browser"],
369
+ # 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
370
+ cwd=str(Path.home()),
371
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
372
+ close_fds=True,
373
+ creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
374
+ return True
375
+
376
+
377
+ def self_quit():
378
+ """旧进程体面自退(HTTP 响应已发出后调用)。"""
379
+ try:
380
+ from . import remote
381
+ remote.stop_quick_tunnel()
382
+ except Exception:
383
+ pass
384
+ os._exit(0)