artifact_manager.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Shared run artifact, backup, and cleanup helpers for the Wuling skill."""
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import re
  8. import shutil
  9. import zipfile
  10. from dataclasses import dataclass
  11. from datetime import datetime, timedelta
  12. from pathlib import Path
  13. from typing import Any, Dict, Iterable, List, Optional
  14. DEFAULT_BACKUP_KEEP = 10
  15. DEFAULT_RETENTION_DAYS = 30
  16. @dataclass
  17. class CleanupItem:
  18. action: str
  19. path: Path
  20. size: int
  21. reason: str
  22. target: Optional[Path] = None
  23. def project_root(start: Optional[Path] = None) -> Path:
  24. """Return the project root; prefer the current working tree over the skill folder."""
  25. start = (start or Path.cwd()).resolve()
  26. candidates = [start, *start.parents]
  27. for candidate in candidates:
  28. if (candidate / ".kimi").exists() or (candidate / "摩洛哥客户建联表-按渠道分类.xlsx").exists():
  29. return candidate
  30. return Path.cwd().resolve()
  31. def today_key() -> str:
  32. return datetime.now().strftime("%Y%m%d")
  33. def timestamp() -> str:
  34. return datetime.now().strftime("%Y%m%d_%H%M%S")
  35. def safe_slug(value: str, default: str = "run") -> str:
  36. slug = re.sub(r"[^0-9A-Za-z_.-]+", "_", (value or "").strip()).strip("._-")
  37. return slug or default
  38. def new_run_id(prefix: str = "run") -> str:
  39. return f"{safe_slug(prefix)}_{timestamp()}"
  40. def run_dir(run_id: Optional[str] = None, root: Optional[Path] = None) -> Path:
  41. run_id = safe_slug(run_id or new_run_id())
  42. return project_root(root) / "runs" / today_key() / run_id
  43. def backups_dir(root: Optional[Path] = None) -> Path:
  44. return project_root(root) / "backups" / today_key()
  45. def archives_dir(root: Optional[Path] = None) -> Path:
  46. return project_root(root) / "archives"
  47. def is_bare_filename(path: Path) -> bool:
  48. return not path.is_absolute() and str(path.parent) in {"", "."}
  49. def resolve_artifact_path(
  50. raw: str,
  51. *,
  52. kind: str,
  53. default_name: str,
  54. run_id: Optional[str] = None,
  55. root: Optional[Path] = None,
  56. ) -> Path:
  57. """Resolve output artifacts into runs/ unless the user supplied a real path."""
  58. if raw:
  59. path = Path(raw).expanduser()
  60. if path.is_absolute():
  61. return path
  62. if not is_bare_filename(path):
  63. return (Path.cwd() / path).resolve()
  64. filename = path.name
  65. else:
  66. filename = default_name
  67. base = run_dir(run_id or f"{safe_slug(kind)}_{timestamp()}", root)
  68. return base / filename
  69. def create_backup_once(
  70. workbook_path: Path,
  71. *,
  72. purpose: str,
  73. run_id: Optional[str] = None,
  74. root: Optional[Path] = None,
  75. ) -> Path:
  76. """Create one workbook backup for a run/purpose; return existing backup if present."""
  77. workbook_path = workbook_path.resolve()
  78. run_id = safe_slug(run_id or f"{safe_slug(purpose)}_{timestamp()}")
  79. backup_dir = backups_dir(root)
  80. backup_dir.mkdir(parents=True, exist_ok=True)
  81. backup_path = backup_dir / f"{workbook_path.stem}_backup_before_{safe_slug(purpose)}_{run_id}{workbook_path.suffix}"
  82. if not backup_path.exists():
  83. shutil.copy2(workbook_path, backup_path)
  84. return backup_path
  85. def write_json(path: Path, data: Dict[str, Any]) -> None:
  86. path.parent.mkdir(parents=True, exist_ok=True)
  87. path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  88. def file_size(path: Path) -> int:
  89. return path.stat().st_size if path.exists() and path.is_file() else 0
  90. def iter_legacy_root_artifacts(root: Path) -> Iterable[Path]:
  91. patterns = [
  92. "email_preview*",
  93. "email_retry*",
  94. "*.sent-log.jsonl",
  95. "facebook_social_status_updates_*.json",
  96. ]
  97. seen = set()
  98. for pattern in patterns:
  99. for path in root.glob(pattern):
  100. key = str(path.resolve()).casefold()
  101. if key not in seen:
  102. seen.add(key)
  103. yield path
  104. def build_cleanup_plan(
  105. *,
  106. root: Optional[Path] = None,
  107. retention_days: int = DEFAULT_RETENTION_DAYS,
  108. backup_keep: int = DEFAULT_BACKUP_KEEP,
  109. ) -> List[CleanupItem]:
  110. root = project_root(root)
  111. cutoff = datetime.now() - timedelta(days=retention_days)
  112. items: List[CleanupItem] = []
  113. tmp_dir = root / ".tmp"
  114. if tmp_dir.exists():
  115. for path in tmp_dir.iterdir():
  116. if path.is_file():
  117. items.append(CleanupItem("delete", path, file_size(path), ".tmp temporary file"))
  118. for path in iter_legacy_root_artifacts(root):
  119. if not path.is_file():
  120. continue
  121. reason = "legacy run artifact in project root"
  122. if path.name == "email_preview_summary_rows_357_419_20260724_170613.html" or path.name == "email_preview_summary_rows_357_419_20260724_170613.json":
  123. items.append(CleanupItem("delete", path, file_size(path), "invalid intermediate preview"))
  124. continue
  125. if re.match(r"email_retry_summary_rows_357_419_chunk_\d+_20260724\.json$", path.name):
  126. items.append(CleanupItem("delete", path, file_size(path), "superseded retry manifest"))
  127. continue
  128. target = root / "runs" / "legacy_202607" / path.name
  129. items.append(CleanupItem("move", path, file_size(path), reason, target))
  130. backups = sorted(
  131. [p for p in root.glob("*_backup_*.xlsx") if p.is_file()],
  132. key=lambda p: p.stat().st_mtime,
  133. reverse=True,
  134. )
  135. for idx, path in enumerate(backups):
  136. target = root / "backups" / "legacy_202607" / path.name
  137. # Keep recent backups discoverable by moving, not deleting. The newest
  138. # backup_keep rule applies to future cleanup runs inside backups/.
  139. items.append(CleanupItem("move", path, file_size(path), f"legacy workbook backup #{idx + 1}", target))
  140. backup_groups: Dict[str, List[Path]] = {}
  141. for path in (root / "backups").rglob("*_backup_*.xlsx") if (root / "backups").exists() else []:
  142. key = re.sub(r"_backup_before_.+$", "", path.name)
  143. backup_groups.setdefault(key, []).append(path)
  144. for group in backup_groups.values():
  145. group.sort(key=lambda p: p.stat().st_mtime, reverse=True)
  146. for path in group[backup_keep:]:
  147. modified = datetime.fromtimestamp(path.stat().st_mtime)
  148. if modified < cutoff:
  149. target = root / "archives" / "old_backups" / (path.name + ".zip")
  150. items.append(CleanupItem("archive", path, file_size(path), "old backup beyond retention", target))
  151. return items
  152. def apply_cleanup_plan(items: Iterable[CleanupItem], *, dry_run: bool = True) -> Dict[str, Any]:
  153. applied = []
  154. total_size = 0
  155. for item in items:
  156. total_size += item.size
  157. entry = {
  158. "action": item.action,
  159. "path": str(item.path),
  160. "target": str(item.target) if item.target else "",
  161. "size": item.size,
  162. "reason": item.reason,
  163. }
  164. if not dry_run:
  165. try:
  166. if item.action == "delete":
  167. item.path.unlink(missing_ok=True)
  168. elif item.action == "move" and item.target:
  169. item.target.parent.mkdir(parents=True, exist_ok=True)
  170. if item.target.exists():
  171. item.target = item.target.with_name(f"{item.target.stem}_{timestamp()}{item.target.suffix}")
  172. shutil.move(str(item.path), str(item.target))
  173. entry["target"] = str(item.target)
  174. elif item.action == "archive" and item.target:
  175. item.target.parent.mkdir(parents=True, exist_ok=True)
  176. with zipfile.ZipFile(item.target, "w", compression=zipfile.ZIP_DEFLATED) as archive:
  177. archive.write(item.path, arcname=item.path.name)
  178. item.path.unlink(missing_ok=True)
  179. entry["status"] = "ok"
  180. except Exception as exc: # keep cleanup best-effort and auditable
  181. entry["status"] = "failed"
  182. entry["error"] = str(exc)
  183. applied.append(entry)
  184. return {
  185. "dry_run": dry_run,
  186. "count": len(applied),
  187. "total_size_bytes": total_size,
  188. "total_size_mb": round(total_size / 1024 / 1024, 3),
  189. "items": applied,
  190. }
  191. def main(argv: Optional[List[str]] = None) -> int:
  192. parser = argparse.ArgumentParser(description="Preview or apply Wuling skill artifact cleanup.")
  193. parser.add_argument("--apply", action="store_true", help="Actually move/delete/archive files. Omit for dry-run.")
  194. parser.add_argument("--retention-days", type=int, default=DEFAULT_RETENTION_DAYS)
  195. parser.add_argument("--backup-keep", type=int, default=DEFAULT_BACKUP_KEEP)
  196. args = parser.parse_args(argv)
  197. plan = build_cleanup_plan(retention_days=args.retention_days, backup_keep=args.backup_keep)
  198. report = apply_cleanup_plan(plan, dry_run=not args.apply)
  199. print(json.dumps(report, ensure_ascii=False, indent=2))
  200. return 0
  201. if __name__ == "__main__":
  202. raise SystemExit(main())