codebee 0.1.96 → 0.1.98
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 +13 -0
- package/README.md +4 -2
- package/app/core/agent_context.py +21 -3
- package/app/core/board.py +39 -2
- package/app/core/builtin_agent.py +409 -34
- package/app/core/checkpoints.py +505 -0
- package/app/core/jobs.py +8 -1
- package/app/core/manager.py +109 -11
- package/app/core/mcp_client.py +200 -29
- package/app/core/pipeline.py +289 -40
- package/app/core/policy.py +36 -4
- package/app/core/project_memory.py +139 -0
- package/app/core/publish/auto.py +80 -19
- package/app/core/publish/browser.py +39 -0
- package/app/core/publish/fanqie.py +2 -0
- package/app/core/publish/flow.py +96 -17
- package/app/core/publish/flows-fanqie-calibrated.json +23 -11
- package/app/core/publish/ledger.py +38 -1
- package/app/core/runner.py +21 -11
- package/app/core/settings.py +13 -5
- package/app/core/settings_schema.py +14 -3
- package/app/core/store.py +21 -6
- package/app/core/tool_outputs.py +107 -0
- package/app/core/wxdigest.py +8 -1
- package/app/main.py +76 -2
- package/app/ui/app.js +356 -82
- package/app/ui/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +97 -94
- package/app/ui/board.js +316 -112
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +93 -10
- package/app/ui/index.html +31 -3
- package/app/ui/style.css +97 -111
- package/package.json +3 -2
package/app/core/checkpoints.py
CHANGED
|
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
|
+
import base64
|
|
8
|
+
import difflib
|
|
9
|
+
import os
|
|
7
10
|
import secrets
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
8
14
|
import threading
|
|
9
15
|
import time
|
|
10
16
|
from pathlib import Path
|
|
@@ -15,6 +21,9 @@ from .redact import scrub_text
|
|
|
15
21
|
_DIR = paths.DATA_DIR / "checkpoints"
|
|
16
22
|
_LOCK = threading.RLock()
|
|
17
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())
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
def _file():
|
|
@@ -141,3 +150,499 @@ def replay_preview(run_id):
|
|
|
141
150
|
def replay(run_id):
|
|
142
151
|
"""Alias used by integrations; intentionally returns a preview only."""
|
|
143
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 与外部服务副作用不保证可回滚。恢复会检查指纹并拒绝符号链接,但不防范恶意本地进程在校验与写入之间替换父目录。"}
|
package/app/core/jobs.py
CHANGED
|
@@ -1013,9 +1013,15 @@ def _do_mgmt(job, ev):
|
|
|
1013
1013
|
# 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
|
|
1014
1014
|
# 命令,白白烧一轮 300s 诊断(2026-09-18 dsh 同版本重装 EBUSY 案)
|
|
1015
1015
|
lock_hit = (not ok and op in ("install", "upgrade") and _file_lock_error(res))
|
|
1016
|
+
# uv 缺失同跳 AI 修复:修复链就算装上 uv,复检目标 CLI 仍未装,结论还是
|
|
1017
|
+
# 失败,白烧一轮;人话指路「先装 uv 再点安装」一步到位(2026-10-09 Mac
|
|
1018
|
+
# aider/trae 双装失败实案)
|
|
1019
|
+
uv_missing = bool(res.get("uv_missing"))
|
|
1016
1020
|
if lock_hit:
|
|
1017
1021
|
summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
|
|
1018
1022
|
"请关闭占用该文件的程序后重试。完整输出见日志。")
|
|
1023
|
+
elif uv_missing:
|
|
1024
|
+
summary = "失败:" + str(res.get("error") or "")[:300]
|
|
1019
1025
|
else:
|
|
1020
1026
|
# note 优先:良性收口(如 winget「已装无可升级」复检在装)给用户
|
|
1021
1027
|
# 看原因,别让「完成」两个字孤零零不带解释
|
|
@@ -1026,7 +1032,7 @@ def _do_mgmt(job, ev):
|
|
|
1026
1032
|
summary=summary,
|
|
1027
1033
|
exit_code=res.get("exit_code"))
|
|
1028
1034
|
# AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
|
|
1029
|
-
if not ok and op in ("install", "upgrade") and not lock_hit:
|
|
1035
|
+
if not ok and op in ("install", "upgrade") and not lock_hit and not uv_missing:
|
|
1030
1036
|
ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
|
|
1031
1037
|
elif op == "smoke":
|
|
1032
1038
|
from . import runner as _r
|
|
@@ -1129,6 +1135,7 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
1129
1135
|
"npm: %s" % (shutil.which("npm") or "缺失"),
|
|
1130
1136
|
"pnpm: %s" % (shutil.which("pnpm") or "缺失"),
|
|
1131
1137
|
"python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
|
|
1138
|
+
"uv: %s" % (shutil.which("uv") or "缺失"),
|
|
1132
1139
|
]
|
|
1133
1140
|
if not is_win:
|
|
1134
1141
|
env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
|