codebee 0.1.101 → 0.1.103

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 (134) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -21
  3. package/README.md +3 -6
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -426
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/board.py +21 -17
  24. package/app/core/bookmeta.py +1036 -1036
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +13 -4
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +28 -6
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/checkpoints.py +117 -31
  32. package/app/core/cleanup.py +303 -303
  33. package/app/core/command_auth.py +302 -302
  34. package/app/core/compaction.py +201 -201
  35. package/app/core/contracts.py +255 -255
  36. package/app/core/covergen.py +356 -356
  37. package/app/core/defectretro.py +43 -43
  38. package/app/core/diagnostics.py +114 -114
  39. package/app/core/dispatch.py +1 -1
  40. package/app/core/dispatch_log.py +128 -128
  41. package/app/core/env_scrub.py +85 -85
  42. package/app/core/errorlog.py +137 -137
  43. package/app/core/eval_matrix.py +91 -82
  44. package/app/core/evaluation.py +174 -174
  45. package/app/core/failure_notify.py +290 -290
  46. package/app/core/flow_graph.py +33 -33
  47. package/app/core/goal_service.py +167 -167
  48. package/app/core/health.py +389 -389
  49. package/app/core/history.py +38 -38
  50. package/app/core/hooks.py +162 -162
  51. package/app/core/knowledge_pipeline.py +86 -86
  52. package/app/core/manager.py +2385 -2385
  53. package/app/core/mcp_client.py +7 -2
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/notify.py +201 -201
  56. package/app/core/novel_quality.py +181 -181
  57. package/app/core/openapi.py +217 -209
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +145 -145
  60. package/app/core/pipeline.py +6 -3
  61. package/app/core/policy.py +9 -0
  62. package/app/core/portguard.py +111 -111
  63. package/app/core/presence.py +77 -77
  64. package/app/core/preview.py +293 -293
  65. package/app/core/project_memory.py +206 -206
  66. package/app/core/prompt_guard.py +78 -78
  67. package/app/core/publish/__init__.py +7 -7
  68. package/app/core/publish/auto.py +659 -562
  69. package/app/core/publish/chaprows.py +138 -138
  70. package/app/core/publish/fanqie.py +16 -10
  71. package/app/core/publish/flow.py +29 -1
  72. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  73. package/app/core/publish/manager.py +1185 -1177
  74. package/app/core/publish/qimao.py +193 -193
  75. package/app/core/publish/volumes.py +108 -108
  76. package/app/core/publish/ws.py +139 -139
  77. package/app/core/quality_gate.py +201 -201
  78. package/app/core/redact.py +38 -38
  79. package/app/core/repeat_guard.py +129 -129
  80. package/app/core/retrieval.py +137 -137
  81. package/app/core/sandbox_runtime.py +95 -0
  82. package/app/core/selfupdate.py +384 -384
  83. package/app/core/session_log.py +161 -161
  84. package/app/core/sessions.py +312 -312
  85. package/app/core/settings.py +215 -215
  86. package/app/core/settings_schema.py +282 -282
  87. package/app/core/share_page.py +114 -114
  88. package/app/core/skill_scan.py +139 -139
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +13 -3
  97. package/app/core/story_tracking.py +275 -275
  98. package/app/core/task_compile.py +199 -199
  99. package/app/core/telemetry.py +292 -292
  100. package/app/core/tlsctx.py +64 -64
  101. package/app/core/token_meter.py +147 -147
  102. package/app/core/tool_outputs.py +107 -107
  103. package/app/core/tracing.py +207 -207
  104. package/app/core/upstream.py +41 -41
  105. package/app/core/usage.py +1038 -1038
  106. package/app/core/volumes.py +389 -389
  107. package/app/core/webhooks.py +89 -89
  108. package/app/core/wxdigest.py +865 -865
  109. package/app/core/zentao.py +2692 -2692
  110. package/app/main.py +1 -0
  111. package/app/mcp_server.py +244 -244
  112. package/app/output/_createbook_body.json +1 -0
  113. package/app/output/_probe_fq_click.py +50 -0
  114. package/app/output/_probe_fq_form.py +42 -0
  115. package/app/output/_probe_fq_modal.py +55 -0
  116. package/app/output/_probe_fq_points.py +71 -0
  117. package/app/output/_probe_fq_submit.py +34 -0
  118. package/app/output/_probe_fq_watch.py +62 -0
  119. package/app/output/workbench-clean-preview.jpg +0 -0
  120. package/app/output/workbench-reference-render.png +0 -0
  121. package/app/output/workbench-shell.png +0 -0
  122. package/app/pick_dialog.py +110 -110
  123. package/app/ui/app.js +165 -29
  124. package/app/ui/artdiff.js +74 -74
  125. package/app/ui/hive3d.js +491 -223
  126. package/app/ui/i18n.js +18 -1
  127. package/app/ui/icons/bee.svg +79 -79
  128. package/app/ui/index.html +24 -4
  129. package/app/ui/manifest.json +16 -16
  130. package/app/ui/qrcode.js +2297 -2297
  131. package/app/ui/style.css +548 -18
  132. package/bin/tutti.js +147 -147
  133. package/package.json +1 -1
  134. package/tools/wx_reader.py +261 -261
