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.
@@ -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 "缺失"))