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
85%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
"""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),
).tessl-plugin
skills
check-travel-bookings
drive-engine
drive-planner
drive-planner-recheck
flight-assist
references
nightly-travel-sync
sync-tripit
travel-core