Step 10j (host + extension): cache contract three-state UX
Completes the two-tier cache contract from step 9 / docs/CACHE_CONTRACT.md
on the extension side. The Python side shipped in the Python repo at
33c495a.
Host (rcjav-host.py):
- fetch_rules_info() memoizes per-script-path calls to
`rc-jav.py --print-rules-info` so handle_cache_status doesn't pay
the Python startup cost on every poll.
- _cache_freshness_fields(data, rules_info) computes the new
cache_schema / id_rules / id_rules_signature trio + their three
*_match booleans + cache_state ('fresh' / 'stale_by_rules' /
'schema_mismatch' / 'missing'). Legacy version:3 caches still on
disk report as stale_by_rules with cache_schema_match=True (we'll
migrate them at next load_cache).
- New handle_reextract_ids() action forwards to
`rc-jav.py --reextract --format json` with a 5-minute timeout.
background.js:
- New `reextract-ids` message forwards to host with a 300s timeout.
options-cache.js + options-library-issues.js:
- renderCacheContractBanner() paints a three-state banner above the
per-remote list: green ✓ fresh / amber ! stale-by-rules (with
"Re-extract IDs (fast, no rescan)" chip button) / red ✗ schema
mismatch. Includes a snippet of the cache signature for diagnostics.
- Delegated click handler in options-library-issues.js catches
.cache-reextract, sends the message, shows transient
"Re-extracting…" state, and replaces the button with a per-summary
line ("Re-extracted N IDs · X changed · Y unchanged · Z dropped").
- rules_info_error from the host surfaces as its own amber line above
the banner.
node --check passes on background.js, options-cache.js,
options-library-issues.js individually and on the concatenation of all
four script files. python -m py_compile passes on rcjav-host.py.
Behavioral verification requires reloading the unpacked extension and
running through:
- Check Cache → banner shows "stale by rules" amber (legacy v3 cache)
- Click "Re-extract IDs" → fast path runs, summary appears
- Check Cache again → banner now shows "Cache up to date" green
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+115
-1
@@ -218,6 +218,40 @@ def run_rcjav(args: list[str], timeout: int = 120, extra_flags: list[str] | None
|
||||
return 1, "", f"spawn error: {e}"
|
||||
|
||||
|
||||
# Memoize rules-info per script path so handle_cache_status doesn't pay
|
||||
# the Python startup cost on every poll. Invalidate on rcjav_path change.
|
||||
_RULES_INFO_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
def fetch_rules_info(rcjav_path: str | None) -> dict:
|
||||
"""Get cache contract constants + current rules signature from rc-jav.py.
|
||||
|
||||
Returns {ok, cache_schema, id_rules, id_rules_signature} on success,
|
||||
or {ok: False, error: ...} when the lookup fails. Memoized per script
|
||||
path; the result is cached for the lifetime of this host process.
|
||||
"""
|
||||
script = resolve_rcjav(rcjav_path)
|
||||
key = str(script)
|
||||
cached = _RULES_INFO_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
rc, stdout, stderr = run_rcjav(
|
||||
["--print-rules-info"],
|
||||
extra_flags=[], # NB: omit --basic / --no-color, --print-rules-info already JSON
|
||||
rcjav_path=rcjav_path,
|
||||
timeout=30,
|
||||
)
|
||||
if rc != 0:
|
||||
info = {"ok": False, "error": (stderr or stdout or "unknown").strip()}
|
||||
else:
|
||||
try:
|
||||
info = json.loads(stdout.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
info = {"ok": False, "error": f"invalid rules-info JSON: {e}"}
|
||||
_RULES_INFO_CACHE[key] = info
|
||||
return info
|
||||
|
||||
|
||||
def part_pattern_args(payload: dict) -> list[str]:
|
||||
args: list[str] = []
|
||||
for pattern in payload.get("part_patterns") or []:
|
||||
@@ -1398,6 +1432,83 @@ def _describe_skipped_id(path: str, remote: str = "") -> dict:
|
||||
return {"path": path, "name": name, "ext": ext, "reason": reason, "full_path": full_path}
|
||||
|
||||
|
||||
def _cache_freshness_fields(data: dict | None, rules_info: dict) -> dict:
|
||||
"""Build the cache-contract fields surfaced to the extension.
|
||||
|
||||
`data` is the parsed cache.json (or None when the file is missing).
|
||||
`rules_info` is the dict returned by fetch_rules_info; when its
|
||||
`ok` flag is False we report match-flags as None and let the UI
|
||||
decide whether to show a "rules lookup failed" state.
|
||||
"""
|
||||
out: dict = {
|
||||
# Legacy field — preserved for any consumer that still reads it.
|
||||
"version": (data or {}).get("version") if data else None,
|
||||
# New two-tier contract:
|
||||
"cache_schema": (data or {}).get("cache_schema") if data else None,
|
||||
"id_rules": (data or {}).get("id_rules") if data else None,
|
||||
"id_rules_signature": (data or {}).get("id_rules_signature") if data else None,
|
||||
"expected_cache_schema": None,
|
||||
"expected_id_rules": None,
|
||||
"expected_id_rules_signature": None,
|
||||
"cache_schema_match": None,
|
||||
"id_rules_match": None,
|
||||
"id_rules_signature_match": None,
|
||||
"cache_state": None, # 'fresh' | 'stale_by_rules' | 'schema_mismatch' | 'missing'
|
||||
"rules_info_error": None,
|
||||
}
|
||||
if not rules_info.get("ok"):
|
||||
out["rules_info_error"] = rules_info.get("error") or "rules lookup failed"
|
||||
return out
|
||||
out["expected_cache_schema"] = rules_info.get("cache_schema")
|
||||
out["expected_id_rules"] = rules_info.get("id_rules")
|
||||
out["expected_id_rules_signature"] = rules_info.get("id_rules_signature")
|
||||
if data is None:
|
||||
out["cache_state"] = "missing"
|
||||
return out
|
||||
# Legacy version:3 cache (pre-migration on disk): treat as stale_by_rules.
|
||||
if "cache_schema" not in data and data.get("version") == 3:
|
||||
out["cache_state"] = "stale_by_rules"
|
||||
out["cache_schema_match"] = True # we'll migrate at next load_cache
|
||||
out["id_rules_match"] = False
|
||||
out["id_rules_signature_match"] = False
|
||||
return out
|
||||
schema_match = data.get("cache_schema") == out["expected_cache_schema"]
|
||||
rules_match = data.get("id_rules") == out["expected_id_rules"]
|
||||
sig_match = data.get("id_rules_signature") == out["expected_id_rules_signature"]
|
||||
out["cache_schema_match"] = schema_match
|
||||
out["id_rules_match"] = rules_match
|
||||
out["id_rules_signature_match"] = sig_match
|
||||
if not schema_match:
|
||||
out["cache_state"] = "schema_mismatch"
|
||||
elif rules_match and sig_match:
|
||||
out["cache_state"] = "fresh"
|
||||
else:
|
||||
out["cache_state"] = "stale_by_rules"
|
||||
return out
|
||||
|
||||
|
||||
def handle_reextract_ids(payload: dict) -> dict:
|
||||
"""Trigger a fast re-extract of jav_ids against the current rule set.
|
||||
|
||||
No rclone calls — walks the on-disk cache.json. Stamps current rules
|
||||
+ signature into the cache on success so the next cache_status call
|
||||
reports `cache_state: "fresh"`.
|
||||
"""
|
||||
rc, stdout, stderr = run_rcjav(
|
||||
["--reextract", "--format", "json"],
|
||||
extra_flags=[], # --reextract already JSON; --basic would prepend noise
|
||||
rcjav_path=payload.get("rcjav_path"),
|
||||
timeout=300,
|
||||
)
|
||||
if rc != 0:
|
||||
return {"ok": False, "error": (stderr or stdout or "unknown").strip()}
|
||||
try:
|
||||
result = json.loads(stdout.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
return {"ok": False, "error": f"invalid reextract JSON: {e}"}
|
||||
return result
|
||||
|
||||
|
||||
def handle_cache_status(payload: dict) -> dict:
|
||||
script = resolve_rcjav(payload.get("rcjav_path", ""))
|
||||
cache_path = script.parent / "cache.json"
|
||||
@@ -1413,6 +1524,7 @@ def handle_cache_status(payload: dict) -> dict:
|
||||
stale_hours = _stale_hours(payload)
|
||||
scan_state = _read_scan_state()
|
||||
configured_roots = set((configured.get("default_source") or []) + (configured.get("default_target") or []))
|
||||
rules_info = fetch_rules_info(payload.get("rcjav_path", ""))
|
||||
if not cache_path.exists():
|
||||
remotes = [{
|
||||
"remote": remote,
|
||||
@@ -1431,6 +1543,7 @@ def handle_cache_status(payload: dict) -> dict:
|
||||
"stale_hours": stale_hours,
|
||||
"scan_state": scan_state,
|
||||
"remotes": remotes,
|
||||
**_cache_freshness_fields(None, rules_info),
|
||||
}
|
||||
try:
|
||||
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
@@ -1525,12 +1638,12 @@ def handle_cache_status(payload: dict) -> dict:
|
||||
"ok": True,
|
||||
"cache_exists": True,
|
||||
"cache_path": str(cache_path),
|
||||
"version": data.get("version"),
|
||||
"stale_hours": stale_hours,
|
||||
"configured": configured,
|
||||
"scan_state": scan_state,
|
||||
"remotes": remotes,
|
||||
"warnings": warnings,
|
||||
**_cache_freshness_fields(data, rules_info),
|
||||
}
|
||||
|
||||
|
||||
@@ -1980,6 +2093,7 @@ DISPATCH = {
|
||||
"host_status": handle_host_status,
|
||||
"host_repair": handle_host_repair,
|
||||
"cache_status": handle_cache_status,
|
||||
"reextract_ids": handle_reextract_ids,
|
||||
"recent_deletes": handle_recent_deletes,
|
||||
"undo_delete": handle_undo_delete,
|
||||
"scan": handle_scan,
|
||||
|
||||
Reference in New Issue
Block a user