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.

68

Quality

85%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

reconcile.pyskills/drive-engine/

"""Reconcile diff — desired legs vs current calendar blocks — pure, no I/O.

Computes the create / update / delete / convert actions that make the calendar
match the engine's desired legs. This is the heart of the unified reconcile and of
shadow mode (#156 R4): the I/O layer applies the returned plan (or, in shadow mode,
just logs it) — the decision of WHAT to change lives here and is fully testable.

Three defect classes from the design collapse into this one diff:

- **Duplicate storm (G1).** Current blocks are indexed by leg identity, and ALL
  blocks sharing an identity are found — one is kept/updated, every extra is
  deleted. A single find-existing can't collapse an N-way storm; scanning all can.
- **Legacy convergence (R4 cutover).** A prior-gen block (fadrive / dp) that
  matches a desired leg is CONVERTED — the new block is created and every matching
  legacy event deleted, atomically, never double-stamped. Because a desired leg
  declares the legacy keys it supersedes (every byAir id of a codeshare, both
  directions), the dual-id storm maps cleanly onto one desired leg.
- **Orphan deletion (G7).** A current block — unified or legacy — that matches NO
  desired leg is deleted, not merely left alone. This is what clears a suppressed
  connection's stale drives (the 7× CPH storm) and pre-filter orphans, rather than
  only ceasing to create.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime

from block_codec import GEN_LEGACY_FADRIVE, GEN_UNIFIED, ParsedBlock

# Legacy directions ↔ unified leg kinds, for building a desired leg's legacy keys.
_KIND_TO_LEGACY_DIRECTION = {
    "airport_departure": "to_airport",
    "airport_arrival": "from_airport",
}

# The legacy block generations the engine currently OWNS and may converge or
# orphan-delete. Today the engine produces only airport legs, so it owns just the
# flight-assist airport blocks (fadrive). Drive-planner MEETING blocks (dp) are NOT
# owned yet — the engine has no meeting-leg source — so they are left completely
# untouched (never orphaned), never mistaken for airport-leg garbage. When the
# meeting source lands, add GEN_LEGACY_DP to the managed set.
_DEFAULT_MANAGED_LEGACY = frozenset({GEN_LEGACY_FADRIVE})


@dataclass(frozen=True)
class DesiredBlock:
    """One drive block the engine wants to exist. `legacy_keys` are the
    `(generation, legacy_id, direction)` triples of prior-gen blocks this leg
    supersedes — every byAir id of a codeshare, its direction — so cutover
    converts them onto this one leg."""

    identity: str
    kind: str
    summary: str
    start: datetime
    end: datetime
    origin: str
    destination: str
    baseline_seconds: int
    anchor: datetime
    window_end: datetime | None = None
    timezone: str | None = None  # IANA tz the block is created in (local display)
    legacy_keys: frozenset[tuple[str, str, str]] = field(default_factory=frozenset)


def legacy_keys_for_airport_leg(
    kind: str, byair_flight_ids: frozenset[int]
) -> frozenset[tuple[str, str, str]]:
    """The fadrive legacy keys a unified airport leg supersedes — one per byAir id.

    A codeshare tracked under two byAir ids yields two keys, so both legacy blocks
    converge onto the single unified leg. Transfer (a new concept with no legacy
    equivalent) gets no keys — any nearby legacy block is an orphan.
    """
    direction = _KIND_TO_LEGACY_DIRECTION.get(kind)
    if direction is None:
        return frozenset()
    return frozenset((GEN_LEGACY_FADRIVE, str(fid), direction) for fid in byair_flight_ids)


@dataclass(frozen=True)
class Create:
    desired: DesiredBlock


@dataclass(frozen=True)
class Update:
    event_id: str | None
    desired: DesiredBlock


@dataclass(frozen=True)
class Delete:
    event_id: str | None
    reason: str


@dataclass(frozen=True)
class Convert:
    """Adopt a prior-gen leg: create the new block and delete every matching legacy
    event, atomically (#156 R4 — never accumulate)."""

    desired: DesiredBlock
    legacy_event_ids: tuple[str | None, ...]


@dataclass(frozen=True)
class ReconcilePlan:
    creates: tuple[Create, ...] = ()
    updates: tuple[Update, ...] = ()
    deletes: tuple[Delete, ...] = ()
    converts: tuple[Convert, ...] = ()

    @property
    def is_noop(self) -> bool:
        return not (self.creates or self.updates or self.deletes or self.converts)


def _legacy_key(block: ParsedBlock) -> tuple[str, str, str] | None:
    if block.legacy_id is None or block.legacy_direction is None:
        return None
    return (block.generation, block.legacy_id, block.legacy_direction)


def _needs_update(current: ParsedBlock, desired: DesiredBlock) -> bool:
    """Whether a matched unified block differs from the desired leg on the fields
    that drive a shift: anchor, endpoints, window, OR the routed drive duration
    (`baseline_seconds`). A route-duration change alone (traffic grew) moves the
    block's leave-by even when the anchor is unchanged, so it must trigger an
    update — otherwise the live writer would leave a stale block."""
    return (
        current.anchor != desired.anchor
        or current.origin != desired.origin
        or current.destination != desired.destination
        or current.window_end != desired.window_end
        or current.baseline_seconds != desired.baseline_seconds
    )


def plan_reconcile(
    desired: list[DesiredBlock],
    current: list[ParsedBlock],
    *,
    managed_legacy: frozenset[str] = _DEFAULT_MANAGED_LEGACY,
) -> ReconcilePlan:
    """Diff desired legs against current calendar blocks (#156 G1 / G7 / R4).

    `managed_legacy` is the set of legacy generations the engine owns and may
    converge or orphan-delete; blocks of any other legacy generation (e.g.
    drive-planner meeting blocks while the engine has no meeting-leg source) are
    left entirely untouched — never orphaned. Pure and deterministic: output
    action lists are ordered by the desired-leg order then the current-block
    order, with no clock or ambient state.
    """
    creates: list[Create] = []
    updates: list[Update] = []
    deletes: list[Delete] = []
    converts: list[Convert] = []

    # Index current blocks: unified by (identity, kind), legacy by their key.
    unified_by_key: dict[tuple[str | None, str | None], list[ParsedBlock]] = {}
    legacy_by_key: dict[tuple[str, str, str], list[ParsedBlock]] = {}
    for block in current:
        if block.generation == GEN_UNIFIED and block.identity is not None:
            unified_by_key.setdefault((block.identity, block.kind), []).append(block)
        elif block.generation in managed_legacy:
            key = _legacy_key(block)
            if key is not None:
                legacy_by_key.setdefault(key, []).append(block)

    desired_keys: set[tuple[str, str]] = set()
    consumed_legacy: set[int] = set()  # id() of legacy blocks turned into converts

    for d in desired:
        key = (d.identity, d.kind)
        desired_keys.add(key)
        matches = unified_by_key.get(key, [])
        if matches:
            # G1: keep the first, delete every extra sharing this identity.
            keep = matches[0]
            if _needs_update(keep, d):
                updates.append(Update(keep.event_id, d))
            for extra in matches[1:]:
                deletes.append(Delete(extra.event_id, "duplicate identity"))
            continue

        # No unified block: converge any legacy blocks this leg supersedes.
        legacy_events: list[str | None] = []
        for lk in d.legacy_keys:
            for block in legacy_by_key.get(lk, []):
                if id(block) not in consumed_legacy:
                    consumed_legacy.add(id(block))
                    legacy_events.append(block.event_id)
        if legacy_events:
            converts.append(Convert(d, tuple(legacy_events)))
        else:
            creates.append(Create(d))

    # Unified orphans: current unified blocks with no desired leg.
    for (identity, kind), blocks in unified_by_key.items():
        if (identity, kind) not in desired_keys:
            for block in blocks:
                deletes.append(Delete(block.event_id, "orphan: no desired leg"))

    # Legacy orphans: any legacy block not consumed by a convert.
    for blocks in legacy_by_key.values():
        for block in blocks:
            if id(block) not in consumed_legacy:
                deletes.append(Delete(block.event_id, "legacy orphan: no desired leg"))

    return ReconcilePlan(
        creates=tuple(creates),
        updates=tuple(updates),
        deletes=tuple(deletes),
        converts=tuple(converts),
    )

README.md

tile.json