Files
workinf_Blender_Wasm/my-tools/wbv2_step_cache.py
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

214 lines
9.0 KiB
Python

"""Content-pinned checkpoints for successful local validation steps."""
from __future__ import annotations
import copy
import hashlib
import json
import math
import os
from pathlib import Path
import re
import tempfile
_SHA256 = re.compile(r"[0-9a-f]{64}\Z")
_FAILURE_FLAGS = (
"interrupted", "timedOut", "idleTimedOut", "logLimitExceeded",
"providerAborted", "aborted", "cancelled", "outputLimitExceeded",
)
def _encoded(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False).encode("utf-8")
def _digest(path):
result = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
result.update(chunk)
return result.hexdigest()
class StepCache:
def __init__(self, root, directory):
self.root = Path(root).resolve(strict=True)
if not self.root.is_dir():
raise ValueError("cache root must be a directory")
self.directory = self._path(directory, existing=False)
self.directory.mkdir(parents=True, exist_ok=True)
self._pins = {}
def _path(self, value, *, existing=True):
if not isinstance(value, (str, os.PathLike)) or not str(value):
raise ValueError("missing path")
candidate = Path(value)
if ".." in candidate.parts:
raise ValueError("path traversal is not permitted")
if not candidate.is_absolute():
candidate = self.root / candidate
resolved = candidate.resolve(strict=existing)
if not resolved.is_relative_to(self.root):
raise ValueError("path must remain under root")
return resolved
def _hashes(self, values):
if not isinstance(values, dict) or not values:
raise ValueError("nonempty input hashes are required")
normalized = {}
for name, digest in values.items():
if not isinstance(digest, str) or not _SHA256.fullmatch(digest):
raise ValueError("invalid SHA-256")
path = self._path(name)
if not path.is_file():
raise ValueError("input must be a file")
relative = self._relative(name)
if relative in normalized and normalized[relative] != digest:
raise ValueError("conflicting input hashes")
normalized[relative] = digest
return normalized
def _relative(self, value):
path = Path(value)
if not path.is_absolute():
path = self.root / path
return path.relative_to(self.root).as_posix()
def key(self, job, check_id, argv, cwd, env, unset_env, input_hashes):
if not isinstance(check_id, str) or not check_id:
raise ValueError("check id is required")
if not isinstance(argv, (list, tuple)) or not argv or any(
not isinstance(part, str) for part in argv):
raise ValueError("argv must contain strings")
if not isinstance(env, dict) or any(
not isinstance(k, str) or not isinstance(v, str)
for k, v in env.items()):
raise ValueError("environment must contain strings")
if not isinstance(unset_env, (list, tuple)) or any(
not isinstance(part, str) for part in unset_env):
raise ValueError("unset environment must contain strings")
directory = self._path(cwd)
if not directory.is_dir():
raise ValueError("cwd must be a directory")
hashes = self._hashes(input_hashes)
payload = {
"schemaVersion": 1, "job": job, "checkId": check_id,
"argv": list(argv), "cwd": directory.relative_to(self.root).as_posix(),
"env": env, "unsetEnv": sorted(set(unset_env)), "inputs": hashes,
}
key = hashlib.sha256(_encoded(payload)).hexdigest()
self._pins[key] = hashes
return key
def _record_path(self, key):
if not isinstance(key, str) or not _SHA256.fullmatch(key):
raise ValueError("invalid cache key")
directory = self._path(self.directory)
return directory / (key + ".json")
def _verify_hashes(self, hashes):
normalized = self._hashes(hashes)
for name, expected in normalized.items():
if _digest(self._path(name)) != expected:
raise ValueError("input content changed")
return normalized
def _verify_receipt(self, receipt):
if not isinstance(receipt, dict):
raise ValueError("receipt must be an object")
if type(receipt.get("exitCode")) is not int or receipt["exitCode"] != 0:
raise ValueError("only successful checks can be reused")
if any(receipt.get(flag) is not None and receipt.get(flag) is not False
for flag in _FAILURE_FLAGS):
raise ValueError("interrupted checks cannot be reused")
if receipt.get("signal") or receipt.get("stopReason"):
raise ValueError("terminated checks cannot be reused")
duration = receipt.get("durationSeconds")
if type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0:
raise ValueError("invalid execution duration")
expected = receipt.get("logSha256")
if not isinstance(expected, str) or not _SHA256.fullmatch(expected):
raise ValueError("log hash is required")
log = self._path(receipt.get("logPath", receipt.get("log")))
if not log.is_file() or _digest(log) != expected:
raise ValueError("log content changed")
for name in ("cwd",):
if name in receipt:
self._path(receipt[name])
if any(name in receipt for name in ("env", "environment", "environmentVariables")):
raise ValueError("raw environment cannot be cached")
_encoded(receipt)
def _verify_artifacts(self, artifacts):
if not isinstance(artifacts, list) or not artifacts:
raise ValueError("output artifact hashes are required")
normalized = []
seen = set()
for artifact in artifacts:
if not isinstance(artifact, dict):
raise ValueError("artifact must be an object")
expected = artifact.get("sha256")
if not isinstance(expected, str) or not _SHA256.fullmatch(expected):
raise ValueError("artifact hash is required")
path = self._path(artifact.get("path"))
if not path.is_file() or _digest(path) != expected:
raise ValueError("artifact content changed")
relative = self._relative(artifact["path"])
if relative in seen:
raise ValueError("duplicate artifact path")
seen.add(relative)
normalized.append({"path": relative, "sha256": expected})
return normalized
def store(self, key, process_receipt, artifacts):
temporary = None
try:
target = self._record_path(key)
hashes = self._verify_hashes(self._pins.get(key))
self._verify_receipt(process_receipt)
outputs = self._verify_artifacts(artifacts)
record = {"schemaVersion": 1, "key": key, "inputHashes": hashes,
"processReceipt": process_receipt, "artifacts": outputs}
data = _encoded(record)
with tempfile.NamedTemporaryFile(mode="wb", prefix=".step-", suffix=".tmp",
dir=target.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
temporary = None
except (OSError, ValueError, TypeError, OverflowError, RuntimeError):
return
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def load(self, key):
try:
path = self._path(self._record_path(key))
record = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(record, dict) or type(record.get("schemaVersion")) is not int:
return None
if record["schemaVersion"] != 1 or record.get("key") != key:
return None
hashes = self._verify_hashes(record.get("inputHashes"))
if key in self._pins and self._pins[key] != hashes:
return None
self._verify_artifacts(record.get("artifacts"))
receipt = record.get("processReceipt")
self._verify_receipt(receipt)
result = copy.deepcopy(receipt)
result["reusedFrom"] = {
"cacheKey": key,
"recordPath": path.relative_to(self.root).as_posix(),
"logPath": receipt.get("logPath", receipt.get("log")),
"logSha256": receipt["logSha256"],
"durationSeconds": receipt["durationSeconds"],
}
return result
except (OSError, ValueError, TypeError, OverflowError, RuntimeError):
return None