839051f532
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 1m50s
Project CI / Backend tests (pull_request) Failing after 24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 6m20s
- 删除 agent/runtime_driver、runtime_protocol、runtime_tools、runtime_actions、runtime_state、runtime_adapter、context_compaction、collaboration、delegation、goal 等自建 Runtime 执行面及专属测试与 fixture - CliCommand 收敛为 LlmStatus | EnvironmentCheck | PreviewServe,删除 --agent-run / --agent-task / --agent-goal-* / --agent-resume / --agent-context-compact 等 CLI 与 Tauri 命令白名单条目 - 外部 Runner 收缩为编辑器桥 RPC 与 runner.attach_gui_owner + GUI owner 参与锁 / watchdog,保留 --agent-runner - 前端删除 AgentRuntime* / AgentGoal* 状态与命令调用链,移除 agent-runtime real-e2e / steer / smoke / llm-transient-fault 脚本与对应 CI job - 内置工具错误收口为 typed error,抽取 agent/tool/error.rs 共享错误供各工具复用 - 新增 agent/redaction.rs、runtime_models.rs、json_sidecar.rs、task_lock.rs、process_identity.rs、art_manifest.rs、design_session.rs、direct_events.rs - 同步更新 ADR、decision-log、Runtime V1.1、App 实施计划、本地开发运维、pitfalls 与 CI 脚本
115 lines
4.6 KiB
Python
115 lines
4.6 KiB
Python
"""定向回收 Gitea 1.26.4 不会自动过期的本仓库缓存上传块。"""
|
|
|
|
import base64
|
|
import binascii
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
|
|
JOBS = {
|
|
"ai-game-creator-shell-rust-lane-1",
|
|
"ai-game-creator-shell-rust-lane-2",
|
|
"ai-game-creator-shell-rust-crates",
|
|
"backend-tests",
|
|
"native-shell-tests",
|
|
}
|
|
CHUNK = re.compile(r"block-([1-9][0-9]*)-([1-9][0-9]*)-(0|[1-9][0-9]*)-([A-Za-z0-9_-]+={0,2})")
|
|
BLOCKLIST = re.compile(r"([1-9][0-9]*)-([1-9][0-9]*)-blocklist")
|
|
MARKER = re.compile(r"genarrative-rust-cache-v1:([a-z0-9-]+):(0|[1-9][0-9]*):([0-9]{8})")
|
|
|
|
|
|
def decode_owner(encoded):
|
|
"""Gitea 文件名编码为 URL-base64(生产者上传的标准 base64 blockid)。"""
|
|
try:
|
|
block_id = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
|
if base64.urlsafe_b64encode(block_id).decode() != encoded:
|
|
return None
|
|
marker = base64.b64decode(block_id, validate=True)
|
|
if base64.b64encode(marker) != block_id:
|
|
return None
|
|
match = MARKER.fullmatch(marker.decode("ascii"))
|
|
if match and match[1] in JOBS:
|
|
return match[1], int(match[2])
|
|
except (binascii.Error, UnicodeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _unchanged_old_regular(path, previous, cutoff):
|
|
try:
|
|
current = path.lstat()
|
|
except FileNotFoundError:
|
|
return False
|
|
return (
|
|
stat.S_ISREG(current.st_mode)
|
|
and current.st_mtime < cutoff
|
|
and (current.st_ino, current.st_size, current.st_mtime_ns)
|
|
== (previous.st_ino, previous.st_size, previous.st_mtime_ns)
|
|
)
|
|
|
|
|
|
def cleanup_upload_chunks(storage_root: Path, eligible_runs: set[int], cutoff_timestamp: float) -> int:
|
|
"""调用方先用仓库 API 确认这些 master run 已结束超过保留期限。"""
|
|
storage_root = Path(storage_root)
|
|
if not storage_root.is_absolute() or storage_root.resolve(strict=True) != storage_root:
|
|
raise ValueError("artifact storage root must be an existing absolute real directory")
|
|
if not stat.S_ISDIR(storage_root.lstat().st_mode):
|
|
raise ValueError("artifact storage root must be a directory")
|
|
if not math.isfinite(cutoff_timestamp) or cutoff_timestamp <= 0:
|
|
raise ValueError("invalid artifact cleanup cutoff")
|
|
if any(type(run_id) is not int or run_id <= 0 for run_id in eligible_runs):
|
|
raise ValueError("invalid eligible run ID")
|
|
temporary = storage_root / "tmp-upload"
|
|
if not temporary.exists() and not temporary.is_symlink():
|
|
return 0
|
|
if temporary.is_symlink() or not temporary.is_dir():
|
|
raise ValueError("artifact temporary path must be a real directory")
|
|
|
|
deleted = 0
|
|
for run_id in sorted(eligible_runs):
|
|
directory = temporary / f"run-{run_id}-v4"
|
|
if directory.is_symlink() or not directory.is_dir():
|
|
continue
|
|
groups = {}
|
|
unknown = False
|
|
for path in directory.iterdir():
|
|
chunk = CHUNK.fullmatch(path.name)
|
|
blocklist = BLOCKLIST.fullmatch(path.name)
|
|
match = chunk or blocklist
|
|
if match and int(match[1]) == run_id:
|
|
artifact_id = int(match[2])
|
|
owner = decode_owner(chunk[4]) if chunk else None
|
|
owned = owner is not None if chunk else True
|
|
else:
|
|
# 若仍能识别 artifact ID,仅保护该组;无法识别时保护整个 run。
|
|
prefix = re.match(rf"(?:block-)?{run_id}-([1-9][0-9]*)-", path.name)
|
|
if not prefix:
|
|
unknown = True
|
|
break
|
|
artifact_id, owner, owned = int(prefix[1]), None, False
|
|
group = groups.setdefault(artifact_id, {"files": [], "owners": set(), "safe": True})
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
group["safe"] = False
|
|
continue
|
|
group["safe"] &= owned and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff_timestamp
|
|
if owner:
|
|
group["owners"].add(owner)
|
|
group["files"].append((path, info))
|
|
if unknown:
|
|
continue
|
|
for group in groups.values():
|
|
if not group["safe"] or len(group["owners"]) != 1:
|
|
continue
|
|
files = group["files"]
|
|
if not all(_unchanged_old_regular(path, info, cutoff_timestamp) for path, info in files):
|
|
continue
|
|
# 保留目录,不使用递归删除;没有所属块证明的孤立 blocklist 也不会删除。
|
|
for path, _ in files:
|
|
path.unlink()
|
|
deleted += 1
|
|
return deleted
|