codebee 0.1.65 → 0.1.66

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 (110) hide show
  1. package/CHANGELOG.md +520 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +796 -759
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4494
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +119 -81
  62. package/app/core/runner.py +2050 -2042
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,65 +1,220 @@
1
- # -*- coding: utf-8 -*-
2
- """错误码体系:把 runner 失败归一成可枚举的 ErrorCode。
3
-
4
- 设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
5
- 参考 dsh docs/subsystems/workflow.zh.md:108-120 WorkflowErrorCode。
6
- """
7
- from __future__ import annotations
8
-
9
- from enum import Enum
10
-
11
-
12
- class ErrorCode(str, Enum):
13
- """所有 runner 失败的可枚举分类。
14
-
15
- 字段值是 JSON 友好的字符串,方便持久化到 store / 跨进程传输。
16
- """
17
-
18
- # === fatal:脚本/参数错,必须人工修 ===
19
- VENDOR_ERROR = "VENDOR_ERROR" # vendor 内部崩溃(exit != 0)
20
- VENDOR_REFUSAL = "VENDOR_REFUSAL" # 明确拒绝(claude is_error / qwen refusal)
21
- PARSE_FAIL = "PARSE_FAIL" # 输出无法解析为预期 schema
22
- ENV_BLOCK = "ENV_BLOCK" # approval 拒 / 策略阻断
23
- SIGNAL_IGNORED = "SIGNAL_IGNORED" # 发了 SIGTERM/任务杀不掉
24
-
25
- # === item:可重试 ===
26
- TIMEOUT = "TIMEOUT"
27
- CANCELLED = "CANCELLED"
28
- MAX_TOKENS = "MAX_TOKENS" # 模型撑爆
29
- EMPTY = "EMPTY" # 空输出
30
- NETWORK = "NETWORK" # 连接失败
31
- CONTEXT_OVERFLOW = "CONTEXT_OVERFLOW" # 触发压缩(与 MAX_TOKENS 不同)
32
-
33
- # === ok ===
34
- # "" 表示成功,不设枚举值
35
-
36
-
37
- # 哪些码视为 fatal(job 终止,不进入重试队列)
38
- FATAL_CODES = frozenset({
39
- ErrorCode.VENDOR_ERROR,
40
- ErrorCode.VENDOR_REFUSAL,
41
- ErrorCode.PARSE_FAIL,
42
- ErrorCode.ENV_BLOCK,
43
- ErrorCode.SIGNAL_IGNORED,
44
- })
45
-
46
-
47
- def is_fatal(code) -> bool:
48
- """code 可以是 ErrorCode 或字符串(如来自 dict.get('error_code'))。"""
49
- if not code:
50
- return False
51
- try:
52
- return ErrorCode(code) in FATAL_CODES
53
- except ValueError:
54
- return False
55
-
56
-
57
- def is_retryable(code) -> bool:
58
- """item 类(可重试)。"""
59
- if not code:
60
- return False
61
- try:
62
- ec = ErrorCode(code)
63
- return ec not in FATAL_CODES and ec != ErrorCode.CANCELLED
64
- except ValueError:
65
- return False
1
+ # -*- coding: utf-8 -*-
2
+ """错误码体系:把 runner 失败归一成可枚举的 ErrorCode。
3
+
4
+ 设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
5
+ 参考 dsh docs/subsystems/workflow.zh.md:108-120 WorkflowErrorCode。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+ import re
11
+
12
+
13
+ class ErrorCode(str, Enum):
14
+ """所有 runner 失败的可枚举分类。
15
+
16
+ 字段值是 JSON 友好的字符串,方便持久化到 store / 跨进程传输。
17
+ """
18
+
19
+ # === fatal:脚本/参数错,必须人工修 ===
20
+ VENDOR_ERROR = "VENDOR_ERROR" # vendor 内部崩溃(exit != 0)
21
+ VENDOR_REFUSAL = "VENDOR_REFUSAL" # 明确拒绝(claude is_error / qwen refusal)
22
+ PARSE_FAIL = "PARSE_FAIL" # 输出无法解析为预期 schema
23
+ ENV_BLOCK = "ENV_BLOCK" # approval 拒 / 策略阻断
24
+ SIGNAL_IGNORED = "SIGNAL_IGNORED" # 发了 SIGTERM/任务杀不掉
25
+
26
+ # === item:可重试 ===
27
+ TIMEOUT = "TIMEOUT"
28
+ CANCELLED = "CANCELLED"
29
+ MAX_TOKENS = "MAX_TOKENS" # 模型撑爆
30
+ EMPTY = "EMPTY" # 空输出
31
+ NETWORK = "NETWORK" # 连接失败
32
+ UPSTREAM_SERVER = "UPSTREAM_SERVER" # 上游 5xx / 服务端异常
33
+ MISSING_CREDENTIAL = "MISSING_CREDENTIAL" # 当前路由没有可用 API key
34
+ AUTH = "AUTH" # 凭据认证失败(401)
35
+ FORBIDDEN = "FORBIDDEN" # 上游拒绝当前账号/模型/请求(403)
36
+ QUOTA = "QUOTA" # 欠费、余额或调用额度耗尽
37
+ RATE_LIMIT = "RATE_LIMIT" # 短时速率/并发限制(429)
38
+ LOCAL_STATE = "LOCAL_STATE" # 本地 CLI 状态/锁冲突
39
+ CONTEXT_OVERFLOW = "CONTEXT_OVERFLOW" # 触发压缩(与 MAX_TOKENS 不同)
40
+
41
+ # === ok ===
42
+ # "" 表示成功,不设枚举值
43
+
44
+
45
+ # 哪些码视为 fatal(job 终止,不进入重试队列)
46
+ FATAL_CODES = frozenset({
47
+ ErrorCode.VENDOR_ERROR,
48
+ ErrorCode.VENDOR_REFUSAL,
49
+ ErrorCode.PARSE_FAIL,
50
+ ErrorCode.ENV_BLOCK,
51
+ ErrorCode.SIGNAL_IGNORED,
52
+ })
53
+
54
+
55
+ _MISSING_CREDENTIAL = (
56
+ "no api key", "missing api key", "api key not found", "api key is required",
57
+ "missing environment variable", "missing env var",
58
+ )
59
+ _AUTH = (
60
+ "invalid api key", "invalid_api_key", "api key is invalid", "incorrect api key",
61
+ "api key not valid", "api key 不可用", "鉴权失败", "认证失败",
62
+ "unauthorized", "unauthorised", "authentication failed", "authentication error",
63
+ "authentication_error", "invalid token", "invalid_token", "http 401",
64
+ "status code 401",
65
+ )
66
+ _FORBIDDEN = ("forbidden", "request not allowed", "permission denied", "access denied")
67
+ _RATE_LIMIT = ("rate limit", "rate_limit", "too many requests", "http 429", "status code 429",
68
+ "concurrent limit", "concurrency limit", "too many concurrent",
69
+ "并发限制", "并发超限", "限流")
70
+ _QUOTA = ("insufficient balance", "insufficient quota", "insufficient credit",
71
+ "quota", "balance is insufficient", "credit balance", "billing", "arrears",
72
+ "payment required", "http 402", "status code 402", "欠费", "余额", "额度",
73
+ "usage limit", "limit exceeded", "quota exhausted")
74
+ _SERVER = ("unexpected server error", "internal server error", "server error",
75
+ "overloaded", "temporarily", "unavailable", "service unavailable",
76
+ "internal error", "no available channel", "unknown model")
77
+ _NETWORK = ("stream disconnected", "stream closed", "connection reset", "connection aborted",
78
+ "broken pipe", "socket hang up", "econnrefused", "enotfound", "getaddrinfo",
79
+ "name resolution", "network error", "network is unreachable", "reqwest error stream",
80
+ "error sending request for url", "no route to host", "connection error",
81
+ "reset by peer", "channel is closed", "name or service not known",
82
+ "输出停滞")
83
+ _LOCAL_STATE = ("database is locked", "database locked", "sqlite busy",
84
+ "unable to open database file")
85
+ _TRANSIENT_HINTS = ("stream closed before response.completed", "output stall", "timed out",
86
+ "timeout", "output stalled", "ENOTFOUND", "Reconnecting...",
87
+ "initialize", "[1211]")
88
+ _REFUSAL = ("can't help with this", "couldn't help with this", '"stop_reason":"refusal"',
89
+ "content policy", "content filtering")
90
+
91
+
92
+ def error_code_value(code):
93
+ """Return the stable wire value for ErrorCode or a string-like code."""
94
+ if isinstance(code, ErrorCode):
95
+ return code.value
96
+ return str(code or "")
97
+
98
+
99
+ def classify_error_text(error):
100
+ """Map a vendor/CLI error message to the shared failure taxonomy.
101
+
102
+ Return ``None`` when text does not provide a reliable signal. Cancellation and
103
+ timeout flags still take precedence in the caller because text may be noisy.
104
+ More specific credential/permission/quota signals are checked before generic
105
+ server and transport wording.
106
+ """
107
+ text = str(error or "").lower()
108
+ if not text:
109
+ return None
110
+ if re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)401\b", text):
111
+ return ErrorCode.AUTH
112
+ # An explicit HTTP 403 is authoritative even when the provider describes the
113
+ # permission problem with credential wording (for example, "invalid API key
114
+ # for this model"). Do not cool down the key as an authentication failure.
115
+ if (re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*|"
116
+ r"code\s*[:=]?\s*)403\b", text)
117
+ or re.search(r"(?:^|:\s*)403(?=$|[\s:{])", text)):
118
+ return ErrorCode.FORBIDDEN
119
+ if any(marker in text for marker in _MISSING_CREDENTIAL):
120
+ return ErrorCode.MISSING_CREDENTIAL
121
+ if (any(marker in text for marker in _AUTH)
122
+ or re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)401\b", text)):
123
+ return ErrorCode.AUTH
124
+ if any(marker in text for marker in _FORBIDDEN):
125
+ return ErrorCode.FORBIDDEN
126
+ if (re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)429\b", text)
127
+ or any(marker in text for marker in _RATE_LIMIT)):
128
+ return ErrorCode.RATE_LIMIT
129
+ if (re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)402\b", text)
130
+ or any(marker in text for marker in _QUOTA)):
131
+ return ErrorCode.QUOTA
132
+ if any(marker in text for marker in _LOCAL_STATE):
133
+ return ErrorCode.LOCAL_STATE
134
+ if (re.search(r"(?<!\d)5\d\d(?!\d)", text)
135
+ or any(marker in text for marker in _SERVER)):
136
+ return ErrorCode.UPSTREAM_SERVER
137
+ if ("timed out" in text or "timeout" in text or "deadline exceeded" in text
138
+ or re.search(r"(?<!\d)408(?!\d)", text)):
139
+ return ErrorCode.TIMEOUT
140
+ if any(marker in text for marker in _NETWORK):
141
+ return ErrorCode.NETWORK
142
+ if any(marker in text for marker in _REFUSAL):
143
+ return ErrorCode.VENDOR_REFUSAL
144
+ return None
145
+
146
+
147
+ def is_auth_error(error):
148
+ return classify_error_text(error) == ErrorCode.AUTH
149
+
150
+
151
+ def is_forbidden_error(error):
152
+ return classify_error_text(error) == ErrorCode.FORBIDDEN
153
+
154
+
155
+ def is_quota_error(error):
156
+ return classify_error_text(error) in (ErrorCode.QUOTA, ErrorCode.RATE_LIMIT)
157
+
158
+
159
+ def is_transient_error(error):
160
+ text = str(error or "").lower()
161
+ return classify_error_text(text) in (
162
+ ErrorCode.NETWORK, ErrorCode.UPSTREAM_SERVER, ErrorCode.TIMEOUT,
163
+ ErrorCode.QUOTA, ErrorCode.RATE_LIMIT, ErrorCode.LOCAL_STATE,
164
+ ) or any(marker.lower() in text for marker in _TRANSIENT_HINTS)
165
+
166
+
167
+ def is_rate_limited_error(error):
168
+ return classify_error_text(error) == ErrorCode.RATE_LIMIT
169
+
170
+
171
+ _SAFE_ERROR_SUMMARIES = {
172
+ ErrorCode.VENDOR_ERROR: "供应商或 CLI 调用失败(未识别具体原因)",
173
+ ErrorCode.VENDOR_REFUSAL: "上游拒绝了本次请求",
174
+ ErrorCode.PARSE_FAIL: "模型响应无法解析",
175
+ ErrorCode.ENV_BLOCK: "本机环境或执行策略阻断",
176
+ ErrorCode.SIGNAL_IGNORED: "子进程未响应终止信号",
177
+ ErrorCode.TIMEOUT: "调用超时",
178
+ ErrorCode.CANCELLED: "调用已取消",
179
+ ErrorCode.MAX_TOKENS: "模型输出达到 token 上限",
180
+ ErrorCode.EMPTY: "模型未返回内容",
181
+ ErrorCode.NETWORK: "网络连接或数据流中断",
182
+ ErrorCode.UPSTREAM_SERVER: "上游服务端异常",
183
+ ErrorCode.MISSING_CREDENTIAL: "所选路由未找到可用 API 凭据",
184
+ ErrorCode.AUTH: "API 凭据认证失败",
185
+ ErrorCode.FORBIDDEN: "上游拒绝账号、模型或请求权限",
186
+ ErrorCode.QUOTA: "上游余额或调用额度不足",
187
+ ErrorCode.RATE_LIMIT: "上游触发速率或并发限制",
188
+ ErrorCode.LOCAL_STATE: "CLI 本地状态或数据库锁冲突",
189
+ ErrorCode.CONTEXT_OVERFLOW: "输入上下文超出模型容量",
190
+ }
191
+
192
+
193
+ def safe_error_summary(code):
194
+ """A content-free summary suitable for durable diagnostics and telemetry."""
195
+ try:
196
+ value = ErrorCode(code)
197
+ except (ValueError, TypeError):
198
+ value = ErrorCode.VENDOR_ERROR
199
+ return _SAFE_ERROR_SUMMARIES.get(value, _SAFE_ERROR_SUMMARIES[ErrorCode.VENDOR_ERROR])
200
+
201
+
202
+ def is_fatal(code) -> bool:
203
+ """code 可以是 ErrorCode 或字符串(如来自 dict.get('error_code'))。"""
204
+ if not code:
205
+ return False
206
+ try:
207
+ return ErrorCode(code) in FATAL_CODES
208
+ except ValueError:
209
+ return False
210
+
211
+
212
+ def is_retryable(code) -> bool:
213
+ """item 类(可重试)。"""
214
+ if not code:
215
+ return False
216
+ try:
217
+ ec = ErrorCode(code)
218
+ return ec not in FATAL_CODES and ec != ErrorCode.CANCELLED
219
+ except ValueError:
220
+ return False
@@ -1,137 +1,137 @@
1
- # -*- coding: utf-8 -*-
2
- """错误台账:把分散在各处的失败(步骤失败/超时/看门狗击杀、CLI 启动失败)
3
- 统一落成结构化记录——「客户遇到了问题我们根本不知道」的第一块拼图。
4
-
5
- 落盘:data/errors/errors-YYYYMM.jsonl(按月分文件,append-only,一行一记录)。
6
- 与 usage.py 同一套纪律:
7
- - record() 任何异常都吞掉——埋点失败绝不能影响任务执行;
8
- - 落盘前先脱敏(scrub_text):密钥/令牌/绝对路径/超长文本一概不进台账。
9
-
10
- 隐私红线(上传侧在 telemetry.py 再兜一道底):
11
- 1. API KEY / 凭据绝不入台账;
12
- 2. 任务正文 / 章节内容(用户版权内容)绝不入台账——detail 只允许失败原因摘录;
13
- 3. 绝对路径剥成相对片段。
14
- """
15
- from __future__ import annotations
16
-
17
- import json
18
- import threading
19
- import time
20
- import uuid
21
-
22
- from . import paths
23
- from .redact import scrub_text
24
-
25
- LOCK = threading.RLock()
26
-
27
- FIELDS = ("id", "ts", "day", "category", "reason", "detail", "provider",
28
- "model", "tool", "role", "run_id", "task_id", "step",
29
- "exit_code", "app_version", "os")
30
-
31
- # detail 摘录上限(字符):失败原因足够,不要变成日志搬运
32
- DETAIL_LIMIT = 600
33
-
34
-
35
- # ---------------------------------------------------------------- 脱敏
36
-
37
- def _coerce_str(v, limit):
38
- return str(v or "")[:limit]
39
-
40
-
41
- def _os_tag():
42
- import sys
43
- return {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
44
-
45
-
46
- def _version():
47
- try:
48
- from . import selfupdate
49
- return str(selfupdate.package_version() or "dev")
50
- except Exception:
51
- return "dev"
52
-
53
-
54
- def record(category="", reason="", detail="", provider="", model="", tool="",
55
- role="", run_id="", task_id="", step=0, exit_code=None, source=""):
56
- """追加一条错误记录。缺省字段留空;任何异常都吞掉(埋点不拖垮业务)。
57
-
58
- category: step / run / launch / service(枚举放宽,未知值原样入库)
59
- reason: 失败原因码(error_codes.ErrorCode 的字符串值优先,如 TIMEOUT)
60
- detail: 失败摘录(内部先过 scrub_text,绝不存原文)
61
- """
62
- try:
63
- rec = {
64
- "id": "%s-%s" % (time.strftime("%Y-%m-%d %H:%M:%S"),
65
- uuid.uuid4().hex[:8]),
66
- "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
67
- "day": time.strftime("%Y-%m-%d"),
68
- "category": _coerce_str(category, 24) or "step",
69
- "reason": _coerce_str(reason, 40) or "UNKNOWN",
70
- "detail": scrub_text(detail),
71
- "provider": _coerce_str(provider, 60),
72
- "model": _coerce_str(model, 80),
73
- "tool": _coerce_str(tool, 24),
74
- "role": _coerce_str(role, 40),
75
- "run_id": _coerce_str(run_id, 64),
76
- "task_id": _coerce_str(task_id, 64),
77
- "step": int(step or 0),
78
- "exit_code": exit_code,
79
- "app_version": _version()[:24],
80
- "os": _os_tag(),
81
- }
82
- line = json.dumps(rec, ensure_ascii=False)
83
- day = rec["day"]
84
- with LOCK:
85
- paths.ERRORS_DIR.mkdir(parents=True, exist_ok=True)
86
- with open(paths.ERRORS_DIR / ("errors-%s.jsonl" % day[:7].replace("-", "")),
87
- "a", encoding="utf-8") as f:
88
- f.write(line + "\n")
89
- except Exception:
90
- pass
91
-
92
-
93
- def iter_records(days=0):
94
- """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
95
- out = []
96
- try:
97
- files = sorted(paths.ERRORS_DIR.glob("errors-*.jsonl")) if paths.ERRORS_DIR.is_dir() else []
98
- except Exception:
99
- return out
100
- since_day = ""
101
- if days:
102
- import datetime
103
- since_day = (datetime.date.today()
104
- - datetime.timedelta(days=int(days) - 1)).isoformat()
105
- for p in files:
106
- try:
107
- for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
108
- line = line.strip()
109
- if not line.startswith("{"):
110
- continue
111
- try:
112
- r = json.loads(line)
113
- except Exception:
114
- continue
115
- if not isinstance(r, dict):
116
- continue
117
- if since_day and str(r.get("day", "")) < since_day:
118
- continue
119
- out.append(r)
120
- except Exception:
121
- continue
122
- return out
123
-
124
-
125
- def pending_since(cursor, limit=200):
126
- """取 cursor(上次上传到的记录 id)之后的记录,最多 limit 条。
127
-
128
- id 以时间开头、uuid 尾巴保证唯一且字典序≈时间序;返回 (records, new_cursor)。
129
- """
130
- try:
131
- recs = [r for r in iter_records(0) if str(r.get("id") or "") > str(cursor or "")]
132
- recs.sort(key=lambda r: str(r.get("id") or ""))
133
- batch = recs[:max(1, int(limit))]
134
- new_cursor = str(batch[-1].get("id")) if batch else str(cursor or "")
135
- return batch, new_cursor
136
- except Exception:
137
- return [], str(cursor or "")
1
+ # -*- coding: utf-8 -*-
2
+ """错误台账:把分散在各处的失败(步骤失败/超时/看门狗击杀、CLI 启动失败)
3
+ 统一落成结构化记录——「客户遇到了问题我们根本不知道」的第一块拼图。
4
+
5
+ 落盘:data/errors/errors-YYYYMM.jsonl(按月分文件,append-only,一行一记录)。
6
+ 与 usage.py 同一套纪律:
7
+ - record() 任何异常都吞掉——埋点失败绝不能影响任务执行;
8
+ - 落盘前先脱敏(scrub_text):密钥/令牌/绝对路径/超长文本一概不进台账。
9
+
10
+ 隐私红线(上传侧在 telemetry.py 再兜一道底):
11
+ 1. API KEY / 凭据绝不入台账;
12
+ 2. 任务正文 / 章节内容(用户版权内容)绝不入台账——detail 只允许失败原因摘录;
13
+ 3. 绝对路径剥成相对片段。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import threading
19
+ import time
20
+ import uuid
21
+
22
+ from . import paths
23
+ from .redact import scrub_text
24
+
25
+ LOCK = threading.RLock()
26
+
27
+ FIELDS = ("id", "ts", "day", "category", "reason", "detail", "provider",
28
+ "model", "tool", "role", "run_id", "task_id", "step",
29
+ "exit_code", "app_version", "os")
30
+
31
+ # detail 摘录上限(字符):失败原因足够,不要变成日志搬运
32
+ DETAIL_LIMIT = 600
33
+
34
+
35
+ # ---------------------------------------------------------------- 脱敏
36
+
37
+ def _coerce_str(v, limit):
38
+ return str(v or "")[:limit]
39
+
40
+
41
+ def _os_tag():
42
+ import sys
43
+ return {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
44
+
45
+
46
+ def _version():
47
+ try:
48
+ from . import selfupdate
49
+ return str(selfupdate.package_version() or "dev")
50
+ except Exception:
51
+ return "dev"
52
+
53
+
54
+ def record(category="", reason="", detail="", provider="", model="", tool="",
55
+ role="", run_id="", task_id="", step=0, exit_code=None, source=""):
56
+ """追加一条错误记录。缺省字段留空;任何异常都吞掉(埋点不拖垮业务)。
57
+
58
+ category: step / run / launch / service(枚举放宽,未知值原样入库)
59
+ reason: 失败原因码(error_codes.ErrorCode 的字符串值优先,如 TIMEOUT)
60
+ detail: 失败摘录(内部先过 scrub_text,绝不存原文)
61
+ """
62
+ try:
63
+ rec = {
64
+ "id": "%s-%s" % (time.strftime("%Y-%m-%d %H:%M:%S"),
65
+ uuid.uuid4().hex[:8]),
66
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
67
+ "day": time.strftime("%Y-%m-%d"),
68
+ "category": _coerce_str(category, 24) or "step",
69
+ "reason": _coerce_str(reason, 40) or "UNKNOWN",
70
+ "detail": scrub_text(detail),
71
+ "provider": _coerce_str(provider, 60),
72
+ "model": _coerce_str(model, 80),
73
+ "tool": _coerce_str(tool, 24),
74
+ "role": _coerce_str(role, 40),
75
+ "run_id": _coerce_str(run_id, 64),
76
+ "task_id": _coerce_str(task_id, 64),
77
+ "step": int(step or 0),
78
+ "exit_code": exit_code,
79
+ "app_version": _version()[:24],
80
+ "os": _os_tag(),
81
+ }
82
+ line = json.dumps(rec, ensure_ascii=False)
83
+ day = rec["day"]
84
+ with LOCK:
85
+ paths.ERRORS_DIR.mkdir(parents=True, exist_ok=True)
86
+ with open(paths.ERRORS_DIR / ("errors-%s.jsonl" % day[:7].replace("-", "")),
87
+ "a", encoding="utf-8") as f:
88
+ f.write(line + "\n")
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ def iter_records(days=0):
94
+ """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
95
+ out = []
96
+ try:
97
+ files = sorted(paths.ERRORS_DIR.glob("errors-*.jsonl")) if paths.ERRORS_DIR.is_dir() else []
98
+ except Exception:
99
+ return out
100
+ since_day = ""
101
+ if days:
102
+ import datetime
103
+ since_day = (datetime.date.today()
104
+ - datetime.timedelta(days=int(days) - 1)).isoformat()
105
+ for p in files:
106
+ try:
107
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
108
+ line = line.strip()
109
+ if not line.startswith("{"):
110
+ continue
111
+ try:
112
+ r = json.loads(line)
113
+ except Exception:
114
+ continue
115
+ if not isinstance(r, dict):
116
+ continue
117
+ if since_day and str(r.get("day", "")) < since_day:
118
+ continue
119
+ out.append(r)
120
+ except Exception:
121
+ continue
122
+ return out
123
+
124
+
125
+ def pending_since(cursor, limit=200):
126
+ """取 cursor(上次上传到的记录 id)之后的记录,最多 limit 条。
127
+
128
+ id 以时间开头、uuid 尾巴保证唯一且字典序≈时间序;返回 (records, new_cursor)。
129
+ """
130
+ try:
131
+ recs = [r for r in iter_records(0) if str(r.get("id") or "") > str(cursor or "")]
132
+ recs.sort(key=lambda r: str(r.get("id") or ""))
133
+ batch = recs[:max(1, int(limit))]
134
+ new_cursor = str(batch[-1].get("id")) if batch else str(cursor or "")
135
+ return batch, new_cursor
136
+ except Exception:
137
+ return [], str(cursor or "")