mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
235 lines
8.9 KiB
Python
Executable File
235 lines
8.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Cache hit/miss check for investigating-agentforce-architecture.
|
|
|
|
Replaces old agent Phase 0.5 (manifest read, schema-version check, TTL age
|
|
check, path-existence check, env export on hit, RESULT-block assembly).
|
|
|
|
Flow:
|
|
1. If FORCE_REFRESH=true → emit CACHE_HIT=false, exit 0.
|
|
2. Read $CACHE_DIR/manifest.json. Any I/O or parse failure → miss.
|
|
3. if schema_version != config.SCHEMA_VERSION → miss,
|
|
emit CACHE_INVALIDATED_REASON=schema-version-mismatch, and
|
|
`shutil.rmtree` the cache dir (gated to paths under CACHE_ROOT).
|
|
4. If data_path file missing → miss (atomic rename failed mid-write).
|
|
5. If age > ttl_days (default 7) → miss (no rmtree — just stale).
|
|
6. Hit path:
|
|
- Copy {agent}_{ver}_metadata_tree.json from cache into DATA_DIR +
|
|
WORK_DIR.
|
|
- Copy `declared_action_tree.json` sidecar into WORK_DIR so emit ctx
|
|
+ downstream greps work.
|
|
- Stdout: eval-able lines (CACHE_HIT=true, CACHED_AT_UTC, NODE_COUNT,
|
|
DEPTH, AGENT_GENERATION, BOT_ID, VERSION_AUTO_PICKED, PARTIAL,
|
|
UNRESOLVED_COUNT, OUTPUT_JSON_PATH, OUTPUT_SUMMARY_PATH).
|
|
|
|
`{tree_base}.summary.md` was dropped from the output contract.
|
|
Cache-hit no longer copies or regenerates a summary file; OUTPUT_SUMMARY_PATH
|
|
is emitted empty for RESULT-block shape stability.
|
|
|
|
Usage:
|
|
eval "$(python3 cache_check.py)"
|
|
|
|
Inputs (env):
|
|
CACHE_DIR required — $CACHE_ROOT/$ORG_ID_15/$AGENT__$VER
|
|
DATA_DIR required — $DATA_ROOT/$ORG_ID_15/$AGENT__$VER
|
|
WORK_DIR required
|
|
AGENT_API_NAME required
|
|
AGENT_VERSION required
|
|
FORCE_REFRESH 'true'|'false' (default false)
|
|
|
|
Outputs:
|
|
stdout: eval-able K=V lines
|
|
files (hit only): DATA_DIR/{tree_base}.json and matching copy in WORK_DIR
|
|
exit 0 always (miss is not an error)
|
|
"""
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shlex
|
|
import shutil
|
|
import sys
|
|
import datetime as dt
|
|
|
|
import config # for SCHEMA_VERSION + CACHE_ROOT path-guard
|
|
|
|
|
|
# refuse to operate if CACHE_ROOT itself is a symlink.
|
|
# _safe_rmtree_under_cache_root calls resolve() on both target and root; a
|
|
# symlinked CACHE_ROOT collapses the is_relative_to check because both sides
|
|
# land on the symlink target, so rmtree would happily delete whatever lives
|
|
# there — outside the sanctioned cache subtree. The guard was designed to
|
|
# prevent rmtree escaping the cache; if the root itself can escape, the
|
|
# guard is moot. Fail fast at import time so the failure surfaces at the
|
|
# correct layer (configuration, not deletion).
|
|
#
|
|
# Path.is_symlink() returns False for non-existent paths, which is the
|
|
# correct behaviour for pristine installs (CACHE_ROOT hasn't been created
|
|
# yet; the script will mkdir it on first write and subsequent imports will
|
|
# validate the real directory).
|
|
if config.CACHE_ROOT.is_symlink():
|
|
raise RuntimeError(
|
|
f"CACHE_ROOT is a symlink ({config.CACHE_ROOT} -> "
|
|
f"{config.CACHE_ROOT.resolve()}). This is rejected for safety; the "
|
|
"_safe_rmtree_under_cache_root guard cannot protect against a "
|
|
"symlinked-root escape. Resolve the symlink or update "
|
|
"config.CACHE_ROOT to a real directory."
|
|
)
|
|
|
|
|
|
def miss(reason: str | None = None):
|
|
"""Emit CACHE_HIT=false and exit 0.
|
|
|
|
if `reason` is provided, emit a second K=V line
|
|
`CACHE_INVALIDATED_REASON=<reason>` so downstream emit_result can
|
|
surface the invalidation cause in the RESULT block.
|
|
"""
|
|
sys.stdout.write("CACHE_HIT=false\n")
|
|
if reason:
|
|
sys.stdout.write(f"CACHE_INVALIDATED_REASON={shlex.quote(reason)}\n")
|
|
sys.exit(0)
|
|
|
|
|
|
def _safe_rmtree_under_cache_root(target: pathlib.Path) -> bool:
|
|
"""`shutil.rmtree(target)` ONLY if `target` resolves under CACHE_ROOT.
|
|
|
|
Defence against a miscomputed cache_dir that could point outside the
|
|
sanctioned cache root (symlink shenanigans, env-var tampering, or a
|
|
future refactor that forgets to run through `build_agent_cache_dir`).
|
|
We resolve both sides to absolute paths and confirm CACHE_ROOT is a
|
|
prefix before handing to rmtree.
|
|
|
|
Returns True on successful deletion, False if the path was refused or
|
|
the rmtree itself failed. Never raises — callers on the miss() path
|
|
can't act on a failure anyway.
|
|
"""
|
|
try:
|
|
target_abs = target.resolve(strict=False)
|
|
root_abs = config.CACHE_ROOT.resolve(strict=False)
|
|
except (OSError, RuntimeError):
|
|
return False
|
|
# Path.is_relative_to exists on 3.9+. Explicit check is clearer than
|
|
# a try/except on relative_to().
|
|
try:
|
|
if not target_abs.is_relative_to(root_abs):
|
|
return False
|
|
except AttributeError: # pragma: no cover — safety for <3.9
|
|
try:
|
|
target_abs.relative_to(root_abs)
|
|
except ValueError:
|
|
return False
|
|
if not target_abs.exists():
|
|
return True # nothing to do, treat as successful no-op
|
|
try:
|
|
shutil.rmtree(target_abs)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
cache_dir = pathlib.Path(os.environ["CACHE_DIR"])
|
|
data_dir = pathlib.Path(os.environ["DATA_DIR"])
|
|
work_dir = pathlib.Path(os.environ["WORK_DIR"])
|
|
agent_api_name = os.environ["AGENT_API_NAME"]
|
|
agent_version = os.environ["AGENT_VERSION"]
|
|
except KeyError as e:
|
|
sys.stderr.write(f"cache_check.py: missing env {e}\n")
|
|
miss()
|
|
|
|
if (os.environ.get("FORCE_REFRESH", "").strip().lower() == "true"):
|
|
miss()
|
|
|
|
manifest_path = cache_dir / "manifest.json"
|
|
if not manifest_path.is_file():
|
|
miss()
|
|
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
miss()
|
|
|
|
# Schema version gate.
|
|
# strict match against config.SCHEMA_VERSION. Any mismatch (too
|
|
# old OR unexpected future/unknown value) invalidates the cache, and
|
|
# we delete the cache directory so the next run starts clean — a
|
|
# stale tree under a legacy schema is worse than no cache, because
|
|
# downstream code may parse shapes it no longer understands. The
|
|
# legacy `< 2.4` check is subsumed (anything that doesn't equal the
|
|
# current SCHEMA_VERSION triggers this branch).
|
|
schema = str(manifest.get("schema_version") or "0")
|
|
if schema != config.SCHEMA_VERSION:
|
|
_safe_rmtree_under_cache_root(cache_dir)
|
|
miss("schema-version-mismatch")
|
|
|
|
# data_path existence
|
|
data_path_s = manifest.get("data_path") or ""
|
|
data_path = pathlib.Path(data_path_s) if data_path_s else None
|
|
if not data_path or not data_path.is_file():
|
|
miss()
|
|
|
|
# TTL age check
|
|
try:
|
|
built = dt.datetime.fromisoformat(
|
|
(manifest.get("built_at_utc") or "").replace("Z", "+00:00")
|
|
)
|
|
except ValueError:
|
|
miss()
|
|
age_days = (dt.datetime.now(dt.timezone.utc) - built).days
|
|
ttl = int(manifest.get("ttl_days") or 7)
|
|
if age_days > ttl:
|
|
miss()
|
|
|
|
# --- Hit path ---
|
|
tree_base = f"{agent_api_name}_{agent_version}_metadata_tree"
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
dst_json = data_dir / f"{tree_base}.json"
|
|
|
|
# The manifest's data_path points at the authoritative tree copy. Re-copy
|
|
# to DATA_DIR + WORK_DIR so the filesystem layout is stable for callers.
|
|
try:
|
|
shutil.copy(data_path, dst_json)
|
|
except OSError:
|
|
miss()
|
|
|
|
# Stage a declared_action_tree.json sidecar in WORK_DIR for any downstream
|
|
# script that expects the generic name.
|
|
try:
|
|
shutil.copy(dst_json, work_dir / "declared_action_tree.json")
|
|
except OSError:
|
|
pass
|
|
|
|
# no summary.md handling — dropped from the output contract.
|
|
agent_meta = manifest.get("agent") or {}
|
|
kind_counts = manifest.get("kind_counts") or {}
|
|
|
|
exports = [
|
|
("CACHE_HIT", "true"),
|
|
("CACHED_AT_UTC", manifest.get("built_at_utc", "")),
|
|
("NODE_COUNT", str(manifest.get("node_count", 0))),
|
|
("DEPTH", str(manifest.get("depth", 0))),
|
|
("AGENT_GENERATION", agent_meta.get("generation") or "unknown"),
|
|
("BOT_ID", agent_meta.get("bot_id") or ""),
|
|
("BOT_MASTER_LABEL", agent_meta.get("master_label") or ""),
|
|
("VERSION_AUTO_PICKED", "true" if agent_meta.get("_version_auto_picked") else "false"),
|
|
("PARTIAL", "true" if manifest.get("partial") else "false"),
|
|
("UNRESOLVED_COUNT", str(manifest.get("unresolved_count", 0))),
|
|
("OUTPUT_JSON_PATH", str(dst_json)),
|
|
# summary.md dropped from the output contract; field kept
|
|
# empty for RESULT-block shape stability.
|
|
("OUTPUT_SUMMARY_PATH", ""),
|
|
("PLANNER_NAME", agent_meta.get("planner_name") or ""),
|
|
]
|
|
# Pass kind counts too, in case finalize.py runs later without re-parsing
|
|
# (it won't on cache hit, but the values keep the RESULT block complete).
|
|
for k, v in kind_counts.items():
|
|
exports.append((f"KC_{k}", str(v)))
|
|
|
|
sys.stdout.write("\n".join(f"{k}={shlex.quote(v)}" for k, v in exports) + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|