CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

airport_facts_cache.pyskills/drive-engine/

"""Cross-sweep cache of STATIC airport facts — IATA code, country flag, IANA tz.

Those three are immutable per airport, but the sweep re-fetched them from byAir
every ~30-min cycle: 13 airports × ~0.6s = ~7.6s re-paid each run, the dominant
cost that pushed the plan phase past its old budget and froze the calendar
(#211). This persists them so a warm sweep resolves a known airport with zero
network calls.

NOT cached here: byAir's live `delay.index` congestion nudge, which shifts
through the day. The sweep fetches that live and only for near-term departures,
where it actually moves the block (`reconcile_sweep.resolve_airport`).

Hint, not authority (per `coding-policy: stateful-artifacts`). A missing OR
unreadable OR future-versioned file simply means "resolve from byAir this
sweep" — the static facts re-derive trivially, so a bad cache degrades to the
pre-cache behaviour (one slow sweep) and never raises. That is the DELIBERATE
OPPOSITE of `skip_state.py`, whose empty fallback would resurrect declined
meetings as a nag: here an empty fallback costs latency only, never
correctness, so the safe move is to swallow-and-refetch, not fail closed.

State file (see `state-schema.md`):
    <state_dir>/airport-facts.json
    {"schema_version": 1, "airports": {"<airport_id>": {"iata","flag","tz"}}}

stdlib-only per `coding-policy: dependency-management` (Stdlib First).

Public API:
    from airport_facts_cache import StaticAirport, load_static_facts, store_static_facts

    facts = load_static_facts()               # {airport_id: StaticAirport}
    facts[3] = StaticAirport("JFK", "🇺🇸", "America/New_York")
    store_static_facts(facts)                 # atomic rewrite
"""

from __future__ import annotations

import json
import os
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path

# Co-locates with the skip store so all drive-engine state lives in one dir; the
# owner of the directory path is `skip_state.state_dir` (single source of truth,
# same `DRIVE_PLANNER_STATE_DIR` override tests point at a tmp_path).
from skip_state import state_dir

AIRPORT_FACTS_SCHEMA_VERSION = 1

_FACTS_FILE = "airport-facts.json"


@dataclass(frozen=True)
class StaticAirport:
    """The immutable byAir facts for one airport. `iata` is always present — a
    None-IATA resolution is a transient miss, never cached (it would pin a flight
    as unresolvable forever)."""

    iata: str
    flag: str | None = None
    timezone: str | None = None


def _facts_path() -> Path:
    return state_dir() / _FACTS_FILE


def _clean_str(value: object) -> str | None:
    """Strip a string value to its non-empty core, else None — matching
    `airport_context._as_str` so a cached fact reads back the same shape byAir
    would have produced (no whitespace padding, no empty strings)."""
    return value.strip() or None if isinstance(value, str) else None


def load_static_facts() -> dict[int, StaticAirport]:
    """Return the persisted `{airport_id: StaticAirport}` map, or `{}` when there
    is no usable cache.

    A missing, unreadable, malformed, or future-versioned file all resolve to
    `{}` — the sweep then re-fetches from byAir, exactly the pre-cache behaviour.
    A diagnostic goes to stderr for anything other than a plain missing file, so
    a corrupt cache is visible without being fatal (fail visibly, don't fail the
    sweep). Malformed individual entries are dropped; a well-formed remainder is
    still returned.
    """
    path = _facts_path()
    if not path.exists():
        return {}
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        print(
            f"[drive-engine] airport-facts cache {path} unreadable ({exc}); "
            "resolving airports from byAir this sweep",
            file=sys.stderr,
        )
        return {}
    if not isinstance(payload, dict):
        print(
            f"[drive-engine] airport-facts cache {path} is not a JSON object; refetching",
            file=sys.stderr,
        )
        return {}
    version = payload.get("schema_version")
    if version != AIRPORT_FACTS_SCHEMA_VERSION:
        # A future version is not an error for a pure hint cache — treat it as
        # "no usable prior state" and refetch (safe, non-disruptive: costs one
        # slow sweep, never a wrong block). A newer writer's file survives
        # untouched until this reader is upgraded (`stateful-artifacts`).
        #
        # The refetch is safe even if byAir is ALSO down: a cache-miss airport
        # that byAir can't resolve makes `reconcile_sweep._resolve_one_airport`
        # raise `AirportUnresolved`, failing the whole sweep closed rather than
        # building a partial plan that would orphan-delete live blocks (#211).
        print(
            f"[drive-engine] airport-facts cache {path} schema_version {version!r} "
            f"!= {AIRPORT_FACTS_SCHEMA_VERSION}; refetching this sweep",
            file=sys.stderr,
        )
        return {}
    airports = payload.get("airports")
    if not isinstance(airports, dict):
        print(
            f"[drive-engine] airport-facts cache {path} `airports` is not a JSON "
            "object; refetching",
            file=sys.stderr,
        )
        return {}
    facts: dict[int, StaticAirport] = {}
    for raw_id, entry in airports.items():
        try:
            airport_id = int(raw_id)
        except (TypeError, ValueError):
            continue
        if not isinstance(entry, dict):
            continue
        # Normalize like `airport_context._as_str` (strip; empty → None) so a
        # hand-edited or partially-corrupt cache can't propagate "JFK " or "" into
        # routing / block creation.
        iata = _clean_str(entry.get("iata"))
        if iata is None:
            continue
        facts[airport_id] = StaticAirport(
            iata=iata,
            flag=_clean_str(entry.get("flag")),
            timezone=_clean_str(entry.get("tz")),
        )
    return facts


def store_static_facts(facts: dict[int, StaticAirport]) -> None:
    """Atomically persist the `{airport_id: StaticAirport}` map (temp file +
    rename, so a crash mid-write never strands a half-written cache).

    Entries are keyed by the airport id as a string (JSON object keys are
    strings). The write is best-effort from the caller's view — it is only a
    latency optimisation — but a failure to write still propagates so a genuinely
    broken state dir surfaces rather than silently disabling the cache forever.
    """
    payload = {
        "schema_version": AIRPORT_FACTS_SCHEMA_VERSION,
        "airports": {
            str(airport_id): {
                "iata": fact.iata,
                "flag": fact.flag,
                "tz": fact.timezone,
            }
            for airport_id, fact in sorted(facts.items())
        },
    }
    path = _facts_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(payload, handle, indent=2, sort_keys=True)
        os.replace(tmp, path)
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)

README.md

tile.json