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

Low

Low-risk findings worth noting

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