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/app/core/store.py CHANGED
@@ -67,14 +67,24 @@ def create_task(payload):
67
67
  type 必须是 flows.py 里的有效流程 ID;流程参数(引擎/维度/阈值/轮数/产出
68
68
  文件/提示词覆盖)在创建时固化到任务上,之后修改流程定义不影响已建任务。
69
69
  """
70
+ if not isinstance(payload, dict):
71
+ raise ValueError("任务参数必须是 JSON 对象")
72
+
73
+ def _text(value, field):
74
+ if value is None:
75
+ return ""
76
+ if not isinstance(value, str):
77
+ raise ValueError("%s 必须是文本" % field)
78
+ return value.strip()
79
+
70
80
  from . import flows as flows_mod
71
81
  flow = flows_mod.get_flow(payload.get("type"))
72
82
  if flow is None:
73
83
  raise ValueError("未知任务类型:%s(可选:%s)"
74
84
  % (payload.get("type"), "、".join(f["id"] for f in flows_mod.list_flows())))
75
- title = (payload.get("title") or "").strip()
76
- goal = (payload.get("goal") or "").strip()
77
- workdir = (payload.get("workdir") or "").strip()
85
+ title = _text(payload.get("title"), "title")
86
+ goal = _text(payload.get("goal"), "goal")
87
+ workdir = _text(payload.get("workdir"), "workdir")
78
88
  if not goal:
79
89
  raise ValueError("目标描述不能为空")
80
90
  title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
@@ -102,7 +112,7 @@ def create_task(payload):
102
112
  task = {
103
113
  "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
104
114
  "title": title, "goal": goal,
105
- "context": (payload.get("context") or "").strip(),
115
+ "context": _text(payload.get("context"), "context"),
106
116
  "workdir": str(wd),
107
117
  "mode": mode,
108
118
  "difficulty": difficulty,
@@ -113,7 +123,7 @@ def create_task(payload):
113
123
  }
114
124
  # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
115
125
  from . import gitmod
116
- git_rev = (payload.get("git_rev") or "").strip()
126
+ git_rev = _text(payload.get("git_rev"), "git_rev")
117
127
  if git_rev:
118
128
  # 前端下拉值带 kind 前缀(branch:main / tag:v1 / commit:abc),此处归一为纯 rev;
119
129
  # git 分支/标签名本身允许含冒号(罕见),前缀剥离只认这三种已知 kind
@@ -122,11 +132,12 @@ def create_task(payload):
122
132
  raise ValueError("非法的代码版本引用:%s" % git_rev[:40])
123
133
  task["git_rev"] = git_rev
124
134
  if flow["engine"] == "code":
125
- task["verify_command"] = (payload.get("verify_command") or "").strip()
135
+ task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
126
136
  elif flow["engine"] == "direct":
127
137
  pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
128
138
  else:
129
- ms = (payload.get("manuscript") or flow.get("manuscript") or "manuscript.md").strip()
139
+ ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
140
+ "manuscript")
130
141
  ms = re.sub(r"[\\/]", "_", ms) # 只允许工作目录内的相对文件名
131
142
  ms = re.sub(r"\.{2,}", "_", ms).lstrip(".") # 顺带清掉残留的 ..
132
143
  task["manuscript"] = ms
@@ -134,6 +145,11 @@ def create_task(payload):
134
145
  task["rounds"] = max(1, min(5, int(payload.get("rounds") or flow.get("rounds") or 2)))
135
146
  except Exception:
136
147
  task["rounds"] = 2
148
+ try:
149
+ # Best-of-N 赛马候选数(非连载单稿;连载走 serial.variants 的同章赛马)
150
+ task["best_of"] = max(1, min(3, int(payload.get("best_of") or flow.get("best_of") or 1)))
151
+ except Exception:
152
+ task["best_of"] = 1
137
153
  try:
138
154
  task["threshold"] = max(1.0, min(10.0,
139
155
  float(payload.get("threshold") or flow.get("threshold") or 7.0)))
@@ -147,8 +163,18 @@ def create_task(payload):
147
163
  for key in ("draft_prompt", "critique_prompt"): # 自定义流程的提示词覆盖
148
164
  if flow.get(key):
149
165
  task[key] = flow[key]
150
- # 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)
151
- serial = payload.get("serial") if isinstance(payload.get("serial"), dict) else flow.get("serial")
166
+ # 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)。
167
+ # payload 中显式传 null 表示关闭流程默认连载;字段缺失才沿用流程默认,
168
+ # 这样前端把章节清空时不会被 serial_novel 的默认值悄悄重新打开。
169
+ serial_unset = object()
170
+ serial_value = payload.get("serial", serial_unset)
171
+ if serial_value is None or (isinstance(serial_value, dict) and
172
+ serial_value.get("enabled") is False):
173
+ serial = None
174
+ elif isinstance(serial_value, dict):
175
+ serial = serial_value
176
+ else:
177
+ serial = flow.get("serial")
152
178
  if isinstance(serial, dict) and serial.get("chapters"):
153
179
  try:
154
180
  s = {
@@ -182,6 +208,15 @@ def create_task(payload):
182
208
  critics = payload.get("critics")
183
209
  if isinstance(critics, list) and critics:
184
210
  task["critics"] = [str(c) for c in critics]
211
+ # 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
212
+ # 只对带连载引擎的任务接收;已有不同内容的圣经拒绝覆盖,避免新任务误伤旧书设定。
213
+ initial_bible = str(payload.get("story_bible") or "").strip()
214
+ if initial_bible:
215
+ if not task.get("serial"):
216
+ raise ValueError("初始故事圣经仅适用于连载小说任务")
217
+ if len(initial_bible) > BIBLE_MAX_CHARS:
218
+ raise ValueError("故事圣经超长(最大 %d 字符,当前 %d 字符)" %
219
+ (BIBLE_MAX_CHARS, len(initial_bible)))
185
220
  resume = payload.get("resume")
186
221
  if isinstance(resume, dict) and resume.get("agent") and resume.get("session"):
187
222
  task["resume"] = {"agent": str(resume["agent"])[:40],
@@ -191,6 +226,11 @@ def create_task(payload):
191
226
  proj = str(resume.get("project") or "")[:260]
192
227
  if proj:
193
228
  task["resume"]["project"] = proj
229
+ # 初始圣经先于附件提交:如果目录已有设定,尽早拒绝,避免附件已移动却
230
+ # 因故事圣经冲突导致任务创建失败。相同内容的重试是幂等的(例如附件
231
+ # 提交中断后重试),不会覆盖已有设定。
232
+ if initial_bible:
233
+ _write_initial_story_bible(str(wd), initial_bible)
194
234
  # 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
195
235
  # 两种形态:字符串 id = 待提交区文件(新建任务);dict 清单 = 已落盘的附件
196
236
  # (继续连载/重试沿用同目录同文件,直接复制清单,不再移文件)。
@@ -388,8 +428,15 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
388
428
  rdir = paths.RUNS_DIR / run["id"]
389
429
  (rdir / "steps").mkdir(parents=True, exist_ok=True)
390
430
  with LOCK:
431
+ # 先完成原子落盘,再发布到内存索引。旧顺序在 _save_json 失败时会
432
+ # 留下只存在于 _RUNS 的“幽灵 run”,后续 UI 看到排队记录却永远无法
433
+ # 读取/恢复其 run.json。
434
+ try:
435
+ _save_json(rdir / "run.json", run)
436
+ except Exception:
437
+ _RUNS.pop(run["id"], None)
438
+ raise
391
439
  _RUNS[run["id"]] = run
392
- _save_json(rdir / "run.json", run)
393
440
  return run
394
441
 
395
442
 
@@ -615,6 +662,39 @@ def recover_orphaned_runs():
615
662
  return recovered
616
663
 
617
664
 
665
+ def recover_interrupted_mgmt():
666
+ """启动时调用:把崩溃遗留的 queued 管理操作 run 标记为 failed。
667
+
668
+ running 的 mgmt run 已由 recover_orphaned_runs 统一收尸;queued 不在
669
+ 其候选里——通用恢复故意留着 queued 给连载任务的 resume_interrupted
670
+ 复活,但 mgmt job 只存在于内存队列,重启后永远没人认领,留着还会
671
+ 堵住同条目的去重闸门。workers 尚未启动,此时 queued 必是遗留。
672
+ """
673
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
674
+ with LOCK:
675
+ candidates = [r["id"] for r in _RUNS.values()
676
+ if r.get("kind") == "mgmt" and r.get("status") == "queued"]
677
+ for run_id in candidates:
678
+ update_run(run_id, status="failed", ended_at=now,
679
+ error="interrupted at startup (mgmt auto-recovered)")
680
+ return len(candidates)
681
+
682
+
683
+ def active_mgmt_run(entry_id):
684
+ """该目录条目当前进行中(queued/running)的管理操作 run;没有则 None。
685
+
686
+ 供「同条目同时只跑一个安装/升级/卸载」去重闸使用:两个同名全局 npm
687
+ 并发装同一包会互锁(2026-09-18 codex 双开案)。内存索引即真源——
688
+ 重启后 recover_* 已把遗留 run 收成终态。
689
+ """
690
+ with LOCK:
691
+ for r in _RUNS.values():
692
+ if r.get("kind") == "mgmt" and r.get("entry_id") == entry_id \
693
+ and r.get("status") in ("queued", "running"):
694
+ return dict(r)
695
+ return None
696
+
697
+
618
698
  def run_workdir(run_id):
619
699
  """该 run 的工作目录(经其 task 关联);无任务的 run(如管理操作)返回空串。"""
620
700
  run = get_run(run_id)
@@ -732,6 +812,52 @@ def _bible_path(workdir):
732
812
  return p
733
813
 
734
814
 
815
+ def _write_initial_story_bible(workdir, text):
816
+ """创建任务时安全播种故事圣经。
817
+
818
+ 初始圣经写入发生在任务入队之前,多个请求可能同时指向同一个工作目录。
819
+ 旧逻辑先在锁外检查、再在锁外写入,两个请求都能通过检查,后写请求会
820
+ 覆盖先写的设定。这里把检查和写入放进同一进程锁,并在文件不存在时用
821
+ ``O_EXCL`` 做最后一道独占创建;已有不同内容的文件始终拒绝覆盖。
822
+ """
823
+ p = _bible_path(workdir)
824
+ if p is None:
825
+ raise ValueError("故事圣经写入失败:工作目录不可用")
826
+ with LOCK:
827
+ try:
828
+ p.parent.mkdir(parents=True, exist_ok=True)
829
+ if p.exists():
830
+ if not p.is_file():
831
+ raise ValueError("故事圣经写入失败:目标路径不是文件")
832
+ existing = runner.read_text_any_enc(p).strip()
833
+ if existing:
834
+ if existing == text:
835
+ return
836
+ raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
837
+ # 空文件是合法的旧占位文件;在锁内覆盖,避免本服务内的并发写入。
838
+ p.write_text(text, encoding="utf-8")
839
+ return
840
+ try:
841
+ fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
842
+ except FileExistsError:
843
+ # 其他进程可能刚创建了文件。相同内容可幂等返回;空文件仍可
844
+ # 完成播种,非空不同内容绝不静默覆盖。
845
+ if p.is_file():
846
+ existing = runner.read_text_any_enc(p).strip()
847
+ if existing == text:
848
+ return
849
+ if not existing:
850
+ p.write_text(text, encoding="utf-8")
851
+ return
852
+ raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
853
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
854
+ fh.write(text)
855
+ except ValueError:
856
+ raise
857
+ except OSError as e:
858
+ raise ValueError("故事圣经写入失败:%s" % e)
859
+
860
+
735
861
  def read_story_bible(task_id):
736
862
  """读取任务工作目录里的故事圣经。返回 (文件路径, 文本内容, None) 或