@@ -200,15 +200,19 @@ DEFAULT_CATALOG = [
200
200
  {
201
201
  "id": "trae-agent", "name": "Trae Agent", "cli_group": "installable",
202
202
  "note": "字节跳动开源 trae-agent;可执行名 trae-cli;PyPI 无包,uv tool 从 "
203
- "GitHub 直装(Python ≥3.12);无头 trae-cli run \"提示词\"(一次性任务"
204
- "形态);配置 trae_config.yaml 默认找当前工作目录(TRAE_CONFIG_FILE "
205
- "可指定),默认模型是 agents.trae_agent.model 指向 models 表别名的两层"
206
- "引用,CodeBee 暂不托管其模型落盘——绑定模型请在 trae_config.yaml 自配",
203
+ "GitHub 直装(Python ≥3.12);--with 钉 cryptography<49——49.0.0 起"
204
+ "上游不再发 Intel Mac wheel,缺 brew 的 Mac 落 sdist 编译必死于 "
205
+ "openssl-sys;无头 trae-cli run \"提示词\"(一次性任务形态);配置 "
206
+ "trae_config.yaml 默认找当前工作目录(TRAE_CONFIG_FILE 可指定),"
207
+ "默认模型是 agents.trae_agent.model 指向 models 表别名的两层引用,"
208
+ "CodeBee 暂不托管其模型落盘——绑定模型请在 trae_config.yaml 自配",
207
209
  "detect": {"cli": "trae-cli"},
208
210
  "orch": {"kind": "generic", "command": "trae-cli", "argv_template": ["run", "{prompt}"]},
209
211
  "config": {"path": "~/.trae/trae_config.yaml", "format": None, "model_key": None},
210
- "install": "uv tool install --python 3.12 git+https://github.com/bytedance/trae-agent",
211
- "upgrade": "uv tool install --force --python 3.12 git+https://github.com/bytedance/trae-agent",
212
+ "install": 'uv tool install --python 3.12 --with "cryptography<49"'
213
+ " git+https://github.com/bytedance/trae-agent",
214
+ "upgrade": 'uv tool install --force --python 3.12 --with "cryptography<49"'
215
+ " git+https://github.com/bytedance/trae-agent",
212
216
  "uninstall": "uv tool uninstall trae-agent",
213
217
  "default_enabled": False,
214
218
  },
@@ -395,6 +399,24 @@ def _apply_install_patch(entries):
395
399
  cur = (e.get("install") or "").strip()
396
400
  if "pip" in cur and "install" in cur:
397
401
  e["install"], e["upgrade"] = aider_uv
402
+ # trae-agent 经 google-genai→google-auth 拉 cryptography,而 cryptography
403
+ # 49.0.0 起不再发 Intel Mac wheel(只剩 macosx_11_0_arm64)——uv 找不到
404
+ # wheel 落 sdist 经 maturin/cargo 编译,openssl-sys 在无 brew 的 Mac 上
405
+ # 缺 pkg-config/OpenSSL 开发头必死(2026-10-10 Mac 实案)。钉 <49(48.x
406
+ # 是最后带 macosx_10_9_universal2 的系列,google-auth 只要求 >=38.0.3)
407
+ # 让 uv 恒走预编译 wheel。凡 uv 形态且未钉 cryptography 的旧命令一律
408
+ # 迁移——钉版是兼容底线不是偏好,trae 的安装命令本就无人手改。
409
+ trae_pinned = ('uv tool install --python 3.12 --with "cryptography<49"'
410
+ " git+https://github.com/bytedance/trae-agent")
411
+ trae_upgrade_pinned = ('uv tool install --force --python 3.12'
412
+ ' --with "cryptography<49"'
413
+ " git+https://github.com/bytedance/trae-agent")
414
+ for e in entries:
415
+ if e.get("id") != "trae-agent":
416
+ continue
417
+ cur = (e.get("install") or "").strip()
418
+ if "uv tool install" in cur and "cryptography<" not in cur:
419
+ e["install"], e["upgrade"] = trae_pinned, trae_upgrade_pinned
398
420
  if sys.platform == "win32":
399
421
  return
400
422
  for e in entries:
@@ -1,32 +1,32 @@
1
- # -*- coding: utf-8 -*-
2
- """章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
3
-
4
- 写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
5
- 判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
6
- 失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
7
- (同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
8
- from __future__ import annotations
9
-
10
- from pathlib import Path
11
-
12
-
13
- def atomic_write_chapter(workdir, i, text):
14
- """第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
15
- root = Path(workdir).resolve()
16
- p = (root / ("chapter-%02d.md" % int(i))).resolve()
17
- # 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
18
- # 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
19
- # 一并拒绝:workdir 必须是已存在的目录)
20
- if root not in p.parents or not root.is_dir():
21
- raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
22
- tmp = Path(str(p) + ".tmp")
23
- try:
24
- tmp.write_text(str(text), encoding="utf-8", errors="replace")
25
- tmp.replace(p)
26
- except BaseException:
27
- try:
28
- tmp.unlink(missing_ok=True)
29
- except OSError:
30
- pass
31
- raise
32
- return p
1
+ # -*- coding: utf-8 -*-
2
+ """章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
3
+
4
+ 写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
5
+ 判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
6
+ 失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
7
+ (同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+
13
+ def atomic_write_chapter(workdir, i, text):
14
+ """第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
15
+ root = Path(workdir).resolve()
16
+ p = (root / ("chapter-%02d.md" % int(i))).resolve()
17
+ # 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
18
+ # 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
19
+ # 一并拒绝:workdir 必须是已存在的目录)
20
+ if root not in p.parents or not root.is_dir():
21
+ raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
22
+ tmp = Path(str(p) + ".tmp")
23
+ try:
24
+ tmp.write_text(str(text), encoding="utf-8", errors="replace")
25
+ tmp.replace(p)
26
+ except BaseException:
27
+ try:
28
+ tmp.unlink(missing_ok=True)
29
+ except OSError:
30
+ pass
31
+ raise
32
+ return p
@@ -7,6 +7,7 @@ import json
7
7
  import base64
8
8
  import difflib
9
9
  import os
10
+ import re
10
11
  import secrets
11
12
  import stat
12
13
  import subprocess
@@ -21,7 +22,7 @@ from .redact import scrub_text
21
22
  _DIR = paths.DATA_DIR / "checkpoints"
22
23
  _LOCK = threading.RLock()
23
24
  _TERMINAL = {"done", "failed", "cancelled", "timeout", "unknown"}
24
- _SNAPSHOT_MAX_BYTES = 1024 * 1024
25
+ _SNAPSHOT_MAX_BYTES = 1024 * 1024 # ≤此值内嵌快照 JSON;超限进 git 对象库
25
26
  _SNAPSHOT_TOTAL_MAX_BYTES = 16 * 1024 * 1024
26
27
  _SUPPORTS_DIR_FD_RENAME = os.rename in getattr(os, "supports_dir_fd", set())
27
28
 
@@ -141,7 +142,12 @@ def replay_preview(run_id):
141
142
  or x.get("step") not in starts), None)
