codebee 0.1.95 → 0.1.97

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 (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -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 +246 -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 +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -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/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -0,0 +1,648 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Append-only execution checkpoints with conservative replay semantics."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import base64
8
+ import difflib
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import subprocess
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from . import paths
19
+ from .redact import scrub_text
20
+
21
+ _DIR = paths.DATA_DIR / "checkpoints"
22
+ _LOCK = threading.RLock()
23
+ _TERMINAL = {"done", "failed", "cancelled", "timeout", "unknown"}
24
+ _SNAPSHOT_MAX_BYTES = 1024 * 1024
25
+ _SNAPSHOT_TOTAL_MAX_BYTES = 16 * 1024 * 1024
26
+ _SUPPORTS_DIR_FD_RENAME = os.rename in getattr(os, "supports_dir_fd", set())
27
+
28
+
29
+ def _file():
30
+ return _DIR / ("checkpoints-%s.jsonl" % time.strftime("%Y%m"))
31
+
32
+
33
+ def _hash(value):
34
+ if isinstance(value, (dict, list, tuple)):
35
+ raw = json.dumps(value, ensure_ascii=False, sort_keys=True,
36
+ separators=(",", ":"), default=str)
37
+ else:
38
+ raw = str(value or "")
39
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
40
+
41
+
42
+ def _append(row):
43
+ _DIR.mkdir(parents=True, exist_ok=True)
44
+ with _file().open("a", encoding="utf-8") as handle:
45
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
46
+ handle.flush()
47
+
48
+
49
+ def start(run_id, step, role, prompt="", *, input_data=None, attempt=1,
50
+ idempotency_key=""):
51
+ """Record a step start without persisting prompt contents."""
52
+ now = time.time()
53
+ try:
54
+ attempt = max(1, min(100, int(attempt)))
55
+ except (TypeError, ValueError):
56
+ attempt = 1
57
+ row = {"event_id": "cp-" + secrets.token_hex(10), "event": "start",
58
+ "run_id": str(run_id or "")[:80], "step": int(step),
59
+ "role": str(role or "")[:80], "status": "running",
60
+ "attempt": attempt, "idempotency_key": str(idempotency_key or "")[:160],
61
+ "prompt_sha256": _hash(prompt), "input_sha256": _hash(input_data),
62
+ "created_at": now}
63
+ with _LOCK:
64
+ try:
65
+ _append(row)
66
+ except OSError:
67
+ pass
68
+ return row
69
+
70
+
71
+ def finish(run_id, step, status, *, error="", output=None, metadata=None):
72
+ status = str(status or "unknown").strip().lower()
73
+ if status not in _TERMINAL:
74
+ status = "unknown"
75
+ row = {"event_id": "cp-" + secrets.token_hex(10), "event": "finish",
76
+ "run_id": str(run_id or "")[:80], "step": int(step),
77
+ "status": status, "error": scrub_text(error, limit=500),
78
+ "output_sha256": _hash(output), "metadata": {
79
+ str(k)[:40]: v for k, v in (metadata or {}).items()
80
+ if isinstance(v, (str, int, float, bool))
81
+ }, "created_at": time.time()}
82
+ with _LOCK:
83
+ try:
84
+ _append(row)
85
+ except OSError:
86
+ pass
87
+ return row
88
+
89
+
90
+ def _read(run_id=""):
91
+ rows = []
92
+ try:
93
+ files = sorted(_DIR.glob("checkpoints-*.jsonl"), reverse=True)
94
+ except OSError:
95
+ files = []
96
+ for path in files[:24]:
97
+ try:
98
+ if path.stat().st_size > 4 * 1024 * 1024:
99
+ continue
100
+ for line in path.read_text(encoding="utf-8").splitlines()[-50000:]:
101
+ try:
102
+ row = json.loads(line)
103
+ except (TypeError, ValueError):
104
+ continue
105
+ if isinstance(row, dict) and (not run_id or row.get("run_id") == run_id):
106
+ rows.append(row)
107
+ except OSError:
108
+ continue
109
+ return rows
110
+
111
+
112
+ def replay_preview(run_id):
113
+ """Return latest checkpoint per step and whether automatic replay is safe."""
114
+ with _LOCK:
115
+ rows = _read(str(run_id or "")[:80])
116
+ latest = {}
117
+ starts = {}
118
+ finishes = {}
119
+ for row in rows:
120
+ try:
121
+ key = int(row.get("step"))
122
+ except (TypeError, ValueError):
123
+ continue
124
+ if key < 0:
125
+ continue
126
+ latest[key] = dict(latest.get(key) or {}, **row)
127
+ if row.get("event") == "start":
128
+ starts[key] = row
129
+ elif row.get("event") == "finish":
130
+ finishes[key] = row
131
+ steps = []
132
+ for key in sorted(latest):
133
+ item = dict(latest[key])
134
+ if key in starts:
135
+ item.setdefault("prompt_sha256", starts[key].get("prompt_sha256"))
136
+ item.setdefault("attempt", starts[key].get("attempt", 1))
137
+ steps.append(item)
138
+ unknown = next((x for x in steps if x.get("status") in ("unknown", "running")
139
+ or x.get("event") != "finish"
140
+ or x.get("step") not in finishes
141
+ or x.get("step") not in starts), None)
142
+ if not steps:
143
+ unknown = {"reason": "checkpoint_not_found"}
144
+ return {"run_id": str(run_id or ""), "steps": steps,
145
+ "replayable": unknown is None,
146
+ "blocked_reason": ("checkpoint_not_found" if not steps else
147
+ "requires_reconciliation" if unknown else "")}
148
+
149
+
150
+ def replay(run_id):
151
+ """Alias used by integrations; intentionally returns a preview only."""
152
+ return replay_preview(run_id)
153
+
154
+
155
+ def _snapshot_dir(run_id):
156
+ key = hashlib.sha256(str(run_id or "").encode("utf-8")).hexdigest()
157
+ return _DIR / "files" / key
158
+
159
+
160
+ def _safe_snapshot_path(workdir, path):
161
+ try:
162
+ if not workdir:
163
+ return None, None
164
+ root = Path(workdir).expanduser().resolve(strict=True)
165
+ target = Path(path).expanduser()
166
+ if not target.is_absolute():
167
+ target = root / target
168
+ target = target.resolve(strict=False)
169
+ if target == root or root not in target.parents:
170
+ return None, None
171
+ rel = target.relative_to(root).as_posix()
172
+ if not rel or any(part in ("", ".", "..") for part in Path(rel).parts):
173
+ return None, None
174
+ # Reject symlink traversal, including dangling links.
175
+ cursor = root
176
+ for part in Path(rel).parts:
177
+ cursor = cursor / part
178
+ if cursor.is_symlink():
179
+ return None, None
180
+ return root, rel
181
+ except (OSError, TypeError, ValueError):
182
+ return None, None
183
+
184
+
185
+ def _snapshot_file(run_id, rel):
186
+ name = hashlib.sha256(rel.encode("utf-8")).hexdigest() + ".json"
187
+ return _snapshot_dir(run_id) / name
188
+
189
+
190
+ def _load_snapshot(run_id, rel):
191
+ try:
192
+ obj = json.loads(_snapshot_file(run_id, rel).read_text(encoding="utf-8"))
193
+ return obj if isinstance(obj, dict) and obj.get("path") == rel else None
194
+ except (OSError, ValueError, TypeError):
195
+ return None
196
+
197
+
198
+ def capture_file(run_id, workdir, path):
199
+ """Persist the first pre-edit bytes for a workspace-relative file.
200
+
201
+ Full-content snapshots are intentionally bounded; unsupported/large files
202
+ are reported as untracked rather than silently treated as reversible.
203
+ """
204
+ root, rel = _safe_snapshot_path(workdir, path)
205
+ if root is None:
206
+ return {"ok": False, "reason": "path_outside_workspace"}
207
+ with _LOCK:
208
+ existing = _load_snapshot(run_id, rel)
209
+ if existing:
210
+ return {"ok": True, "existing": True, "path": rel}
211
+ snap_dir = _snapshot_dir(run_id)
212
+ try:
213
+ snap_dir.mkdir(parents=True, exist_ok=True)
214
+ total = sum(p.stat().st_size for p in snap_dir.glob("*.json"))
215
+ target = root / rel
216
+ existed = target.exists()
217
+ 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}
227
+ except OSError as exc:
228
+ return {"ok": False, "reason": "snapshot_io_error", "path": rel,
229
+ "detail": str(exc)[:200]}
230
+
231
+
232
+ def capture_bytes(run_id, workdir, path, raw, *, existed=True):
233
+ """Persist an explicit pre-step byte image (used for clean Git baseline files)."""
234
+ root, rel = _safe_snapshot_path(workdir, path)
235
+ if root is None:
236
+ return {"ok": False, "reason": "path_outside_workspace"}
237
+ raw = bytes(raw or b"") if existed else b""
238
+ if len(raw) > _SNAPSHOT_MAX_BYTES:
239
+ return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
240
+ with _LOCK:
241
+ if _load_snapshot(run_id, rel):
242
+ return {"ok": True, "existing": True, "path": rel}
243
+ snap_dir = _snapshot_dir(run_id)
244
+ try:
245
+ snap_dir.mkdir(parents=True, exist_ok=True)
246
+ total = sum(p.stat().st_size for p in snap_dir.glob("*.json") if p.is_file())
247
+ if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
248
+ 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}
256
+ except OSError as exc:
257
+ return {"ok": False, "reason": "snapshot_io_error", "path": rel,
258
+ "detail": str(exc)[:200]}
259
+
260
+
261
+ def _git_bytes(workdir, *args):
262
+ try:
263
+ return subprocess.run(["git", "-C", str(workdir), *args],
264
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
265
+ timeout=15, check=False)
266
+ except (OSError, subprocess.SubprocessError):
267
+ return None
268
+
269
+
270
+ def _git_changed_paths(workdir, gitmod):
271
+ """Return every changed path, including both sides of renames/copies."""
272
+ result = _git_bytes(workdir, "status", "--porcelain", "-z", "--untracked-files=all")
273
+ if not result or result.returncode:
274
+ return None
275
+ fields = result.stdout.split(b"\0")
276
+ paths = []
277
+ index = 0
278
+ while index < len(fields):
279
+ field = fields[index]
280
+ index += 1
281
+ if len(field) < 4:
282
+ continue
283
+ status = os.fsdecode(field[:2])
284
+ rel = os.fsdecode(field[3:])
285
+ if not rel:
286
+ continue
287
+ # The destination of a Git rename/copy did not exist at the pinned HEAD;
288
+ # the following NUL field is its source path and is captured separately.
289
+ paths.append(("??" if "R" in status or "C" in status else status, rel))
290
+ if "R" in status or "C" in status:
291
+ if index < len(fields) and fields[index]:
292
+ old_rel = os.fsdecode(fields[index])
293
+ paths.append(("D", old_rel))
294
+ index += 1
295
+ return [(gitmod._status_code(status), rel) for status, rel in paths
296
+ if not (status == "?" and gitmod._attach_path(rel))
297
+ and rel != ".codebee" and not rel.startswith(".codebee/")]
298
+
299
+
300
+ def begin_git_step(run_id, workdir):
301
+ """Capture pre-existing dirty Git files before an external CLI step starts.
302
+
303
+ Clean tracked files can be reconstructed from the pinned HEAD at finish time;
304
+ dirty/untracked files must be captured now or the step is refused.
305
+ """
306
+ root = Path(workdir or "").expanduser().resolve()
307
+ probe = _git_bytes(root, "rev-parse", "--show-toplevel")
308
+ if not probe or probe.returncode:
309
+ return {"ok": True, "supported": False,
310
+ "warning": "非 Git 工作目录:外部 CLI 文件改动无法生成可恢复快照"}
311
+ repo_root = Path(os.fsdecode(probe.stdout).strip()).resolve()
312
+ if repo_root != root:
313
+ return {"ok": True, "supported": False,
314
+ "warning": "工作目录不是 Git 仓库根目录:外部 CLI 文件改动无法安全快照"}
315
+ head = _git_bytes(root, "rev-parse", "HEAD")
316
+ if not head or head.returncode:
317
+ return {"ok": True, "supported": False,
318
+ "warning": "Git 仓库没有可用 HEAD:外部 CLI 文件改动无法安全快照"}
319
+ try:
320
+ from . import gitmod
321
+ entries = _git_changed_paths(root, gitmod)
322
+ if entries is None:
323
+ return {"ok": False, "error": "无法读取 Git 工作区状态,未启动外部 CLI"}
324
+ except Exception:
325
+ return {"ok": False, "error": "无法读取 Git 工作区状态,未启动外部 CLI"}
326
+ changes = []
327
+ for status, rel in entries:
328
+ rel = str(rel or "")
329
+ if not rel:
330
+ continue
331
+ changes.append({"status": gitmod._status_code(status), "path": rel})
332
+ before_paths = []
333
+ for row in changes:
334
+ rel = str(row.get("path") or "")
335
+ if not rel:
336
+ continue
337
+ saved = capture_file(run_id, root, root / rel)
338
+ if not saved.get("ok"):
339
+ return {"ok": False, "error": "已有改动无法完整快照(%s),未启动外部 CLI" % rel}
340
+ before_paths.append(rel)
341
+ return {"ok": True, "supported": True,
342
+ "head": os.fsdecode(head.stdout).strip(),
343
+ "before_paths": before_paths}
344
+
345
+
346
+ def finish_git_step(run_id, workdir, head, before_paths=()):
347
+ """Reconcile Git-visible CLI changes against pre-step state and mark restore hashes."""
348
+ root = Path(workdir or "").expanduser().resolve()
349
+ if not head:
350
+ return {"ok": False, "captured": 0, "warning": "缺少 Git 基线,未生成外部 CLI 文件快照"}
351
+ current_head = _git_bytes(root, "rev-parse", "HEAD")
352
+ if not current_head or current_head.returncode or os.fsdecode(current_head.stdout).strip() != head:
353
+ return {"ok": False, "captured": 0,
354
+ "warning": "步骤期间 Git HEAD 发生变化,快照无法安全对账"}
355
+ try:
356
+ from . import gitmod
357
+ entries = _git_changed_paths(root, gitmod)
358
+ if entries is None:
359
+ return {"ok": False, "captured": 0, "warning": "无法读取 Git 变更清单"}
360
+ except Exception:
361
+ return {"ok": False, "captured": 0, "warning": "无法读取 Git 变更清单"}
362
+ changes = []
363
+ for status, rel in entries:
364
+ rel = str(rel or "")
365
+ if not rel:
366
+ continue
367
+ changes.append({"status": gitmod._status_code(status), "path": rel})
368
+ by_path = {str(row.get("path") or ""): row for row in changes if row.get("path")}
369
+ for rel in before_paths or ():
370
+ by_path.setdefault(str(rel), {"path": str(rel), "status": ""})
371
+ captured = 0
372
+ failed = []
373
+ for row in by_path.values():
374
+ rel = str(row.get("path") or "")
375
+ if not rel:
376
+ continue
377
+ item = _load_snapshot(run_id, rel)
378
+ if item is None:
379
+ if row.get("status") == "?":
380
+ saved = capture_bytes(run_id, root, root / rel, b"", existed=False)
381
+ else:
382
+ # -- literal path suffix prevents pathspec interpretation; bytes
383
+ # preserve binary files and exact line endings.
384
+ base = _git_bytes(root, "show", "%s:%s" % (head, rel))
385
+ if not base or base.returncode:
386
+ failed.append(rel)
387
+ continue
388
+ saved = capture_bytes(run_id, root, root / rel, base.stdout, existed=True)
389
+ if not saved.get("ok"):
390
+ failed.append(rel)
391
+ continue
392
+ captured += 1
393
+ if not mark_file_after(run_id, root, root / rel):
394
+ failed.append(rel)
395
+ warning = "仅覆盖 Git 可见文件改动;忽略文件、shell/MCP 与外部服务副作用不保证可回滚。"
396
+ if failed:
397
+ warning += "以下路径未能完整快照,不能保证恢复:%s。" % "、".join(failed[:20])
398
+ if len(failed) > 20:
399
+ warning += "另有 %d 项。" % (len(failed) - 20)
400
+ return {"ok": not failed, "captured": captured, "failed_paths": failed,
401
+ "warning": warning}
402
+
403
+
404
+ def mark_file_after(run_id, workdir, path):
405
+ """Record the post-edit fingerprint used to detect restore conflicts."""
406
+ root, rel = _safe_snapshot_path(workdir, path)
407
+ if root is None:
408
+ return False
409
+ with _LOCK:
410
+ item = _load_snapshot(run_id, rel)
411
+ if not item:
412
+ return False
413
+ target = root / rel
414
+ exists = target.is_file() and not target.is_symlink()
415
+ if exists:
416
+ raw = target.read_bytes()
417
+ item["after_sha256"] = hashlib.sha256(raw).hexdigest()
418
+ else:
419
+ item["after_sha256"] = None
420
+ item["after_exists"] = exists
421
+ try:
422
+ _snapshot_file(run_id, rel).write_text(
423
+ json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
424
+ return True
425
+ except OSError:
426
+ return False
427
+
428
+
429
+ def snapshot_preview(run_id, workdir):
430
+ """Return bounded snapshot status and text diffs; shell side effects excluded."""
431
+ if not workdir:
432
+ return {"run_id": str(run_id or ""), "files": [], "warning": "工作目录不可用"}
433
+ root, _ = _safe_snapshot_path(workdir, Path(workdir) / "sentinel")
434
+ if root is None:
435
+ try:
436
+ root = Path(workdir).expanduser().resolve(strict=True)
437
+ except (OSError, TypeError, ValueError):
438
+ return {"run_id": str(run_id or ""), "files": [], "warning": "工作目录不可用"}
439
+ files = []
440
+ with _LOCK:
441
+ for file in sorted(_snapshot_dir(run_id).glob("*.json")):
442
+ try:
443
+ item = json.loads(file.read_text(encoding="utf-8"))
444
+ rel = item.get("path")
445
+ safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
446
+ if safe_root is None or safe_rel != rel:
447
+ if isinstance(rel, str) and rel and not Path(rel).is_absolute() and ".." not in Path(rel).parts:
448
+ files.append({"path": rel, "status": "unsafe_path",
449
+ "before_sha256": item.get("before_sha256"),
450
+ "current_sha256": None, "restorable": False,
451
+ "diff": "路径已变为符号链接或超出工作区,拒绝恢复"})
452
+ continue
453
+ target = root / rel
454
+ current = target.read_bytes() if target.is_file() and not target.is_symlink() else None
455
+ current_hash = hashlib.sha256(current).hexdigest() if current is not None else None
456
+ before = base64.b64decode(item.get("before_b64") or "", validate=True)
457
+ status = "unchanged" if current_hash == item.get("before_sha256") else "changed"
458
+ if "after_exists" in item:
459
+ can_restore = (bool(item.get("after_exists")) == (current is not None)
460
+ and item.get("after_sha256") == current_hash)
461
+ elif item.get("after_sha256") is None and status == "changed":
462
+ # Old snapshot records did not encode post-state existence.
463
+ can_restore = not item.get("existed") and current is not None
464
+ else:
465
+ can_restore = item.get("after_sha256") == current_hash
466
+ diff = ""
467
+ if current is not None and item.get("existed"):
468
+ try:
469
+ old_text = before.decode("utf-8").splitlines(True)
470
+ new_text = current.decode("utf-8").splitlines(True)
471
+ diff = "".join(difflib.unified_diff(old_text, new_text,
472
+ fromfile="before/" + rel,
473
+ tofile="current/" + rel))[:12000]
474
+ except UnicodeDecodeError:
475
+ diff = "二进制文件,无法生成文本差异"
476
+ files.append({"path": rel, "status": status,
477
+ "before_sha256": item.get("before_sha256"),
478
+ "current_sha256": current_hash,
479
+ "restorable": bool(can_restore), "diff": diff})
480
+ except (OSError, ValueError, TypeError):
481
+ continue
482
+ return {"run_id": str(run_id or ""), "files": files,
483
+ "warning": "仅覆盖已记录的工作区文件;shell 命令、MCP 与外部服务副作用不保证可回滚。恢复会检查指纹并拒绝符号链接,但不防范恶意本地进程在校验与写入之间替换父目录。"}
484
+
485
+
486
+ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
487
+ """Restore using directory handles so parent-path swaps cannot escape root."""
488
+ required = (os.open in getattr(os, "supports_dir_fd", set())
489
+ and os.mkdir in getattr(os, "supports_dir_fd", set())
490
+ and _SUPPORTS_DIR_FD_RENAME
491
+ and os.unlink in getattr(os, "supports_dir_fd", set()))
492
+ if not required:
493
+ return False
494
+ parts = Path(rel).parts
495
+ if not parts:
496
+ return False
497
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
498
+ root_fd = os.open(str(root), directory_flags)
499
+ parent_fd = root_fd
500
+ owned_fds = []
501
+ missing_parent = False
502
+ try:
503
+ for part in parts[:-1]:
504
+ try:
505
+ child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
506
+ except FileNotFoundError:
507
+ missing_parent = True
508
+ break
509
+ owned_fds.append(child_fd)
510
+ parent_fd = child_fd
511
+
512
+ leaf = parts[-1]
513
+ current = None
514
+ if not missing_parent:
515
+ try:
516
+ file_fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
517
+ dir_fd=parent_fd)
518
+ except FileNotFoundError:
519
+ pass
520
+ else:
521
+ try:
522
+ if not stat.S_ISREG(os.fstat(file_fd).st_mode):
523
+ return False
524
+ with os.fdopen(file_fd, "rb", closefd=False) as handle:
525
+ current = handle.read(_SNAPSHOT_MAX_BYTES + 1)
526
+ finally:
527
+ os.close(file_fd)
528
+ current_exists = current is not None
529
+ current_hash = hashlib.sha256(current).hexdigest() if current_exists else None
530
+ if current_exists != bool(expected_exists) or current_hash != expected_hash:
531
+ return False
532
+
533
+ if item.get("existed"):
534
+ if missing_parent:
535
+ parent_fd = root_fd
536
+ for part in parts[:-1]:
537
+ try:
538
+ os.mkdir(part, mode=0o700, dir_fd=parent_fd)
539
+ except FileExistsError:
540
+ pass
541
+ child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
542
+ owned_fds.append(child_fd)
543
+ parent_fd = child_fd
544
+ raw = base64.b64decode(item.get("before_b64") or "", validate=True)
545
+ temp_name = ".codebee-restore-%s.tmp" % secrets.token_hex(12)
546
+ temp_fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL
547
+ | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent_fd)
548
+ try:
549
+ with os.fdopen(temp_fd, "wb", closefd=False) as handle:
550
+ handle.write(raw)
551
+ handle.flush()
552
+ os.fsync(temp_fd)
553
+ os.close(temp_fd)
554
+ temp_fd = None
555
+ os.rename(temp_name, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
556
+ except Exception:
557
+ if temp_fd is not None:
558
+ os.close(temp_fd)
559
+ try:
560
+ os.unlink(temp_name, dir_fd=parent_fd)
561
+ except OSError:
562
+ pass
563
+ raise
564
+ elif current_exists:
565
+ os.unlink(leaf, dir_fd=parent_fd)
566
+ return True
567
+ finally:
568
+ for fd in reversed(owned_fds):
569
+ os.close(fd)
570
+ os.close(root_fd)
571
+
572
+
573
+ def restore_files(run_id, workdir):
574
+ """Restore only files whose current fingerprint still matches our post-edit state."""
575
+ if not workdir:
576
+ return {"restored": 0, "conflicts": [], "error": "工作目录不可用"}
577
+ preview = snapshot_preview(run_id, workdir)
578
+ if not preview.get("files") and preview.get("warning") == "工作目录不可用":
579
+ return {"restored": 0, "conflicts": [], "error": "工作目录不可用"}
580
+ restored, conflicts = 0, []
581
+ root = Path(workdir).expanduser().resolve()
582
+ with _LOCK:
583
+ for view in preview.get("files") or []:
584
+ rel = view.get("path")
585
+ item = _load_snapshot(run_id, rel)
586
+ safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
587
+ if not item or safe_root is None or safe_rel != rel or not view.get("restorable"):
588
+ conflicts.append(rel)
589
+ continue
590
+ if os.name == "posix":
591
+ try:
592
+ did_restore = _restore_file_posix(
593
+ root, rel, item, view.get("current_sha256") is not None,
594
+ view.get("current_sha256"))
595
+ except (OSError, ValueError):
596
+ did_restore = False
597
+ if did_restore:
598
+ restored += 1
599
+ else:
600
+ conflicts.append(rel)
601
+ continue
602
+ target = root / rel
603
+ try:
604
+ # Re-check after preview to avoid overwriting edits made while the
605
+ # restore request was being prepared.
606
+ current_exists = target.is_file() and not target.is_symlink()
607
+ current_raw = target.read_bytes() if current_exists else None
608
+ current_hash = hashlib.sha256(current_raw).hexdigest() if current_raw is not None else None
609
+ if (current_exists != bool(item.get("after_exists"))
610
+ or current_hash != item.get("after_sha256")):
611
+ conflicts.append(rel)
612
+ continue
613
+ if item.get("existed"):
614
+ raw = base64.b64decode(item.get("before_b64") or "", validate=True)
615
+ target.parent.mkdir(parents=True, exist_ok=True)
616
+ safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
617
+ if safe_root is None or safe_rel != rel:
618
+ conflicts.append(rel)
619
+ continue
620
+ temp_path = None
621
+ try:
622
+ with tempfile.NamedTemporaryFile(mode="wb", dir=str(target.parent),
623
+ prefix=".codebee-restore-", delete=False) as handle:
624
+ temp_path = Path(handle.name)
625
+ handle.write(raw)
626
+ handle.flush()
627
+ os.fsync(handle.fileno())
628
+ # Revalidate immediately before replacement. os.replace replaces a
629
+ # raced-in leaf symlink itself instead of following its destination.
630
+ safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
631
+ if safe_root is None or safe_rel != rel:
632
+ conflicts.append(rel)
633
+ continue
634
+ os.replace(str(temp_path), str(target))
635
+ temp_path = None
636
+ finally:
637
+ if temp_path is not None:
638
+ try:
639
+ temp_path.unlink()
640
+ except OSError:
641
+ pass
642
+ elif target.exists() and target.is_file() and not target.is_symlink():
643
+ target.unlink()
644
+ restored += 1
645
+ except OSError:
646
+ conflicts.append(rel)
647
+ return {"restored": restored, "conflicts": conflicts,
648
+ "warning": "仅恢复已记录的工作区文件;shell 命令、MCP 与外部服务副作用不保证可回滚。恢复会检查指纹并拒绝符号链接,但不防范恶意本地进程在校验与写入之间替换父目录。"}