codebee 0.1.95 → 0.1.96

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 (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -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 +209 -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 +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -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/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,303 +1,303 @@
1
- # -*- coding: utf-8 -*-
2
- """每日垃圾清理:只清 CodeBee 自己产生的可再生数据,绝不碰用户成果。
3
-
4
- 垃圾来源(实测 data/ 923MB 构成):
5
- - runs/<id>/steps/*.log 运行过程日志,单文件可达 22MB(保留 run.json
6
- 与 report.md,运行记录/结果页签不受影响)
7
- - publish/shots/ 发布过程截图证据
8
- - data 根 *.bak* 配置改写工具留下的备份残留
9
- - pending/ 运行中指挥信箱的过期附件
10
- - publish/profiles/ 浏览器 profile 迁移残留(新版已搬家目录,
11
- 旧位置 688MB 实测;仅当家目录同款平台目录
12
- 存在——即迁移已完成——才按废弃清理)
13
- - exports/ imports/ 备份模块自己的历史产物
14
- - data 根 *.log 服务日志按体量截尾,不是按天删
15
-
16
- 调度挂在 automation._tick(fire_due 自节流:启用 + 当天没清过才真跑)。
17
- 配置在 data/settings.json(cleanup_enabled / cleanup_retention_days),
18
- 状态在 data/cleanup.json(last_run/last_freed/history 供设置页展示)。
19
-
20
- 发布浏览器 profile(家目录 ~/.codebee/publish_profiles,约 1GB)含平台
21
- 登录态,清了要重新扫码——绝不进每日自动清理,只留手动入口
22
- (run_cleanup(include_profiles=True))。
23
- """
24
- from __future__ import annotations
25
-
26
- import json
27
- import os
28
- import time
29
- from pathlib import Path
30
-
31
- from . import paths, settings as settings_mod
32
-
33
- DEFAULT_RETENTION_DAYS = 14
34
- LOG_MAX_BYTES = 5 * 1024 * 1024 # 服务日志超过这个体量才截
35
- LOG_KEEP_BYTES = 1 * 1024 * 1024 # 截尾保留的末段
36
- STATE_NAME = "cleanup.json"
37
-
38
-
39
- # ---------------------------------------------------------------- 状态
40
-
41
- def _state_path():
42
- return Path(paths.DATA_DIR) / STATE_NAME
43
-
44
-
45
- def _load_state():
46
- try:
47
- d = json.loads(_state_path().read_text(encoding="utf-8"))
48
- return d if isinstance(d, dict) else {}
49
- except Exception:
50
- return {}
51
-
52
-
53
- def _save_state(st):
54
- p = _state_path()
55
- p.parent.mkdir(parents=True, exist_ok=True)
56
- tmp = p.with_suffix(".tmp")
57
- tmp.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
58
- os.replace(str(tmp), str(p))
59
-
60
-
61
- def _profiles_base(home=None):
62
- """家目录浏览器 profile 根(与 publish/manager 同款落点)。home 供测试注入。"""
63
- if home:
64
- return Path(home) / ".codebee" / "publish_profiles"
65
- return Path.home() / ".codebee" / "publish_profiles"
66
-
67
-
68
- def _parse_local_dt(s):
69
- try:
70
- return time.mktime(time.strptime(str(s)[:19], "%Y-%m-%d %H:%M:%S"))
71
- except Exception:
72
- return None
73
-
74
-
75
- # ---------------------------------------------------------------- 扫描
76
-
77
- def _run_ended(run_dir, cutoff_ts):
78
- """run 目录整体是否过期:看 run.json 的 ended_at;没有就退回目录 mtime。"""
79
- rj = run_dir / "run.json"
80
- if rj.is_file():
81
- ts = None
82
- try:
83
- ts = _parse_local_dt(json.loads(rj.read_text(encoding="utf-8"))
84
- .get("ended_at"))
85
- except Exception:
86
- ts = None
87
- if ts:
88
- return ts < cutoff_ts
89
- try:
90
- return run_dir.stat().st_mtime < cutoff_ts
91
- except OSError:
92
- return False
93
-
94
-
95
- def _plan_items(retention_days=None, include_profiles=False, home=None, now=None):
96
- """扫描各类垃圾,返回条目列表。每条:key/label/note/bytes/count/files,
97
- files 元素为 {path, op},op ∈ delete | rmtree | truncate。"""
98
- now = now or time.time()
99
- try:
100
- days = int(retention_days if retention_days is not None
101
- else settings_mod.load().get("cleanup_retention_days"))
102
- except (TypeError, ValueError):
103
- days = DEFAULT_RETENTION_DAYS
104
- days = max(1, min(365, days))
105
- cutoff = now - days * 86400
106
- data_dir = Path(paths.DATA_DIR)
107
- items = []
108
-
109
- def add(key, label, note, files):
110
- size = 0
111
- good = []
112
- for f in files:
113
- try:
114
- p = Path(f["path"])
115
- if f["op"] == "truncate":
116
- sz = p.stat().st_size - LOG_KEEP_BYTES
117
- elif p.is_dir():
118
- sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
119
- else:
120
- sz = p.stat().st_size
121
- except OSError:
122
- continue
123
- # truncate 只在有得砍时才留;delete/rmtree 即使 0 字节(空目录)也要清
124
- if sz > 0 or f["op"] != "truncate":
125
- good.append(f)
126
- size += max(0, sz)
127
- if good:
128
- items.append({"key": key, "label": label, "note": note,
129
- "bytes": size, "count": len(good), "files": good})
130
-
131
- # 1) 过期运行的过程日志(保留 run.json / report.md / 封面)
132
- runs_dir = data_dir / "runs"
133
- files = []
134
- if runs_dir.is_dir():
135
- for rd in runs_dir.iterdir():
136
- if not rd.is_dir() or not _run_ended(rd, cutoff):
137
- continue
138
- steps = rd / "steps"
139
- if steps.is_dir():
140
- files.append({"path": str(steps), "op": "rmtree"})
141
- add("runs_old_logs", "运行过程日志",
142
- "保留运行记录与结果报告,只删过程中的原始日志", files)
143
-
144
- # 2) 发布截图
145
- shots = data_dir / "publish" / "shots"
146
- add("publish_shots", "发布截图", "发布过程留档截图,超期即清",
147
- [{"path": str(p), "op": "delete"}
148
- for p in (shots.iterdir() if shots.is_dir() else [])
149
- if p.is_file() and p.stat().st_mtime < cutoff])
150
-
151
- # 3) 配置备份残留 *.bak*
152
- add("bak_files", "配置备份残留", "改配置时自动留的 .bak 副本,超期即清",
153
- [{"path": str(p), "op": "delete"}
154
- for p in data_dir.glob("*.bak*")
155
- if p.is_file() and p.stat().st_mtime < cutoff])
156
-
157
- # 4) 过期指挥信箱(运行早已结束,附件永远不会被消费)
158
- pend = data_dir / "pending"
159
- files = []
160
- for p in (pend.iterdir() if pend.is_dir() else []):
161
- try:
162
- if p.stat().st_mtime >= cutoff:
163
- continue
164
- except OSError:
165
- continue
166
- files.append({"path": str(p), "op": "rmtree" if p.is_dir() else "delete"})
167
- add("pending_stale", "过期指挥信箱", "已结束任务的运行中留言附件",
168
- files)
169
-
170
- # 5) 浏览器 profile 迁移残留:家目录同款平台目录存在(迁移已完成)才清
171
- legacy = data_dir / "publish" / "profiles"
172
- files = []
173
- if legacy.is_dir():
174
- for p in legacy.iterdir():
175
- if not p.is_dir():
176
- continue
177
- try:
178
- if (_profiles_base(home) / p.name).exists():
179
- files.append({"path": str(p), "op": "rmtree"})
180
- except OSError:
181
- continue
182
- add("legacy_profiles", "旧版浏览器缓存残留",
183
- "发布浏览器 profile 已迁往用户主目录,旧位置属废弃残留", files)
184
-
185
- # 6) 历史备份包(导出 zip / 导入前自动备份,超期即清)
186
- files = []
187
- for d in ("exports", "imports"):
188
- base = data_dir / d
189
- for p in (base.iterdir() if base.is_dir() else []):
190
- try:
191
- if p.stat().st_mtime < cutoff:
192
- files.append({"path": str(p),
193
- "op": "rmtree" if p.is_dir() else "delete"})
194
- except OSError:
195
- continue
196
- add("backups_old", "历史备份包", "导出与导入前自动备份的旧 zip", files)
197
-
198
- # 7) 服务日志截尾(不删文件,砍掉前段)
199
- files = [{"path": str(p), "op": "truncate"}
200
- for p in data_dir.glob("*.log")
201
- if p.is_file() and p.stat().st_size > LOG_MAX_BYTES]
202
- add("logs_truncate", "服务日志瘦身", "只保留每个日志的末尾 1MB", files)
203
-
204
- # 8) 手动:发布浏览器缓存(含登录态,清了要重新扫码;绝不自动清)
205
- if include_profiles:
206
- base = _profiles_base(home)
207
- files = [{"path": str(p), "op": "rmtree"}
208
- for p in (base.iterdir() if base.is_dir() else []) if p.is_dir()]
209
- add("publish_profiles", "发布浏览器缓存",
210
- "含平台登录态,清理后发布时需重新扫码登录", files)
211
- return {"retention_days": days, "items": items,
212
- "total_bytes": sum(i["bytes"] for i in items)}
213
-
214
-
215
- def plan(retention_days=None, include_profiles=False, home=None):
216
- return _plan_items(retention_days=retention_days,
217
- include_profiles=include_profiles, home=home)
218
-
219
-
220
- def status():
221
- """设置页一次拉全:配置 + 上次清理状态 + 当前可清理预估。"""
222
- cfg = settings_mod.load()
223
- return {"config": {"enabled": bool(cfg.get("cleanup_enabled")),
224
- "retention_days": cfg.get("cleanup_retention_days")},
225
- "state": _load_state(), "plan": plan()}
226
-
227
-
228
- # ---------------------------------------------------------------- 执行
229
-
230
- def _delete_path(p: Path, op):
231
- if op == "rmtree":
232
- import shutil
233
- shutil.rmtree(str(p), ignore_errors=False)
234
- elif op == "truncate":
235
- with open(str(p), "rb") as f:
236
- f.seek(-LOG_KEEP_BYTES, 2)
237
- tail = f.read()
238
- tmp = p.with_suffix(".tmp-cl")
239
- tmp.write_bytes(tail)
240
- os.replace(str(tmp), str(p))
241
- else:
242
- p.unlink()
243
-
244
-
245
- def run_cleanup(retention_days=None, include_profiles=False):
246
- """按当前扫描结果执行清理。返回汇总并落状态(供设置页展示)。"""
247
- scan = _plan_items(retention_days=retention_days,
248
- include_profiles=include_profiles)
249
- freed = 0
250
- done = []
251
- errors = []
252
- for item in scan["items"]:
253
- got = 0
254
- n = 0
255
- for f in item["files"]:
256
- try:
257
- p = Path(f["path"])
258
- if f["op"] == "truncate":
259
- sz = p.stat().st_size - LOG_KEEP_BYTES
260
- elif p.is_dir():
261
- sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
262
- else:
263
- sz = p.stat().st_size
264
- _delete_path(p, f["op"])
265
- got += max(0, sz)
266
- n += 1
267
- except OSError as e:
268
- errors.append("%s:%s" % (f["path"], e.strerror or e))
269
- if n:
270
- done.append({"key": item["key"], "label": item["label"],
271
- "bytes": got, "count": n})
272
- freed += got
273
- result = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "freed": freed,
274
- "items": done, "errors": errors[:10],
275
- "include_profiles": bool(include_profiles)}
276
- st = _load_state()
277
- st["last_run"] = result["ts"]
278
- st["last_freed"] = freed
279
- st["last_result"] = result
280
- hist = st.get("history") or []
281
- hist.insert(0, {"ts": result["ts"], "freed": freed})
282
- st["history"] = hist[:30]
283
- _save_state(st)
284
- try:
285
- from . import store
286
- store.bump_state()
287
- except Exception:
288
- pass
289
- return result
290
-
291
-
292
- def fire_due():
293
- """automation._tick 每拍调用:启用且当天没清过才真跑,否则零开销返回。"""
294
- try:
295
- cfg = settings_mod.load()
296
- except Exception:
297
- return None
298
- if not cfg.get("cleanup_enabled"):
299
- return None
300
- today = time.strftime("%Y-%m-%d")
301
- if str(_load_state().get("last_run") or "").startswith(today):
302
- return None
303
- return run_cleanup()
1
+ # -*- coding: utf-8 -*-
2
+ """每日垃圾清理:只清 CodeBee 自己产生的可再生数据,绝不碰用户成果。
3
+
4
+ 垃圾来源(实测 data/ 923MB 构成):
5
+ - runs/<id>/steps/*.log 运行过程日志,单文件可达 22MB(保留 run.json
6
+ 与 report.md,运行记录/结果页签不受影响)
7
+ - publish/shots/ 发布过程截图证据
8
+ - data 根 *.bak* 配置改写工具留下的备份残留
9
+ - pending/ 运行中指挥信箱的过期附件
10
+ - publish/profiles/ 浏览器 profile 迁移残留(新版已搬家目录,
11
+ 旧位置 688MB 实测;仅当家目录同款平台目录
12
+ 存在——即迁移已完成——才按废弃清理)
13
+ - exports/ imports/ 备份模块自己的历史产物
14
+ - data 根 *.log 服务日志按体量截尾,不是按天删
15
+
16
+ 调度挂在 automation._tick(fire_due 自节流:启用 + 当天没清过才真跑)。
17
+ 配置在 data/settings.json(cleanup_enabled / cleanup_retention_days),
18
+ 状态在 data/cleanup.json(last_run/last_freed/history 供设置页展示)。
19
+
20
+ 发布浏览器 profile(家目录 ~/.codebee/publish_profiles,约 1GB)含平台
21
+ 登录态,清了要重新扫码——绝不进每日自动清理,只留手动入口
22
+ (run_cleanup(include_profiles=True))。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from . import paths, settings as settings_mod
32
+
33
+ DEFAULT_RETENTION_DAYS = 14
34
+ LOG_MAX_BYTES = 5 * 1024 * 1024 # 服务日志超过这个体量才截
35
+ LOG_KEEP_BYTES = 1 * 1024 * 1024 # 截尾保留的末段
36
+ STATE_NAME = "cleanup.json"
37
+
38
+
39
+ # ---------------------------------------------------------------- 状态
40
+
41
+ def _state_path():
42
+ return Path(paths.DATA_DIR) / STATE_NAME
43
+
44
+
45
+ def _load_state():
46
+ try:
47
+ d = json.loads(_state_path().read_text(encoding="utf-8"))
48
+ return d if isinstance(d, dict) else {}
49
+ except Exception:
50
+ return {}
51
+
52
+
53
+ def _save_state(st):
54
+ p = _state_path()
55
+ p.parent.mkdir(parents=True, exist_ok=True)
56
+ tmp = p.with_suffix(".tmp")
57
+ tmp.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
58
+ os.replace(str(tmp), str(p))
59
+
60
+
61
+ def _profiles_base(home=None):
62
+ """家目录浏览器 profile 根(与 publish/manager 同款落点)。home 供测试注入。"""
63
+ if home:
64
+ return Path(home) / ".codebee" / "publish_profiles"
65
+ return Path.home() / ".codebee" / "publish_profiles"
66
+
67
+
68
+ def _parse_local_dt(s):
69
+ try:
70
+ return time.mktime(time.strptime(str(s)[:19], "%Y-%m-%d %H:%M:%S"))
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ # ---------------------------------------------------------------- 扫描
76
+
77
+ def _run_ended(run_dir, cutoff_ts):
78
+ """run 目录整体是否过期:看 run.json 的 ended_at;没有就退回目录 mtime。"""
79
+ rj = run_dir / "run.json"
80
+ if rj.is_file():
81
+ ts = None
82
+ try:
83
+ ts = _parse_local_dt(json.loads(rj.read_text(encoding="utf-8"))
84
+ .get("ended_at"))
85
+ except Exception:
86
+ ts = None
87
+ if ts:
88
+ return ts < cutoff_ts
89
+ try:
90
+ return run_dir.stat().st_mtime < cutoff_ts
91
+ except OSError:
92
+ return False
93
+
94
+
95
+ def _plan_items(retention_days=None, include_profiles=False, home=None, now=None):
96
+ """扫描各类垃圾,返回条目列表。每条:key/label/note/bytes/count/files,
97
+ files 元素为 {path, op},op ∈ delete | rmtree | truncate。"""
98
+ now = now or time.time()
99
+ try:
100
+ days = int(retention_days if retention_days is not None
101
+ else settings_mod.load().get("cleanup_retention_days"))
102
+ except (TypeError, ValueError):
103
+ days = DEFAULT_RETENTION_DAYS
104
+ days = max(1, min(365, days))
105
+ cutoff = now - days * 86400
106
+ data_dir = Path(paths.DATA_DIR)
107
+ items = []
108
+
109
+ def add(key, label, note, files):
110
+ size = 0
111
+ good = []
112
+ for f in files:
113
+ try:
114
+ p = Path(f["path"])
115
+ if f["op"] == "truncate":
116
+ sz = p.stat().st_size - LOG_KEEP_BYTES
117
+ elif p.is_dir():
118
+ sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
119
+ else:
120
+ sz = p.stat().st_size
121
+ except OSError:
122
+ continue
123
+ # truncate 只在有得砍时才留;delete/rmtree 即使 0 字节(空目录)也要清
124
+ if sz > 0 or f["op"] != "truncate":
125
+ good.append(f)
126
+ size += max(0, sz)
127
+ if good:
128
+ items.append({"key": key, "label": label, "note": note,
129
+ "bytes": size, "count": len(good), "files": good})
130
+
131
+ # 1) 过期运行的过程日志(保留 run.json / report.md / 封面)
132
+ runs_dir = data_dir / "runs"
133
+ files = []
134
+ if runs_dir.is_dir():
135
+ for rd in runs_dir.iterdir():
136
+ if not rd.is_dir() or not _run_ended(rd, cutoff):
137
+ continue
138
+ steps = rd / "steps"
139
+ if steps.is_dir():
140
+ files.append({"path": str(steps), "op": "rmtree"})
141
+ add("runs_old_logs", "运行过程日志",
142
+ "保留运行记录与结果报告,只删过程中的原始日志", files)
143
+
144
+ # 2) 发布截图
145
+ shots = data_dir / "publish" / "shots"
146
+ add("publish_shots", "发布截图", "发布过程留档截图,超期即清",
147
+ [{"path": str(p), "op": "delete"}
148
+ for p in (shots.iterdir() if shots.is_dir() else [])
149
+ if p.is_file() and p.stat().st_mtime < cutoff])
150
+
151
+ # 3) 配置备份残留 *.bak*
152
+ add("bak_files", "配置备份残留", "改配置时自动留的 .bak 副本,超期即清",
153
+ [{"path": str(p), "op": "delete"}
154
+ for p in data_dir.glob("*.bak*")
155
+ if p.is_file() and p.stat().st_mtime < cutoff])
156
+
157
+ # 4) 过期指挥信箱(运行早已结束,附件永远不会被消费)
158
+ pend = data_dir / "pending"
159
+ files = []
160
+ for p in (pend.iterdir() if pend.is_dir() else []):
161
+ try:
162
+ if p.stat().st_mtime >= cutoff:
163
+ continue
164
+ except OSError:
165
+ continue
166
+ files.append({"path": str(p), "op": "rmtree" if p.is_dir() else "delete"})
167
+ add("pending_stale", "过期指挥信箱", "已结束任务的运行中留言附件",
168
+ files)
169
+
170
+ # 5) 浏览器 profile 迁移残留:家目录同款平台目录存在(迁移已完成)才清
171
+ legacy = data_dir / "publish" / "profiles"
172
+ files = []
173
+ if legacy.is_dir():
174
+ for p in legacy.iterdir():
175
+ if not p.is_dir():
176
+ continue
177
+ try:
178
+ if (_profiles_base(home) / p.name).exists():
179
+ files.append({"path": str(p), "op": "rmtree"})
180
+ except OSError:
181
+ continue
182
+ add("legacy_profiles", "旧版浏览器缓存残留",
183
+ "发布浏览器 profile 已迁往用户主目录,旧位置属废弃残留", files)
184
+
185
+ # 6) 历史备份包(导出 zip / 导入前自动备份,超期即清)
186
+ files = []
187
+ for d in ("exports", "imports"):
188
+ base = data_dir / d
189
+ for p in (base.iterdir() if base.is_dir() else []):
190
+ try:
191
+ if p.stat().st_mtime < cutoff:
192
+ files.append({"path": str(p),
193
+ "op": "rmtree" if p.is_dir() else "delete"})
194
+ except OSError:
195
+ continue
196
+ add("backups_old", "历史备份包", "导出与导入前自动备份的旧 zip", files)
197
+
198
+ # 7) 服务日志截尾(不删文件,砍掉前段)
199
+ files = [{"path": str(p), "op": "truncate"}
200
+ for p in data_dir.glob("*.log")
201
+ if p.is_file() and p.stat().st_size > LOG_MAX_BYTES]
202
+ add("logs_truncate", "服务日志瘦身", "只保留每个日志的末尾 1MB", files)
203
+
204
+ # 8) 手动:发布浏览器缓存(含登录态,清了要重新扫码;绝不自动清)
205
+ if include_profiles:
206
+ base = _profiles_base(home)
207
+ files = [{"path": str(p), "op": "rmtree"}
208
+ for p in (base.iterdir() if base.is_dir() else []) if p.is_dir()]
209
+ add("publish_profiles", "发布浏览器缓存",
210
+ "含平台登录态,清理后发布时需重新扫码登录", files)
211
+ return {"retention_days": days, "items": items,
212
+ "total_bytes": sum(i["bytes"] for i in items)}
213
+
214
+
215
+ def plan(retention_days=None, include_profiles=False, home=None):
216
+ return _plan_items(retention_days=retention_days,
217
+ include_profiles=include_profiles, home=home)
218
+
219
+
220
+ def status():
221
+ """设置页一次拉全:配置 + 上次清理状态 + 当前可清理预估。"""
222
+ cfg = settings_mod.load()
223
+ return {"config": {"enabled": bool(cfg.get("cleanup_enabled")),
224
+ "retention_days": cfg.get("cleanup_retention_days")},
225
+ "state": _load_state(), "plan": plan()}
226
+
227
+
228
+ # ---------------------------------------------------------------- 执行
229
+
230
+ def _delete_path(p: Path, op):
231
+ if op == "rmtree":
232
+ import shutil
233
+ shutil.rmtree(str(p), ignore_errors=False)
234
+ elif op == "truncate":
235
+ with open(str(p), "rb") as f:
236
+ f.seek(-LOG_KEEP_BYTES, 2)
237
+ tail = f.read()
238
+ tmp = p.with_suffix(".tmp-cl")
239
+ tmp.write_bytes(tail)
240
+ os.replace(str(tmp), str(p))
241
+ else:
242
+ p.unlink()
243
+
244
+
245
+ def run_cleanup(retention_days=None, include_profiles=False):
246
+ """按当前扫描结果执行清理。返回汇总并落状态(供设置页展示)。"""
247
+ scan = _plan_items(retention_days=retention_days,
248
+ include_profiles=include_profiles)
249
+ freed = 0
250
+ done = []
251
+ errors = []
252
+ for item in scan["items"]:
253
+ got = 0
254
+ n = 0
255
+ for f in item["files"]:
256
+ try:
257
+ p = Path(f["path"])
258
+ if f["op"] == "truncate":
259
+ sz = p.stat().st_size - LOG_KEEP_BYTES
260
+ elif p.is_dir():
261
+ sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
262
+ else:
263
+ sz = p.stat().st_size
264
+ _delete_path(p, f["op"])
265
+ got += max(0, sz)
266
+ n += 1
267
+ except OSError as e:
268
+ errors.append("%s:%s" % (f["path"], e.strerror or e))
269
+ if n:
270
+ done.append({"key": item["key"], "label": item["label"],
271
+ "bytes": got, "count": n})
272
+ freed += got
273
+ result = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "freed": freed,
274
+ "items": done, "errors": errors[:10],
275
+ "include_profiles": bool(include_profiles)}
276
+ st = _load_state()
277
+ st["last_run"] = result["ts"]
278
+ st["last_freed"] = freed
279
+ st["last_result"] = result
280
+ hist = st.get("history") or []
281
+ hist.insert(0, {"ts": result["ts"], "freed": freed})
282
+ st["history"] = hist[:30]
283
+ _save_state(st)
284
+ try:
285
+ from . import store
286
+ store.bump_state()
287
+ except Exception:
288
+ pass
289
+ return result
290
+
291
+
292
+ def fire_due():
293
+ """automation._tick 每拍调用:启用且当天没清过才真跑,否则零开销返回。"""
294
+ try:
295
+ cfg = settings_mod.load()
296
+ except Exception:
297
+ return None
298
+ if not cfg.get("cleanup_enabled"):
299
+ return None
300
+ today = time.strftime("%Y-%m-%d")
301
+ if str(_load_state().get("last_run") or "").startswith(today):
302
+ return None
303
+ return run_cleanup()