737
863
  (None, None, 错误信息)。"""
@@ -757,23 +883,26 @@ def write_story_bible(task_id, text):
757
883
  - 文本超长(BIBLE_MAX_CHARS)→ 拒绝;
758
884
  - 写入失败 → 报错。
759
885
  返回 (ok, 错误信息)。"""
760
- task = get_task(task_id)
761
- if not task:
762
- return False, "任务不存在"
763
- if task.get("status") in ("queued", "running"):
764
- return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
765
- p = _bible_path(task.get("workdir"))
766
- if p is None:
767
- return False, "工作目录不存在或路径越界"
768
- text = (text or "").strip()
769
- if len(text) > BIBLE_MAX_CHARS:
770
- return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
771
- try:
772
- p.parent.mkdir(parents=True, exist_ok=True)
773
- p.write_text(text, encoding="utf-8")
774
- return True, ""
775
- except OSError as e:
776
- return False, "写入失败: %s" % e
886
+ with LOCK:
887
+ task = get_task(task_id)
888
+ if not task:
889
+ return False, "任务不存在"
890
+ if task.get("status") in ("queued", "running"):
891
+ return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
892
+ p = _bible_path(task.get("workdir"))
893
+ if p is None:
894
+ return False, "工作目录不存在或路径越界"
895
+ text = (text or "").strip()
896
+ if len(text) > BIBLE_MAX_CHARS:
897
+ return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
898
+ try:
899
+ p.parent.mkdir(parents=True, exist_ok=True)
900
+ p.write_text(text, encoding="utf-8")
901
+ except OSError as e:
902
+ return False, "写入失败: %s" % e
903
+ # 故事圣经是提示词输入,写入后让 SSE/轮询端尽快看到新状态。
904
+ bump_state()
905
+ return True, ""
777
906
 