142
143
  if not steps:
143
144
  unknown = {"reason": "checkpoint_not_found"}
145
+ total_steps = len(steps)
146
+ max_preview_steps = 200
147
+ steps = steps[-max_preview_steps:]
144
148
  return {"run_id": str(run_id or ""), "steps": steps,
149
+ "total_steps": total_steps,
150
+ "truncated": total_steps > max_preview_steps,
145
151
  "replayable": unknown is None,
146
152
  "blocked_reason": ("checkpoint_not_found" if not steps else
147
153
  "requires_reconciliation" if unknown else "")}
@@ -198,15 +204,17 @@ def _load_snapshot(run_id, rel):
198
204
  def capture_file(run_id, workdir, path):
199
205
  """Persist the first pre-edit bytes for a workspace-relative file.
200
206
 
201
- Full-content snapshots are intentionally bounded; unsupported/large files
202
- are reported as untracked rather than silently treated as reversible.
207
+ ≤1MB 内嵌进快照 JSON(b64);更大的文件(连载全书稿这类持续增长的
208
+ tracked 脏文件)前态进工作区 git 对象库,不再顶着 1MB 上限把整步拒死
209
+ (2026-10-10 实案:manuscript.md 涨到 1.14MB 后全部评审步被「已有改动
210
+ 无法完整快照」拒起 CLI)。对象库也存不进(非 git 目录)才维持拒绝:
211
+ 起跑闸宁拒不假装可恢复。
203
212
  """
