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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -21
- package/README.md +3 -6
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -426
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/board.py +21 -17
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +13 -4
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +28 -6
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +117 -31
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -82
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mcp_client.py +7 -2
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -209
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +6 -3
- package/app/core/policy.py +9 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -562
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/fanqie.py +16 -10
- package/app/core/publish/flow.py +29 -1
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1177
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -0
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +13 -3
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +1 -0
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +165 -29
- package/app/ui/artdiff.js +74 -74
- package/app/ui/hive3d.js +491 -223
- package/app/ui/i18n.js +18 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +24 -4
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +548 -18
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/catalog.py
CHANGED
|
@@ -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
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
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":
|
|
211
|
-
|
|
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:
|
package/app/core/chaptersafe.py
CHANGED
|
@@ -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
|
package/app/core/checkpoints.py
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
525
|
-
|
|
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 =
|
|
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 =
|
|
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:
|