778
907
 
779
908
  def read_run_file(run_id, rel):
@@ -1144,7 +1273,8 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
1144
1273
 
1145
1274
 
1146
1275
  def finish_step(run_id, n, status, summary="", exit_code=None,
1147
- cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None):
1276
+ cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
1277
+ followups=None):
1148
1278
  with LOCK:
1149
1279
  run = _RUNS.get(run_id)
1150
1280
  if not run:
@@ -1163,6 +1293,8 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
1163
1293
  s["duration_s"] = round(duration_s, 1)
1164
1294
  if output is not None:
1165
1295
  s["output"] = str(output)[:6000]
1296
+ if followups:
1297
+ s["followups"] = list(followups)[:3]
1166
1298
  break
1167
1299
  run["cost_usd"] = round(run.get("cost_usd", 0.0) + cost_usd, 4)
1168
1300
  run["tokens"] = run.get("tokens", 0) + tokens
@@ -0,0 +1,291 @@
1
+ # -*- coding: utf-8 -*-
2
+ """遥测回传(L2)+ 版本 ping(L3)+ 诊断包构建(L1)。
3
+
4
+ 设计原则:
5
+ - 默认只传「匿名错误元数据」:errorlog 台账里的结构化失败记录(厂商/模型/原因码/
6
+ 脱敏摘录/版本/OS),不含任何任务正文、章节内容、密钥、绝对路径;
7
+ - 上传前对每条记录再兜一道 scrub_text(不信任入台账时的第一道);
8
+ - 开关 settings.telemetry_errors(默认开);关掉后什么都不出机器(含版本 ping);
9
+ - 网络失败静默吞掉:遥测永远不影响业务,也不制造新错误;
10
+ - 端点:CloudBase HTTP 函数(国内可达、零运维),可用环境变量
11
+ TUTTI_TELEMETRY_URL 覆盖;端点为空/未配置时整个模块自动休眠。
12
+
13
+ SSRF 防线(端点可被 env 覆盖,所以请求前必须自证安全):
14
+ https 限定 + 域名后缀白名单 + 解析 IP 拒绝私网/环回/链路本地。
15
+
16
+ 线程模型:start_background() 起一条 daemon 线程,首轮延迟 45s(不挡启动),
17
+ 之后每 6 小时一轮(ping 每轮一次,错误按游标增量上传)。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import ipaddress
23
+ import json
24
+ import os
25
+ import platform
26
+ import socket
27
+ import threading
28
+ import time
29
+ import urllib.request
30
+ import zipfile
31
+
32
+ from . import errorlog, paths, settings
33
+
34
+ # 端点:部署后把 CloudBase HTTP 函数 URL 填到这里(见 cloudfunctions/telemetry-collect/)
35
+ ENDPOINT = os.environ.get("TUTTI_TELEMETRY_URL", "").strip()
36
+
37
+ # 请求只允许发往我们自己的收集域(后缀匹配,大小写不敏感);
38
+ # 测试需要假端点时 monkeypatch 本常量或直接 patch _post。
39
+ ALLOWED_SUFFIXES = (".tcloudbase.com", ".tencentyun.com", ".tencentcs.com")
40
+
41
+ FIRST_DELAY_S = 45
42
+ INTERVAL_S = 6 * 3600
43
+ BATCH_LIMIT = 200
44
+ TIMEOUT_S = 10
45
+
46
+ _thread_started = False
47
+
48
+
49
+ def _cursor_path():
50
+ """游标文件路径惰性计算:测试会把 paths.ERRORS_DIR 重定向到临时目录,
51
+ import 期绑定会钉死在真实数据目录(与 settings._FILE 同一个坑)。"""
52
+ return paths.ERRORS_DIR / "upload.cursor"
53
+
54
+
55
+ def enabled():
56
+ try:
57
+ return bool(settings.load().get("telemetry_errors", True))
58
+ except Exception:
59
+ return False
60
+
61
+
62
+ def _os_tag():
63
+ return {"win32": "windows", "darwin": "macos"}.get(platform.system().lower(),
64
+ platform.system().lower())
65
+
66
+
67
+ def _version():
68
+ try:
69
+ from . import selfupdate
70
+ return str(selfupdate.package_version() or "dev")
71
+ except Exception:
72
+ return "dev"
73
+
74
+
75
+ def _endpoint_safe(url):
76
+ """端点自证安全:https + 域名后缀白名单 + 解析 IP 全部公网。
77
+ 不合格返回 ""(调用方静默跳过——遥测绝不为安全边界让步)。"""
78
+ if not url:
79
+ return ""
80
+ try:
81
+ from urllib.parse import urlparse
82
+ p = urlparse(url)
83
+ if p.scheme != "https" or not p.hostname:
84
+ return ""
85
+ host = p.hostname.lower()
86
+ if not any(host == s.lstrip(".") or host.endswith(s)
87
+ for s in ALLOWED_SUFFIXES):
88
+ return ""
89
+ for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP):
90
+ ip = ipaddress.ip_address(str(info[4][0]))
91
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
92
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
93
+ return ""
94
+ return url
95
+ except Exception:
96
+ return ""
97
+
98
+
99
+ def _post(url, payload):
100
+ """POST JSON 到已通过 _endpoint_safe 校验的 URL;返回状态码,异常返回 0。"""
101
+ safe = _endpoint_safe(url)
102
+ if not safe:
103
+ return 0
104
+ try:
105
+ req = urllib.request.Request(
106
+ safe, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
107
+ headers={"Content-Type": "application/json",
108
+ "User-Agent": "CodeBee-Telemetry/1"},
109
+ method="POST")
110
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
111
+ return int(resp.status or 0)
112
+ except Exception:
113
+ return 0
114
+
115
+
116
+ # ---------------------------------------------------------------- 上报循环
117
+
118
+ def _read_cursor():
119
+ try:
120
+ return _cursor_path().read_text(encoding="utf-8").strip()
121
+ except Exception:
122
+ return ""
123
+
124
+
125
+ def _write_cursor(v):
126
+ try:
127
+ p = _cursor_path()
128
+ p.parent.mkdir(parents=True, exist_ok=True)
129
+ p.write_text(str(v), encoding="utf-8")
130
+ except Exception:
131
+ pass
132
+
133
+
134
+ def _sanitize_record(r):
135
+ """上传前的最后一道闸:只挑白名单字段,detail 再过一次 scrub。"""
136
+ keep = ("id", "ts", "day", "category", "reason", "provider", "model",
137
+ "tool", "role", "run_id", "task_id", "step", "exit_code",
138
+ "app_version", "os")
139
+ out = {k: r.get(k) for k in keep if k in r}
140
+ out["detail"] = errorlog.scrub_text(r.get("detail") or "", limit=600)
141
+ return out
142
+
143
+
144
+ def run_once():
145
+ """一轮上报:版本 ping + 增量错误批量。返回 (ping_ok, uploaded_n)。"""
146
+ if not enabled() or not ENDPOINT:
147
+ return (False, 0)
148
+ ping_ok = _post(ENDPOINT, {
149
+ "kind": "ping", "app": "codebee", "version": _version(),
150
+ "os": _os_tag(), "python": "%d.%d" % platform.python_version_tuple()[:2],
151
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
152
+ }) == 200
153
+ uploaded = 0
154
+ cursor = _read_cursor()
155
+ batch, new_cursor = errorlog.pending_since(cursor, limit=BATCH_LIMIT)
156
+ if batch:
157
+ payload = {"kind": "errors", "records": [_sanitize_record(r) for r in batch]}
158
+ if _post(ENDPOINT, payload) == 200:
159
+ _write_cursor(new_cursor)
160
+ uploaded = len(batch)
161
+ return (ping_ok, uploaded)
162
+
163
+
164
+ def _loop():
165
+ while True:
166
+ try:
167
+ run_once()
168
+ except Exception:
169
+ pass
170
+ time.sleep(INTERVAL_S)
171
+
172
+
173
+ def start_background():
174
+ """启动上报线程(daemon,首轮延迟 45s)。重复调用安全;未配端点直接休眠。"""
175
+ global _thread_started
176
+ if _thread_started or not ENDPOINT:
177
+ return
178
+ _thread_started = True
179
+
180
+ def _delayed():
181
+ time.sleep(FIRST_DELAY_S)
182
+ try:
183
+ run_once()
184
+ except Exception:
185
+ pass
186
+ _loop()
187
+
188
+ threading.Thread(target=_delayed, name="telemetry-up", daemon=True).start()
189
+
190
+
191
+ # ---------------------------------------------------------------- 诊断包(L1)
192
+
193
+ def build_bundle_bytes(days=30):
194
+ """构建「诊断包」zip 字节流:脱敏错误台账 + 用量台账 + 环境元信息。
195
+
196
+ 只装可安全外发的聚合数据——绝不打包 tasks/runs/models.json(后者含密钥)。
197
+ 错误记录出包前再 scrub 一遍(与上传同一道闸,出机器的东西不过两道不放行)。
198
+ """
199
+ meta = {
200
+ "app": "CodeBee",
201
+ "version": _version(),
202
+ "os": _os_tag(),
203
+ "os_version": platform.platform(),
204
+ "python": platform.python_version(),
205
+ "telemetry_enabled": enabled(),
206
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
207
+ "note": "此包只含匿名错误元数据与用量统计;已剥离密钥、路径与任务内容。",
208
+ }
209
+ errors = [_sanitize_record(r) for r in errorlog.iter_records(days)]
210
+ buf = io.BytesIO()
211
+ stamp = time.strftime("%Y%m%d-%H%M%S")
212
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
213
+ z.writestr("codebee-diag-%s/meta.json" % stamp,
214
+ json.dumps(meta, ensure_ascii=False, indent=2))
215
+ z.writestr("codebee-diag-%s/errors-%ddays.jsonl" % (stamp, days),
216
+ "\n".join(json.dumps(r, ensure_ascii=False) for r in errors))
217
+ usage_lines = []
218
+ try:
219
+ from . import usage
220
+ for r in usage._iter_records(days):
221
+ usage_lines.append(json.dumps(r, ensure_ascii=False))
222
+ except Exception:
223
+ pass
224
+ z.writestr("codebee-diag-%s/usage-%ddays.jsonl" % (stamp, days),
225
+ "\n".join(usage_lines))
226
+ return buf.getvalue()
227
+
228
+
229
+ # ---------------------------------------------------------------- 一键反馈 Issue(免费通道)
230
+
231
+ # 反馈目标仓库(Issue 预填正文,由用户在浏览器亲手提交——不自动回传)
232
+ ISSUE_URL = "https://github.com/Vercel-By-WXP/CodeBee/issues/new"
233
+
234
+
235
+ def issue_report(days=30, max_groups=8, max_recent=10, detail_chars=120):
236
+ """本地错误台账 → GitHub Issue 预填摘要。返回 {title, body}。
237
+
238
+ 聚合 + 明细全部来自已脱敏的 errorlog 台账,出正文前再 scrub 一道
239
+ (与上传/诊断包同一纪律:出机器的东西过两道不放行)。明细压成单行,
240
+ 方便 URL 预填。days=0 表示全部历史。
241
+ """
242
+ errors = errorlog.iter_records(days)
243
+ groups = {}
244
+ for r in errors:
245
+ key = (str(r.get("reason") or "UNKNOWN"),
246
+ str(r.get("provider") or "-"),
247
+ str(r.get("model") or "-"))
248
+ g = groups.setdefault(key, {"n": 0, "last": ""})
249
+ g["n"] += 1
250
+ if str(r.get("ts") or "") > g["last"]:
251
+ g["last"] = str(r.get("ts") or "")
252
+ top = sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:max_groups]
253
+ version = _version()
254
+
255
+ if top:
256
+ t_reason, t_prov, t_model = top[0][0]
257
+ title = "错误反馈:%s ×%d(v%s / %s)" % (
258
+ t_reason, top[0][1]["n"], version, _os_tag())
259
+ else:
260
+ title = "CodeBee 错误反馈(无自动记录的错误)"
261
+
262
+ lines = [
263
+ "## CodeBee 错误反馈", "",
264
+ "- 版本:v%s(%s)" % (version, _os_tag()),
265
+ "- Python:%s" % platform.python_version(),
266
+ "- 生成时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"),
267
+ "",
268
+ ("近 %d 天失败聚合(原因 × 次数)" % days) if days
269
+ else "全部历史失败聚合(原因 × 次数)",
270
+ ]
271
+ if top:
272
+ lines.append("| 原因 | 供应商/模型 | 次数 | 最近一次 |")
273
+ lines.append("|---|---|---|---|")
274
+ for (reason, prov, model), g in top:
275
+ lines.append("| %s | %s/%s | %d | %s |" % (reason, prov, model,
276
+ g["n"], g["last"]))
277
+ else:
278
+ lines.append("(本机错误台账为空)")
279
+ lines += ["", "## 最近失败明细(最多 %d 条,已脱敏)" % max_recent]
280
+ if errors:
281
+ for r in errors[-max_recent:]:
282
+ detail = errorlog.scrub_text(r.get("detail") or "", limit=detail_chars)
283
+ detail = detail.replace("\r", " ").replace("\n", " ")
284
+ lines.append("- `%s` %s [%s/%s] %s" % (
285
+ str(r.get("ts") or ""), str(r.get("reason") or "UNKNOWN"),
286
+ str(r.get("provider") or "-"), str(r.get("model") or "-"), detail))
287
+ else:
288
+ lines.append("(无)")
289
+ lines += ["", "> 提示:可在 设置 → 关于与更新 → 导出诊断包 生成 zip 附在本 Issue"
290
+ "(含更完整的脱敏错误记录与用量统计)。", ""]
291
+ return {"title": title, "body": "\n".join(lines)}
@@ -121,6 +121,24 @@ class TokenMeter:
121
121
  cap = cap_table.get(m, cap_table.get("", 128_000))
122
122
  return self.used(run_id) / max(cap, 1)
123
123
 
124
+ def capacity(self, model: str = "") -> int:
125
+ """模型上下文容量(未命中用默认行)。"""
126
+ cap_table = self._capacity()
127
+ return cap_table.get(model or "", cap_table.get("", 128_000))
128
+
129
+ def last_context(self, run_id: str) -> int:
130
+ """最近一次调用的 input+output+reasoning——下个请求要重发的上下文锚点。
131
+
132
+ used() 是窗口累计(多次调用叠加,系统性偏大,适合做响应式预算闸);
133
+ 事前预检判断「这个请求会不会被拒」要用最近一次的真实上下文体量,
134
+ 两者口径不同不可混用(借鉴 freebuff base-chat 每步容量重估)。"""
135
+ with self._lock:
136
+ window = self._windows.get(run_id)
137
+ if not window:
138
+ return 0
139
+ _, i, o, r, _ = window[-1]
140
+ return i + o + r
141
+
124
142
  def reset(self, run_id: str):
125
143
  with self._lock:
126
144
  self._windows.pop(run_id, None)
package/app/core/usage.py CHANGED
@@ -214,7 +214,10 @@ def agent_tokens_recent(agent, hours=1):
214
214
  if str(r.get("ts") or "") < bound:
215
215
  continue
216
216
  a = str(r.get("agent") or "")
217
- total[a] = total.get(a, 0) + max(0, _parse_int((r.get("usage") or {}).get("total")))
217
+ # record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
218
+ # 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
219
+ # 用量为 0,超过供应商额度也不会降权。
220
+ total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
218
221
  _HOURLY_CACHE.update(ts=now, val=total)
219
222
  return int(_HOURLY_CACHE["val"].get(agent) or 0)
220
223
  except Exception:
@@ -448,3 +451,50 @@ def summary(days=30, recent_limit=30):
448
451
  "by_task_type": _dim_rows(records, "task_type"),
449
452
  "recent": recent,
450
453
  }
454
+
455
+
456
+ def estimate(task_type="", days=90):
457
+ """同类任务开跑前成本预估(借鉴 omnigent 的 pre-run estimate;数据源就是本台账)。
458
+
459
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
460
+ task_type 为空=全类型。无样本时 samples=0,前端不展示。"""
461
+ span = max(1, min(3650, int(days or 90)))
462
+ with LOCK:
463
+ records = _iter_records(span)
464
+ runs = {}
465
+ for r in records:
466
+ tt = str(r.get("task_type") or "unknown")
467
+ if task_type and tt != task_type:
468
+ continue
469
+ rid = str(r.get("run_id") or "")
470
+ if not rid:
471
+ continue
472
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0, "ok": False})
473
+ g["tokens"] += _num(r, "total")
474
+ g["cost"] += _parse_float(r.get("cost_usd"))
475
+ if r.get("ok"):
476
+ g["ok"] = True
477
+ ok_runs = [g for g in runs.values() if g["ok"]]
478
+ basis = ok_runs or list(runs.values())
479
+ if not basis:
480
+ return {"task_type": task_type or "", "days": span, "samples": 0}
481
+ toks = sorted(g["tokens"] for g in basis)
482
+ costs = sorted(g["cost"] for g in basis)
483
+ n = len(toks)
484
+ mid = n // 2
485
+ med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
486
+ med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
487
+ import datetime
488
+ import math
489
+ return {
490
+ "task_type": task_type or "",
491
+ "days": span,
492
+ "samples": n,
493
+ "ok_samples": len(ok_runs),
494
+ "avg_tokens": int(sum(toks) / n),
495
+ "median_tokens": int(med),
496
+ "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
497
+ "avg_cost_usd": round(sum(costs) / n, 4),
498
+ "median_cost_usd": round(med_c, 4),
499
+ "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
500
+ }