204
213
  root, rel = _safe_snapshot_path(workdir, path)
205
214
  if root is None:
206
215
  return {"ok": False, "reason": "path_outside_workspace"}
207
216
  with _LOCK:
208
- existing = _load_snapshot(run_id, rel)
209
- if existing:
217
+ if _load_snapshot(run_id, rel):
210
218
  return {"ok": True, "existing": True, "path": rel}
211
219
  snap_dir = _snapshot_dir(run_id)
212
220
  try:
@@ -215,28 +223,32 @@ def capture_file(run_id, workdir, path):
215
223
  target = root / rel
216
224
  existed = target.exists()
217
225
  raw = target.read_bytes() if existed else b""
218
- if len(raw) > _SNAPSHOT_MAX_BYTES or total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
219
- return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
220
- item = {"path": rel, "existed": existed,
221
- "before_b64": base64.b64encode(raw).decode("ascii"),
222
- "before_sha256": hashlib.sha256(raw).hexdigest() if existed else "",
223
- "after_sha256": None, "captured_at": time.time()}
224
- _snapshot_file(run_id, rel).write_text(
225
- json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
226
- return {"ok": True, "existing": False, "path": rel}
226
+ if len(raw) > _SNAPSHOT_MAX_BYTES:
227
+ return _write_oversize_snapshot(run_id, root, rel, existed,
228
+ file_path=target)
229
+ if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
230
+ # b64 预算满:大内容照样可以走对象库,别在这里误杀
231
+ return _write_oversize_snapshot(run_id, root, rel, existed,
232
+ file_path=target) if raw else \
233
+ {"ok": False, "reason": "snapshot_size_limit", "path": rel}
234
+ return _write_snapshot(run_id, rel, existed, raw=raw)
227
235
  except OSError as exc:
228
236
  return {"ok": False, "reason": "snapshot_io_error", "path": rel,
229
237
  "detail": str(exc)[:200]}
230
238
 
231
239
 
232
240
  def capture_bytes(run_id, workdir, path, raw, *, existed=True):
233
- """Persist an explicit pre-step byte image (used for clean Git baseline files)."""
241
+ """Persist an explicit pre-step byte image (used for clean Git baseline files).
242
+
243
+ 超限字节流同样进 git 对象库兜底:finish 阶段从 HEAD 取回的 clean 基线
244
+ (如归档后的整本 manuscript.md)此前只能给 warning,现在也能完整留底。
245
+ """
234
246
  root, rel = _safe_snapshot_path(workdir, path)
235
247
  if root is None:
236
248
  return {"ok": False, "reason": "path_outside_workspace"}
237
249
  raw = bytes(raw or b"") if existed else b""
238
250
  if len(raw) > _SNAPSHOT_MAX_BYTES:
239
- return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
251
+ return _write_oversize_snapshot(run_id, root, rel, existed, raw=raw)
240
252
  with _LOCK:
241
253
  if _load_snapshot(run_id, rel):
242
254
  return {"ok": True, "existing": True, "path": rel}
@@ -246,27 +258,87 @@ def capture_bytes(run_id, workdir, path, raw, *, existed=True):
246
258
  total = sum(p.stat().st_size for p in snap_dir.glob("*.json") if p.is_file())
247
259
  if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
248
260
  return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
249
- item = {"path": rel, "existed": bool(existed),
250
- "before_b64": base64.b64encode(raw).decode("ascii"),
251
- "before_sha256": hashlib.sha256(raw).hexdigest() if existed else "",
252
- "after_sha256": None, "captured_at": time.time()}
253
- _snapshot_file(run_id, rel).write_text(
254
- json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
255
- return {"ok": True, "existing": False, "path": rel}
261
+ return _write_snapshot(run_id, rel, existed, raw=raw)
256
262
  except OSError as exc:
257
263
  return {"ok": False, "reason": "snapshot_io_error", "path": rel,
258
264
  "detail": str(exc)[:200]}
259
265
 
260
266
 
261
- def _git_bytes(workdir, *args):
267
+ def _git_bytes(workdir, *args, data=None):
262
268
  try:
263
269
  return subprocess.run(["git", "-C", str(workdir), *args],
264
270
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
271
+ input=data,
265
272
  timeout=15, check=False)
266
273
  except (OSError, subprocess.SubprocessError):
267
274
  return None
268
275
 
269
276
 
277
+ def _git_blob_sha(workdir, *, file_path=None, raw=None):
278
+ """把内容存进工作区 git 仓库的对象库,返回 blob sha(空串=失败)。
279
+
280
+ 超限文件的前态走这里:blob 内容寻址、批间归档提交后自然转为被引用;
281
+ 未被引用期间也只在 git gc 的两周宽限后才可能被清,与检查点生命周期同量级。
282
+ """
283
+ if file_path is not None:
284
+ res = _git_bytes(workdir, "hash-object", "-w", "--", str(file_path))
285
+ else:
286
+ res = _git_bytes(workdir, "hash-object", "-w", "--stdin", data=bytes(raw or b""))
287
+ if not res or res.returncode:
288
+ return ""
289
+ sha = os.fsdecode(res.stdout).strip()
290
+ return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else ""
291
+
292
+
293
+ def _snapshot_before_bytes(item, root):
294
+ """快照的前态字节:小文件取 JSON 内嵌 b64,超限文件回对象库取 blob。
295
+
296
+ 返回 None = 前态不可得(对象库不可用或已被清理),调用方按不可恢复处理。
297
+ """
298
+ b64 = item.get("before_b64")
299
+ if b64:
300
+ return base64.b64decode(b64, validate=True)
301
+ sha = item.get("before_git_blob") or ""
302
+ if sha:
303
+ res = _git_bytes(root, "cat-file", "blob", sha)
304
+ if res is not None and not res.returncode:
305
+ return res.stdout
306
+ return None
307
+
308
+
309
+ def _write_snapshot(run_id, rel, existed, raw=None, blob_sha=""):
310
+ """落一份前态快照记录:小文件内嵌 b64,超限文件只记 blob 引用。"""
311
+ item = {"path": rel, "existed": bool(existed),
312
+ "before_sha256": hashlib.sha256(raw or b"").hexdigest() if existed else "",
313
+ "after_sha256": None, "captured_at": time.time()}
314
+ if blob_sha:
315
+ item["before_git_blob"] = blob_sha
316
+ else:
317
+ item["before_b64"] = base64.b64encode(raw or b"").decode("ascii")
318
+ _snapshot_file(run_id, rel).write_text(
319
+ json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
320
+ return {"ok": True, "existing": False, "path": rel}
321
+
322
+
323
+ def _write_oversize_snapshot(run_id, root, rel, existed, *, file_path=None, raw=None):
324
+ """超限前态的落盘:存进 git 对象库;存不进(非 git 目录)才维持拒绝。"""
325
+ if file_path is not None:
326
+ sha = _git_blob_sha(root, file_path=file_path)
327
+ else:
328
+ sha = _git_blob_sha(root, raw=raw)
329
+ if not sha:
330
+ return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
331
+ with _LOCK:
332
+ if _load_snapshot(run_id, rel):
333
+ return {"ok": True, "existing": True, "path": rel}
334
+ try:
335
+ _snapshot_dir(run_id).mkdir(parents=True, exist_ok=True)
336
+ return _write_snapshot(run_id, rel, existed, blob_sha=sha)
337
+ except OSError as exc:
338
+ return {"ok": False, "reason": "snapshot_io_error", "path": rel,
339
+ "detail": str(exc)[:200]}
340
+
341
+
270
342
  def _git_changed_paths(workdir, gitmod):
271
343
  """Return every changed path, including both sides of renames/copies."""
272
344
  result = _git_bytes(workdir, "status", "--porcelain", "-z", "--untracked-files=all")
@@ -453,7 +525,7 @@ def snapshot_preview(run_id, workdir):
453
525
  target = root / rel
454
526
  current = target.read_bytes() if target.is_file() and not target.is_symlink() else None
455
527
  current_hash = hashlib.sha256(current).hexdigest() if current is not None else None
456
- before = base64.b64decode(item.get("before_b64") or "", validate=True)
528
+ before = _snapshot_before_bytes(item, root)
457
529
  status = "unchanged" if current_hash == item.get("before_sha256") else "changed"
458
530
  if "after_exists" in item:
459
531
  can_restore = (bool(item.get("after_exists")) == (current is not None)
@@ -464,7 +536,9 @@ def snapshot_preview(run_id, workdir):
464
536
  else:
465
537
  can_restore = item.get("after_sha256") == current_hash
466
538
  diff = ""
467
- if current is not None and item.get("existed"):
539
+ if before is None and item.get("before_git_blob"):
540
+ diff = "(大文件前态存于 git 对象库且当前不可读取,差异省略)"
541
+ elif current is not None and item.get("existed") and before is not None:
468
542
  try:
469
543
  old_text = before.decode("utf-8").splitlines(True)
470
544
  new_text = current.decode("utf-8").splitlines(True)
@@ -521,16 +595,26 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
521
595
  try:
522
596
  if not stat.S_ISREG(os.fstat(file_fd).st_mode):
523
597
  return False
524
- with os.fdopen(file_fd, "rb", closefd=False) as handle:
525
- current = handle.read(_SNAPSHOT_MAX_BYTES + 1)
598
+ # 全量流式哈希:截断读会让超限文件(前态走对象库的那种)
599
+ # 的指纹恒不匹配、永远拒恢复。
600
+ digest = hashlib.sha256()
601
+ while True:
602
+ chunk = os.read(file_fd, 1 << 20)
603
+ if not chunk:
604
+ break
605
+ digest.update(chunk)
606
+ current = digest.hexdigest()
526
607
  finally:
527
608
  os.close(file_fd)
528
609
  current_exists = current is not None
529
- current_hash = hashlib.sha256(current).hexdigest() if current_exists else None
610
+ current_hash = current if current_exists else None
530
611
  if current_exists != bool(expected_exists) or current_hash != expected_hash:
531
612
  return False
532
613
 
533
614
  if item.get("existed"):
615
+ raw = _snapshot_before_bytes(item, root)
616
+ if raw is None:
617
+ return False
534
618
  if missing_parent:
535
619
  parent_fd = root_fd
536
620
  for part in parts[:-1]:
@@ -541,7 +625,6 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
541
625
  child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
542
626
  owned_fds.append(child_fd)
543
627
  parent_fd = child_fd
544
- raw = base64.b64decode(item.get("before_b64") or "", validate=True)
545
628
  temp_name = ".codebee-restore-%s.tmp" % secrets.token_hex(12)
546
629
  temp_fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL
547
630
  | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent_fd)
@@ -611,7 +694,10 @@ def restore_files(run_id, workdir):
611
694
  conflicts.append(rel)
612
695
  continue
613
696
  if item.get("existed"):
614
- raw = base64.b64decode(item.get("before_b64") or "", validate=True)
697
+ raw = _snapshot_before_bytes(item, root)
698
+ if raw is None:
699
+ conflicts.append(rel)
700
+ continue
615
701
  target.parent.mkdir(parents=True, exist_ok=True)
616
702
  safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
617
703
  if safe_root is None or safe_rel != rel: