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_block.pyskills/flight-assist/

"""Encode/decode a flight-assist airport drive block — the calendar IS the state.

The airport drive blocks (#90) carry the drive to/from an airport around a
flight. Like the retired drive-planner's meeting blocks (Epic #59 §4),
flight-assist keeps no local store of them: the created calendar event itself
carries everything needed to re-evaluate it, read back by a direct API fetch.
This module is that codec.

It was written as a deliberately SELF-CONTAINED sibling of drive-planner's
`block_props.py` rather than a shared extraction — the two codecs shared a
shape but differed in marker namespace, directions, and anchor semantics, and
a shared module would have needed cross-skill mounting that wasn't worth the
coupling (#90 decision). That sibling is gone (#181, with the rest of the
retired planner); drive-engine's `block_codec.py` is the codec for every block
the engine writes, and this one stays flight-assist's own.

State lives entirely in the event **description**. The Composio toolkit this
plugin shipped on exposed no writable `extendedProperties`, which forced the
choice; the native Calendar API it now speaks does expose them, but every block
already deployed carries its state in the description, so moving is a migration
(tracked separately), not a side effect of the transport swap. The description
carries:

  * the human line (`Drive: → BNA (DL123)` / `Drive: BNA → home`),
  * the self-marker `[flight-assist:flight=<id>:dir=<to_airport|from_airport>]`,
  * a compact `<!--fadrive:{...}-->` JSON comment with the machine state the recheck
    poll reads back: schema version, baseline drive seconds, the anchor instant,
    the routed origin/destination, and the alert-suppression record. Parsed
    defensively — a malformed comment yields `None`, never raises.

Anchor semantics by direction (the `a` field):
  * `to_airport`  — the be-at-the-airport DEADLINE (`dep − clearance`). The block
    runs `[anchor − drive, anchor]`; you must LEAVE BY `anchor − drive`.
  * `from_airport`— the earliest the drive home can START (`actual_arr +
    post_arrival_delay`). The block runs `[anchor, anchor + drive]`.

The write contract (native Calendar events.insert): nested `start` / `end`
objects; `location` carries the destination; `transparency` is "transparent"
(Free) by default (#90 decision); the airport's IANA zone names the event's own
`timeZone` (see `_event_time`).

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

Public API:
    from airport_block import build_block_args, build_description, parse_block, BlockState

    args = build_block_args(
        calendar_id="primary", flight_id="12345", direction="to_airport",
        summary="Drive: → BNA (DL123)", leg_start=leave_by, anchor=be_at_airport_by,
        baseline_seconds=1800, origin="<live location>", destination="BNA",
        timezone="America/Chicago",
    )
    state = parse_block(fetched_event)   # -> BlockState | None
"""

from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

# Self-marker stamped into the description so flight-assist recognizes its own
# airport drive blocks (idempotent create — never duplicate a block for the
# same flight+direction).
_MARKER_TEMPLATE = "[flight-assist:flight={flight_id}:dir={direction}]"
_MARKER_RE = re.compile(r"\[flight-assist:flight=(?P<id>[^:\]]+):dir=(?P<dir>[^:\]]+)\]")

DIRECTIONS = ("to_airport", "from_airport")

# Schema version of the calendar-as-state block record (per `coding-policy:
# stateful-artifacts`). Bump on any shape change to the description state JSON
# and add the owner-side upgrade in `parse_block`.
BLOCK_SCHEMA_VERSION = 1

# The machine state rides in an HTML comment so it stays out of the way in
# calendar UIs while remaining round-trippable. Short keys keep it compact.
# Distinct `fadrive:` prefix (not `fa:`): historically flight-assist's
# calendar_tags.py read `<!--fa:{...}-->` boarding/flight event tags, so the
# airport drive block carried its own prefix to avoid matching those. #200
# dropped that description reader (tags live in extendedProperties now); the
# distinct prefix is retained for the airport block's own description state.
_STATE_RE = re.compile(r"<!--fadrive:(?P<json>\{.*?\})-->", re.DOTALL)
# The version key is spelled out (`schema_version`), not abbreviated like the
# other keys: `coding-policy: stateful-artifacts` requires every record to
# carry an auditable `schema_version` field by that name.
_STATE_KEY_VERSION = "schema_version"
_STATE_KEY_BASELINE = "b"
_STATE_KEY_ANCHOR = "a"
_STATE_KEY_ORIGIN = "o"
_STATE_KEY_DESTINATION = "d"
_STATE_KEY_ALERTED = "al"

ALERT_GROWTH = "growth"
ALERT_LEAVE_NOW = "leave_now"
_ALERT_VALUES = (ALERT_GROWTH, ALERT_LEAVE_NOW)

# How far ahead of a to_airport block's leave-by the recheck poll starts
# evaluating it, and how long after it keeps evaluating. Black-box constants
# (per `coding-policy: script-as-black-box`); the poll overrides via kwargs.
DEFAULT_RECHECK_HORIZON_SECONDS = 45 * 60
DEFAULT_DEPARTED_GRACE_SECONDS = 15 * 60


def build_marker(flight_id: str, direction: str) -> str:
    """The self-marker token for a block serving `flight_id` in `direction`."""
    return _MARKER_TEMPLATE.format(flight_id=flight_id, direction=direction)


def parse_marker(text: object) -> tuple[str, str] | None:
    """Extract `(flight_id, direction)` from a description marker, or None."""
    if not isinstance(text, str):
        return None
    match = _MARKER_RE.search(text)
    return (match["id"], match["dir"]) if match else None


def serialize_alerted(alerted: frozenset | set) -> str:
    """Serialize an alert set to the stable comma-joined record."""
    return ",".join(value for value in _ALERT_VALUES if value in alerted)


def parse_alerted(raw: object) -> frozenset:
    """Parse the comma-joined alert record into a set.

    Unknown tokens are dropped; a non-string yields the empty set. Tolerant by
    design — a corrupt record must not crash the poll; at worst it re-sends an
    alert (annoying, not unsafe).
    """
    if not isinstance(raw, str):
        return frozenset()
    return frozenset(token.strip() for token in raw.split(",") if token.strip() in _ALERT_VALUES)


def build_description(
    *,
    summary: str,
    flight_id: str,
    direction: str,
    baseline_seconds: int,
    anchor: datetime,
    origin: str,
    destination: str,
    alerted: frozenset | set = frozenset(),
) -> str:
    """The full block description: human line + self-marker + state JSON comment.

    The single source of the description format — `build_block_args` uses it for
    create, and the recheck poll re-runs it (with an updated `alerted`) to PATCH
    the suppression record back onto the event.
    """
    state = {
        _STATE_KEY_VERSION: BLOCK_SCHEMA_VERSION,
        _STATE_KEY_BASELINE: baseline_seconds,
        _STATE_KEY_ANCHOR: anchor.isoformat(),
        _STATE_KEY_ORIGIN: origin,
        _STATE_KEY_DESTINATION: destination,
        _STATE_KEY_ALERTED: serialize_alerted(alerted),
    }
    marker = build_marker(flight_id, direction)
    blob = json.dumps(state, separators=(",", ":"))
    return f"{summary}\n{marker}\n<!--fadrive:{blob}-->"


def _event_time(dt: datetime, tz_name: str | None) -> dict:
    """A native Calendar event time object for `dt`, rendered in `tz_name`.

    `dateTime`'s offset IS the instant — Calendar reads it verbatim — so this
    can never land the block at the wrong time the way #131 did (Composio's
    adapter dropped the offset and re-read the wall-clock in its `timezone`
    arg, so a leg computed in the home offset but created with the venue tz
    landed shifted by the delta). What `timeZone` still does natively is name
    the event's own zone, which is what a reader sees in the event details.

    So `dt` is rendered in `tz_name` and `timeZone` names it — the two agree
    and the event is self-describing. A `tz_name` ZoneInfo cannot resolve (a
    raw offset string like "+01:00" — never emitted by `_extract_timezone`,
    but a caller could pass one) is DROPPED rather than forwarded: Calendar
    requires an IANA name and rejects the offset form, and `dt`'s own offset
    already carries the instant, so the block lands correctly with no
    `timeZone` at all.
    """
    if not tz_name:
        return {"dateTime": dt.isoformat()}
    try:
        zone = ZoneInfo(tz_name)
    except (ZoneInfoNotFoundError, ValueError):
        return {"dateTime": dt.isoformat()}
    return {"dateTime": dt.astimezone(zone).isoformat(), "timeZone": tz_name}


def _min_end(leg_start: datetime, leg_end: datetime) -> datetime:
    """`leg_end`, floored to at least a minute after `leg_start`.

    Calendar accepts a zero-length event, but one renders as a hairline the
    operator cannot see or click. The flat Composio contract this replaced got
    the same floor for free (it sent whole-minute durations and clamped to
    `max(minutes, 1)`); nested start/end has to say so explicitly.
    """
    return max(leg_end, leg_start + timedelta(minutes=1))


def build_block_args(
    *,
    calendar_id: str,
    flight_id: str,
    direction: str,
    summary: str,
    leg_start: datetime,
    anchor: datetime,
    baseline_seconds: int,
    origin: str,
    destination: str,
    leg_end: datetime | None = None,
    busy: bool = False,
    timezone: str | None = None,
) -> dict:
    """Build the native events.insert body for an airport block.

    Nested `start` / `end` objects carry the times; `location` is the
    destination; the machine state rides in the description. Free
    (`transparency: "transparent"`) unless `busy`.

    Args:
        calendar_id: the calendar to create the block on (primary, per #90).
        flight_id: the served flight's byAir id (as a string).
        direction: "to_airport" or "from_airport".
        summary: the block's human title.
        leg_start: when the block starts. For to_airport this is the leave-by
            (`anchor − drive`); for from_airport it is the anchor itself.
        anchor: the deadline (to_airport: `dep − clearance`) or the earliest
            drive-home start (from_airport: `actual_arr + post_arrival_delay`).
            Stored in state; the poll re-derives leave-by from baseline + anchor.
        baseline_seconds: routed drive seconds captured at creation.
        origin / destination: the routed leg endpoints (the poll re-routes
            exactly this pair).
        leg_end: block end; defaults to `anchor` (correct for to_airport, where
            the drive ends at the deadline). from_airport passes `anchor + drive`.
        busy: create Busy instead of Free.
        timezone: the airport's IANA timezone; names the event's own zone (see
            `_event_time`). Omitted from the body when None or unresolvable.

    Raises:
        ValueError: on a naive datetime, an empty endpoint, a negative or
            non-int baseline, an empty flight_id, or an unknown direction.
    """
    for label, value in (("leg_start", leg_start), ("anchor", anchor)):
        if value.tzinfo is None:
            raise ValueError(f"build_block_args: `{label}` must be timezone-aware")
    if leg_end is not None and leg_end.tzinfo is None:
        raise ValueError("build_block_args: `leg_end` must be timezone-aware")
    if not flight_id:
        raise ValueError("build_block_args: `flight_id` must be non-empty")
    if direction not in DIRECTIONS:
        raise ValueError(
            f"build_block_args: `direction` must be one of {DIRECTIONS} (got {direction!r})"
        )
    if not isinstance(baseline_seconds, int) or isinstance(baseline_seconds, bool):
        raise ValueError("build_block_args: `baseline_seconds` must be an int")
    if baseline_seconds < 0:
        raise ValueError("build_block_args: `baseline_seconds` must be non-negative")
    if not origin or not destination:
        raise ValueError("build_block_args: `origin` and `destination` must be non-empty")

    end = leg_end if leg_end is not None else anchor
    if end < leg_start:
        raise ValueError(
            "build_block_args: block end must not be before `leg_start` "
            "(a leg_end earlier than leg_start, or leg_start later than anchor)"
        )
    end = _min_end(leg_start, end)
    description = build_description(
        summary=summary,
        flight_id=flight_id,
        direction=direction,
        baseline_seconds=baseline_seconds,
        anchor=anchor,
        origin=origin,
        destination=destination,
    )
    return {
        "calendar_id": calendar_id,
        "summary": summary,
        "description": description,
        "location": destination,
        "start": _event_time(leg_start, timezone),
        "end": _event_time(end, timezone),
        "transparency": "opaque" if busy else "transparent",
    }


@dataclass(frozen=True)
class BlockState:
    """An airport drive block parsed back off a fetched calendar event.

    Carries exactly what the recheck poll needs to re-evaluate one block without
    a local store: the leg endpoints to re-route, the baseline to compare
    against, the anchor instant, which alerts already fired, and the summary (so
    the suppression patch can rebuild the full description).
    """

    event_id: str
    flight_id: str
    direction: str
    summary: str
    baseline_seconds: int
    anchor: datetime
    origin: str
    destination: str
    calendar_id: str | None = None
    alerted: frozenset = field(default_factory=frozenset)

    @property
    def baseline_leave_by(self) -> datetime:
        """When you must leave at the baseline drive time.

        Meaningful for `to_airport` (anchor is the deadline): `anchor − drive`.
        For `from_airport` the anchor IS the start, so this returns the anchor.
        """
        if self.direction == "from_airport":
            return self.anchor
        return self.anchor - timedelta(seconds=self.baseline_seconds)

    def due_for_recheck(
        self,
        now: datetime,
        *,
        horizon_seconds: int = DEFAULT_RECHECK_HORIZON_SECONDS,
        departed_grace_seconds: int = DEFAULT_DEPARTED_GRACE_SECONDS,
    ) -> bool:
        """True when `now` is inside this block's recheck window.

        The window opens `horizon_seconds` before the baseline leave-by and
        closes `departed_grace_seconds` after it.
        """
        leave_by = self.baseline_leave_by
        opens = leave_by - timedelta(seconds=horizon_seconds)
        closes = leave_by + timedelta(seconds=departed_grace_seconds)
        return opens <= now <= closes

    def already_alerted(self, kind: str) -> bool:
        """True when an alert of `kind` ("growth" / "leave_now") already fired."""
        return kind in self.alerted

    def description_with_alerts(self, alerted: frozenset | set) -> str:
        """Rebuild the full block description with an updated alert record."""
        return build_description(
            summary=self.summary,
            flight_id=self.flight_id,
            direction=self.direction,
            baseline_seconds=self.baseline_seconds,
            anchor=self.anchor,
            origin=self.origin,
            destination=self.destination,
            alerted=alerted,
        )


def next_alerts(
    alerted: frozenset | set, *, grew: bool, leave_now: bool
) -> tuple[tuple[str, ...], frozenset]:
    """Decide which alerts fire now and the suppression record after.

    The recheck gate reports `grew` (traffic grew past the threshold) and
    `leave_now` (the recomputed leave-by has arrived). Each fires AT MOST ONCE
    per block — re-pinging a still-grown drive every poll is the trust-eroding
    nag. Returns `(kinds_to_fire, new_alerted)`; `kinds_to_fire` is empty when
    both already fired (the poll then stays silent and patches nothing).
    """
    fire: list[str] = []
    if grew and ALERT_GROWTH not in alerted:
        fire.append(ALERT_GROWTH)
    if leave_now and ALERT_LEAVE_NOW not in alerted:
        fire.append(ALERT_LEAVE_NOW)
    new_alerted = frozenset(alerted) | frozenset(fire)
    return tuple(fire), new_alerted


def _parse_iso(raw: object) -> datetime | None:
    """Parse an ISO-8601 / RFC3339 string into a tz-aware datetime, or None."""
    if not isinstance(raw, str):
        return None
    text = raw.strip()
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return None
    return parsed


def _decode_state(description: object) -> dict | None:
    """Pull the `<!--fadrive:{...}-->` state JSON out of a description, defensively."""
    if not isinstance(description, str):
        return None
    match = _STATE_RE.search(description)
    if match is None:
        return None
    try:
        decoded = json.loads(match["json"])
    except ValueError:
        return None
    return decoded if isinstance(decoded, dict) else None


def parse_block(event: object) -> BlockState | None:
    """Parse a fetched calendar event into a `BlockState`, or None.

    Recognition is by the description's `<!--fadrive:{...}-->` state JSON plus the
    `[flight-assist:flight=...:dir=...]` marker. Returns None when the event
    carries no airport-block state, the marker is absent/malformed, or a
    required field is missing — the recheck poll treats None as "not a block I
    recheck" and moves on.

    Schema version (per `coding-policy: stateful-artifacts`): every record
    carries `schema_version`, and `parse_block` accepts ONLY the EXACT current
    version. v1 is the first version, so no owner-side migration exists yet — a
    missing, non-int, OLDER, or NEWER version all read as None
    (no-usable-prior-state, the safe non-disruptive fallback). When a future
    shape bumps the version, add the owner-side v1→vN upgrade here and widen
    acceptance accordingly.
    """
    if not isinstance(event, dict):
        return None
    description = event.get("description")
    state = _decode_state(description)
    if state is None:
        return None

    version = state.get(_STATE_KEY_VERSION)
    if not isinstance(version, int) or isinstance(version, bool) or version != BLOCK_SCHEMA_VERSION:
        return None

    marker = _MARKER_RE.search(description) if isinstance(description, str) else None
    if marker is None:
        return None
    flight_id = marker["id"]
    direction = marker["dir"]
    if direction not in DIRECTIONS:
        return None

    baseline = state.get(_STATE_KEY_BASELINE)
    anchor = _parse_iso(state.get(_STATE_KEY_ANCHOR))
    origin = state.get(_STATE_KEY_ORIGIN)
    destination = state.get(_STATE_KEY_DESTINATION)
    if not isinstance(baseline, int) or isinstance(baseline, bool) or baseline < 0:
        return None
    if anchor is None:
        return None
    if not isinstance(origin, str) or not origin:
        return None
    if not isinstance(destination, str) or not destination:
        return None

    event_id = event.get("id")
    if not isinstance(event_id, str) or not event_id:
        return None

    summary = event.get("summary")
    calendar_id = event.get("calendar_id")
    return BlockState(
        event_id=event_id,
        calendar_id=calendar_id if isinstance(calendar_id, str) and calendar_id else None,
        flight_id=flight_id,
        direction=direction,
        summary=summary if isinstance(summary, str) else "",
        baseline_seconds=baseline,
        anchor=anchor,
        origin=origin,
        destination=destination,
        alerted=parse_alerted(state.get(_STATE_KEY_ALERTED)),
    )

README.md

tile.json